feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口

这个提交包含在:
2026-03-08 16:29:05 +08:00
父节点 a1b1d7cfc9
当前提交 855bcdffeb
共修改 14 个文件,包含 1361 行新增和 145 行删除
@@ -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');
});
});