feat(P19ERP2-1,P19ERP2-2): 实现采购ERP凭证与摘要出站服务

这个提交包含在:
2026-03-08 23:50:02 +08:00
父节点 8f4a50cdd7
当前提交 486660df06
共修改 15 个文件,包含 1912 行新增和 181 行删除
+206 -110
查看文件
@@ -1,9 +1,9 @@
const {
IntegrationDispatchError,
dispatchOutboxEvent,
} = require("../../src/services/integrations/dispatcher");
} = require('../../src/services/integrations/dispatcher');
describe("dispatchOutboxEvent", () => {
describe('dispatchOutboxEvent', () => {
const originalFetch = global.fetch;
afterEach(() => {
@@ -11,25 +11,25 @@ describe("dispatchOutboxEvent", () => {
vi.restoreAllMocks();
});
it("handles mock ERP success", async () => {
it('handles mock ERP success', async () => {
const result = await dispatchOutboxEvent({
config: {
erpSyncEnabled: true,
erpSyncProvider: "mock",
erpSyncProvider: 'mock',
},
eventType: "erp.reimbursement.paid",
eventType: 'erp.reimbursement.paid',
payload: {
reimbursementId: "rb-1",
formNumber: "BX-001",
reimbursementId: 'rb-1',
formNumber: 'BX-001',
},
});
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
expect(result.response.externalId).toContain("BX-001");
expect(result.channel).toBe('erp');
expect(result.code).toBe('mock_synced');
expect(result.response.externalId).toContain('BX-001');
});
it("records integration binding for standard journal export", async () => {
it('records integration binding for reimbursement journal export', async () => {
const prisma = {
integrationBinding: {
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
@@ -44,6 +44,7 @@ describe("dispatchOutboxEvent", () => {
},
eventType: 'erp.journal.export',
payload: {
journalType: 'reimbursement_paid',
entityType: 'reimbursement_form',
entityId: 'rb-1',
documentNo: 'BX-20260308-0001',
@@ -64,23 +65,135 @@ describe("dispatchOutboxEvent", () => {
);
});
it("loads IM channel and dispatches mock wecom message", async () => {
it('records commitment binding for procurement journal export', async () => {
const prisma = {
integrationBinding: {
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
},
};
await dispatchOutboxEvent({
prisma,
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
eventType: 'erp.journal.export',
payload: {
journalType: 'purchase_request_commitment',
entityType: 'purchase_request',
entityId: 'pr-1',
documentNo: 'PR-001',
references: { purchaseRequestId: 'pr-1', requestNo: 'PR-001' },
},
});
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
provider_entity_type_local_id: {
provider: 'erp',
entity_type: 'purchase_request_commitment_journal',
local_id: 'pr-1',
},
},
}),
);
});
it('records accrual binding for procurement journal export', async () => {
const prisma = {
integrationBinding: {
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
},
};
await dispatchOutboxEvent({
prisma,
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
eventType: 'erp.journal.export',
payload: {
journalType: 'purchase_order_accrual',
entityType: 'purchase_order_archive',
entityId: 'archive-1',
documentNo: 'PO-001',
references: { purchaseOrderArchiveId: 'archive-1', poNo: 'PO-001' },
},
});
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
provider_entity_type_local_id: {
provider: 'erp',
entity_type: 'purchase_order_accrual_journal',
local_id: 'archive-1',
},
},
}),
);
});
it('records PO and contract bindings for procurement summary export', async () => {
const prisma = {
integrationBinding: {
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
},
};
await dispatchOutboxEvent({
prisma,
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
eventType: 'erp.procurement.export',
payload: {
exportType: 'purchase_order_archive',
entityType: 'purchase_order_archive',
entityId: 'archive-1',
poNo: 'PO-001',
contractNo: 'CT-001',
},
});
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: {
provider_entity_type_local_id: {
provider: 'erp',
entity_type: 'purchase_order_po',
local_id: 'archive-1',
},
},
}),
);
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: {
provider_entity_type_local_id: {
provider: 'erp',
entity_type: 'purchase_order_contract',
local_id: 'archive-1',
},
},
}),
);
});
it('loads IM channel and dispatches mock wecom message', async () => {
const prisma = {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-1",
channel_type: "wecom",
name: "采购经理群",
id: 'channel-1',
channel_type: 'wecom',
name: '采购经理群',
enabled: true,
config_json: {
mode: "mock",
mode: 'mock',
routing: {},
},
template_json: {
title: "{{title}}",
body: "{{body}}",
footer: "来自海音达工作台",
deepLinkLabel: "查看详情",
title: '{{title}}',
body: '{{body}}',
footer: '来自海音达工作台',
deepLinkLabel: '查看详情',
},
}),
},
@@ -91,18 +204,18 @@ describe("dispatchOutboxEvent", () => {
config: {
imNotifierEnabled: true,
},
eventType: "notification.im",
eventType: 'notification.im',
payload: {
channelId: "channel-1",
channelType: "wecom",
notificationType: "workflow.task_assigned",
title: "【待办】采购申请待审批",
body: "采购申请已流转到你,请及时处理。",
channelId: 'channel-1',
channelType: 'wecom',
notificationType: 'workflow.task_assigned',
title: '【待办】采购申请待审批',
body: '采购申请已流转到你,请及时处理。',
},
});
expect(prisma.notificationChannel.findUnique).toHaveBeenCalledWith({
where: { id: "channel-1" },
where: { id: 'channel-1' },
select: {
id: true,
channel_type: true,
@@ -112,12 +225,12 @@ describe("dispatchOutboxEvent", () => {
template_json: true,
},
});
expect(result.channel).toBe("im");
expect(result.code).toBe("mock_sent");
expect(result.previewMessage).toContain("采购申请待审批");
expect(result.channel).toBe('im');
expect(result.code).toBe('mock_sent');
expect(result.previewMessage).toContain('采购申请待审批');
});
it("safely skips missing IM channel", async () => {
it('safely skips missing IM channel', async () => {
const result = await dispatchOutboxEvent({
prisma: {
notificationChannel: {
@@ -127,28 +240,28 @@ describe("dispatchOutboxEvent", () => {
config: {
imNotifierEnabled: true,
},
eventType: "notification.im",
eventType: 'notification.im',
payload: {
channelId: "missing-channel",
channelType: "wecom",
channelId: 'missing-channel',
channelType: 'wecom',
},
});
expect(result.channel).toBe("im");
expect(result.code).toBe("channel_not_found");
expect(result.channel).toBe('im');
expect(result.code).toBe('channel_not_found');
expect(result.skipped).toBe(true);
});
it("safely skips disabled IM channel", async () => {
it('safely skips disabled IM channel', async () => {
const result = await dispatchOutboxEvent({
prisma: {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-2",
channel_type: "wecom",
name: "disabled",
id: 'channel-2',
channel_type: 'wecom',
name: 'disabled',
enabled: false,
config_json: { mode: "mock", routing: {} },
config_json: { mode: 'mock', routing: {} },
template_json: null,
}),
},
@@ -156,22 +269,23 @@ describe("dispatchOutboxEvent", () => {
config: {
imNotifierEnabled: true,
},
eventType: "notification.im",
eventType: 'notification.im',
payload: {
channelId: "channel-2",
channelType: "wecom",
channelId: 'channel-2',
channelType: 'wecom',
},
});
expect(result.code).toBe("channel_disabled");
expect(result.code).toBe('channel_disabled');
expect(result.skipped).toBe(true);
});
it("maps wecom 4xx as non-retryable IM dispatch error", async () => {
it('maps wecom 4xx as non-retryable IM dispatch error', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: vi.fn().mockResolvedValue({ errcode: 40013, errmsg: "bad request" }),
json: vi.fn().mockResolvedValue({ errcode: 40013, errmsg: 'bad request' }),
headers: { get: () => 'application/json' },
});
await expect(
@@ -179,13 +293,13 @@ describe("dispatchOutboxEvent", () => {
prisma: {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-3",
channel_type: "wecom",
name: "wecom-4xx",
id: 'channel-3',
channel_type: 'wecom',
name: 'wecom-4xx',
enabled: true,
config_json: {
mode: "wecom_webhook",
webhookUrl: "https://example.com/wecom",
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
timeoutMs: 500,
routing: {},
},
@@ -196,26 +310,27 @@ describe("dispatchOutboxEvent", () => {
config: {
imNotifierEnabled: true,
},
eventType: "notification.im",
eventType: 'notification.im',
payload: {
channelId: "channel-3",
channelType: "wecom",
title: "test",
body: "body",
channelId: 'channel-3',
channelType: 'wecom',
title: 'test',
body: 'body',
},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "wecom_http_4xx",
name: 'IntegrationDispatchError',
code: 'wecom_http_4xx',
retryable: false,
});
});
it("maps wecom 5xx as retryable IM dispatch error", async () => {
it('maps wecom 5xx as retryable IM dispatch error', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: vi.fn().mockResolvedValue({ errcode: 500, errmsg: "server error" }),
json: vi.fn().mockResolvedValue({ errcode: 500, errmsg: 'server error' }),
headers: { get: () => 'application/json' },
});
await expect(
@@ -223,13 +338,13 @@ describe("dispatchOutboxEvent", () => {
prisma: {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-4",
channel_type: "wecom",
name: "wecom-5xx",
id: 'channel-4',
channel_type: 'wecom',
name: 'wecom-5xx',
enabled: true,
config_json: {
mode: "wecom_webhook",
webhookUrl: "https://example.com/wecom",
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
timeoutMs: 500,
routing: {},
},
@@ -240,36 +355,36 @@ describe("dispatchOutboxEvent", () => {
config: {
imNotifierEnabled: true,
},
eventType: "notification.im",
eventType: 'notification.im',
payload: {
channelId: "channel-4",
channelType: "wecom",
title: "test",
body: "body",
channelId: 'channel-4',
channelType: 'wecom',
title: 'test',
body: 'body',
},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "wecom_http_5xx",
name: 'IntegrationDispatchError',
code: 'wecom_http_5xx',
retryable: true,
});
});
it("maps wecom timeout as retryable IM dispatch error", async () => {
global.fetch = vi.fn().mockRejectedValue({ name: "AbortError" });
it('maps wecom timeout as retryable IM dispatch error', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
await expect(
dispatchOutboxEvent({
prisma: {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-5",
channel_type: "wecom",
name: "wecom-timeout",
id: 'channel-5',
channel_type: 'wecom',
name: 'wecom-timeout',
enabled: true,
config_json: {
mode: "wecom_webhook",
webhookUrl: "https://example.com/wecom",
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
timeoutMs: 500,
routing: {},
},
@@ -280,46 +395,27 @@ describe("dispatchOutboxEvent", () => {
config: {
imNotifierEnabled: true,
},
eventType: "notification.im",
eventType: 'notification.im',
payload: {
channelId: "channel-5",
channelType: "wecom",
title: "test",
body: "body",
channelId: 'channel-5',
channelType: 'wecom',
title: 'test',
body: 'body',
},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "wecom_timeout",
name: 'IntegrationDispatchError',
code: 'wecom_timeout',
retryable: true,
});
});
it("throws non-retryable error for unsupported event", async () => {
it('throws error for unsupported event', async () => {
await expect(
dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "unsupported.event",
payload: {},
config: {},
eventType: 'unknown.event',
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "unsupported_outbox_event",
retryable: false,
});
});
it("preserves error class semantics", () => {
const error = new IntegrationDispatchError({
code: "unit_test",
message: "unit test",
retryable: true,
statusCode: 503,
});
expect(error).toBeInstanceOf(Error);
expect(error.code).toBe("unit_test");
expect(error.retryable).toBe(true);
expect(error.statusCode).toBe(503);
).rejects.toThrow('unsupported_outbox_event:unknown.event');
});
});