汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
110 行
6.2 KiB
JavaScript
110 行
6.2 KiB
JavaScript
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);
|
|
});
|
|
});
|