const { resolveEntityPrecheck } = require('../../../src/services/feeControl'); const ID = '11111111-1111-4111-8111-111111111111'; function project(value, select) { if (!select || value == null) return value; if (Array.isArray(value)) return value.map(row => project(row, select)); return Object.fromEntries(Object.entries(select).map(([key, choice]) => [ key, choice === true ? value[key] : project(value[key], choice.select), ])); } function fixture() { const state = { form: { id: ID, department_id: 'department', owner_id: 'owner', submitter_id: 'owner', reimbursement_date: new Date('2026-09-18T00:00:00Z'), total_amount: '180.00', status: 'draft', items: [{ id: 'item', expense_type_id: 'type', amount: '180.00', expense_date: new Date('2026-09-18T00:00:00Z'), expense_type: { id: 'type', name: '合成办公费', category: 'office' } }], } }; const tx = { reimbursementForm: { findUnique: vi.fn(async ({ select }) => project(state.form, select)) }, featureFlag: { findUnique: vi.fn(async () => ({ flag_key: 'budget_precheck_v1', override_mode: 'force_on', rollout_type: 'global', department_ids: [], version: 1 })) }, expenseStandard: { findMany: vi.fn(async () => [{ id: 'standard', expense_type_id: 'type', name: '合成上限', max_amount: '100.00', per_unit: 'item', control_mode: 'block', scope_type: 'global' }]) }, employee: { findUnique: vi.fn(async () => ({ id: 'owner', roles: [{ role_code: 'employee' }] })) }, employeeRole: { findFirst: vi.fn(async () => ({ employee_id: 'approver' })) }, invoicePool: { count: vi.fn(async () => 1), findMany: vi.fn(async () => [{ linked_item_id: 'item' }]) }, reimbursementAttachment: { count: vi.fn(async () => 2) }, workflowInstance: { findFirst: vi.fn(async () => null) }, approvalRule: { findFirst: vi.fn(async () => ({ id: 'rule', level1_role: 'finance', priority: 1 })) }, budgetControlRule: { findMany: vi.fn(async () => []) }, budgetPool: { findMany: vi.fn(async () => []) }, purchaseRequest: { findUnique: vi.fn(async () => ({ id: ID, department_id: 'department' })) }, }; const run = (enabled = false, extra = {}) => resolveEntityPrecheck({ prisma: tx, config: { budgetPrecheckEnabled: enabled, appTimezone: 'Asia/Shanghai' }, entityType: 'reimbursement', entityId: ID, ...extra, }); return { state, tx, run }; } describe('reimbursement precheck record hydration', () => { beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-09-18T04:00:00Z')); }); afterEach(() => vi.useRealTimers()); it.each([false, true])('shares one database record across all checks with budget enabled=%s', async enabled => { const { tx, run } = fixture(); const result = await run(enabled); expect(result.decision).toBe('block'); expect(result.standardChecks).toEqual([expect.objectContaining({ amount: '180.00', exceededAmount: '80.00', controlMode: 'block' })]); expect(result.evidenceSummary).toEqual({ invoiceCount: 1, attachmentCount: 2, items: [{ itemId: 'item', linkedInvoiceCount: 1, invoiceState: 'linked' }] }); expect(result.timelinessChecks[0]).toMatchObject({ ageDays: 0, expenseDate: '2026-09-18' }); // A relation hydration issues multiple SQL statements; repeated reads were // measured inside the five-second submit transaction under load. expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(1); expect(tx.approvalRule.findFirst).toHaveBeenCalledTimes(enabled ? 1 : 0); }); it('does not cache a record between requests', async () => { const { tx, state, run } = fixture(); expect((await run()).standardChecks[0].amount).toBe('180.00'); state.form.items[0].amount = '80.00'; expect((await run()).standardChecks[0]).toMatchObject({ amount: '80.00', exceeded: false }); expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(2); }); it('uses the locked pool snapshot without a second budget selection and preserves one entity read', async () => { const { tx, run } = fixture(); tx.expenseStandard.findMany.mockResolvedValue([]); const result = await run(true, { budgetPoolForWrite: { id: 'locked-pool', code: 'SYN', name: '合成预算', used_amount: '60.00', allocated_amount: '200.00', warning_threshold_percent: 100 } }); expect(result.budgetHint).toMatchObject({ budgetId: 'locked-pool', usedAmount: '60.00', nextUsedAmount: '240.00', exceedsBudget: true }); expect(tx.budgetPool.findMany).not.toHaveBeenCalled(); expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(1); }); it('keeps finance-owner CEO requirements when budget checks are disabled', async () => { const { tx, run } = fixture(); tx.employee.findUnique.mockResolvedValue({ roles: [{ role_code: 'finance' }] }); tx.employeeRole.findFirst.mockResolvedValue(null); const result = await run(); expect(result.blockingReasons).toContainEqual(expect.objectContaining({ code: 'finance_owner_ceo_unavailable' })); expect(tx.employeeRole.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { role_code: 'ceo', employee_id: { notIn: ['owner'] }, employee: { status: 'active', archived_at: null }, } })); }); it.each([false, true])('rejects missing records before downstream checks: budget=%s', async enabled => { const { state, tx, run } = fixture(); state.form = null; await expect(run(enabled)).rejects.toMatchObject({ code: 'SYS_404', statusCode: 404 }); expect(tx.featureFlag.findUnique).not.toHaveBeenCalled(); expect(tx.expenseStandard.findMany).not.toHaveBeenCalled(); }); it('propagates database failures without a disabled or empty fallback', async () => { const { tx, run } = fixture(); const failure = new Error('synthetic_read_failure'); tx.reimbursementForm.findUnique.mockRejectedValue(failure); await expect(run()).rejects.toBe(failure); }); it('retains the non-reimbursement disabled branch', async () => { const { tx, run } = fixture(); const result = await run(false, { entityType: 'purchase_request' }); expect(result).toMatchObject({ entityType: 'purchase_request', decision: 'pass', standardChecks: [] }); expect(tx.reimbursementForm.findUnique).not.toHaveBeenCalled(); expect(tx.purchaseRequest.findUnique).toHaveBeenCalledTimes(1); }); });