feat(P19ERP2-3,P19ERP2-4): 新增采购ERP轨道接口与详情挂件
这个提交包含在:
@@ -111,6 +111,78 @@ export type PurchaseOrderArchive = {
|
|||||||
archivedFiles: PurchaseArchivedFile[];
|
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 & {
|
export type PurchaseRequestDetail = PurchaseRequestSummary & {
|
||||||
items: PurchaseRequestItem[];
|
items: PurchaseRequestItem[];
|
||||||
attachments: PurchaseAttachment[];
|
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),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { cn } from "../../lib/utils";
|
|||||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||||
draft: { label: "草稿", className: "badge-neutral bg-zinc-200/50" },
|
draft: { label: "草稿", className: "badge-neutral bg-zinc-200/50" },
|
||||||
reviewing: { label: "审批中", className: "badge-warning" },
|
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" },
|
pending_ocr: { label: "识别中", className: "badge-warning" },
|
||||||
approved: { label: "已通过", className: "badge-success" },
|
approved: { label: "已通过", className: "badge-success" },
|
||||||
completed: { label: "已完成", className: "badge-success" },
|
completed: { label: "已完成", className: "badge-success" },
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ import {
|
|||||||
deletePurchaseAttachment,
|
deletePurchaseAttachment,
|
||||||
fetchPurchaseOrderArchive,
|
fetchPurchaseOrderArchive,
|
||||||
fetchPurchaseRequestDetail,
|
fetchPurchaseRequestDetail,
|
||||||
|
fetchPurchaseRequestErpTrack,
|
||||||
fetchPurchaseRequests,
|
fetchPurchaseRequests,
|
||||||
issuePurchaseAttachmentSignedUrl,
|
issuePurchaseAttachmentSignedUrl,
|
||||||
|
requeuePurchaseRequestErpSync,
|
||||||
submitPurchaseRequest,
|
submitPurchaseRequest,
|
||||||
updatePurchaseRequest,
|
updatePurchaseRequest,
|
||||||
uploadPurchaseAttachment,
|
uploadPurchaseAttachment,
|
||||||
withdrawPurchaseRequest,
|
withdrawPurchaseRequest,
|
||||||
|
type PurchaseErpBinding,
|
||||||
|
type PurchaseRequestErpRequeueTarget,
|
||||||
|
type PurchaseRequestErpTrack,
|
||||||
type PurchaseRequestItemPayload,
|
type PurchaseRequestItemPayload,
|
||||||
type PurchaseRequestStatus,
|
type PurchaseRequestStatus,
|
||||||
} from "../api/purchaseRequests";
|
} 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() {
|
export function PurchaseRequestsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
|
|
||||||
const canCreate = hasPermission(permissions, "procurement.create");
|
const canCreate = hasPermission(permissions, "procurement.create");
|
||||||
const canManage = hasPermission(permissions, "procurement.manage");
|
const canManage = hasPermission(permissions, "procurement.manage");
|
||||||
|
const canIntegrationManage = hasPermission(permissions, "integration.manage");
|
||||||
const canViewAll =
|
const canViewAll =
|
||||||
hasPermission(permissions, "procurement.view_all") ||
|
hasPermission(permissions, "procurement.view_all") ||
|
||||||
canManage;
|
canManage;
|
||||||
@@ -164,6 +270,14 @@ export function PurchaseRequestsPage() {
|
|||||||
queryFn: () => fetchPurchaseOrderArchive(selectedId),
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
if (detailQuery.isLoading) {
|
if (detailQuery.isLoading) {
|
||||||
@@ -337,6 +451,7 @@ export function PurchaseRequestsPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
|
||||||
]);
|
]);
|
||||||
setFormError("");
|
setFormError("");
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -364,6 +479,7 @@ export function PurchaseRequestsPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
|
||||||
]);
|
]);
|
||||||
setFormError("");
|
setFormError("");
|
||||||
toast.success("采购申请已撤回到草稿");
|
toast.success("采购申请已撤回到草稿");
|
||||||
@@ -394,6 +510,7 @@ export function PurchaseRequestsPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
|
||||||
]);
|
]);
|
||||||
toast.success("PO/合同归档已保存");
|
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({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
@@ -455,6 +605,8 @@ export function PurchaseRequestsPage() {
|
|||||||
|
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
|
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 showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
|
||||||
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
|
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
|
||||||
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
||||||
@@ -1027,6 +1179,93 @@ export function PurchaseRequestsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,12 +19,28 @@ const {
|
|||||||
buildReimbursementJournalPreview,
|
buildReimbursementJournalPreview,
|
||||||
enqueueReimbursementJournalExport,
|
enqueueReimbursementJournalExport,
|
||||||
} = require('../services/integrations/erpJournal');
|
} = require('../services/integrations/erpJournal');
|
||||||
|
const {
|
||||||
|
PROCUREMENT_ERP_TARGETS,
|
||||||
|
buildPurchaseRequestErpTrack,
|
||||||
|
enqueuePurchaseAccrualJournalExport,
|
||||||
|
enqueuePurchaseArchiveSummaryExport,
|
||||||
|
enqueuePurchaseCommitmentJournalExport,
|
||||||
|
loadPurchaseRequestErpContext,
|
||||||
|
} = require('../services/integrations/erpProcurement');
|
||||||
|
|
||||||
const UUID_SCHEMA = z.string().uuid();
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
||||||
const MASTER_DATA_SYNC_BODY_SCHEMA = z.object({
|
const MASTER_DATA_SYNC_BODY_SCHEMA = z.object({
|
||||||
resources: z.array(MASTER_DATA_RESOURCE_ENUM).max(3).optional(),
|
resources: z.array(MASTER_DATA_RESOURCE_ENUM).max(3).optional(),
|
||||||
});
|
});
|
||||||
|
const PROCUREMENT_REQUEUE_TARGET_ENUM = z.enum([
|
||||||
|
'commitment_journal',
|
||||||
|
'accrual_journal',
|
||||||
|
'archive_summary',
|
||||||
|
]);
|
||||||
|
const PROCUREMENT_REQUEUE_BODY_SCHEMA = z.object({
|
||||||
|
targets: z.array(PROCUREMENT_REQUEUE_TARGET_ENUM).min(1).max(3),
|
||||||
|
});
|
||||||
|
|
||||||
const ERP_SYNC_RESPONSE_SCHEMA = {
|
const ERP_SYNC_RESPONSE_SCHEMA = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -195,6 +211,145 @@ function ensurePaidReimbursement(reimbursement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canReadPurchaseRequest(request, purchaseRequest) {
|
||||||
|
if (!purchaseRequest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionCodes = request?.employee?.permissionCodes ?? [];
|
||||||
|
if (permissionCodes.includes('*') || permissionCodes.includes('procurement.view_all') || permissionCodes.includes('procurement.manage')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
purchaseRequest.applicant_id === request.employee.id ||
|
||||||
|
purchaseRequest.submitter_id === request.employee.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcurementTargets(body) {
|
||||||
|
const parsed = PROCUREMENT_REQUEUE_BODY_SCHEMA.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'VALID_001',
|
||||||
|
message: '参数校验失败',
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedTargets = [
|
||||||
|
PROCUREMENT_ERP_TARGETS.commitmentJournal,
|
||||||
|
PROCUREMENT_ERP_TARGETS.accrualJournal,
|
||||||
|
PROCUREMENT_ERP_TARGETS.archiveSummary,
|
||||||
|
];
|
||||||
|
const requested = new Set(parsed.data.targets);
|
||||||
|
return orderedTargets.filter((target) => requested.has(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReadablePurchaseRequestContext(prisma, purchaseRequestId, request) {
|
||||||
|
const context = await loadPurchaseRequestErpContext(prisma, purchaseRequestId);
|
||||||
|
if (!context?.purchaseRequest) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'SYS_404',
|
||||||
|
message: 'Not Found',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canReadPurchaseRequest(request, context.purchaseRequest)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'PERM_001',
|
||||||
|
message: '无权限',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProcurementTargetEligible(target, context) {
|
||||||
|
const purchaseRequest = context?.purchaseRequest;
|
||||||
|
const archive = purchaseRequest?.order_archive ?? null;
|
||||||
|
|
||||||
|
if (target === PROCUREMENT_ERP_TARGETS.commitmentJournal) {
|
||||||
|
if (purchaseRequest?.status !== 'approved') {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'VALID_001',
|
||||||
|
message: '参数校验失败',
|
||||||
|
statusCode: 409,
|
||||||
|
details: { purchaseRequest: 'purchase_request_not_approved', target },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!archive) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'VALID_001',
|
||||||
|
message: '参数校验失败',
|
||||||
|
statusCode: 409,
|
||||||
|
details: { purchaseOrderArchive: 'purchase_order_archive_missing', target },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueProcurementTargetsAndMaybeRun({
|
||||||
|
prisma,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
context,
|
||||||
|
actorId,
|
||||||
|
targets,
|
||||||
|
runNow,
|
||||||
|
}) {
|
||||||
|
const purchaseRequest = context.purchaseRequest;
|
||||||
|
const archive = purchaseRequest.order_archive ?? null;
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
ensureProcurementTargetEligible(target, context);
|
||||||
|
|
||||||
|
if (target === PROCUREMENT_ERP_TARGETS.commitmentJournal) {
|
||||||
|
const enqueued = await enqueuePurchaseCommitmentJournalExport(prisma, config, {
|
||||||
|
purchaseRequest,
|
||||||
|
occurredAt: context.approvalOccurredAt ?? new Date(),
|
||||||
|
actorId,
|
||||||
|
requeueOnFailure: true,
|
||||||
|
});
|
||||||
|
items.push({ target, ...enqueued });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === PROCUREMENT_ERP_TARGETS.accrualJournal) {
|
||||||
|
const enqueued = await enqueuePurchaseAccrualJournalExport(prisma, config, {
|
||||||
|
purchaseRequest,
|
||||||
|
archive,
|
||||||
|
occurredAt: archive.archived_at ?? new Date(),
|
||||||
|
actorId,
|
||||||
|
requeueOnFailure: true,
|
||||||
|
});
|
||||||
|
items.push({ target, ...enqueued });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueued = await enqueuePurchaseArchiveSummaryExport(prisma, config, {
|
||||||
|
purchaseRequest,
|
||||||
|
archive,
|
||||||
|
archivedAt: archive.archived_at ?? new Date(),
|
||||||
|
actorId,
|
||||||
|
requeueOnFailure: true,
|
||||||
|
});
|
||||||
|
items.push({ target, ...enqueued });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runNow && items.some((item) => !item.duplicated || item.requeued)) {
|
||||||
|
await runIntegrationOutboxOnce(prisma, config, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
async function enqueueReimbursementExportAndMaybeRun({
|
async function enqueueReimbursementExportAndMaybeRun({
|
||||||
prisma,
|
prisma,
|
||||||
config,
|
config,
|
||||||
@@ -454,6 +609,148 @@ async function integrationsRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track',
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
schema: {
|
||||||
|
tags: ['integrations'],
|
||||||
|
summary: '查询采购 ERP 同步轨道',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['purchaseRequestId'],
|
||||||
|
properties: {
|
||||||
|
purchaseRequestId: { type: 'string', format: 'uuid' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsedId = UUID_SCHEMA.safeParse(request.params?.purchaseRequestId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'VALID_001',
|
||||||
|
message: '参数校验失败',
|
||||||
|
statusCode: 400,
|
||||||
|
details: { purchaseRequestId: 'invalid_uuid' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [context, gate] = await Promise.all([
|
||||||
|
fetchReadablePurchaseRequestContext(fastify.prisma, parsedId.data, request),
|
||||||
|
resolveErpGate(fastify.prisma, fastify.config),
|
||||||
|
]);
|
||||||
|
const canManage = fastify.hasPermission(request.employee, 'integration.manage');
|
||||||
|
const track = await buildPurchaseRequestErpTrack(fastify.prisma, fastify.config, parsedId.data, {
|
||||||
|
context,
|
||||||
|
canManage,
|
||||||
|
gate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, track);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue',
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission('integration.manage'),
|
||||||
|
schema: {
|
||||||
|
tags: ['integrations'],
|
||||||
|
summary: '重试或补发采购 ERP 同步',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['purchaseRequestId'],
|
||||||
|
properties: {
|
||||||
|
purchaseRequestId: { type: 'string', format: 'uuid' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
runNow: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
|
||||||
|
{ type: 'boolean' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsedId = UUID_SCHEMA.safeParse(request.params?.purchaseRequestId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'VALID_001',
|
||||||
|
message: '参数校验失败',
|
||||||
|
statusCode: 400,
|
||||||
|
details: { purchaseRequestId: 'invalid_uuid' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = normalizeProcurementTargets(request.body);
|
||||||
|
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||||
|
const context = await loadPurchaseRequestErpContext(fastify.prisma, parsedId.data);
|
||||||
|
if (!context?.purchaseRequest) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'SYS_404',
|
||||||
|
message: 'Not Found',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await enqueueProcurementTargetsAndMaybeRun({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
logger: fastify.log,
|
||||||
|
context,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
targets,
|
||||||
|
runNow: parseRunNow(request.query),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: 'ERP_SYNC_ENQUEUED',
|
||||||
|
targetType: item.target === PROCUREMENT_ERP_TARGETS.commitmentJournal ? 'purchase_request' : 'purchase_order_archive',
|
||||||
|
targetId:
|
||||||
|
item.target === PROCUREMENT_ERP_TARGETS.commitmentJournal
|
||||||
|
? context.purchaseRequest.id
|
||||||
|
: context.purchaseRequest.order_archive?.id ?? null,
|
||||||
|
extra: {
|
||||||
|
erpTarget: item.target,
|
||||||
|
eventType: item.eventType,
|
||||||
|
purchaseRequestId: context.purchaseRequest.id,
|
||||||
|
purchaseOrderArchiveId: context.purchaseRequest.order_archive?.id ?? null,
|
||||||
|
outboxId: item.id,
|
||||||
|
duplicated: item.duplicated,
|
||||||
|
requeued: item.requeued,
|
||||||
|
gateReason: gate.effectiveReason,
|
||||||
|
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
items: items.map((item) => ({
|
||||||
|
target: item.target,
|
||||||
|
outboxId: item.id,
|
||||||
|
status: item.status,
|
||||||
|
duplicated: item.duplicated,
|
||||||
|
requeued: item.requeued,
|
||||||
|
eventType: item.eventType,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/integrations/erp/sync/:reimbursementId',
|
'/integrations/erp/sync/:reimbursementId',
|
||||||
{
|
{
|
||||||
@@ -555,6 +852,9 @@ async function integrationsRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
integrationsRoutes.__testables = {
|
integrationsRoutes.__testables = {
|
||||||
|
canReadPurchaseRequest,
|
||||||
|
ensureProcurementTargetEligible,
|
||||||
|
normalizeProcurementTargets,
|
||||||
normalizeResourcesInput,
|
normalizeResourcesInput,
|
||||||
parseRunNow,
|
parseRunNow,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||||
|
|
||||||
|
describe('Integrations procurement ERP route helpers', () => {
|
||||||
|
const {
|
||||||
|
canReadPurchaseRequest,
|
||||||
|
ensureProcurementTargetEligible,
|
||||||
|
normalizeProcurementTargets,
|
||||||
|
} = integrationsRoutes.__testables;
|
||||||
|
|
||||||
|
it('normalizes procurement targets in fixed order', () => {
|
||||||
|
expect(
|
||||||
|
normalizeProcurementTargets({
|
||||||
|
targets: ['archive_summary', 'commitment_journal', 'archive_summary'],
|
||||||
|
}),
|
||||||
|
).toEqual(['commitment_journal', 'archive_summary']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects purchase read permission rules', () => {
|
||||||
|
const purchaseRequest = {
|
||||||
|
applicant_id: 'employee-1',
|
||||||
|
submitter_id: 'employee-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canReadPurchaseRequest(
|
||||||
|
{ employee: { id: 'employee-1', permissionCodes: ['procurement.view_own'] } },
|
||||||
|
purchaseRequest,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canReadPurchaseRequest(
|
||||||
|
{ employee: { id: 'other', permissionCodes: ['procurement.view_own'] } },
|
||||||
|
purchaseRequest,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canReadPurchaseRequest(
|
||||||
|
{ employee: { id: 'other', permissionCodes: ['integration.manage', 'procurement.view_all'] } },
|
||||||
|
purchaseRequest,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects commitment target when purchase is not approved', () => {
|
||||||
|
expect(() =>
|
||||||
|
ensureProcurementTargetEligible('commitment_journal', {
|
||||||
|
purchaseRequest: { status: 'reviewing' },
|
||||||
|
}),
|
||||||
|
).toThrow('参数校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects archive targets when archive is missing', () => {
|
||||||
|
expect(() =>
|
||||||
|
ensureProcurementTargetEligible('accrual_journal', {
|
||||||
|
purchaseRequest: { status: 'approved', order_archive: null },
|
||||||
|
}),
|
||||||
|
).toThrow('参数校验失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户