feat(P19ERP2-3,P19ERP2-4): 新增采购ERP轨道接口与详情挂件
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户