feat(P19ERP2-3,P19ERP2-4): 新增采购ERP轨道接口与详情挂件

这个提交包含在:
2026-03-08 23:50:11 +08:00
父节点 486660df06
当前提交 69c831d578
共修改 5 个文件,包含 707 行新增和 1 行删除
+103
查看文件
@@ -111,6 +111,78 @@ export type PurchaseOrderArchive = {
archivedFiles: PurchaseArchivedFile[];
};
export type PurchaseErpTrackStatus =
| "not_triggered"
| "pending"
| "processing"
| "succeeded"
| "failed"
| "dead_letter"
| "skipped";
export type PurchaseErpBinding = {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
};
export type PurchaseErpOutbox = {
id: string;
eventType: string;
status: string;
attempts: number;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
};
export type PurchaseErpTrackStage = {
eligible: boolean;
reason: string | null;
status: PurchaseErpTrackStatus | string;
preview: Record<string, unknown>;
outbox: PurchaseErpOutbox | null;
binding?: PurchaseErpBinding | null;
bindings?: {
po: PurchaseErpBinding | null;
contract: PurchaseErpBinding | null;
};
};
export type PurchaseRequestErpTrack = {
purchaseRequest: {
id: string;
requestNo: string | null;
status: string;
totalAmount: string;
supplier: {
id: string;
code: string | null;
name: string | null;
} | null;
archiveOrder: {
id: string;
poNo: string;
contractNo: string | null;
archivedAt: string | null;
} | null;
};
stages: {
commitmentJournal: PurchaseErpTrackStage;
accrualJournal: PurchaseErpTrackStage;
archiveSummary: PurchaseErpTrackStage;
};
permissions: {
canRetry: boolean;
};
};
export type PurchaseRequestErpRequeueTarget = "commitment_journal" | "accrual_journal" | "archive_summary";
export type PurchaseRequestDetail = PurchaseRequestSummary & {
items: PurchaseRequestItem[];
attachments: PurchaseAttachment[];
@@ -290,3 +362,34 @@ export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string
},
);
}
export async function fetchPurchaseRequestErpTrack(purchaseRequestId: string) {
return apiRequestData<PurchaseRequestErpTrack>(
`/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/track`,
);
}
export async function requeuePurchaseRequestErpSync(
purchaseRequestId: string,
payload: {
targets: PurchaseRequestErpRequeueTarget[];
},
runNow = true,
) {
return apiRequestData<{
items: Array<{
target: PurchaseRequestErpRequeueTarget;
outboxId: string;
status: string;
duplicated: boolean;
requeued: boolean;
eventType: string;
}>;
}>(
`/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/requeue${toQueryString({ runNow })}`,
{
method: 'POST',
body: JSON.stringify(payload),
},
);
}
+6 -1
查看文件
@@ -5,7 +5,12 @@ import { cn } from "../../lib/utils";
const STATUS_MAP: Record<string, { label: string; className: string }> = {
draft: { label: "草稿", className: "badge-neutral bg-zinc-200/50" },
reviewing: { label: "审批中", className: "badge-warning" },
pending: { label: "处理中", className: "badge-warning" },
pending: { label: "待派发", className: "badge-warning" },
processing: { label: "处理中", className: "badge-warning" },
succeeded: { label: "已成功", className: "badge-success" },
failed: { label: "失败", className: "badge-error" },
dead_letter: { label: "死信", className: "badge-error" },
not_triggered: { label: "未触发", className: "badge-neutral" },
pending_ocr: { label: "识别中", className: "badge-warning" },
approved: { label: "已通过", className: "badge-success" },
completed: { label: "已完成", className: "badge-success" },
+239
查看文件
@@ -9,12 +9,17 @@ import {
deletePurchaseAttachment,
fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail,
fetchPurchaseRequestErpTrack,
fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
requeuePurchaseRequestErpSync,
submitPurchaseRequest,
updatePurchaseRequest,
uploadPurchaseAttachment,
withdrawPurchaseRequest,
type PurchaseErpBinding,
type PurchaseRequestErpRequeueTarget,
type PurchaseRequestErpTrack,
type PurchaseRequestItemPayload,
type PurchaseRequestStatus,
} from "../api/purchaseRequests";
@@ -87,12 +92,113 @@ function statusOptions(): Array<{ value: PurchaseRequestStatus | ""; label: stri
];
}
type PurchaseErpStageKey = keyof PurchaseRequestErpTrack["stages"];
const PURCHASE_ERP_STAGE_META: Record<
PurchaseErpStageKey,
{ title: string; target: PurchaseRequestErpRequeueTarget }
> = {
commitmentJournal: { title: "Commitment", target: "commitment_journal" },
accrualJournal: { title: "Accrual", target: "accrual_journal" },
archiveSummary: { title: "PO/合同同步", target: "archive_summary" },
};
function formatStageReason(reason: string | null) {
switch (reason) {
case "purchase_request_not_approved":
return "当前采购单尚未处于已通过状态";
case "purchase_order_archive_missing":
return "尚未完成 PO/合同归档";
default:
return reason ?? null;
}
}
function resolveStageTimestamp(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
const poBinding = stage.bindings?.po ?? null;
const contractBinding = stage.bindings?.contract ?? null;
return (
stage.outbox?.updatedAt ??
stage.outbox?.processedAt ??
binding?.lastSyncedAt ??
poBinding?.lastSyncedAt ??
contractBinding?.lastSyncedAt ??
null
);
}
function resolveStageExternalCode(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalCode ?? stage.bindings?.po?.externalCode ?? stage.bindings?.contract?.externalCode ?? null;
}
function resolveStageExternalId(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalId ?? stage.bindings?.po?.externalId ?? stage.bindings?.contract?.externalId ?? null;
}
function resolveStageSummary(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const preview = stage.preview as {
documentNo?: string | null;
requestNo?: string | null;
poNo?: string | null;
contractNo?: string | null;
totalAmount?: string | null;
supplier?: {
code?: string | null;
};
dimensions?: {
legalEntityCode?: string | null;
costCenterCode?: string | null;
supplierCode?: string | null;
};
};
return {
documentNo:
preview.documentNo ??
preview.requestNo ??
(stageKey === "archiveSummary" ? preview.poNo ?? null : null),
totalAmount: preview.totalAmount ?? null,
legalEntityCode: preview.dimensions?.legalEntityCode ?? null,
costCenterCode: preview.dimensions?.costCenterCode ?? null,
supplierCode: preview.dimensions?.supplierCode ?? preview.supplier?.code ?? null,
poNo: preview.poNo ?? null,
contractNo: preview.contractNo ?? null,
};
}
function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
if (!stage.eligible) {
return null;
}
if (stage.status === "failed" || stage.status === "dead_letter") {
return {
label: stageKey === "archiveSummary" ? "重试同步摘要" : "重试同步",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
if (stage.status === "not_triggered" || stage.status === "skipped") {
return {
label: stageKey === "archiveSummary" ? "补发摘要" : "补发",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
return null;
}
export function PurchaseRequestsPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canCreate = hasPermission(permissions, "procurement.create");
const canManage = hasPermission(permissions, "procurement.manage");
const canIntegrationManage = hasPermission(permissions, "integration.manage");
const canViewAll =
hasPermission(permissions, "procurement.view_all") ||
canManage;
@@ -164,6 +270,14 @@ export function PurchaseRequestsPage() {
queryFn: () => fetchPurchaseOrderArchive(selectedId),
});
const erpTrackQuery = useQuery({
enabled:
Boolean(selectedId) &&
(detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
queryKey: ["purchase-requests", "erp-track", selectedId],
queryFn: () => fetchPurchaseRequestErpTrack(selectedId),
});
useEffect(() => {
if (selectedId) {
if (detailQuery.isLoading) {
@@ -337,6 +451,7 @@ export function PurchaseRequestsPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
toast.success(
@@ -364,6 +479,7 @@ export function PurchaseRequestsPage() {
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
toast.success("采购申请已撤回到草稿");
@@ -394,6 +510,7 @@ export function PurchaseRequestsPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
toast.success("PO/合同归档已保存");
},
@@ -403,6 +520,39 @@ export function PurchaseRequestsPage() {
},
});
const requeueErpMutation = useMutation({
mutationFn: async (payload: { target: PurchaseRequestErpRequeueTarget; label: string }) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return {
label: payload.label,
response: await requeuePurchaseRequestErpSync(selectedId, { targets: [payload.target] }),
};
},
onSuccess: async ({ label, response }) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
const firstItem = response.items[0];
if (firstItem?.duplicated) {
toast.success(`${label} 已存在待处理记录`);
return;
}
if (firstItem?.requeued) {
toast.success(`${label} 已重新入队`);
return;
}
toast.success(`${label} 已加入同步队列`);
},
onError: (error) => {
const message = resolveError(error);
toast.error(message);
},
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
if (!selectedId) {
@@ -455,6 +605,8 @@ export function PurchaseRequestsPage() {
const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const erpTrack = erpTrackQuery.data ?? null;
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
const canSubmit = Boolean(detail?.permissions.canSubmit);
@@ -1027,6 +1179,93 @@ export function PurchaseRequestsPage() {
) : null}
</div>
) : null}
{showArchiveSection ? (
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">采购 ERP 同步轨道</h4>
<p className="text-sm text-zinc-500">展示 Commitment / Accrual / PO/合同摘要三段 ERP 同步状态,失败后可在此重试。</p>
</div>
</div>
{erpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 轨道加载中...</p>
) : erpTrackQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(erpTrackQuery.error)}</p>
) : erpTrack ? (
<div className="grid gap-3 xl:grid-cols-3">
{(Object.entries(PURCHASE_ERP_STAGE_META) as Array<
[PurchaseErpStageKey, (typeof PURCHASE_ERP_STAGE_META)[PurchaseErpStageKey]]
>).map(([stageKey, meta]) => {
const stage = erpTrack.stages[stageKey];
const summary = resolveStageSummary(stageKey, stage);
const action = canRetryErp ? resolveStageAction(stageKey, stage) : null;
const stageTime = resolveStageTimestamp(stage);
const externalCode = resolveStageExternalCode(stage);
const externalId = resolveStageExternalId(stage);
const reasonText = formatStageReason(stage.reason);
const isSubmittingAction = requeueErpMutation.isPending && requeueErpMutation.variables?.target === action?.target;
return (
<div key={stageKey} className="space-y-3 rounded-lg border border-zinc-200 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">{meta.title}</div>
<div className="text-xs text-zinc-500">
{stageTime ? `最近更新时间:${new Date(stageTime).toLocaleString()}` : "尚无同步记录"}
</div>
</div>
<StatusBadge status={stage.status} />
</div>
<div className="space-y-1 text-xs text-zinc-600">
<div>单号:{summary.documentNo ?? "-"}</div>
<div>金额:{summary.totalAmount ?? "-"}</div>
<div>法体 / 成本中心:{summary.legalEntityCode ?? "-"} / {summary.costCenterCode ?? "-"}</div>
<div>供应商:{summary.supplierCode ?? "-"}</div>
{stageKey !== "commitmentJournal" ? (
<div>PO / 合同:{summary.poNo ?? "-"} / {summary.contractNo ?? "-"}</div>
) : null}
</div>
{reasonText ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-500">{reasonText}</div>
) : null}
{stage.outbox?.lastError ? (
<div className="rounded bg-red-50 px-2 py-1 text-xs text-red-600">{stage.outbox.lastError}</div>
) : null}
{canIntegrationManage && (externalCode || externalId) ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-600">
ERP 编号:{externalCode ?? "-"}
{externalId ? ` · ${externalId}` : ""}
</div>
) : null}
{action ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="secondary"
disabled={requeueErpMutation.isPending}
onClick={() => requeueErpMutation.mutate({ target: action.target, label: meta.title })}
>
{isSubmittingAction ? "处理中..." : action.label}
</Button>
</div>
) : null}
</div>
);
})}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</div>
) : null}
</>
) : null}
</div>