feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口

这个提交包含在:
2026-03-08 16:29:05 +08:00
父节点 a1b1d7cfc9
当前提交 855bcdffeb
共修改 14 个文件,包含 1361 行新增和 145 行删除
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
import {
createReimbursementItem,
deleteReimbursementItem,
@@ -31,6 +32,11 @@ import {
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const { permissions } = useAuth();
@@ -59,6 +65,15 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const canManageIntegration = hasPermission(permissions, "integration.manage");
const erpPreviewQuery = useQuery({
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
queryFn: () => previewReimbursementJournal(reimbursementId),
});
const mutation = useMutation({
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
if (!reimbursementId) {
@@ -192,6 +207,41 @@ export function ReimbursementDetailPage() {
},
});
const erpExportMutation = useMutation({
mutationFn: async () => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return exportReimbursementJournal(reimbursementId, true);
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
toast.success(`ERP 导出${suffix}`);
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("ERP 导出失败,请稍后重试");
toast.error("ERP 导出失败,请稍后重试");
},
});
if (!reimbursementId) {
return (
<div className="card-std p-6">
@@ -312,6 +362,57 @@ export function ReimbursementDetailPage() {
</div>
) : null}
{canManageIntegration && detail.status === "paid" ? (
<div className="card-std space-y-4 p-6" data-testid="reimbursement-erp-export">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">ERP 导出</h3>
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。</p>
</div>
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
</Button>
</div>
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载 ERP 导出预览失败"}
</p>
) : null}
{erpPreviewQuery.data ? (
<div className="grid gap-4 lg:grid-cols-2 text-sm">
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : null}
</div>
) : null}
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">