From 855bcdffebb560adbd6323258528f682c41a72d1 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 8 Mar 2026 16:29:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(P19ERP1-3,P19ERP1-4):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?ERP=E5=87=AD=E8=AF=81=E5=AF=BC=E5=87=BA=E4=B8=8E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/integrations.ts | 96 +++ client/src/components/navigation/NavLinks.tsx | 6 + client/src/pages/ErpIntegrationPage.tsx | 191 ++++++ client/src/pages/ReimbursementDetailPage.tsx | 101 +++ client/src/router/index.tsx | 10 + server/src/jobs/integrationOutboxJob.js | 5 + server/src/routes/integrations.js | 607 ++++++++++++++---- server/src/routes/reimbursements.js | 28 +- .../src/services/integrations/dispatcher.js | 88 ++- .../src/services/integrations/erpJournal.js | 182 ++++++ server/src/services/integrations/outbox.js | 55 +- .../unit/integrations.dispatcher.test.js | 35 + .../unit/routes/integrations.erp.test.js | 22 + server/tests/unit/services/erpJournal.test.js | 80 +++ 14 files changed, 1361 insertions(+), 145 deletions(-) create mode 100644 client/src/pages/ErpIntegrationPage.tsx create mode 100644 server/src/services/integrations/erpJournal.js create mode 100644 server/tests/unit/routes/integrations.erp.test.js create mode 100644 server/tests/unit/services/erpJournal.test.js diff --git a/client/src/api/integrations.ts b/client/src/api/integrations.ts index c36c4db1..688f15c1 100644 --- a/client/src/api/integrations.ts +++ b/client/src/api/integrations.ts @@ -1,5 +1,71 @@ import { apiRequestData } from "./http"; +export type ErpMasterDataResourceType = "legal_entity" | "cost_center" | "supplier"; + +export type ErpGateStatus = { + envEnabled: boolean; + effectiveEnabled: boolean; + effectiveReason: string; + provider: string; + masterDataEndpointConfigured: boolean; + journalEndpointConfigured: boolean; + masterDataSyncCron: string | null; +}; + +export type ErpMasterDataSyncSummary = { + requested: number; + created: number; + updated: number; + archived: number; + skipped: number; + sampleSkipped: Array<{ key: string; reason: string }>; +}; + +export type ErpMasterDataSyncItem = { + resourceType: ErpMasterDataResourceType; + status: string; + lastStartedAt: string | null; + lastFinishedAt: string | null; + lastSuccessAt: string | null; + lastError: string | null; + lastResult: ErpMasterDataSyncSummary | null; +}; + +export type ErpMasterDataStatusResponse = { + gate: ErpGateStatus; + items: ErpMasterDataSyncItem[]; + triggerMode?: string; +}; + +export type ReimbursementJournalPreview = { + journal: { + journalType: string; + sourceEventName: string; + entityType: string; + entityId: string; + documentNo: string | null; + currencyCode: string; + totalAmount: string | null; + occurredAt: string | null; + paymentVoucherNo: string | null; + paymentDate: string | null; + dimensions: { + departmentId: string | null; + ownerEmployeeId: string | null; + submitterEmployeeId: string | null; + legalEntityCode: string | null; + costCenterCode: string | null; + }; + }; + exportState: { + externalId: string | null; + lastOutboxId: string | null; + lastOutboxStatus: string | null; + lastError: string | null; + exportedAt: string | null; + }; +}; + export async function triggerErpSync(reimbursementId: string, runNow = true) { return apiRequestData<{ outboxId: string; @@ -11,6 +77,36 @@ export async function triggerErpSync(reimbursementId: string, runNow = true) { }); } +export async function fetchErpMasterDataStatus() { + return apiRequestData("/api/integrations/erp/master-data/status"); +} + +export async function syncErpMasterData(payload: { resources?: ErpMasterDataResourceType[] }) { + return apiRequestData("/api/integrations/erp/master-data/sync", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function previewReimbursementJournal(reimbursementId: string) { + return apiRequestData( + `/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`, + ); +} + +export async function exportReimbursementJournal(reimbursementId: string, runNow = true) { + return apiRequestData<{ + outboxId: string; + status: string; + duplicated: boolean; + requeued: boolean; + eventType: string; + }>(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/export?runNow=${runNow ? "1" : "0"}`, { + method: "POST", + body: JSON.stringify({}), + }); +} + export async function importTravelOrders(payload: { orders: Array<{ source: string; diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 26ed533f..e9c7c2f9 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -108,6 +108,12 @@ export const NAV_ITEMS: NavItem[] = [ icon: "notifications_active", anyOf: ["notification.channel.manage"], }, + { + to: "/system/integrations/erp", + label: "ERP 集成", + icon: "hub", + anyOf: ["integration.manage"], + }, { to: "/system/approval-rules", label: "审批规则", diff --git a/client/src/pages/ErpIntegrationPage.tsx b/client/src/pages/ErpIntegrationPage.tsx new file mode 100644 index 00000000..e297ebb8 --- /dev/null +++ b/client/src/pages/ErpIntegrationPage.tsx @@ -0,0 +1,191 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + fetchErpMasterDataStatus, + syncErpMasterData, + type ErpMasterDataResourceType, + type ErpMasterDataSyncItem, +} from "../api/integrations"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { isApiError } from "../api/types"; +import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; + +const RESOURCE_LABELS: Record = { + legal_entity: "法体", + cost_center: "成本中心", + supplier: "供应商", +}; + +function resolveError(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + if (error instanceof Error) { + return error.message; + } + return "请求失败,请稍后重试。"; +} + +function formatDateTime(value: string | null) { + if (!value) return "-"; + return value.slice(0, 19).replace("T", " "); +} + +function formatGateReason(reason: string) { + if (reason === "env_hard_off") return "Env 总闸关闭"; + if (reason === "flag_force_off") return "功能开关强制关闭"; + if (reason === "department_deferred") return "部门灰度在管理端按全局集成动作放行"; + if (reason === "env_inherit_global_on") return "Env 继承开启"; + if (reason === "flag_force_on_global") return "功能开关强制开启"; + return reason; +} + +export function ErpIntegrationPage() { + const queryClient = useQueryClient(); + const [error, setError] = useState(""); + + const statusQuery = useQuery({ + queryKey: ["erp-master-data-status"], + queryFn: () => fetchErpMasterDataStatus(), + }); + + const syncMutation = useMutation({ + mutationFn: async (resources?: ErpMasterDataResourceType[]) => syncErpMasterData({ resources }), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ queryKey: ["erp-master-data-status"] }); + setError(""); + const finished = result.items + .map((item) => `${RESOURCE_LABELS[item.resourceType]}:${item.status}`) + .join(" / "); + toast.success(`主数据同步完成:${finished}`); + }, + onError: (mutationError) => { + const message = resolveError(mutationError); + setError(message); + toast.error(message); + }, + }); + + const itemsByType = useMemo(() => { + const map = new Map(); + for (const item of statusQuery.data?.items ?? []) { + map.set(item.resourceType, item); + } + return map; + }, [statusQuery.data?.items]); + + const gate = statusQuery.data?.gate ?? null; + + return ( +
+ syncMutation.mutate(undefined)} disabled={syncMutation.isPending}> + {syncMutation.isPending ? "同步中..." : "同步全部"} + + } + /> + + {error ? ( +

{error}

+ ) : null} + +
+
+

ERP 状态

+ {statusQuery.isLoading ?

加载中...

: null} + {statusQuery.isError ? ( +

{resolveError(statusQuery.error)}

+ ) : null} + {gate ? ( +
+
+
Env 总闸
+
{gate.envEnabled ? "已开启" : "已关闭"}
+
+
+
功能开关
+
{gate.effectiveEnabled ? "允许" : "阻断"}
+
{formatGateReason(gate.effectiveReason)}
+
+
+
Provider / 主数据端点
+
{gate.provider}
+
+ {gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"} +
+
+
+
凭证端点 / Cron
+
+ {gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"} +
+
{gate.masterDataSyncCron || "未启用自动同步"}
+
+
+ ) : null} +
+ + {(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => { + const item = itemsByType.get(resourceType) ?? null; + return ( +
+
+
+

{RESOURCE_LABELS[resourceType]}

+

状态:{item?.status ?? "idle"}

+
+ +
+ +
+
最近开始:{formatDateTime(item?.lastStartedAt ?? null)}
+
最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}
+
最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}
+
最近错误:{item?.lastError ?? "-"}
+
+ + {item?.lastResult ? ( +
+
+ 请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated} +
+
+ 归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped} +
+ {item.lastResult.sampleSkipped.length ? ( +
+ 示例跳过: + {item.lastResult.sampleSkipped + .map((sample) => `${sample.key}:${sample.reason}`) + .join(";")} +
+ ) : null} +
+ ) : ( +

暂无同步记录。

+ )} +
+ ); + })} +
+ +
+

说明

+

- 本页只负责 ERP 主数据同步,不承担报销凭证的手工单号输入导出。

+

- 报销付款凭证导出请前往已付款报销详情页执行,系统会按标准凭证切片通过 Outbox 异步发送。

+

- 当前阶段不包含采购、资产、盘点的真实 ERP 过账。

+
+
+ ); +} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 98c3683d..59a37559 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -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 (
@@ -312,6 +362,57 @@ export function ReimbursementDetailPage() {
) : null} + + {canManageIntegration && detail.status === "paid" ? ( +
+
+
+

ERP 导出

+

已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。

+
+ +
+ + {erpPreviewQuery.isLoading ?

正在加载凭证切片预览...

: null} + {erpPreviewQuery.isError ? ( +

+ {isApiError(erpPreviewQuery.error) + ? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage( + erpPreviewQuery.error.code, + erpPreviewQuery.error.details, + erpPreviewQuery.error.message, + )}` + : "加载 ERP 导出预览失败"} +

+ ) : null} + + {erpPreviewQuery.data ? ( +
+
+

凭证切片

+
类型:{erpPreviewQuery.data.journal.journalType}
+
单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}
+
金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}
+
法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}
+
成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}
+
付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}
+
付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}
+
+
+

