feat(P19ERP3-1,P19ERP3-2): 实现资产ERP摘要服务与建卡/流转出站联动
这个提交包含在:
@@ -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: {
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
在新工单中引用
屏蔽一个用户