文件
hyb-oa/server/tests/unit/services/reimbursementPrecheck.snapshot.test.js
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

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