const { checkExpenseStandards, mergeReimbursementPrecheck, } = require("../../../src/services/feeControl"); const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222"; const OTHER_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333"; const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444"; function buildTx(options = {}) { return { reimbursementForm: { findUnique: vi.fn().mockResolvedValue( options.reimbursement ?? { id: REIMBURSEMENT_ID, department_id: DEPARTMENT_ID, items: [ { id: "item-1", expense_type_id: EXPENSE_TYPE_ID, amount: "150.00", expense_type: { id: EXPENSE_TYPE_ID, name: "交通费", }, }, ], }, ), }, expenseStandard: { findMany: vi.fn().mockResolvedValue( options.standards ?? [ { id: "standard-global", expense_type_id: EXPENSE_TYPE_ID, name: "交通费全局上限", max_amount: "120.00", per_unit: "item", control_mode: "warn", scope_type: "global", scope_key: null, created_at: new Date("2026-04-08T09:00:00.000Z"), }, ], ), }, }; } describe("checkExpenseStandards", () => { it("prefers department scoped standard over global standard", async () => { const tx = buildTx({ standards: [ { id: "standard-global", expense_type_id: EXPENSE_TYPE_ID, name: "交通费全局上限", max_amount: "120.00", per_unit: "item", control_mode: "warn", scope_type: "global", scope_key: null, created_at: new Date("2026-04-08T09:00:00.000Z"), }, { id: "standard-department", expense_type_id: EXPENSE_TYPE_ID, name: "交通费部门上限", max_amount: "100.00", per_unit: "item", control_mode: "block", scope_type: "department", scope_key: DEPARTMENT_ID, created_at: new Date("2026-04-08T10:00:00.000Z"), }, ], }); const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); expect(result).toEqual([ expect.objectContaining({ standardId: "standard-department", standardName: "交通费部门上限", controlMode: "block", exceeded: true, exceededAmount: "50.00", }), ]); }); it("returns warn level check without blocking when amount only exceeds warn standard", async () => { const tx = buildTx(); const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); const merged = mergeReimbursementPrecheck( { entityType: "reimbursement", entityId: REIMBURSEMENT_ID, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["可直接提交"], }, result, ); expect(result[0]).toMatchObject({ controlMode: "warn", exceeded: true, }); expect(merged.decision).toBe("warn"); expect(merged.warnings).toHaveLength(1); expect(merged.blockingReasons).toHaveLength(0); }); it("turns decision into block when exceeded item hits block standard", async () => { const tx = buildTx({ standards: [ { id: "standard-block", expense_type_id: EXPENSE_TYPE_ID, name: "交通费阻断上限", max_amount: "120.00", per_unit: "item", control_mode: "block", scope_type: "global", scope_key: null, created_at: new Date("2026-04-08T09:00:00.000Z"), }, ], }); const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); const merged = mergeReimbursementPrecheck( { entityType: "reimbursement", entityId: REIMBURSEMENT_ID, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["可直接提交"], }, result, ); expect(result[0].controlMode).toBe("block"); expect(merged.decision).toBe("block"); expect(merged.blockingReasons).toHaveLength(1); }); it("skips items that do not match any standard", async () => { const tx = buildTx({ reimbursement: { id: REIMBURSEMENT_ID, department_id: DEPARTMENT_ID, items: [ { id: "item-2", expense_type_id: OTHER_EXPENSE_TYPE_ID, amount: "88.00", expense_type: { id: OTHER_EXPENSE_TYPE_ID, name: "办公费", }, }, ], }, standards: [ { id: "standard-global", expense_type_id: EXPENSE_TYPE_ID, name: "交通费全局上限", max_amount: "120.00", per_unit: "item", control_mode: "warn", scope_type: "global", scope_key: null, created_at: new Date("2026-04-08T09:00:00.000Z"), }, ], }); const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); expect(result).toEqual([]); }); it("keeps perUnit as display metadata without any day or trip aggregation", async () => { const tx = buildTx({ reimbursement: { id: REIMBURSEMENT_ID, department_id: DEPARTMENT_ID, items: [ { id: "item-1", expense_type_id: EXPENSE_TYPE_ID, amount: "300.00", expense_type: { id: EXPENSE_TYPE_ID, name: "交通费", }, }, { id: "item-2", expense_type_id: EXPENSE_TYPE_ID, amount: "300.00", expense_type: { id: EXPENSE_TYPE_ID, name: "交通费", }, }, ], }, standards: [ { id: "standard-trip", expense_type_id: EXPENSE_TYPE_ID, name: "差旅交通上限", max_amount: "250.00", per_unit: "trip", control_mode: "warn", scope_type: "global", scope_key: null, created_at: new Date("2026-04-08T09:00:00.000Z"), }, ], }); const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ perUnit: "trip", exceededAmount: "50.00", }); expect(result[1]).toMatchObject({ perUnit: "trip", exceededAmount: "50.00", }); }); it("preserves manual_review decision priority over warn and under block", () => { const mergedWarn = mergeReimbursementPrecheck( { entityType: "reimbursement", entityId: REIMBURSEMENT_ID, decision: "manual_review", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [{ code: "budget_exceeded", level: "high", title: "预算超用", detail: "需要复核" }], nextActions: ["允许提交,但会生成待复核风险项"], }, [ { itemId: "item-1", expenseTypeId: EXPENSE_TYPE_ID, expenseTypeName: "交通费", amount: "150.00", standardId: "standard-global", standardName: "交通费全局上限", maxAmount: "120.00", perUnit: "item", controlMode: "warn", scopeType: "global", scopeKey: null, exceeded: true, exceededAmount: "30.00", }, ], ); const mergedBlock = mergeReimbursementPrecheck(mergedWarn, [ { itemId: "item-2", expenseTypeId: EXPENSE_TYPE_ID, expenseTypeName: "交通费", amount: "200.00", standardId: "standard-block", standardName: "交通费阻断上限", maxAmount: "120.00", perUnit: "item", controlMode: "block", scopeType: "global", scopeKey: null, exceeded: true, exceededAmount: "80.00", }, ]); expect(mergedWarn.decision).toBe("manual_review"); expect(mergedBlock.decision).toBe("block"); }); });