const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const budgetPool = require("../../../src/services/budgetPool"); const feeControl = require("../../../src/services/feeControl"); const notifications = require("../../../src/services/notifications"); const resolveApplicableBudgetPoolByContextMock = vi.fn(); const resolveEntityPrecheckMock = vi.fn(); const createRiskFlagsFromPrecheckMock = vi.fn(); const resolveRecipientsForAssigneeMock = vi.fn(); const createNotificationsWithRecordsMock = vi.fn(async () => []); const originalResolveApplicableBudgetPoolByContext = budgetPool.resolveApplicableBudgetPoolByContext; const originalResolveEntityPrecheck = feeControl.resolveEntityPrecheck; const originalCreateRiskFlagsFromPrecheck = feeControl.createRiskFlagsFromPrecheck; const originalResolveRecipientsForAssignee = notifications.resolveRecipientsForAssignee; const originalCreateNotificationsWithRecords = notifications.createNotificationsWithRecords; budgetPool.resolveApplicableBudgetPoolByContext = resolveApplicableBudgetPoolByContextMock; feeControl.resolveEntityPrecheck = resolveEntityPrecheckMock; feeControl.createRiskFlagsFromPrecheck = createRiskFlagsFromPrecheckMock; notifications.resolveRecipientsForAssignee = resolveRecipientsForAssigneeMock; notifications.createNotificationsWithRecords = createNotificationsWithRecordsMock; delete require.cache[require.resolve("../../../src/routes/reimbursements")]; const reimbursementRoutes = require("../../../src/routes/reimbursements"); const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333"; const FINANCE_MGR_ID = "44444444-4444-4444-8444-444444444444"; function buildPrecheck(overrides = {}) { return { entityType: "reimbursement", entityId: REIMBURSEMENT_ID, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["可直接提交"], ...overrides, }; } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, roleCodes: [], permissionCodes: ["expense.submit", "expense.view_own"], }; const reimbursement = options.reimbursement ?? { id: REIMBURSEMENT_ID, owner_id: EMPLOYEE_ID, submitter_id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, reimbursement_date: new Date("2026-04-08T00:00:00.000Z"), status: "draft", form_number: "BX-20260408-001", total_amount: "180.00", }; const approvalRule = options.approvalRule ?? { id: "55555555-5555-4555-8555-555555555555", level1_role: "finance", level2_role: null, level3_role: null, owner_self_fallback_role: null, priority: 1, }; const ownerRoles = options.ownerRoles ?? ["employee"]; const ownerManagerId = Object.prototype.hasOwnProperty.call(options, "ownerManagerId") ? options.ownerManagerId : null; const fallbackHasHolder = options.fallbackHasHolder ?? true; const state = { workflowInstanceCreatePayload: null, workflowTaskCreatePayload: null, }; const tx = { $queryRaw: vi.fn(async () => [reimbursement]), workflowInstance: { findFirst: vi.fn(async () => null), create: vi.fn(async ({ data, select }) => { state.workflowInstanceCreatePayload = data; return { id: "44444444-4444-4444-8444-444444444444", current_step_id: data.current_step_id, ...(select?.current_step_id ? {} : {}), }; }), }, invoicePool: { count: vi.fn(async ({ where } = {}) => ( where?.linked_item_id === null ? (options.unlinkedBoundInvoiceCount ?? 0) : (options.boundInvoiceCount ?? 1) )), }, reimbursementItem: { findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)), findMany: vi.fn(async () => (options.reimbursementItems ?? [ { id: "item-1", amount: reimbursement.total_amount }, ]).map((item) => ({ asset_prior_approval_basis: null, expense_type: { code: "office_supplies", category: "office", requires_invoice: true }, invoice_pool_items: [{ id: "invoice-1", total_amount: item.amount }], ...item, }))), aggregate: vi.fn(async () => ({ _sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount }, })), }, budgetLedgerEntry: { create: vi.fn(async () => ({ id: "budget-ledger-1" })), }, budgetPool: { update: vi.fn(async () => ({ id: "budget-pool-1" })), }, approvalRule: { findFirst: vi.fn(async () => approvalRule), }, employee: { findUnique: vi.fn(async ({ where }) => { if (where.id === reimbursement.owner_id) { return { id: reimbursement.owner_id, direct_manager_id: ownerManagerId, roles: ownerRoles.map((role_code) => ({ role_code })), }; } if (where.id === ownerManagerId) { return { id: ownerManagerId, direct_manager_id: null, roles: [{ role_code: "manager" }], }; } return null; }), }, employeeRole: { findFirst: vi.fn(async () => ( fallbackHasHolder ? { employee_id: FINANCE_MGR_ID } : null )), }, workflowDefinition: { findFirst: vi.fn(async () => ({ id: "66666666-6666-4666-8666-666666666666", })), }, reimbursementForm: { update: vi.fn(async () => ({ id: reimbursement.id, })), }, workflowTask: { create: vi.fn(async ({ data }) => { state.workflowTaskCreatePayload = data; return { id: "77777777-7777-4777-8777-777777777777", assignee_id: data.assignee_id, assignee_role: data.assignee_role, }; }), }, workflowHistory: { create: vi.fn(async () => ({ id: "history-1" })), }, auditLog: { create: vi.fn(async () => ({ id: "audit-1" })), }, featureFlag: { findUnique: vi.fn(async () => options.invoiceGuardEnabled ? { id: "88888888-8888-4888-8888-888888888888", flag_key: "reimbursement_invoice_guard_v1", override_mode: "force_on", rollout_type: "global", department_ids: [], note: "test", version: 1, created_at: new Date(), updated_at: new Date(), } : null), }, }; const prisma = { $transaction: vi.fn(async (callback) => callback(tx)), reimbursementForm: { findUnique: vi.fn(async () => ({ id: reimbursement.id, owner_id: reimbursement.owner_id, submitter_id: reimbursement.submitter_id, form_number: reimbursement.form_number, total_amount: reimbursement.total_amount, owner: { id: EMPLOYEE_ID, name: "申请人", }, })), }, }; app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", budgetControlMode: "warn", imNotifierEnabled: false, emailEnabled: false, reimbursementInvoiceGuardEnabled: options.invoiceGuardEnabled ?? false, }); app.decorate("hasPermission", (employee, permissionCode) => { const permissionCodes = employee?.permissionCodes ?? []; return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; const permissionCodes = actor.permissionCodes ?? []; if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } }); app.setErrorHandler((error, request, reply) => { reply.status(error.statusCode || 500).send({ requestId: request.id, error: { code: error.code || "SYS_001", message: error.message, details: error.details ?? null, }, }); }); await app.register(reimbursementRoutes, { prefix: "/api" }); return { app, prisma, tx, state }; } describe("reimbursements routes expense standard integration", () => { afterAll(() => { budgetPool.resolveApplicableBudgetPoolByContext = originalResolveApplicableBudgetPoolByContext; feeControl.resolveEntityPrecheck = originalResolveEntityPrecheck; feeControl.createRiskFlagsFromPrecheck = originalCreateRiskFlagsFromPrecheck; notifications.resolveRecipientsForAssignee = originalResolveRecipientsForAssignee; notifications.createNotificationsWithRecords = originalCreateNotificationsWithRecords; }); beforeEach(() => { vi.clearAllMocks(); resolveApplicableBudgetPoolByContextMock.mockResolvedValue(null); createRiskFlagsFromPrecheckMock.mockResolvedValue([]); resolveRecipientsForAssigneeMock.mockResolvedValue([]); }); it("rejects an admin view-all nonparticipant before creating submission side effects", async () => { const { app, prisma, tx } = await buildApp({ actor: { id: "finance-viewer", department_id: DEPARTMENT_ID, roleCodes: ["admin"], permissionCodes: ["expense.submit", "expense.view_all"], }, }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, }); expect(response.statusCode).toBe(403); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); expect(tx.workflowTask.create).not.toHaveBeenCalled(); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(prisma.reimbursementForm.findUnique).not.toHaveBeenCalled(); await app.close(); }); it("blocks travel reimbursement submit until the structured summary is complete", async () => { const { app, tx } = await buildApp({ travelItem: true }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual(expect.objectContaining({ travelSummary: "incomplete", missingFields: ["travelPurpose", "travelLocation", "travelStartDate", "travelEndDate"], })); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); it("rejects submit when the reimbursement has no items", async () => { const { app, tx } = await buildApp({ reimbursementItems: [] }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ items: "at_least_one_required" }); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); await app.close(); }); it("rejects submit and identifies items with non-positive amounts", async () => { const { app, tx } = await buildApp({ reimbursementItems: [ { id: "item-zero", amount: "0.00" }, { id: "item-valid", amount: "20.00" }, ], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ items: "positive_amount_required", itemIds: ["item-zero"], }); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); it("allows assets items without an approval basis and continues submission", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app, tx } = await buildApp({ reimbursementItems: [{ id: "asset-item", amount: "2200.00", expense_type: { code: "assets_office_electronics", category: "assets", requires_invoice: true }, }], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode, response.body).toBe(200); expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1); await app.close(); }); it("skips invoice amount checks for invoice-free allowance items when the guard is on", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app } = await buildApp({ invoiceGuardEnabled: true, boundInvoiceCount: 0, reimbursementItems: [{ id: "allowance-item", amount: "180.00", expense_type: { code: "travel_meal", category: "travel", requires_invoice: false }, invoice_pool_items: [], }], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(200); await app.close(); }); it("allows an invoice-free allowance-only reimbursement when the guard is off", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app } = await buildApp({ invoiceGuardEnabled: false, boundInvoiceCount: 0, reimbursementItems: [{ id: "allowance-item", amount: "180.00", expense_type: { code: "travel_meal", category: "travel", requires_invoice: false }, invoice_pool_items: [], }], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(200); await app.close(); }); it("keeps the legacy whole-form invoice requirement for mixed or required items when the guard is off", async () => { const { app, tx } = await buildApp({ invoiceGuardEnabled: false, boundInvoiceCount: 0, reimbursementItems: [ { id: "allowance-item", amount: "180.00", expense_type: { code: "travel_meal", category: "travel", requires_invoice: false }, invoice_pool_items: [], }, { id: "required-item", amount: "100.00", expense_type: { code: "office_supplies", category: "office", requires_invoice: true }, invoice_pool_items: [], }, ], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ invoices: "bound_invoice_required" }); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); it("blocks required-invoice items without linked invoices when the guard is on", async () => { const { app, tx } = await buildApp({ invoiceGuardEnabled: true, reimbursementItems: [{ id: "missing-invoice", amount: "180.00", invoice_pool_items: [] }], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual(expect.objectContaining({ items: "invoice_item_guard_failed", missingInvoiceItemIds: ["missing-invoice"], })); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); it("blocks bound invoices that have no explicit item link when the guard is on", async () => { const { app, tx } = await buildApp({ invoiceGuardEnabled: true, unlinkedBoundInvoiceCount: 1, }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual(expect.objectContaining({ items: "invoice_item_guard_failed", unlinkedInvoiceCount: 1, })); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); it("blocks declared amounts above the linked invoice total when the guard is on", async () => { const { app, tx } = await buildApp({ invoiceGuardEnabled: true, reimbursementItems: [{ id: "amount-over", amount: "180.00", invoice_pool_items: [{ id: "invoice-low", total_amount: "120.00" }], }], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(400); expect(response.json().error.details.amountExceededItems).toEqual([{ itemId: "amount-over", declaredAmount: "180.00", invoiceTotal: "120.00", }]); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); it("recomputes a stale total before selecting the approval rule", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app, tx } = await buildApp({ authoritativeTotal: "320.00" }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); expect(response.statusCode).toBe(200); expect(tx.reimbursementForm.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ total_amount: "320.00" }), })); expect(tx.approvalRule.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ min_amount: { lte: "320.00" } }), })); await app.close(); }); it("returns standardChecks from reimbursement precheck", async () => { resolveEntityPrecheckMock.mockResolvedValue( buildPrecheck({ decision: "warn", standardChecks: [ { itemId: "item-1", expenseTypeId: "88888888-8888-4888-8888-888888888888", expenseTypeName: "交通费", amount: "180.00", standardId: "99999999-9999-4999-8999-999999999999", standardName: "交通费上限", maxAmount: "120.00", perUnit: "item", controlMode: "warn", scopeType: "global", scopeKey: null, exceeded: true, exceededAmount: "60.00", }, ], }), ); const { app } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`, }); expect(response.statusCode).toBe(200); expect(response.json().data.standardChecks).toEqual([ expect.objectContaining({ itemId: "item-1", controlMode: "warn", exceededAmount: "60.00", }), ]); expect(resolveEntityPrecheckMock).toHaveBeenCalledWith( expect.objectContaining({ entityType: "reimbursement", entityId: REIMBURSEMENT_ID, actorDepartmentId: DEPARTMENT_ID, }), ); await app.close(); }); it("rejects an ordinary nonparticipant before resolving reimbursement precheck", async () => { const { app } = await buildApp({ actor: { id: "ordinary-nonparticipant", department_id: DEPARTMENT_ID, roleCodes: ["employee"], permissionCodes: ["expense.submit"], }, }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`, }); expect(response.statusCode).toBe(403); expect(resolveEntityPrecheckMock).not.toHaveBeenCalled(); await app.close(); }); it("allows a view-all nonparticipant to read reimbursement precheck", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app } = await buildApp({ actor: { id: "finance-viewer", department_id: DEPARTMENT_ID, roleCodes: ["finance"], permissionCodes: ["expense.submit", "expense.view_all"], }, }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`, }); expect(response.statusCode).toBe(200); expect(resolveEntityPrecheckMock).toHaveBeenCalledTimes(1); await app.close(); }); it("blocks submit with 400 and standardViolations when block standard is exceeded", async () => { resolveEntityPrecheckMock.mockResolvedValue( buildPrecheck({ decision: "block", blockingReasons: [ { code: "expense_standard_blocked", title: "费用标准超限", detail: "交通费超出上限", }, ], standardChecks: [ { itemId: "item-block", expenseTypeId: "88888888-8888-4888-8888-888888888888", expenseTypeName: "交通费", amount: "260.00", standardId: "99999999-9999-4999-8999-999999999999", standardName: "交通费阻断上限", maxAmount: "120.00", perUnit: "item", controlMode: "block", scopeType: "global", scopeKey: null, exceeded: true, exceededAmount: "140.00", }, ], }), ); const { app, tx } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, }); expect(response.statusCode).toBe(400); expect(response.json().error).toEqual( expect.objectContaining({ code: "VALID_001", details: expect.objectContaining({ standardViolations: [ expect.objectContaining({ itemId: "item-block", controlMode: "block", }), ], }), }), ); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); it("allows warn submit and persists standardViolations into workflow context", async () => { resolveEntityPrecheckMock.mockResolvedValue( buildPrecheck({ decision: "warn", warnings: [ { code: "expense_standard_warning", title: "费用标准预警", detail: "交通费超出上限", }, ], standardChecks: [ { itemId: "item-warn", expenseTypeId: "88888888-8888-4888-8888-888888888888", expenseTypeName: "交通费", amount: "180.00", standardId: "99999999-9999-4999-8999-999999999999", standardName: "交通费预警上限", maxAmount: "120.00", perUnit: "item", controlMode: "warn", scopeType: "department", scopeKey: DEPARTMENT_ID, exceeded: true, exceededAmount: "60.00", }, ], }), ); const { app, state } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ reimbursementId: REIMBURSEMENT_ID, status: "reviewing", precheckDecision: "warn", standardWarnings: [ expect.objectContaining({ itemId: "item-warn", controlMode: "warn", }), ], }), ); expect(state.workflowInstanceCreatePayload?.context_data?.standardViolations).toEqual([ expect.objectContaining({ itemId: "item-warn", exceededAmount: "60.00", }), ]); await app.close(); }); it("uses one finance fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app, state } = await buildApp({ ownerRoles: ["ceo"], ownerManagerId: null, approvalRule: { id: "55555555-5555-4555-8555-555555555555", level1_role: "manager", level2_role: "finance", level3_role: null, owner_self_fallback_role: "finance", priority: 1, }, }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, }); expect(response.statusCode).toBe(200); expect(state.workflowTaskCreatePayload).toEqual( expect.objectContaining({ assignee_id: null, assignee_role: "finance", }), ); expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback"); expect( state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map( (step) => step.assignee_rule, ), ).toEqual([{ type: "role", value: "finance" }]); await app.close(); }); it("blocks CEO owner submit when owner fallback has no active non-self holder", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app, tx } = await buildApp({ ownerRoles: ["ceo"], ownerManagerId: null, fallbackHasHolder: false, approvalRule: { id: "55555555-5555-4555-8555-555555555555", level1_role: "manager", level2_role: "finance", level3_role: null, owner_self_fallback_role: "finance", priority: 1, }, }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual( expect.objectContaining({ workflow: "no_actionable_approver", reason: "owner_self_fallback_role_unavailable", fallbackRole: "finance", }), ); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); await app.close(); }); });