feat(P19ERP2-1,P19ERP2-2): 实现采购ERP凭证与摘要出站服务
这个提交包含在:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
const purchaseRequestRoutes = require('../../../src/routes/purchaseRequests');
|
||||
|
||||
describe('purchase archive ERP helper', () => {
|
||||
const { handlePurchaseArchiveErpSync } = purchaseRequestRoutes.__testables;
|
||||
|
||||
function createRequest() {
|
||||
return {
|
||||
id: 'req-1',
|
||||
ip: '127.0.0.1',
|
||||
headers: {},
|
||||
employee: {
|
||||
id: 'actor-1',
|
||||
department_id: 'dept-1',
|
||||
permissionCodes: ['procurement.manage'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTx() {
|
||||
return {
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createContext() {
|
||||
return {
|
||||
purchaseRequest: {
|
||||
id: 'pr-1',
|
||||
department_id: 'dept-1',
|
||||
order_archive: {
|
||||
id: 'archive-1',
|
||||
po_no: 'PO-001',
|
||||
contract_no: 'CT-001',
|
||||
archived_at: new Date('2026-03-08T10:30:00.000Z'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('first archive enqueues accrual and summary', async () => {
|
||||
const tx = createTx();
|
||||
const loadContext = vi.fn().mockResolvedValue(createContext());
|
||||
const evaluateGate = vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
reason: 'env_inherit_department_allowed',
|
||||
});
|
||||
const enqueueAccrual = vi.fn().mockResolvedValue({
|
||||
id: 'outbox-accrual',
|
||||
status: 'pending',
|
||||
duplicated: false,
|
||||
requeued: false,
|
||||
eventType: 'erp.journal.export',
|
||||
erpTarget: 'accrual_journal',
|
||||
});
|
||||
const enqueueSummary = vi.fn().mockResolvedValue({
|
||||
id: 'outbox-summary',
|
||||
status: 'pending',
|
||||
duplicated: false,
|
||||
requeued: false,
|
||||
eventType: 'erp.procurement.export',
|
||||
erpTarget: 'archive_summary',
|
||||
});
|
||||
|
||||
const result = await handlePurchaseArchiveErpSync({
|
||||
tx,
|
||||
config: {},
|
||||
request: createRequest(),
|
||||
actorId: 'actor-1',
|
||||
purchaseRequestId: 'pr-1',
|
||||
archiveId: 'archive-1',
|
||||
isFirstArchive: true,
|
||||
loadContext,
|
||||
evaluateGate,
|
||||
enqueueAccrual,
|
||||
enqueueSummary,
|
||||
});
|
||||
|
||||
expect(enqueueAccrual).toHaveBeenCalledWith(
|
||||
tx,
|
||||
{},
|
||||
expect.objectContaining({ requeueOnFailure: false }),
|
||||
);
|
||||
expect(enqueueSummary).toHaveBeenCalledWith(
|
||||
tx,
|
||||
{},
|
||||
expect.objectContaining({ requeueOnFailure: false }),
|
||||
);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('archive update only re-enqueues summary', async () => {
|
||||
const tx = createTx();
|
||||
const loadContext = vi.fn().mockResolvedValue(createContext());
|
||||
const evaluateGate = vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
reason: 'env_inherit_department_allowed',
|
||||
});
|
||||
const enqueueAccrual = vi.fn();
|
||||
const enqueueSummary = vi.fn().mockResolvedValue({
|
||||
id: 'outbox-summary',
|
||||
status: 'pending',
|
||||
duplicated: false,
|
||||
requeued: true,
|
||||
eventType: 'erp.procurement.export',
|
||||
erpTarget: 'archive_summary',
|
||||
});
|
||||
|
||||
const result = await handlePurchaseArchiveErpSync({
|
||||
tx,
|
||||
config: {},
|
||||
request: createRequest(),
|
||||
actorId: 'actor-1',
|
||||
purchaseRequestId: 'pr-1',
|
||||
archiveId: 'archive-1',
|
||||
isFirstArchive: false,
|
||||
loadContext,
|
||||
evaluateGate,
|
||||
enqueueAccrual,
|
||||
enqueueSummary,
|
||||
});
|
||||
|
||||
expect(enqueueAccrual).not.toHaveBeenCalled();
|
||||
expect(enqueueSummary).toHaveBeenCalled();
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].erpTarget).toBe('archive_summary');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
const workflowRoutes = require('../../../src/routes/workflow');
|
||||
|
||||
describe('workflow purchase ERP helper', () => {
|
||||
const { handlePurchaseApprovalErpSync } = workflowRoutes.__testables;
|
||||
|
||||
function createRequest() {
|
||||
return {
|
||||
id: 'req-1',
|
||||
ip: '127.0.0.1',
|
||||
headers: {},
|
||||
employee: {
|
||||
id: 'actor-1',
|
||||
department_id: 'dept-1',
|
||||
permissionCodes: ['procurement.manage'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTx() {
|
||||
return {
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('auto enqueues commitment journal when gate is enabled', async () => {
|
||||
const tx = createTx();
|
||||
const loadContext = vi.fn().mockResolvedValue({
|
||||
purchaseRequest: {
|
||||
id: 'pr-1',
|
||||
department_id: 'dept-1',
|
||||
},
|
||||
approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'),
|
||||
});
|
||||
const evaluateGate = vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
reason: 'env_inherit_department_allowed',
|
||||
});
|
||||
const enqueueCommitment = vi.fn().mockResolvedValue({
|
||||
id: 'outbox-1',
|
||||
status: 'pending',
|
||||
duplicated: false,
|
||||
requeued: false,
|
||||
eventType: 'erp.journal.export',
|
||||
});
|
||||
|
||||
const result = await handlePurchaseApprovalErpSync({
|
||||
tx,
|
||||
config: {},
|
||||
request: createRequest(),
|
||||
actorId: 'actor-1',
|
||||
purchaseRequestId: 'pr-1',
|
||||
loadContext,
|
||||
evaluateGate,
|
||||
enqueueCommitment,
|
||||
});
|
||||
|
||||
expect(enqueueCommitment).toHaveBeenCalledWith(
|
||||
tx,
|
||||
{},
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
requeueOnFailure: false,
|
||||
}),
|
||||
);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'ERP_SYNC_ENQUEUED',
|
||||
target_type: 'purchase_request',
|
||||
target_id: 'pr-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.skipped).toBe(false);
|
||||
});
|
||||
|
||||
it('writes skip audit when approval gate is disabled', async () => {
|
||||
const tx = createTx();
|
||||
const loadContext = vi.fn().mockResolvedValue({
|
||||
purchaseRequest: {
|
||||
id: 'pr-1',
|
||||
department_id: 'dept-1',
|
||||
},
|
||||
approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'),
|
||||
});
|
||||
const evaluateGate = vi.fn().mockResolvedValue({ enabled: false, reason: 'flag_force_off' });
|
||||
const enqueueCommitment = vi.fn();
|
||||
|
||||
const result = await handlePurchaseApprovalErpSync({
|
||||
tx,
|
||||
config: {},
|
||||
request: createRequest(),
|
||||
actorId: 'actor-1',
|
||||
purchaseRequestId: 'pr-1',
|
||||
loadContext,
|
||||
evaluateGate,
|
||||
enqueueCommitment,
|
||||
});
|
||||
|
||||
expect(enqueueCommitment).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
|
||||
target_type: 'purchase_request',
|
||||
target_id: 'pr-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.skipped).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户