feat(P19ERP3-1,P19ERP3-2): 实现资产ERP摘要服务与建卡/流转出站联动

这个提交包含在:
2026-03-09 11:15:21 +08:00
父节点 7613d7d912
当前提交 0168ebfe2e
共修改 10 个文件,包含 1128 行新增和 4 行删除
@@ -177,6 +177,38 @@ describe('dispatchOutboxEvent', () => {
);
});
it('records asset binding for asset export', async () => {
const prisma = {
integrationBinding: {
upsert: vi.fn().mockResolvedValue({ id: 'binding-asset-1' }),
},
};
await dispatchOutboxEvent({
prisma,
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
eventType: 'erp.asset.export',
payload: {
exportType: 'asset_ledger_summary',
entityType: 'asset_card',
entityId: 'asset-1',
assetNo: 'ZC-001',
},
});
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
provider_entity_type_local_id: {
provider: 'erp',
entity_type: 'asset_erp_card',
local_id: 'asset-1',
},
},
}),
);
});
it('loads IM channel and dispatches mock wecom message', async () => {
const prisma = {
notificationChannel: {
+148
查看文件
@@ -0,0 +1,148 @@
const assetsRoutes = require('../../../src/routes/assets');
const { resolveAssetLedgerSummaryChange } = require('../../../src/services/integrations/erpAsset');
describe('asset ERP helper', () => {
const { handleAssetLedgerSummaryErpSync } = assetsRoutes.__testables;
function createRequest() {
return {
id: 'req-1',
ip: '127.0.0.1',
headers: {},
employee: {
id: 'actor-1',
permissionCodes: ['asset.manage'],
},
};
}
function createTx() {
return {
auditLog: {
create: vi.fn().mockResolvedValue({ id: 'audit-1' }),
},
};
}
function createAsset(overrides = {}) {
return {
id: 'asset-1',
asset_no: 'ZC-001',
legal_entity_id: 'le-1',
cost_center_id: 'cc-1',
status: 'in_use',
...overrides,
};
}
it('auto enqueues asset export when gate is enabled', async () => {
const tx = createTx();
const evaluateGate = vi.fn().mockResolvedValue({
enabled: true,
reason: 'department_deferred',
});
const enqueueExport = vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'pending',
duplicated: false,
requeued: false,
eventType: 'erp.asset.export',
});
const result = await handleAssetLedgerSummaryErpSync({
tx,
config: {},
request: createRequest(),
actorId: 'actor-1',
asset: createAsset(),
sourceEventName: 'asset.card.created',
changeType: 'created',
changedFields: [],
evaluateGate,
enqueueExport,
});
expect(enqueueExport).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: 'asset_card',
target_id: 'asset-1',
}),
}),
);
expect(result.skipped).toBe(false);
});
it('writes skip audit when asset ERP gate is disabled', async () => {
const tx = createTx();
const evaluateGate = vi.fn().mockResolvedValue({ enabled: false, reason: 'flag_force_off' });
const enqueueExport = vi.fn();
const result = await handleAssetLedgerSummaryErpSync({
tx,
config: {},
request: createRequest(),
actorId: 'actor-1',
asset: createAsset(),
sourceEventName: 'asset.card.created',
changeType: 'created',
changedFields: [],
evaluateGate,
enqueueExport,
});
expect(enqueueExport).not.toHaveBeenCalled();
expect(tx.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
target_type: 'asset_card',
target_id: 'asset-1',
}),
}),
);
expect(result.skipped).toBe(true);
});
it('only marks financial field and retired status changes as ERP-relevant', () => {
const current = createAsset();
expect(
resolveAssetLedgerSummaryChange(current, {
...current,
name: '新资产名称',
}),
).toBeNull();
expect(
resolveAssetLedgerSummaryChange(current, {
...current,
legal_entity_id: 'le-2',
}),
).toEqual({
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'financial_dimensions_changed',
changedFields: ['legalEntityId'],
});
expect(
resolveAssetLedgerSummaryChange(current, {
...current,
status: 'retired',
}),
).toEqual({
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'retired',
changedFields: ['status'],
});
});
});
+138
查看文件
@@ -0,0 +1,138 @@
const {
buildAssetLedgerSummaryDedupeKey,
buildAssetLedgerSummaryPayload,
enqueueAssetLedgerSummaryExport,
hashAssetPayload,
resolveAssetLedgerSummaryChange,
} = require('../../../src/services/integrations/erpAsset');
describe('ERP Asset Service', () => {
function createAsset(overrides = {}) {
return {
id: 'asset-1',
asset_no: 'ZC-001',
name: 'ThinkPad X1',
status: 'in_use',
location: '上海办公室-A区',
activated_at: '2026-03-09T00:00:00.000Z',
retired_at: null,
created_at: '2026-03-09T08:00:00.000Z',
updated_at: '2026-03-09T08:00:00.000Z',
legal_entity_id: 'le-1',
cost_center_id: 'cc-1',
purchase_request_id: 'pr-1',
assignee_employee_id: 'emp-1',
category: {
id: 'cat-1',
code: 'LAPTOP',
name: '笔记本电脑',
document_category: { code: 'ASSET_STD' },
},
legal_entity: { id: 'le-1', code: 'HY', name: '海音达' },
cost_center: { id: 'cc-1', code: 'IT001', name: 'IT中心' },
purchase_request: { id: 'pr-1', request_no: 'PR-001', title: '办公电脑采购' },
assignee: { id: 'emp-1', employee_no: 'E0001', name: '张三' },
...overrides,
};
}
it('builds asset ledger summary payload for create event', () => {
const payload = buildAssetLedgerSummaryPayload({
asset: createAsset(),
sourceEventName: 'asset.card.created',
changeType: 'created',
changedFields: [],
occurredAt: '2026-03-09T08:00:00.000Z',
});
expect(payload.exportType).toBe('asset_ledger_summary');
expect(payload.sourceEventName).toBe('asset.card.created');
expect(payload.changeType).toBe('created');
expect(payload.assetNo).toBe('ZC-001');
expect(payload.category.code).toBe('LAPTOP');
expect(payload.legalEntity.code).toBe('HY');
expect(payload.costCenter.code).toBe('IT001');
expect(payload.purchaseRequest.requestNo).toBe('PR-001');
expect(payload.assignee.employeeNo).toBe('E0001');
});
it('detects financial-dimension changes only', () => {
const previousAsset = createAsset();
expect(
resolveAssetLedgerSummaryChange(previousAsset, {
...previousAsset,
name: 'ThinkPad X1 Carbon Gen 12',
location: '上海办公室-B区',
}),
).toBeNull();
expect(
resolveAssetLedgerSummaryChange(previousAsset, {
...previousAsset,
cost_center_id: 'cc-2',
}),
).toEqual({
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'financial_dimensions_changed',
changedFields: ['costCenterId'],
});
expect(
resolveAssetLedgerSummaryChange(previousAsset, {
...previousAsset,
status: 'retired',
}),
).toEqual({
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'retired',
changedFields: ['status'],
});
});
it('builds deterministic dedupe key and payload hash', () => {
const asset = createAsset();
const payload = buildAssetLedgerSummaryPayload({ asset, changedFields: [] });
const changedPayload = buildAssetLedgerSummaryPayload({
asset: createAsset({ cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' }, cost_center_id: 'cc-2' }),
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'financial_dimensions_changed',
changedFields: ['costCenterId'],
});
expect(buildAssetLedgerSummaryDedupeKey('asset-1')).toBe('asset_card:asset-1:ledger_summary');
expect(hashAssetPayload(payload)).not.toBe(hashAssetPayload(changedPayload));
});
it('resets same outbox when asset payload changes', async () => {
const prisma = {
integrationOutbox: {
findUnique: vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'succeeded',
payload: { assetNo: 'ZC-001', costCenter: { id: 'cc-1', code: 'IT001' } },
}),
update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
},
};
const result = await enqueueAssetLedgerSummaryExport(
prisma,
{ integrationOutboxMaxAttempts: 5 },
{
actorId: 'actor-1',
asset: createAsset({
cost_center_id: 'cc-2',
cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' },
}),
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'financial_dimensions_changed',
changedFields: ['costCenterId'],
},
);
expect(prisma.integrationOutbox.update).toHaveBeenCalled();
expect(result.eventType).toBe('erp.asset.export');
expect(result.requeued).toBe(true);
});
});
@@ -167,6 +167,59 @@ describe('ERP Sync Services', () => {
expect(result.retryable).toBe(true);
});
it('returns mock asset export result when provider is mock', async () => {
const result = await erp.exportAssetToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ assetNo: 'ZC-001', entityId: 'asset-1' },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.response.externalAssetId).toContain('ZC-001');
expect(result.response.externalAssetNo).toContain('ZC-001');
});
it('handles http asset 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.exportAssetToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpAssetExportEndpoint: 'https://example.com/assets',
erpAssetTimeoutMs: 500,
},
{ exportType: 'asset_ledger_summary', assetNo: 'ZC-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_asset_http_4xx');
expect(result.retryable).toBe(false);
});
it('handles http asset export timeout', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
const result = await erp.exportAssetToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpAssetExportEndpoint: 'https://example.com/assets',
erpAssetTimeoutMs: 500,
},
{ exportType: 'asset_ledger_summary', assetNo: 'ZC-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_asset_timeout');
expect(result.retryable).toBe(true);
});
it('returns invalid provider if unsupported', async () => {
const result = await erp.exportJournalToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'invalid_xyz' },