完善借款、编号、付款与预算并发及验收(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 行删除
@@ -2,22 +2,19 @@ const dispatcher = require("../../../../src/services/integrations/dispatcher");
const { clearWecomAppAccessTokenCache } = require("../../../../src/services/integrations/wecomAccessToken");
describe("Integration Dispatcher", () => {
it.each(["erp.reimbursement.paid", "erp.journal.export", "erp.procurement.export", "erp.asset.export", "erp.travel.export", "erp.future"])("refuses retired %s without any dispatch or binding write", async (eventType) => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
try {
await expect(dispatcher.dispatchOutboxEvent({ eventType, payload: {} })).rejects.toMatchObject({ code: "erp_retired", retryable: false });
expect(fetch).not.toHaveBeenCalled();
} finally { vi.unstubAllGlobals(); }
});
afterEach(() => {
clearWecomAppAccessTokenCache();
vi.restoreAllMocks();
});
it("dispatches erp.reimbursement.paid", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "erp.reimbursement.paid",
payload: { reimbursementId: "rb-1", formNumber: "BX-001" },
});
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
});
it("dispatches notification.im with mock channel", async () => {
const result = await dispatcher.dispatchOutboxEvent({
prisma: {
@@ -1,237 +0,0 @@
const erp = require('../../../../src/services/integrations/erp');
describe('ERP Sync Services', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('builds reimbursement journal payload via compat helper', () => {
const payload = erp.buildErpPayload({
reimbursement: {
id: 'r1',
form_number: 'F001',
owner_id: 'owner-1',
submitter_id: 'submitter-1',
department_id: 'dept-1',
total_amount: { toString: () => '100.00' },
payment_voucher_no: 'PV001',
payment_date: '2026-03-01T12:00:00.000Z',
paid_at: '2026-03-02T08:00:00.000Z',
owner: { legal_entity: { code: 'HY' } },
},
});
expect(payload.journalType).toBe('reimbursement_paid');
expect(payload.documentNo).toBe('F001');
expect(payload.paymentDate).toBe('2026-03-01');
expect(payload.dimensions.legalEntityCode).toBe('HY');
});
it('returns mock synced when journal export provider is mock', async () => {
const result = await erp.exportJournalToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ journalType: 'purchase_request_commitment', documentNo: 'PR-001' },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.response.externalId).toContain('PR-001');
});
it('returns mock procurement result when provider is mock', async () => {
const result = await erp.exportProcurementToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ poNo: 'PO-001', contractNo: 'CT-001' },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.response.externalPoNo).toContain('PO-001');
expect(result.response.externalContractNo).toContain('CT-001');
});
it('returns mock master data result when provider is mock', async () => {
const result = await erp.pullMasterDataFromErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ resources: ['legal_entity', 'supplier'] },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.data.legalEntities).toEqual([]);
expect(result.data.suppliers).toEqual([]);
});
it('handles http master data 4xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 422,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'invalid_payload' }),
});
const result = await erp.pullMasterDataFromErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpMasterDataEndpoint: 'https://example.com/master-data',
erpMasterDataTimeoutMs: 500,
},
{ resources: ['legal_entity'] },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_master_data_http_4xx');
expect(result.retryable).toBe(false);
});
it('handles http journal export 5xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'service_unavailable' }),
});
const result = await erp.exportJournalToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpJournalExportEndpoint: 'https://example.com/journals',
erpSyncTimeoutMs: 500,
},
{ journalType: 'purchase_order_accrual', documentNo: 'PO-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_http_5xx');
expect(result.retryable).toBe(true);
});
it('handles http procurement export 4xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'bad_request' }),
});
const result = await erp.exportProcurementToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpProcurementExportEndpoint: 'https://example.com/procurement',
erpProcurementTimeoutMs: 500,
},
{ exportType: 'purchase_order_archive', poNo: 'PO-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_procurement_http_4xx');
expect(result.retryable).toBe(false);
});
it('handles http procurement export timeout', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
const result = await erp.exportProcurementToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpProcurementExportEndpoint: 'https://example.com/procurement',
erpProcurementTimeoutMs: 500,
},
{ exportType: 'purchase_order_archive', poNo: 'PO-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_procurement_timeout');
expect(result.retryable).toBe(true);
});
it('handles http journal export timeout', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
const result = await erp.exportJournalToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpJournalExportEndpoint: 'https://example.com/journals',
erpSyncTimeoutMs: 500,
},
{ documentNo: 'BX-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_timeout');
expect(result.retryable).toBe(true);
});
it('returns mock asset export result when provider is mock', async () => {
const result = await erp.exportAssetToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ assetNo: 'ZC-001', entityId: 'asset-1' },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.response.externalAssetId).toContain('ZC-001');
expect(result.response.externalAssetNo).toContain('ZC-001');
});
it('handles http asset export 4xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'bad_request' }),
});
const result = await erp.exportAssetToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpAssetExportEndpoint: 'https://example.com/assets',
erpAssetTimeoutMs: 500,
},
{ exportType: 'asset_ledger_summary', assetNo: 'ZC-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_asset_http_4xx');
expect(result.retryable).toBe(false);
});
it('handles http asset export timeout', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
const result = await erp.exportAssetToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpAssetExportEndpoint: 'https://example.com/assets',
erpAssetTimeoutMs: 500,
},
{ exportType: 'asset_ledger_summary', assetNo: 'ZC-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_asset_timeout');
expect(result.retryable).toBe(true);
});
it('returns invalid provider if unsupported', async () => {
const result = await erp.exportJournalToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'invalid_xyz' },
{},
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_provider_invalid');
});
it('returns disabled if ERP sync is disabled', async () => {
const result = await erp.exportJournalToErp({ erpSyncEnabled: false }, {});
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_disabled');
});
});
@@ -2,6 +2,10 @@
const outbox = require("../../../../src/services/integrations/outbox");
describe("Integration Outbox", () => {
it("rejects retired ERP enqueue before touching history", async () => {
await expect(outbox.enqueueIntegrationEvent({}, { eventType: "erp.journal.export" })).rejects.toMatchObject({ code: "erp_retired" });
});
it("enqueues new event", async () => {
const mockPrisma = {
integrationOutbox: {
@@ -43,6 +47,9 @@ describe("Integration Outbox", () => {
};
const items = await outbox.fetchPendingOutboxEvents(mockPrisma, { batchSize: 5 });
expect(items.length).toBe(1);
expect(mockPrisma.integrationOutbox.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ NOT: { event_type: { startsWith: "erp." } } }),
}));
});
it("marks outbox succeeded", async () => {