feat(P19ERP2-1,P19ERP2-2): 实现采购ERP凭证与摘要出站服务
这个提交包含在:
@@ -0,0 +1,144 @@
|
||||
const {
|
||||
buildPurchaseAccrualJournalPayload,
|
||||
buildPurchaseArchiveSummaryPayload,
|
||||
buildPurchaseCommitmentJournalPayload,
|
||||
enqueuePurchaseArchiveSummaryExport,
|
||||
hashProcurementPayload,
|
||||
} = require('../../../src/services/integrations/erpProcurement');
|
||||
|
||||
describe('ERP Procurement Service', () => {
|
||||
it('builds commitment payload with procurement dimensions', () => {
|
||||
const payload = buildPurchaseCommitmentJournalPayload({
|
||||
purchaseRequest: {
|
||||
id: 'pr-1',
|
||||
request_no: 'PR-001',
|
||||
applicant_id: 'applicant-1',
|
||||
submitter_id: 'submitter-1',
|
||||
department_id: 'dept-1',
|
||||
total_amount: { toString: () => '880.00' },
|
||||
currency_code: 'CNY',
|
||||
legal_entity: { code: 'HY' },
|
||||
cost_center: { code: 'IT001' },
|
||||
supplier: { code: 'SUP001' },
|
||||
budget_pool: { code: 'BG-IT-2026' },
|
||||
document_category: { code: 'PR_STANDARD' },
|
||||
},
|
||||
occurredAt: '2026-03-08T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(payload.journalType).toBe('purchase_request_commitment');
|
||||
expect(payload.references.requestNo).toBe('PR-001');
|
||||
expect(payload.dimensions.legalEntityCode).toBe('HY');
|
||||
expect(payload.dimensions.costCenterCode).toBe('IT001');
|
||||
expect(payload.dimensions.supplierCode).toBe('SUP001');
|
||||
expect(payload.dimensions.budgetPoolCode).toBe('BG-IT-2026');
|
||||
});
|
||||
|
||||
it('builds accrual payload from archive facts', () => {
|
||||
const payload = buildPurchaseAccrualJournalPayload({
|
||||
purchaseRequest: {
|
||||
id: 'pr-1',
|
||||
request_no: 'PR-001',
|
||||
applicant_id: 'applicant-1',
|
||||
submitter_id: 'submitter-1',
|
||||
department_id: 'dept-1',
|
||||
total_amount: '880.00',
|
||||
currency_code: 'CNY',
|
||||
legal_entity: { code: 'HY' },
|
||||
cost_center: { code: 'IT001' },
|
||||
supplier: { code: 'SUP001' },
|
||||
budget_pool: { code: 'BG-IT-2026' },
|
||||
},
|
||||
archive: {
|
||||
id: 'archive-1',
|
||||
po_no: 'PO-001',
|
||||
contract_no: 'CT-001',
|
||||
archived_at: '2026-03-08T10:30:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.journalType).toBe('purchase_order_accrual');
|
||||
expect(payload.entityId).toBe('archive-1');
|
||||
expect(payload.documentNo).toBe('PO-001');
|
||||
expect(payload.references.contractNo).toBe('CT-001');
|
||||
});
|
||||
|
||||
it('builds archive summary payload and hashes payload changes', () => {
|
||||
const basePayload = buildPurchaseArchiveSummaryPayload({
|
||||
purchaseRequest: {
|
||||
id: 'pr-1',
|
||||
request_no: 'PR-001',
|
||||
department_id: 'dept-1',
|
||||
total_amount: '880.00',
|
||||
currency_code: 'CNY',
|
||||
legal_entity: { code: 'HY' },
|
||||
cost_center: { code: 'IT001' },
|
||||
supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' },
|
||||
},
|
||||
archive: {
|
||||
id: 'archive-1',
|
||||
po_no: 'PO-001',
|
||||
contract_no: 'CT-001',
|
||||
archived_at: '2026-03-08T10:30:00.000Z',
|
||||
archived_files: [
|
||||
{
|
||||
storagePath: 'purchase-requests/pr-1/contract.pdf',
|
||||
originalName: 'contract.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: 1024,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const changedPayload = {
|
||||
...basePayload,
|
||||
contractNo: 'CT-002',
|
||||
};
|
||||
|
||||
expect(basePayload.files).toHaveLength(1);
|
||||
expect(hashProcurementPayload(basePayload)).not.toBe(hashProcurementPayload(changedPayload));
|
||||
});
|
||||
|
||||
it('resets archive summary outbox when payload changes', async () => {
|
||||
const prisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'outbox-1',
|
||||
status: 'succeeded',
|
||||
payload: { contractNo: 'CT-001', poNo: 'PO-001' },
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await enqueuePurchaseArchiveSummaryExport(
|
||||
prisma,
|
||||
{ integrationOutboxMaxAttempts: 5 },
|
||||
{
|
||||
actorId: 'actor-1',
|
||||
purchaseRequest: {
|
||||
id: 'pr-1',
|
||||
request_no: 'PR-001',
|
||||
department_id: 'dept-1',
|
||||
total_amount: '880.00',
|
||||
currency_code: 'CNY',
|
||||
supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' },
|
||||
legal_entity: { code: 'HY' },
|
||||
cost_center: { code: 'IT001' },
|
||||
},
|
||||
archive: {
|
||||
id: 'archive-1',
|
||||
po_no: 'PO-001',
|
||||
contract_no: 'CT-002',
|
||||
archived_at: '2026-03-08T10:30:00.000Z',
|
||||
archived_files: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(prisma.integrationOutbox.update).toHaveBeenCalled();
|
||||
expect(result.requeued).toBe(true);
|
||||
expect(result.eventType).toBe('erp.procurement.export');
|
||||
});
|
||||
});
|
||||
@@ -33,11 +33,22 @@ describe('ERP Sync Services', () => {
|
||||
it('returns mock synced when journal export provider is mock', async () => {
|
||||
const result = await erp.exportJournalToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||
{ documentNo: 'BX-001' },
|
||||
{ journalType: 'purchase_request_commitment', documentNo: 'PR-001' },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.code).toBe('mock_synced');
|
||||
expect(result.response.externalId).toContain('BX-001');
|
||||
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 () => {
|
||||
@@ -74,6 +85,70 @@ describe('ERP Sync Services', () => {
|
||||
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' });
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户