完善借款、编号、付款与预算并发及验收(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
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
@@ -40,7 +40,7 @@ async function buildApp(options = {}) {
const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildOutbox()]);
const findUnique = options.findUnique ?? vi.fn().mockResolvedValue(buildOutbox());
const update = options.update ?? vi.fn().mockImplementation(async ({ where, data }) => ({
...buildOutbox({ id: where.id }),
...buildOutbox({ id: where.id, event_type: "notification.im" }),
...data,
updated_at: new Date('2026-04-07T10:30:00.000Z'),
}));
@@ -112,6 +112,19 @@ async function buildApp(options = {}) {
}
describe('integrations outbox routes', () => {
it('keeps retired ERP history readable but refuses retry and removed endpoints', async () => {
const { app, update, auditCreate } = await buildApp();
const read = await app.inject({ method: 'GET', url: `/api/integrations/outbox/${OUTBOX_ID}` });
expect(read.statusCode).toBe(200);
const retry = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, payload: {} });
expect(retry.statusCode).toBe(409);
expect(retry.json().error.details.reasonCode).toBe('integration.erp_retired');
expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled();
const removed = await app.inject({ method: 'POST', url: `/api/integrations/erp/sync/${OUTBOX_ID}`, payload: {} });
expect(removed.statusCode).toBe(404);
await app.close();
});
it('lists outbox events with filters and payload summary', async () => {
const { app, findMany, count } = await buildApp();
@@ -203,7 +216,7 @@ describe('integrations outbox routes', () => {
it('retries failed or dead-letter outbox events by resetting status to pending', async () => {
const { app, update, auditCreate } = await buildApp({
findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })),
findUnique: vi.fn().mockResolvedValue(buildOutbox({ event_type: 'notification.im', status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })),
});
const response = await app.inject({
@@ -230,13 +243,13 @@ describe('integrations outbox routes', () => {
expect(auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'ERP_OUTBOX_RETRIED',
action: 'INTEGRATION_OUTBOX_RETRIED',
target_type: 'integration_outbox',
target_id: OUTBOX_ID,
extra: expect.objectContaining({
previousStatus: 'dead_letter',
nextStatus: 'pending',
eventType: 'erp.journal.export',
eventType: 'notification.im',
outboxId: OUTBOX_ID,
}),
}),
@@ -283,3 +296,9 @@ describe('integrations outbox routes', () => {
await app.close();
});
});
describe('shared outbox operation reasons', () => {
it.each([['GET', '/integrations/outbox?page=0', 'integration.outbox_query_invalid'], ['GET', '/integrations/outbox/bad', 'integration.outbox_id_invalid'], ['POST', '/integrations/outbox/bad/retry', 'integration.outbox_id_invalid']])('rejects %s %s before reads or mutations', async (method, path, reasonCode) => { const { app, findMany, findUnique, update, auditCreate } = await buildApp(); try { const r = await app.inject({ method, url: `/api${path}` }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe(reasonCode); expect(findMany).not.toHaveBeenCalled(); expect(findUnique).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } });
it.each(['pending', 'processing', 'succeeded'])('keeps non-retryable notification state %s', async (status) => { const { app, update, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(buildOutbox({ event_type: 'notification.im', status })) }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ outbox: 'integration_outbox_retry_status_invalid', status, reasonCode: 'integration.retry_state_invalid' }); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } });
it.each(['failed', 'dead_letter'])('retains notification %s retry as pending without delivery', async (status) => { const before = buildOutbox({ event_type: 'notification.im', status, payload: { channelId: 'synthetic-channel' } }); const update = vi.fn(async ({ data }) => ({ ...before, ...data })); const { app, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(before), update }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(200); expect(update.mock.calls[0][0].data).toMatchObject({ status: 'pending', attempts: 0, last_error: null }); expect(auditCreate).toHaveBeenCalledOnce(); } finally { await app.close(); } });
});