feat(P19ERP1-1,P19ERP1-2): 新增ERP主数据同步模型与服务
这个提交包含在:
@@ -0,0 +1,145 @@
|
||||
const {
|
||||
normalizeRequestedResources,
|
||||
syncErpMasterData,
|
||||
} = require('../../../src/services/integrations/erpMasterData');
|
||||
|
||||
function createPrismaMock() {
|
||||
const syncStates = new Map();
|
||||
|
||||
const prisma = {
|
||||
$transaction: async (fn) => fn(prisma),
|
||||
integrationSyncState: {
|
||||
upsert: vi.fn().mockImplementation(async ({ where, create, update }) => {
|
||||
const key = `${where.provider_resource_type.provider}:${where.provider_resource_type.resource_type}`;
|
||||
const current = syncStates.get(key) ?? null;
|
||||
const next = current ? { ...current, ...update } : { ...create };
|
||||
syncStates.set(key, next);
|
||||
return next;
|
||||
}),
|
||||
findMany: vi.fn().mockImplementation(async () => Array.from(syncStates.values())),
|
||||
},
|
||||
integrationBinding: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
||||
},
|
||||
legalEntity: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue({ id: 'le-1' }),
|
||||
update: vi.fn().mockResolvedValue({ id: 'le-1' }),
|
||||
},
|
||||
costCenter: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue({ id: 'cc-1' }),
|
||||
update: vi.fn().mockResolvedValue({ id: 'cc-1' }),
|
||||
},
|
||||
supplier: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue({ id: 'sup-1' }),
|
||||
update: vi.fn().mockResolvedValue({ id: 'sup-1' }),
|
||||
},
|
||||
};
|
||||
|
||||
return prisma;
|
||||
}
|
||||
|
||||
describe('ERP Master Data Service', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('normalizes resource order and dedupes input', () => {
|
||||
expect(normalizeRequestedResources(['supplier', 'legal_entity', 'supplier'])).toEqual([
|
||||
'legal_entity',
|
||||
'supplier',
|
||||
]);
|
||||
expect(normalizeRequestedResources([])).toEqual(['legal_entity', 'cost_center', 'supplier']);
|
||||
});
|
||||
|
||||
it('skips supplier rows without stable code', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: vi.fn().mockResolvedValue({
|
||||
suppliers: [{ code: '', name: 'bad supplier' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await syncErpMasterData(prisma, {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'http',
|
||||
erpMasterDataEndpoint: 'https://example.com/master-data',
|
||||
erpMasterDataTimeoutMs: 500,
|
||||
}, {
|
||||
resources: ['supplier'],
|
||||
actorId: 'actor-1',
|
||||
logger: { error: vi.fn() },
|
||||
});
|
||||
|
||||
const supplierState = result.items.find((item) => item.resourceType === 'supplier');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(supplierState.lastResult.skipped).toBe(1);
|
||||
expect(prisma.supplier.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips cost centers when legal entity is missing', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: vi.fn().mockResolvedValue({
|
||||
costCenters: [{ code: 'IT001', legalEntityCode: 'MISSING', name: 'IT中心' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await syncErpMasterData(prisma, {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'http',
|
||||
erpMasterDataEndpoint: 'https://example.com/master-data',
|
||||
erpMasterDataTimeoutMs: 500,
|
||||
}, {
|
||||
resources: ['cost_center'],
|
||||
actorId: 'actor-1',
|
||||
logger: { error: vi.fn() },
|
||||
});
|
||||
|
||||
const costCenterState = result.items.find((item) => item.resourceType === 'cost_center');
|
||||
expect(costCenterState.lastResult.skipped).toBe(1);
|
||||
expect(costCenterState.lastResult.sampleSkipped[0].reason).toBe('legal_entity_missing');
|
||||
});
|
||||
|
||||
it('skips rows when integration binding conflicts', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.supplier.findMany.mockResolvedValue([{ id: 'sup-local', code: 'SUP001', enabled: true, archived_at: null }]);
|
||||
prisma.integrationBinding.findFirst.mockResolvedValue({ local_id: 'another-local' });
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: vi.fn().mockResolvedValue({
|
||||
suppliers: [{ code: 'SUP001', externalId: 'ERP-SUP-001', name: '供应商' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await syncErpMasterData(prisma, {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'http',
|
||||
erpMasterDataEndpoint: 'https://example.com/master-data',
|
||||
erpMasterDataTimeoutMs: 500,
|
||||
}, {
|
||||
resources: ['supplier'],
|
||||
actorId: 'actor-1',
|
||||
logger: { error: vi.fn() },
|
||||
});
|
||||
|
||||
const supplierState = result.items.find((item) => item.resourceType === 'supplier');
|
||||
expect(supplierState.lastResult.skipped).toBe(1);
|
||||
expect(supplierState.lastResult.sampleSkipped[0].reason).toBe('binding_conflict');
|
||||
expect(prisma.supplier.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,109 @@
|
||||
const erp = require('../../../../src/services/integrations/erp');
|
||||
|
||||
const erp = require("../../../../src/services/integrations/erp");
|
||||
describe('ERP Sync Services', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("ERP Sync Services", () => {
|
||||
it("builds correct payload map", () => {
|
||||
const payload = erp.buildErpPayload({
|
||||
reimbursement: {
|
||||
id: "r1",
|
||||
form_number: "F001",
|
||||
payment_date: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(payload.formNumber).toBe("F001");
|
||||
expect(payload.paymentDate).toBe("2026-03-01");
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds reimbursement journal payload via compat helper', () => {
|
||||
const payload = erp.buildErpPayload({
|
||||
reimbursement: {
|
||||
id: 'r1',
|
||||
form_number: 'F001',
|
||||
owner_id: 'owner-1',
|
||||
submitter_id: 'submitter-1',
|
||||
department_id: 'dept-1',
|
||||
total_amount: { toString: () => '100.00' },
|
||||
payment_voucher_no: 'PV001',
|
||||
payment_date: '2026-03-01T12:00:00.000Z',
|
||||
paid_at: '2026-03-02T08:00:00.000Z',
|
||||
owner: { legal_entity: { code: 'HY' } },
|
||||
},
|
||||
});
|
||||
|
||||
it("returns mock synced when provider is mock", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
{ formNumber: "F001" }
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.code).toBe("mock_synced");
|
||||
expect(payload.journalType).toBe('reimbursement_paid');
|
||||
expect(payload.documentNo).toBe('F001');
|
||||
expect(payload.paymentDate).toBe('2026-03-01');
|
||||
expect(payload.dimensions.legalEntityCode).toBe('HY');
|
||||
});
|
||||
|
||||
it('returns mock synced when journal export provider is mock', async () => {
|
||||
const result = await erp.exportJournalToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||
{ documentNo: 'BX-001' },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.code).toBe('mock_synced');
|
||||
expect(result.response.externalId).toContain('BX-001');
|
||||
});
|
||||
|
||||
it('returns mock master data result when provider is mock', async () => {
|
||||
const result = await erp.pullMasterDataFromErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||
{ resources: ['legal_entity', 'supplier'] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.code).toBe('mock_synced');
|
||||
expect(result.data.legalEntities).toEqual([]);
|
||||
expect(result.data.suppliers).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles http master data 4xx errors', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: vi.fn().mockResolvedValue({ code: 'invalid_payload' }),
|
||||
});
|
||||
|
||||
it("returns invalid provider if unsupported", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: "invalid_xyz" },
|
||||
{}
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe("erp_sync_provider_invalid");
|
||||
});
|
||||
const result = await erp.pullMasterDataFromErp(
|
||||
{
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'http',
|
||||
erpMasterDataEndpoint: 'https://example.com/master-data',
|
||||
erpMasterDataTimeoutMs: 500,
|
||||
},
|
||||
{ resources: ['legal_entity'] },
|
||||
);
|
||||
|
||||
it("returns disabled if disabled in config", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp({ erpSyncEnabled: false }, {});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe("erp_sync_disabled");
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe('erp_master_data_http_4xx');
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('handles http journal export timeout', async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
|
||||
|
||||
const result = await erp.exportJournalToErp(
|
||||
{
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'http',
|
||||
erpJournalExportEndpoint: 'https://example.com/journals',
|
||||
erpSyncTimeoutMs: 500,
|
||||
},
|
||||
{ documentNo: 'BX-001' },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe('erp_sync_timeout');
|
||||
expect(result.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns invalid provider if unsupported', async () => {
|
||||
const result = await erp.exportJournalToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: 'invalid_xyz' },
|
||||
{},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe('erp_sync_provider_invalid');
|
||||
});
|
||||
|
||||
it('returns disabled if ERP sync is disabled', async () => {
|
||||
const result = await erp.exportJournalToErp({ erpSyncEnabled: false }, {});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe('erp_sync_disabled');
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户