导出状态

+
外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}
+
Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}
+
队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}
+
最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}
+
最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}
+
+
+ ) : null} +
+ ) : null} +
diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 91178134..4b1d5eab 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -18,6 +18,7 @@ import { LoginPage } from "../pages/LoginPage"; import { LeaveRequestPage } from "../pages/LeaveRequestPage"; import { NotificationsPage } from "../pages/NotificationsPage"; import { NotificationChannelsPage } from "../pages/NotificationChannelsPage"; +import { ErpIntegrationPage } from "../pages/ErpIntegrationPage"; import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage"; import { AssetRegistryPage } from "../pages/AssetRegistryPage"; import { InventoryChecksPage } from "../pages/InventoryChecksPage"; @@ -230,6 +231,15 @@ export function AppRouter() { } /> + + + + } + /> + { + const [items, gate] = await Promise.all([ + listMasterDataSyncStates(fastify.prisma), + resolveErpGate(fastify.prisma, fastify.config), + ]); + return sendData(reply, request, { gate, items }); + }, + ); + + fastify.post( + '/integrations/erp/master-data/sync', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '手动触发 ERP 主数据同步', + security: [{ bearerAuth: [] }], + response: { + 200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步结果'), + 400: buildErrorEnvelopeSchema({ + description: '参数校验失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + 409: buildErrorEnvelopeSchema({ + description: 'ERP 总闸关闭或同步失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + }, + }, + }, + async (request, reply) => { + const resources = normalizeResourcesInput(request.body); + const gate = await requireErpManagementGate(fastify.prisma, fastify.config); + const result = await syncErpMasterData(fastify.prisma, fastify.config, { + resources, + actorId: request.employee.id, + logger: fastify.log, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: 'ERP_MASTER_DATA_SYNC_TRIGGERED', + targetType: 'integration_master_data', + extra: { + resources, + gateReason: gate.effectiveReason, + provider: gate.provider, + ok: result.ok, + errorCode: result.error?.code ?? null, + }, + }), + }); + + if (!result.ok) { + throw new AppError({ + code: 'VALID_001', + message: '参数校验失败', + statusCode: 409, + details: { + integration: result.error?.code ?? 'erp_master_data_sync_failed', + reason: result.error?.message ?? 'erp_master_data_sync_failed', + }, + }); + } + + return sendData(reply, request, { + gate, + items: result.items.filter((item) => resources.includes(item.resourceType)), + triggerMode: 'manual', + }); + }, + ); + + fastify.get( + '/integrations/erp/journals/reimbursements/:reimbursementId/preview', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '预览报销付款凭证切片', security: [{ bearerAuth: [] }], params: { - type: "object", - required: ["reimbursementId"], + type: 'object', + required: ['reimbursementId'], properties: { - reimbursementId: { type: "string", format: "uuid" }, + reimbursementId: { type: 'string', format: 'uuid' }, + }, + }, + }, + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId); + if (!parsedId.success) { + throw new AppError({ + code: 'VALID_001', + message: '参数校验失败', + statusCode: 400, + details: { reimbursementId: 'invalid_uuid' }, + }); + } + + const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data); + ensurePaidReimbursement(reimbursement); + const preview = await buildReimbursementJournalPreview(fastify.prisma, reimbursement); + return sendData(reply, request, { + journal: preview.journal, + exportState: preview.exportState, + }); + }, + ); + + fastify.post( + '/integrations/erp/journals/reimbursements/:reimbursementId/export', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '导出报销付款凭证到 ERP', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['reimbursementId'], + properties: { + reimbursementId: { type: 'string', format: 'uuid' }, }, }, querystring: { - type: "object", + type: 'object', properties: { runNow: { - anyOf: [{ type: "string", enum: ["1", "true"] }, { type: "boolean" }], + anyOf: [ + { type: 'string', enum: ['0', '1', 'false', 'true'] }, + { type: 'boolean' }, + ], }, }, additionalProperties: true, }, response: { - 200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, "ERP 同步任务已入队"), + 200: buildDataEnvelopeSchema(ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA, 'ERP 导出任务已入队'), 400: buildErrorEnvelopeSchema({ - description: "参数校验失败", - codeExample: "VALID_001", - messageExample: "参数校验失败", + description: '参数校验失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', }), 404: buildErrorEnvelopeSchema({ - description: "报销单不存在", - codeExample: "SYS_404", - messageExample: "Not Found", + description: '报销单不存在', + codeExample: 'SYS_404', + messageExample: 'Not Found', }), 409: buildErrorEnvelopeSchema({ - description: "状态不满足或开关阻断", - codeExample: "VALID_001", - messageExample: "参数校验失败", + description: '状态不满足或开关阻断', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + }, + }, + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId); + if (!parsedId.success) { + throw new AppError({ + code: 'VALID_001', + message: '参数校验失败', + statusCode: 400, + details: { reimbursementId: 'invalid_uuid' }, + }); + } + + const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data); + ensurePaidReimbursement(reimbursement); + const gate = await requireErpManagementGate(fastify.prisma, fastify.config); + const enqueued = await enqueueReimbursementExportAndMaybeRun({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + reimbursement, + actorId: request.employee.id, + runNow: parseRunNow(request.query), + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: 'ERP_SYNC_ENQUEUED', + targetType: 'reimbursement_form', + targetId: reimbursement.id, + extra: { + outboxId: enqueued.id, + duplicated: enqueued.duplicated, + requeued: enqueued.requeued, + eventType: enqueued.eventType, + featureFlag: FEATURE_FLAG_KEYS.erpSync, + featureFlagReason: gate.effectiveReason, + }, + }), + }); + + return sendData(reply, request, { + outboxId: enqueued.id, + status: enqueued.status, + duplicated: enqueued.duplicated, + requeued: enqueued.requeued, + eventType: enqueued.eventType, + }); + }, + ); + + fastify.post( + '/integrations/erp/sync/:reimbursementId', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '手动触发 ERP 同步', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['reimbursementId'], + properties: { + reimbursementId: { type: 'string', format: 'uuid' }, + }, + }, + querystring: { + type: 'object', + properties: { + runNow: { + anyOf: [ + { type: 'string', enum: ['0', '1', 'false', 'true'] }, + { type: 'boolean' }, + ], + }, + }, + additionalProperties: true, + }, + response: { + 200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, 'ERP 同步任务已入队'), + 400: buildErrorEnvelopeSchema({ + description: '参数校验失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + 404: buildErrorEnvelopeSchema({ + description: '报销单不存在', + codeExample: 'SYS_404', + messageExample: 'Not Found', + }), + 409: buildErrorEnvelopeSchema({ + description: '状态不满足或开关阻断', + codeExample: 'VALID_001', + messageExample: '参数校验失败', detailsExample: { - integration: "feature_flag_blocked", - featureFlag: "erp_sync", - reason: "department_not_allowed", + integration: 'feature_flag_blocked', + featureFlag: 'erp_sync', + reason: 'department_not_allowed', }, }), }, }, }, async (request, reply) => { - const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId); + const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId); if (!parsedId.success) { throw new AppError({ - code: "VALID_001", - message: "参数校验失败", + code: 'VALID_001', + message: '参数校验失败', statusCode: 400, - details: { reimbursementId: "invalid_uuid" }, + details: { reimbursementId: 'invalid_uuid' }, }); } - const reimbursementId = parsedId.data; - - const reimbursement = await fastify.prisma.reimbursementForm.findUnique({ - where: { id: reimbursementId }, - select: { - id: true, - form_number: true, - owner_id: true, - submitter_id: true, - department_id: true, - status: true, - total_amount: true, - payment_voucher_no: true, - payment_date: true, - paid_at: true, - }, - }); - - if (!reimbursement) { - throw new AppError({ - code: "SYS_404", - message: "Not Found", - statusCode: 404, - }); - } - - if (reimbursement.status !== "paid") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { reimbursement: "erp_sync_requires_paid" }, - }); - } - - const erpGate = await evaluateFeatureFlag({ + const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data); + ensurePaidReimbursement(reimbursement); + const gate = await requireErpManagementGate(fastify.prisma, fastify.config); + const enqueued = await enqueueReimbursementExportAndMaybeRun({ prisma: fastify.prisma, config: fastify.config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, - strictDepartment: true, - }); - - if (!erpGate.enabled) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { - integration: "feature_flag_blocked", - featureFlag: FEATURE_FLAG_KEYS.erpSync, - reason: erpGate.reason, - }, - }); - } - - const payload = buildErpPayload({ reimbursement }); - const enqueued = await enqueueIntegrationEvent(fastify.prisma, { - eventType: "erp.reimbursement.paid", - payload, - dedupeKey: reimbursement.id, - maxAttempts: fastify.config.integrationOutboxMaxAttempts, + logger: fastify.log, + reimbursement, actorId: request.employee.id, + runNow: parseRunNow(request.query), }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, - action: "ERP_SYNC_ENQUEUED", - targetType: "reimbursement_form", - targetId: reimbursementId, + action: 'ERP_SYNC_ENQUEUED', + targetType: 'reimbursement_form', + targetId: reimbursement.id, extra: { outboxId: enqueued.id, duplicated: enqueued.duplicated, + requeued: enqueued.requeued, featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId: reimbursement.department_id ?? null, - featureFlagReason: erpGate.reason, + featureFlagReason: gate.effectiveReason, }, }), }); - const runNow = - request.query?.runNow === "1" || - request.query?.runNow === 1 || - request.query?.runNow === true || - request.query?.runNow === "true"; - if (runNow) { - await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log); - } - return sendData(reply, request, { outboxId: enqueued.id, status: enqueued.status, @@ -182,4 +554,9 @@ async function integrationsRoutes(fastify) { ); } +integrationsRoutes.__testables = { + normalizeResourcesInput, + parseRunNow, +}; + module.exports = integrationsRoutes; diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 43549119..39d944ec 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -11,8 +11,7 @@ const { const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendNotificationEmailSafe } = require("../services/email"); -const { enqueueIntegrationEvent } = require("../services/integrations/outbox"); -const { buildErpPayload } = require("../services/integrations/erp"); +const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { issueDocumentNumber } = require("../services/documentSequence"); @@ -1771,6 +1770,24 @@ async function reimbursementRoutes(fastify) { payment_voucher_no: true, payment_date: true, paid_at: true, + owner: { + select: { + legal_entity: { + select: { + code: true, + }, + }, + }, + }, + submitter: { + select: { + legal_entity: { + select: { + code: true, + }, + }, + }, + }, }, }); @@ -1786,11 +1803,8 @@ async function reimbursementRoutes(fastify) { }); if (erpGate.enabled) { - await enqueueIntegrationEvent(tx, { - eventType: "erp.reimbursement.paid", - dedupeKey: refreshed.id, - payload: buildErpPayload({ reimbursement: refreshed }), - maxAttempts: fastify.config.integrationOutboxMaxAttempts, + await enqueueReimbursementJournalExport(tx, fastify.config, { + reimbursement: refreshed, actorId: request.employee.id, }); } else { diff --git a/server/src/services/integrations/dispatcher.js b/server/src/services/integrations/dispatcher.js index c186a3c7..c3c11ee4 100644 --- a/server/src/services/integrations/dispatcher.js +++ b/server/src/services/integrations/dispatcher.js @@ -1,4 +1,4 @@ -const { pushPaidReimbursementToErp } = require('./erp'); +const { pushPaidReimbursementToErp, exportJournalToErp } = require('./erp'); const { sendInstantMessageByChannel } = require('../notifications/channels'); class IntegrationDispatchError extends Error { @@ -22,18 +22,82 @@ function toDispatchError(result) { }); } -async function dispatchOutboxEvent({ prisma, config, eventType, payload }) { - if (eventType === 'erp.reimbursement.paid') { - const result = await pushPaidReimbursementToErp(config, payload); - if (!result.ok) { - throw toDispatchError(result); - } +function extractExternalId(response) { + if (!response) { + return null; + } - return { - channel: 'erp', - code: result.code, - response: result.response ?? null, - }; + if (typeof response.externalId === 'string' && response.externalId.trim()) { + return response.externalId.trim(); + } + + if ( + typeof response.body?.externalId === 'string' && + response.body.externalId.trim() + ) { + return response.body.externalId.trim(); + } + + return null; +} + +async function upsertErpBindingForPayload(prisma, payload, response) { + const entityType = String(payload?.entityType ?? '').trim(); + const entityId = String(payload?.entityId ?? '').trim(); + if (!prisma || !entityType || !entityId) { + return; + } + + if (entityType !== 'reimbursement_form') { + return; + } + + await prisma.integrationBinding.upsert({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: 'reimbursement_journal', + local_id: entityId, + }, + }, + create: { + provider: 'erp', + entity_type: 'reimbursement_journal', + local_id: entityId, + external_id: extractExternalId(response), + external_code: payload?.documentNo ?? payload?.formNumber ?? null, + last_synced_at: new Date(), + }, + update: { + external_id: extractExternalId(response), + external_code: payload?.documentNo ?? payload?.formNumber ?? null, + last_synced_at: new Date(), + }, + }); +} + +async function dispatchErpEvent({ prisma, config, eventType, payload }) { + const result = + eventType === 'erp.journal.export' + ? await exportJournalToErp(config, payload) + : await pushPaidReimbursementToErp(config, payload); + + if (!result.ok) { + throw toDispatchError(result); + } + + await upsertErpBindingForPayload(prisma, payload, result.response ?? null); + + return { + channel: 'erp', + code: result.code, + response: result.response ?? null, + }; +} + +async function dispatchOutboxEvent({ prisma, config, eventType, payload }) { + if (eventType === 'erp.reimbursement.paid' || eventType === 'erp.journal.export') { + return dispatchErpEvent({ prisma, config, eventType, payload }); } if (eventType === 'notification.im') { diff --git a/server/src/services/integrations/erpJournal.js b/server/src/services/integrations/erpJournal.js new file mode 100644 index 00000000..466c9858 --- /dev/null +++ b/server/src/services/integrations/erpJournal.js @@ -0,0 +1,182 @@ +const { enqueueIntegrationEvent } = require('./outbox'); + +const ERP_JOURNAL_EVENT_TYPES = Object.freeze(['erp.journal.export', 'erp.reimbursement.paid']); +const REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX = 'reimbursement'; + +function toDateOnly(value) { + if (!value) { + return null; + } + try { + return new Date(value).toISOString().slice(0, 10); + } catch { + return null; + } +} + +function toIsoString(value) { + if (!value) { + return null; + } + try { + return new Date(value).toISOString(); + } catch { + return null; + } +} + +function resolveLegalEntityCode(reimbursement) { + return ( + reimbursement?.owner?.legal_entity?.code ?? + reimbursement?.submitter?.legal_entity?.code ?? + null + ); +} + +function buildReimbursementJournalPayload(input) { + const reimbursement = input?.reimbursement ?? {}; + const occurredAt = + toIsoString(reimbursement.paid_at) ?? + (reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null); + + const payload = { + journalType: 'reimbursement_paid', + sourceEventName: 'reimbursement.paid', + entityType: 'reimbursement_form', + entityId: reimbursement.id, + documentNo: reimbursement.form_number ?? null, + currencyCode: 'CNY', + totalAmount: reimbursement.total_amount?.toString?.() ?? null, + occurredAt, + paymentVoucherNo: reimbursement.payment_voucher_no ?? null, + paymentDate: toDateOnly(reimbursement.payment_date), + dimensions: { + departmentId: reimbursement.department_id ?? null, + ownerEmployeeId: reimbursement.owner_id ?? null, + submitterEmployeeId: reimbursement.submitter_id ?? null, + legalEntityCode: resolveLegalEntityCode(reimbursement), + costCenterCode: null, + }, + reimbursementId: reimbursement.id, + formNumber: reimbursement.form_number ?? null, + }; + + return payload; +} + +function buildReimbursementJournalDedupeKey(reimbursementId) { + return `${REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX}:${reimbursementId}:paid`; +} + +async function enqueueReimbursementJournalExport(prisma, config, input) { + const payload = + input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement }); + + const enqueued = await enqueueIntegrationEvent(prisma, { + eventType: 'erp.journal.export', + payload, + dedupeKey: buildReimbursementJournalDedupeKey(payload.entityId), + maxAttempts: config.integrationOutboxMaxAttempts, + actorId: input?.actorId ?? null, + requeueOnFailure: true, + }); + + return { + ...enqueued, + eventType: 'erp.journal.export', + payload, + }; +} + +function mapIntegrationBinding(record) { + if (!record) { + return null; + } + + return { + id: record.id, + provider: record.provider, + entityType: record.entity_type, + localId: record.local_id, + externalId: record.external_id ?? null, + externalCode: record.external_code ?? null, + lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, + }; +} + +function mapIntegrationOutboxState(record) { + if (!record) { + return null; + } + + return { + id: record.id, + eventType: record.event_type, + status: record.status, + lastError: record.last_error ?? null, + processedAt: record.processed_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + }; +} + +async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) { + const current = await prisma.integrationOutbox.findFirst({ + where: { + event_type: 'erp.journal.export', + dedupe_key: buildReimbursementJournalDedupeKey(reimbursementId), + }, + orderBy: { updated_at: 'desc' }, + }); + + if (current) { + return current; + } + + return prisma.integrationOutbox.findFirst({ + where: { + event_type: 'erp.reimbursement.paid', + dedupe_key: reimbursementId, + }, + orderBy: { updated_at: 'desc' }, + }); +} + +async function buildReimbursementJournalPreview(prisma, reimbursement) { + const [binding, outbox] = await Promise.all([ + prisma.integrationBinding.findUnique({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: 'reimbursement_journal', + local_id: reimbursement.id, + }, + }, + }), + findLatestReimbursementJournalOutbox(prisma, reimbursement.id), + ]); + + const journal = buildReimbursementJournalPayload({ reimbursement }); + const exportState = { + externalId: binding?.external_id ?? null, + lastOutboxId: outbox?.id ?? null, + lastOutboxStatus: outbox?.status ?? null, + lastError: outbox?.last_error ?? null, + exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null, + }; + + return { + journal, + exportState, + binding: mapIntegrationBinding(binding), + outbox: mapIntegrationOutboxState(outbox), + }; +} + +module.exports = { + ERP_JOURNAL_EVENT_TYPES, + buildReimbursementJournalDedupeKey, + buildReimbursementJournalPayload, + buildReimbursementJournalPreview, + enqueueReimbursementJournalExport, + findLatestReimbursementJournalOutbox, +}; diff --git a/server/src/services/integrations/outbox.js b/server/src/services/integrations/outbox.js index 62418f88..f7e302d4 100644 --- a/server/src/services/integrations/outbox.js +++ b/server/src/services/integrations/outbox.js @@ -1,9 +1,9 @@ const OUTBOX_STATUSES = { - pending: "pending", - processing: "processing", - succeeded: "succeeded", - failed: "failed", - dead_letter: "dead_letter", + pending: 'pending', + processing: 'processing', + succeeded: 'succeeded', + failed: 'failed', + dead_letter: 'dead_letter', }; function nextRetryAt(config, attempts) { @@ -15,7 +15,7 @@ function nextRetryAt(config, attempts) { function formatDispatchError(error) { const code = error?.code ? String(error.code) : null; const statusCode = Number.isFinite(error?.statusCode) ? Number(error.statusCode) : null; - const message = String(error?.message ?? error ?? "integration_outbox_error").trim(); + const message = String(error?.message ?? error ?? 'integration_outbox_error').trim(); const parts = []; if (code) { @@ -29,10 +29,10 @@ function formatDispatchError(error) { } if (!parts.length) { - return "integration_outbox_error"; + return 'integration_outbox_error'; } - return parts.join(" | "); + return parts.join(' | '); } async function enqueueIntegrationEvent(prisma, input) { @@ -52,10 +52,42 @@ async function enqueueIntegrationEvent(prisma, input) { : null; if (existed) { + if ( + input.requeueOnFailure && + [OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status) + ) { + const reset = await prisma.integrationOutbox.update({ + where: { id: existed.id }, + data: { + status: OUTBOX_STATUSES.pending, + payload: input.payload, + attempts: 0, + max_attempts: input.maxAttempts, + last_error: null, + next_retry_at: null, + processed_at: null, + dead_letter_at: null, + updated_by: input.actorId ?? null, + }, + select: { + id: true, + status: true, + }, + }); + + return { + id: reset.id, + status: reset.status, + duplicated: false, + requeued: true, + }; + } + return { id: existed.id, status: existed.status, duplicated: true, + requeued: false, }; } @@ -79,6 +111,7 @@ async function enqueueIntegrationEvent(prisma, input) { id: created.id, status: created.status, duplicated: false, + requeued: false, }; } @@ -93,7 +126,7 @@ async function fetchPendingOutboxEvents(prisma, options) { }, OR: [{ next_retry_at: null }, { next_retry_at: { lte: now } }], }, - orderBy: [{ created_at: "asc" }], + orderBy: [{ created_at: 'asc' }], take: batchSize, }); } @@ -128,9 +161,9 @@ async function markOutboxFailed(prisma, config, item, error, actorId, options = ); const retryable = - typeof options.retryable === "boolean" + typeof options.retryable === 'boolean' ? options.retryable - : typeof error?.retryable === "boolean" + : typeof error?.retryable === 'boolean' ? error.retryable : true; diff --git a/server/tests/unit/integrations.dispatcher.test.js b/server/tests/unit/integrations.dispatcher.test.js index 25695022..3ecf8d05 100644 --- a/server/tests/unit/integrations.dispatcher.test.js +++ b/server/tests/unit/integrations.dispatcher.test.js @@ -29,6 +29,41 @@ describe("dispatchOutboxEvent", () => { expect(result.response.externalId).toContain("BX-001"); }); + it("records integration binding for standard journal export", async () => { + const prisma = { + integrationBinding: { + upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), + }, + }; + + const result = await dispatchOutboxEvent({ + prisma, + config: { + erpSyncEnabled: true, + erpSyncProvider: 'mock', + }, + eventType: 'erp.journal.export', + payload: { + entityType: 'reimbursement_form', + entityId: 'rb-1', + documentNo: 'BX-20260308-0001', + }, + }); + + expect(result.channel).toBe('erp'); + expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: 'reimbursement_journal', + local_id: 'rb-1', + }, + }, + }), + ); + }); + it("loads IM channel and dispatches mock wecom message", async () => { const prisma = { notificationChannel: { diff --git a/server/tests/unit/routes/integrations.erp.test.js b/server/tests/unit/routes/integrations.erp.test.js new file mode 100644 index 00000000..4bb9e42e --- /dev/null +++ b/server/tests/unit/routes/integrations.erp.test.js @@ -0,0 +1,22 @@ +const integrationsRoutes = require('../../../src/routes/integrations'); + +describe('Integrations ERP route helpers', () => { + const { normalizeResourcesInput, parseRunNow } = integrationsRoutes.__testables; + + it('normalizes and orders resources', () => { + expect(normalizeResourcesInput({ resources: ['supplier', 'legal_entity', 'supplier'] })).toEqual([ + 'legal_entity', + 'supplier', + ]); + }); + + it('rejects invalid resource values', () => { + expect(() => normalizeResourcesInput({ resources: ['unknown'] })).toThrow(); + }); + + it('parses runNow truthy flags', () => { + expect(parseRunNow({ runNow: '1' })).toBe(true); + expect(parseRunNow({ runNow: true })).toBe(true); + expect(parseRunNow({ runNow: '0' })).toBe(false); + }); +}); diff --git a/server/tests/unit/services/erpJournal.test.js b/server/tests/unit/services/erpJournal.test.js new file mode 100644 index 00000000..d8f71942 --- /dev/null +++ b/server/tests/unit/services/erpJournal.test.js @@ -0,0 +1,80 @@ +const { + buildReimbursementJournalDedupeKey, + buildReimbursementJournalPayload, + enqueueReimbursementJournalExport, +} = require('../../../src/services/integrations/erpJournal'); + +describe('ERP Journal Service', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('prefers owner legal entity code and keeps cost center null', () => { + const payload = buildReimbursementJournalPayload({ + reimbursement: { + id: 'rb-1', + form_number: 'BX-001', + owner_id: 'owner-1', + submitter_id: 'submitter-1', + department_id: 'dept-1', + total_amount: { toString: () => '88.80' }, + payment_date: '2026-03-08T00:00:00.000Z', + paid_at: '2026-03-08T12:00:00.000Z', + owner: { legal_entity: { code: 'OWNER_LE' } }, + submitter: { legal_entity: { code: 'SUBMITTER_LE' } }, + }, + }); + + expect(payload.dimensions.legalEntityCode).toBe('OWNER_LE'); + expect(payload.dimensions.costCenterCode).toBeNull(); + expect(payload.currencyCode).toBe('CNY'); + }); + + it('falls back to submitter legal entity code', () => { + const payload = buildReimbursementJournalPayload({ + reimbursement: { + id: 'rb-1', + form_number: 'BX-001', + owner: { legal_entity: null }, + submitter: { legal_entity: { code: 'SUBMITTER_LE' } }, + }, + }); + + expect(payload.dimensions.legalEntityCode).toBe('SUBMITTER_LE'); + }); + + it('builds dedupe key and enqueues standard export event', async () => { + const prisma = { + integrationOutbox: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }), + }, + }; + const config = { integrationOutboxMaxAttempts: 5 }; + const reimbursement = { + id: 'rb-1', + form_number: 'BX-001', + owner_id: 'owner-1', + submitter_id: 'submitter-1', + department_id: 'dept-1', + }; + + const result = await enqueueReimbursementJournalExport(prisma, config, { + reimbursement, + actorId: 'actor-1', + }); + + expect(buildReimbursementJournalDedupeKey('rb-1')).toBe('reimbursement:rb-1:paid'); + expect(prisma.integrationOutbox.findUnique).toHaveBeenCalledWith({ + where: { + event_type_dedupe_key: { + event_type: 'erp.journal.export', + dedupe_key: 'reimbursement:rb-1:paid', + }, + }, + select: { id: true, status: true }, + }); + expect(prisma.integrationOutbox.create).toHaveBeenCalled(); + expect(result.eventType).toBe('erp.journal.export'); + }); +});