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(); }); });