feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口
这个提交包含在:
@@ -29,6 +29,41 @@ describe("dispatchOutboxEvent", () => {
|
||||
expect(result.response.externalId).toContain("BX-001");
|
||||
});
|
||||
|
||||
it("records integration binding for standard journal export", async () => {
|
||||
const prisma = {
|
||||
integrationBinding: {
|
||||
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await dispatchOutboxEvent({
|
||||
prisma,
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'mock',
|
||||
},
|
||||
eventType: 'erp.journal.export',
|
||||
payload: {
|
||||
entityType: 'reimbursement_form',
|
||||
entityId: 'rb-1',
|
||||
documentNo: 'BX-20260308-0001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.channel).toBe('erp');
|
||||
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: 'erp',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: 'rb-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads IM channel and dispatches mock wecom message", async () => {
|
||||
const prisma = {
|
||||
notificationChannel: {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||
|
||||
describe('Integrations ERP route helpers', () => {
|
||||
const { normalizeResourcesInput, parseRunNow } = integrationsRoutes.__testables;
|
||||
|
||||
it('normalizes and orders resources', () => {
|
||||
expect(normalizeResourcesInput({ resources: ['supplier', 'legal_entity', 'supplier'] })).toEqual([
|
||||
'legal_entity',
|
||||
'supplier',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid resource values', () => {
|
||||
expect(() => normalizeResourcesInput({ resources: ['unknown'] })).toThrow();
|
||||
});
|
||||
|
||||
it('parses runNow truthy flags', () => {
|
||||
expect(parseRunNow({ runNow: '1' })).toBe(true);
|
||||
expect(parseRunNow({ runNow: true })).toBe(true);
|
||||
expect(parseRunNow({ runNow: '0' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const {
|
||||
buildReimbursementJournalDedupeKey,
|
||||
buildReimbursementJournalPayload,
|
||||
enqueueReimbursementJournalExport,
|
||||
} = require('../../../src/services/integrations/erpJournal');
|
||||
|
||||
describe('ERP Journal Service', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('prefers owner legal entity code and keeps cost center null', () => {
|
||||
const payload = buildReimbursementJournalPayload({
|
||||
reimbursement: {
|
||||
id: 'rb-1',
|
||||
form_number: 'BX-001',
|
||||
owner_id: 'owner-1',
|
||||
submitter_id: 'submitter-1',
|
||||
department_id: 'dept-1',
|
||||
total_amount: { toString: () => '88.80' },
|
||||
payment_date: '2026-03-08T00:00:00.000Z',
|
||||
paid_at: '2026-03-08T12:00:00.000Z',
|
||||
owner: { legal_entity: { code: 'OWNER_LE' } },
|
||||
submitter: { legal_entity: { code: 'SUBMITTER_LE' } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.dimensions.legalEntityCode).toBe('OWNER_LE');
|
||||
expect(payload.dimensions.costCenterCode).toBeNull();
|
||||
expect(payload.currencyCode).toBe('CNY');
|
||||
});
|
||||
|
||||
it('falls back to submitter legal entity code', () => {
|
||||
const payload = buildReimbursementJournalPayload({
|
||||
reimbursement: {
|
||||
id: 'rb-1',
|
||||
form_number: 'BX-001',
|
||||
owner: { legal_entity: null },
|
||||
submitter: { legal_entity: { code: 'SUBMITTER_LE' } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.dimensions.legalEntityCode).toBe('SUBMITTER_LE');
|
||||
});
|
||||
|
||||
it('builds dedupe key and enqueues standard export event', async () => {
|
||||
const prisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
|
||||
},
|
||||
};
|
||||
const config = { integrationOutboxMaxAttempts: 5 };
|
||||
const reimbursement = {
|
||||
id: 'rb-1',
|
||||
form_number: 'BX-001',
|
||||
owner_id: 'owner-1',
|
||||
submitter_id: 'submitter-1',
|
||||
department_id: 'dept-1',
|
||||
};
|
||||
|
||||
const result = await enqueueReimbursementJournalExport(prisma, config, {
|
||||
reimbursement,
|
||||
actorId: 'actor-1',
|
||||
});
|
||||
|
||||
expect(buildReimbursementJournalDedupeKey('rb-1')).toBe('reimbursement:rb-1:paid');
|
||||
expect(prisma.integrationOutbox.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
event_type_dedupe_key: {
|
||||
event_type: 'erp.journal.export',
|
||||
dedupe_key: 'reimbursement:rb-1:paid',
|
||||
},
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
expect(prisma.integrationOutbox.create).toHaveBeenCalled();
|
||||
expect(result.eventType).toBe('erp.journal.export');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户