完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -564,7 +564,7 @@ describe('travelReconciliation routes', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('finalizes fully confirmed batch and enqueues ERP export when gate is on', async () => {
|
||||
it('finalizes confirmed batch without retired ERP side effects', async () => {
|
||||
const { prisma, state } = buildPrisma({
|
||||
orders: [buildOrder()],
|
||||
transactions: [buildTransaction()],
|
||||
@@ -596,14 +596,9 @@ describe('travelReconciliation routes', () => {
|
||||
expect.objectContaining({
|
||||
batchId,
|
||||
status: 'reconciled',
|
||||
erpExport: expect.objectContaining({
|
||||
eventType: 'erp.travel.export',
|
||||
enqueued: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(state.integrationOutboxes).toHaveLength(1);
|
||||
expect(state.integrationOutboxes[0].event_type).toBe('erp.travel.export');
|
||||
expect(state.integrationOutboxes).toHaveLength(0);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -648,3 +643,86 @@ describe('travelReconciliation routes', () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('travel reconciliation calendar input', () => {
|
||||
it.each(['POST', 'GET'].flatMap((method) => ['periodStart', 'periodEnd'].map((field) => [method, field])))('rejects %s invalid %s before queries and writes', async (method, field) => {
|
||||
const { prisma, state } = buildPrisma();
|
||||
const app = await buildApp({ prisma });
|
||||
try {
|
||||
const input = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2026-04-01', periodEnd: '2026-04-30', [field]: '2026-04-31' };
|
||||
const response = await app.inject({ method, url: '/api/travel/reconciliation/batches' + (method === 'GET' ? `?${new URLSearchParams(input)}` : ''), headers: { authorization: 'Bearer test' }, ...(method === 'POST' ? { payload: input } : {}) });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(prisma.travelReconciliationBatch.findMany).not.toHaveBeenCalled();
|
||||
expect(state.batches).toEqual([]);
|
||||
expect(state.auditLogs).toEqual([]);
|
||||
expect(state.integrationOutboxes).toEqual([]);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
it('keeps leap-day/month-end, optional filters and reversed-period semantics', async () => {
|
||||
const { prisma } = buildPrisma({ orders: [buildOrder({ payment_date: new Date('2024-03-10T00:00:00.000Z') })], transactions: [buildTransaction({ occurred_at: new Date('2024-03-10T08:30:00.000Z') })] });
|
||||
const app = await buildApp({ prisma });
|
||||
try {
|
||||
const base = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2024-02-29', periodEnd: '2024-03-31' };
|
||||
const created = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: base });
|
||||
expect(created.statusCode).toBe(200);
|
||||
const listed = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' } });
|
||||
expect(listed.statusCode).toBe(200);
|
||||
prisma.$transaction.mockClear();
|
||||
const reversed = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { ...base, periodStart: '2024-04-30' } });
|
||||
expect(reversed.statusCode).toBe(409);
|
||||
expect(reversed.json().error.details.period).toBe('period_start_after_period_end');
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
const errorApps = [];
|
||||
afterEach(async () => { await Promise.all(errorApps.splice(0).map((app) => app.close())); });
|
||||
const ERROR_ID = '66666666-6666-4666-8666-666666666666';
|
||||
const ERROR_SCOPE = { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31' };
|
||||
async function errorContext(options) { const context = buildPrisma(options); const app = await buildApp({ prisma: context.prisma }); errorApps.push(app); return { ...context, app }; }
|
||||
function errorRequest(method, path, payload) { return { method, url: `/api/travel/reconciliation${path}`, headers: { authorization: 'Bearer synthetic' }, ...(payload ? { payload } : {}) }; }
|
||||
function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expect(response.json().error.code).toBe('VALID_001'); }
|
||||
function noMutations(prisma) { for (const model of [prisma.travelReconciliationBatch, prisma.travelReconciliationItem, prisma.integrationOutbox, prisma.auditLog]) for (const method of ['create', 'createMany', 'update', 'upsert']) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); }
|
||||
it.each([
|
||||
['GET', '/batches?page=0', undefined, 'query_invalid'], ['POST', '/batches', {}, 'create_input_invalid'],
|
||||
['POST', `/items/${ERROR_ID}/confirm`, { note: 'x'.repeat(501) }, 'confirm_input_invalid'],
|
||||
])('explains %s %s input before transaction or queries', async (method, path, payload, reason) => {
|
||||
const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, path, payload)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `travel_reconciliation.${reason}`, fieldErrors: expect.any(Object) }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([['GET', '/batches/bad', 'batchId'], ['POST', '/batches/bad/finalize', 'batchId'], ['POST', '/items/bad/confirm', 'itemId'], ['POST', '/items/bad/reopen', 'itemId']])('explains %s %s invalid id with the original 400', async (method, path, key) => {
|
||||
const { app, prisma } = await errorContext(); expectError(await app.inject(errorRequest(method, path, {})), 400, `travel_reconciliation.${key === 'batchId' ? 'batch' : 'item'}_id_invalid`, { [key]: 'invalid_uuid' }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['period', 'company', 'center', 'mismatch', 'empty'])('preserves batch creation %s refusal as 409', async (state) => {
|
||||
const { app, prisma } = await errorContext(); const payload = { ...ERROR_SCOPE };
|
||||
if (state === 'period') payload.periodStart = '2026-04-01';
|
||||
if (state === 'company') prisma.legalEntity.findUnique.mockResolvedValue(null);
|
||||
if (state === 'center') prisma.costCenter.findUnique.mockResolvedValue(null);
|
||||
if (state === 'mismatch') prisma.costCenter.findUnique.mockResolvedValue({ id: ERROR_SCOPE.costCenterId, legal_entity_id: ERROR_ID });
|
||||
const [reason, details] = { period: ['period_reversed', { period: 'period_start_after_period_end' }], company: ['company_not_found', { legalEntityId: 'not_found' }], center: ['cost_center_not_found', { costCenterId: 'not_found' }], mismatch: ['cost_center_company_mismatch', { costCenterId: 'legal_entity_mismatch' }], empty: ['empty_scope', { batch: 'travel_reconciliation_empty_scope' }] }[state];
|
||||
expectError(await app.inject(errorRequest('POST', '/batches', payload)), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
async function createdBatch(matched = false) {
|
||||
const context = await errorContext({ orders: [buildOrder()], transactions: matched ? [buildTransaction()] : [] });
|
||||
const response = await context.app.inject(errorRequest('POST', '/batches', ERROR_SCOPE)); expect(response.statusCode).toBe(200);
|
||||
// Preserve the synthetic database contents while clearing prior creation call history.
|
||||
for (const model of Object.values(context.prisma)) for (const method of Object.values(model ?? {})) if (vi.isMockFunction(method)) method.mockClear();
|
||||
return { ...context, batch: context.state.batches[0], item: context.state.items[0] };
|
||||
}
|
||||
it.each(['confirm', 'reopen'])('preserves non-draft batch guard on %s', async (action) => {
|
||||
const { app, prisma, state, batch, item } = await createdBatch(); batch.status = 'reconciled'; const before = JSON.stringify(state);
|
||||
expectError(await app.inject(errorRequest('POST', `/items/${item.id}/${action}`, {})), 409, 'travel_reconciliation.batch_not_editable', { batch: 'reconciliation_batch_not_editable' }); noMutations(prisma); expect(JSON.stringify(state)).toBe(before);
|
||||
});
|
||||
it.each(['already-confirmed', 'not-confirmed', 'matched', 'pending-items'])('preserves reconciliation %s service guard through actual route', async (condition) => {
|
||||
const { app, prisma, state, batch, item } = await createdBatch(condition === 'matched');
|
||||
if (condition === 'already-confirmed') item.resolution_status = 'confirmed';
|
||||
const [path, reason, details] = {
|
||||
'already-confirmed': [`/items/${item.id}/confirm`, 'item_not_pending', { item: 'reconciliation_item_not_pending' }],
|
||||
'not-confirmed': [`/items/${item.id}/reopen`, 'item_not_confirmed', { item: 'reconciliation_item_not_confirmed' }],
|
||||
matched: [`/items/${item.id}/reopen`, 'matched_item_not_reopenable', { item: 'matched_item_not_reopenable' }],
|
||||
'pending-items': [`/batches/${batch.id}/finalize`, 'batch_has_pending_items', { batch: 'reconciliation_batch_has_pending_items' }],
|
||||
}[condition]; const before = JSON.stringify(state);
|
||||
expectError(await app.inject(errorRequest('POST', path, {})), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(JSON.stringify(state)).toBe(before);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户