完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -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 () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户