From 300420aa25e57669feda9f752229440db01a3ff6 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 29 Apr 2026 10:55:55 +0800 Subject: [PATCH] =?UTF-8?q?R78=20A2=EF=BC=9A=E8=A1=A5=E9=BD=90=E6=8A=A5?= =?UTF-8?q?=E9=94=80=E6=94=AF=E4=BB=98=E6=89=B9=E6=AC=A1=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E5=90=8E=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/scripts/regression-payment-batches.sh | 10 + server/src/routes/paymentBatches.js | 153 +++++- .../paymentBatches.reimbursement.test.js | 446 ++++++++++++++++++ 3 files changed, 608 insertions(+), 1 deletion(-) create mode 100755 server/scripts/regression-payment-batches.sh create mode 100644 server/tests/unit/routes/paymentBatches.reimbursement.test.js diff --git a/server/scripts/regression-payment-batches.sh b/server/scripts/regression-payment-batches.sh new file mode 100755 index 00000000..bc2c1b13 --- /dev/null +++ b/server/scripts/regression-payment-batches.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +npm run prisma:validate +node --check src/routes/paymentBatches.js +node --check src/routes/reimbursements.js +npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index 86438a8b..89493886 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -5,6 +5,11 @@ const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString } = require("../utils/date"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { createNotificationsWithRecords } = require("../services/notifications"); +const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); +const { sendNotificationEmailSafe } = require("../services/email"); +const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); +const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl"); const UUID_SCHEMA = z.string().uuid(); @@ -371,6 +376,140 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index }) return { idempotent: false }; } +async function fetchPaidReimbursementForFollowUp(prisma, reimbursementId) { + return prisma.reimbursementForm.findUnique({ + where: { id: reimbursementId }, + select: { + id: true, + status: true, + form_number: true, + owner_id: true, + submitter_id: true, + department_id: true, + total_amount: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + owner: { + select: { + legal_entity: { + select: { + code: true, + }, + }, + }, + }, + submitter: { + select: { + legal_entity: { + select: { + code: true, + }, + }, + }, + }, + }, + }); +} + +async function processPaidReimbursementFollowUps({ fastify, request, reimbursementId }) { + const reimbursement = await fetchPaidReimbursementForFollowUp(fastify.prisma, reimbursementId); + if (!reimbursement || reimbursement.status !== "paid") { + return; + } + + const departmentId = reimbursement.department_id ?? request.employee.department_id ?? null; + const title = `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`; + const body = `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`; + const targetPath = `/reimbursements/${reimbursementId}`; + + try { + const erpGate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.erpSync, + departmentId, + strictDepartment: true, + }); + + if (erpGate.enabled) { + await enqueueReimbursementJournalExport(fastify.prisma, fastify.config, { + reimbursement, + actorId: request.employee.id, + }); + } else { + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", + targetType: "reimbursement_form", + targetId: reimbursement.id, + extra: { + featureFlag: FEATURE_FLAG_KEYS.erpSync, + departmentId, + reason: erpGate.reason, + source: "payment_batch", + }, + }), + }); + } + } catch (error) { + fastify.log.error( + { err: error, reimbursementId }, + "Failed to enqueue reimbursement ERP follow-up after payment batch execution", + ); + } + + try { + const recipients = Array.from(new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean))); + const notifications = await createNotificationsWithRecords(fastify.prisma, { + recipientEmployeeIds: recipients, + type: NOTIFICATION_TYPES.reimbursementPaid, + title, + body, + targetType: "reimbursement_form", + targetId: reimbursementId, + }); + + for (const recipientEmployeeId of recipients) { + void sendNotificationEmailSafe({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + type: NOTIFICATION_TYPES.reimbursementPaid, + recipientEmployeeIds: [recipientEmployeeId], + subject: title, + body, + targetPath, + }); + } + + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: [{ + notifications, + type: NOTIFICATION_TYPES.reimbursementPaid, + title, + body, + targetType: "reimbursement_form", + targetId: reimbursementId, + targetPath, + actorId: request.employee.id, + }], + }); + } catch (error) { + fastify.log.error( + { err: error, reimbursementId }, + "Failed to dispatch reimbursement paid notifications after payment batch execution", + ); + } +} + async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) { const current = await tx.travelOrder.findUnique({ where: { id: item.entity_id }, @@ -680,6 +819,7 @@ async function paymentBatchesRoutes(fastify) { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const batchId = parseId(request.params?.id); + const successfulReimbursementIds = []; const result = await fastify.prisma.$transaction(async (tx) => { const batch = await loadBatchWithItems(tx, batchId); @@ -702,7 +842,10 @@ async function paymentBatchesRoutes(fastify) { try { if (batch.entity_type === "reimbursement") { - await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index }); + const paymentResult = await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index }); + if (!paymentResult.idempotent) { + successfulReimbursementIds.push(item.entity_id); + } } else { await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index }); } @@ -780,6 +923,14 @@ async function paymentBatchesRoutes(fastify) { return refreshed; }); + for (const reimbursementId of new Set(successfulReimbursementIds)) { + await processPaidReimbursementFollowUps({ + fastify, + request, + reimbursementId, + }); + } + const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result); return sendData(reply, request, { item: mapBatch(result, openRiskFlags) }); }); diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js new file mode 100644 index 00000000..820255c1 --- /dev/null +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -0,0 +1,446 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const featureFlags = require("../../../src/services/featureFlags"); +const notifications = require("../../../src/services/notifications"); +const email = require("../../../src/services/email"); +const notificationDispatcher = require("../../../src/services/notificationDispatcher"); +const erpJournal = require("../../../src/services/integrations/erpJournal"); + +const evaluateFeatureFlagMock = vi.fn(); +const createNotificationsWithRecordsMock = vi.fn(async () => []); +const sendNotificationEmailSafeMock = vi.fn(async () => undefined); +const flushNotificationDispatchTasksMock = vi.fn(async () => undefined); +const enqueueReimbursementJournalExportMock = vi.fn(async () => ({ + id: "outbox-1", + status: "pending", + duplicated: false, + requeued: false, +})); + +const originalEvaluateFeatureFlag = featureFlags.evaluateFeatureFlag; +const originalCreateNotificationsWithRecords = notifications.createNotificationsWithRecords; +const originalSendNotificationEmailSafe = email.sendNotificationEmailSafe; +const originalFlushNotificationDispatchTasks = notificationDispatcher.flushNotificationDispatchTasks; +const originalEnqueueReimbursementJournalExport = erpJournal.enqueueReimbursementJournalExport; + +featureFlags.evaluateFeatureFlag = evaluateFeatureFlagMock; +notifications.createNotificationsWithRecords = createNotificationsWithRecordsMock; +email.sendNotificationEmailSafe = sendNotificationEmailSafeMock; +notificationDispatcher.flushNotificationDispatchTasks = flushNotificationDispatchTasksMock; +erpJournal.enqueueReimbursementJournalExport = enqueueReimbursementJournalExportMock; + +delete require.cache[require.resolve("../../../src/routes/paymentBatches")]; +const paymentBatchesRoutes = require("../../../src/routes/paymentBatches"); + +const ACTOR_ID = "11111111-1111-4111-8111-111111111111"; +const MAKER_ID = "22222222-2222-4222-8222-222222222222"; +const REVIEWER_ID = "33333333-3333-4333-8333-333333333333"; +const REIMBURSEMENT_ID = "44444444-4444-4444-8444-444444444444"; +const BATCH_ID = "55555555-5555-4555-8555-555555555555"; +const ITEM_ID = "66666666-6666-4666-8666-666666666666"; +const OWNER_ID = "77777777-7777-4777-8777-777777777777"; +const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888"; + +function buildPaymentBatch(overrides = {}) { + return { + id: BATCH_ID, + batch_no: "PB-20260429-001", + entity_type: "reimbursement", + name: "报销付款批次", + status: "reviewed", + default_payment_date: new Date("2026-04-29T00:00:00.000Z"), + default_payment_channel: "bank", + voucher_prefix: "PBX", + created_by: MAKER_ID, + reviewed_at: new Date("2026-04-29T08:00:00.000Z"), + reviewed_by: REVIEWER_ID, + secondary_reviewed_at: null, + secondary_reviewed_by: null, + executed_at: null, + executed_by: null, + cancelled_at: null, + cancelled_by: null, + review_comment: null, + secondary_review_comment: null, + execution_summary_json: null, + created_at: new Date("2026-04-29T07:00:00.000Z"), + updated_by: MAKER_ID, + items: [ + { + id: ITEM_ID, + payment_batch_id: BATCH_ID, + entity_id: REIMBURSEMENT_ID, + entity_code: "BX-20260429-0001", + entity_title: "研发报销", + amount: "1683.00", + currency_code: "CNY", + status: "pending", + payment_voucher_no_override: null, + payment_date_override: null, + payment_channel_override: null, + failure_code: null, + failure_message: null, + snapshot_json: {}, + created_at: new Date("2026-04-29T07:00:00.000Z"), + }, + ], + ...overrides, + }; +} + +function buildReimbursement(overrides = {}) { + return { + id: REIMBURSEMENT_ID, + status: "approved", + form_number: "BX-20260429-0001", + owner_id: OWNER_ID, + submitter_id: SUBMITTER_ID, + department_id: "dept-1", + total_amount: "1683.00", + payment_voucher_no: null, + payment_date: null, + payment_channel: null, + paid_at: null, + paid_by: null, + owner: { + legal_entity: { + code: "HYB", + }, + }, + submitter: { + legal_entity: { + code: "HYB", + }, + }, + ...overrides, + }; +} + +function buildPrisma(options = {}) { + const state = { + batch: buildPaymentBatch(options.batch), + reimbursement: buildReimbursement(options.reimbursement), + invoice: { + linked_reimbursement_id: REIMBURSEMENT_ID, + status: "bound", + linked_item_id: "invoice-item-1", + archived_reason: null, + archived_at: null, + updated_by: null, + ...(options.invoice ?? {}), + }, + }; + + const tx = { + paymentBatch: { + findUnique: vi.fn(async ({ where }) => { + if (where.id !== state.batch.id) { + return null; + } + return { + ...state.batch, + items: state.batch.items.map((item) => ({ ...item })), + }; + }), + update: vi.fn(async ({ where, data }) => { + if (where.id !== state.batch.id) { + throw new Error("batch not found"); + } + state.batch = { + ...state.batch, + ...data, + }; + return { + ...state.batch, + items: state.batch.items.map((item) => ({ ...item })), + }; + }), + }, + paymentBatchItem: { + update: vi.fn(async ({ where, data }) => { + const item = state.batch.items.find((entry) => entry.id === where.id); + if (!item) { + throw new Error("item not found"); + } + Object.assign(item, data); + return { ...item }; + }), + }, + reimbursementForm: { + findUnique: vi.fn(async ({ where }) => { + if (where.id !== state.reimbursement.id) { + return null; + } + return { ...state.reimbursement }; + }), + update: vi.fn(async ({ where, data }) => { + if (where.id !== state.reimbursement.id) { + throw new Error("reimbursement not found"); + } + state.reimbursement = { + ...state.reimbursement, + ...data, + }; + return { ...state.reimbursement }; + }), + }, + invoicePool: { + updateMany: vi.fn(async ({ where, data }) => { + const matches = + state.invoice.linked_reimbursement_id === where.linked_reimbursement_id && + state.invoice.status === where.status; + if (!matches) { + return { count: 0 }; + } + state.invoice = { + ...state.invoice, + ...data, + }; + return { count: 1 }; + }), + }, + entityRiskFlag: { + findMany: vi.fn(async () => []), + findFirst: vi.fn(async () => null), + updateMany: vi.fn(async () => ({ count: 0 })), + create: vi.fn(async ({ data }) => ({ id: "risk-1", ...data })), + }, + auditLog: { + create: vi.fn(async () => ({ id: "audit-1" })), + }, + }; + + const prisma = { + ...tx, + $transaction: vi.fn(async (callback) => callback(tx)), + }; + + return { prisma, tx, state }; +} + +async function buildApp(options = {}) { + const { prisma, tx, state } = buildPrisma(options); + const app = Fastify(); + const actor = options.actor ?? { + id: ACTOR_ID, + department_id: "dept-1", + permissionCodes: ["payment.batch.manage", "risk.review.manage"], + }; + const paymentBatchEnabled = options.paymentBatchEnabled ?? true; + const erpSyncEnabled = options.erpSyncEnabled ?? true; + + evaluateFeatureFlagMock.mockImplementation(async ({ flagKey }) => { + if (flagKey === featureFlags.FEATURE_FLAG_KEYS.paymentBatch) { + return { + flagKey, + enabled: paymentBatchEnabled, + reason: paymentBatchEnabled ? "mock_on" : "mock_off", + envEnabled: paymentBatchEnabled, + matchedDepartmentId: null, + requiresDepartment: false, + }; + } + if (flagKey === featureFlags.FEATURE_FLAG_KEYS.erpSync) { + return { + flagKey, + enabled: erpSyncEnabled, + reason: erpSyncEnabled ? "mock_on" : "mock_off", + envEnabled: erpSyncEnabled, + matchedDepartmentId: null, + requiresDepartment: false, + }; + } + return { + flagKey, + enabled: false, + reason: "mock_off", + envEnabled: false, + matchedDepartmentId: null, + requiresDepartment: false, + }; + }); + + app.decorate("prisma", prisma); + app.decorate("config", { + paymentBatchEnabled: true, + erpSyncEnabled: true, + integrationOutboxMaxAttempts: 5, + imNotifierEnabled: false, + appOrigin: "http://127.0.0.1:3001", + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = actor; + }); + app.decorate("hasPermission", (employee, permissionCode) => + (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode), + ); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(paymentBatchesRoutes, { prefix: "/api" }); + + return { app, prisma, tx, state }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +afterAll(() => { + featureFlags.evaluateFeatureFlag = originalEvaluateFeatureFlag; + notifications.createNotificationsWithRecords = originalCreateNotificationsWithRecords; + email.sendNotificationEmailSafe = originalSendNotificationEmailSafe; + notificationDispatcher.flushNotificationDispatchTasks = originalFlushNotificationDispatchTasks; + erpJournal.enqueueReimbursementJournalExport = originalEnqueueReimbursementJournalExport; +}); + +describe("payment batch reimbursement routes", () => { + it("executes reimbursement batch payment and queues downstream follow-ups", async () => { + createNotificationsWithRecordsMock.mockResolvedValueOnce([ + { id: "notif-1", recipient_employee_id: OWNER_ID }, + { id: "notif-2", recipient_employee_id: SUBMITTER_ID }, + ]); + + const { app, tx, state } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: `/api/payment-batches/${BATCH_ID}/execute`, + headers: { authorization: "Bearer test" }, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.item.status).toBe("executed"); + expect(state.batch.status).toBe("executed"); + expect(state.batch.items[0].status).toBe("executed"); + expect(state.reimbursement.status).toBe("paid"); + expect(state.reimbursement.payment_voucher_no).toBe("PBX-001"); + expect(state.reimbursement.payment_channel).toBe("bank"); + expect(state.invoice.status).toBe("archived"); + expect(state.invoice.archived_reason).toBe("used_complete"); + expect(tx.invoicePool.updateMany).toHaveBeenCalledWith({ + where: { + linked_reimbursement_id: REIMBURSEMENT_ID, + status: "bound", + }, + data: expect.objectContaining({ + status: "archived", + archived_reason: "used_complete", + updated_by: ACTOR_ID, + }), + }); + expect(enqueueReimbursementJournalExportMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + paymentBatchEnabled: true, + erpSyncEnabled: true, + }), + expect.objectContaining({ + actorId: ACTOR_ID, + reimbursement: expect.objectContaining({ + id: REIMBURSEMENT_ID, + status: "paid", + }), + }), + ); + expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + recipientEmployeeIds: [OWNER_ID, SUBMITTER_ID], + targetId: REIMBURSEMENT_ID, + }), + ); + expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith( + expect.objectContaining({ + tasks: expect.arrayContaining([ + expect.objectContaining({ + targetId: REIMBURSEMENT_ID, + type: "reimbursement.paid", + }), + ]), + }), + ); + + await app.close(); + }); + + it("rejects first review when maker tries to review own batch", async () => { + const { app } = await buildApp({ + actor: { + id: MAKER_ID, + department_id: "dept-1", + permissionCodes: ["risk.review.manage"], + }, + batch: { + status: "draft", + reviewed_at: null, + reviewed_by: null, + }, + }); + + const response = await app.inject({ + method: "POST", + url: `/api/payment-batches/${BATCH_ID}/review`, + headers: { authorization: "Bearer test" }, + payload: {}, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details.review).toBe("maker_checker_conflict"); + + await app.close(); + }); + + it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => { + const { app, state } = await buildApp({ + reimbursement: { + status: "reviewing", + }, + }); + + const response = await app.inject({ + method: "POST", + url: `/api/payment-batches/${BATCH_ID}/execute`, + headers: { authorization: "Bearer test" }, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.item.status).toBe("failed"); + expect(state.batch.status).toBe("failed"); + expect(state.batch.items[0].status).toBe("failed"); + expect(state.batch.items[0].failure_code).toBe("VALID_001"); + expect(state.reimbursement.status).toBe("reviewing"); + expect(state.invoice.status).toBe("bound"); + expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); + expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); + + await app.close(); + }); +});