diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 1cb85901..9620b201 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -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; + 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( + `/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), + }, + ); +} diff --git a/client/src/components/common/StatusBadge.tsx b/client/src/components/common/StatusBadge.tsx index ba8ff1ea..53e07cb3 100644 --- a/client/src/components/common/StatusBadge.tsx +++ b/client/src/components/common/StatusBadge.tsx @@ -5,7 +5,12 @@ import { cn } from "../../lib/utils"; const STATUS_MAP: Record = { 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" }, diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index a3cbcd86..c5f6ebd1 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -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} ) : null} + + {showArchiveSection ? ( +
+
+
+

采购 ERP 同步轨道

+

展示 Commitment / Accrual / PO/合同摘要三段 ERP 同步状态,失败后可在此重试。

+
+
+ + {erpTrackQuery.isLoading ? ( +

ERP 轨道加载中...

+ ) : erpTrackQuery.isError ? ( +

{resolveError(erpTrackQuery.error)}

+ ) : erpTrack ? ( +
+ {(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 ( +
+
+
+
{meta.title}
+
+ {stageTime ? `最近更新时间:${new Date(stageTime).toLocaleString()}` : "尚无同步记录"} +
+
+ +
+ +
+
单号:{summary.documentNo ?? "-"}
+
金额:{summary.totalAmount ?? "-"}
+
法体 / 成本中心:{summary.legalEntityCode ?? "-"} / {summary.costCenterCode ?? "-"}
+
供应商:{summary.supplierCode ?? "-"}
+ {stageKey !== "commitmentJournal" ? ( +
PO / 合同:{summary.poNo ?? "-"} / {summary.contractNo ?? "-"}
+ ) : null} +
+ + {reasonText ? ( +
{reasonText}
+ ) : null} + + {stage.outbox?.lastError ? ( +
{stage.outbox.lastError}
+ ) : null} + + {canIntegrationManage && (externalCode || externalId) ? ( +
+ ERP 编号:{externalCode ?? "-"} + {externalId ? ` · ${externalId}` : ""} +
+ ) : null} + + {action ? ( +
+ +
+ ) : null} +
+ ); + })} +
+ ) : ( +

暂无 ERP 轨道信息

+ )} +
+ ) : null} ) : null} diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js index bd818b8d..b24e27d1 100644 --- a/server/src/routes/integrations.js +++ b/server/src/routes/integrations.js @@ -19,12 +19,28 @@ const { buildReimbursementJournalPreview, enqueueReimbursementJournalExport, } = require('../services/integrations/erpJournal'); +const { + PROCUREMENT_ERP_TARGETS, + buildPurchaseRequestErpTrack, + enqueuePurchaseAccrualJournalExport, + enqueuePurchaseArchiveSummaryExport, + enqueuePurchaseCommitmentJournalExport, + loadPurchaseRequestErpContext, +} = require('../services/integrations/erpProcurement'); const UUID_SCHEMA = z.string().uuid(); const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']); const MASTER_DATA_SYNC_BODY_SCHEMA = z.object({ 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 = { 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({ prisma, 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( '/integrations/erp/sync/:reimbursementId', { @@ -555,6 +852,9 @@ async function integrationsRoutes(fastify) { } integrationsRoutes.__testables = { + canReadPurchaseRequest, + ensureProcurementTargetEligible, + normalizeProcurementTargets, normalizeResourcesInput, parseRunNow, }; diff --git a/server/tests/unit/routes/integrations.erp.procurement.test.js b/server/tests/unit/routes/integrations.erp.procurement.test.js new file mode 100644 index 00000000..48c3ac2c --- /dev/null +++ b/server/tests/unit/routes/integrations.erp.procurement.test.js @@ -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('参数校验失败'); + }); +});