diff --git a/server/src/config.js b/server/src/config.js index 9b25141a..ed789056 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -110,11 +110,15 @@ function getConfig() { erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "", erpJournalExportEndpoint: process.env.ERP_JOURNAL_EXPORT_ENDPOINT ?? "", erpProcurementExportEndpoint: process.env.ERP_PROCUREMENT_EXPORT_ENDPOINT ?? "", + erpAssetExportEndpoint: process.env.ERP_ASSET_EXPORT_ENDPOINT ?? "", erpMasterDataEndpoint: process.env.ERP_MASTER_DATA_ENDPOINT ?? "", erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000), erpProcurementTimeoutMs: Number( process.env.ERP_PROCUREMENT_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, ), + erpAssetTimeoutMs: Number( + process.env.ERP_ASSET_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, + ), erpMasterDataTimeoutMs: Number( process.env.ERP_MASTER_DATA_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, ), diff --git a/server/src/jobs/integrationOutboxJob.js b/server/src/jobs/integrationOutboxJob.js index 3a1d6a8f..b065f696 100644 --- a/server/src/jobs/integrationOutboxJob.js +++ b/server/src/jobs/integrationOutboxJob.js @@ -52,6 +52,11 @@ async function runIntegrationOutboxOnce(prisma, config, logger) { journalType: item.payload?.journalType ?? null, poNo: item.payload?.poNo ?? item.payload?.references?.poNo ?? null, contractNo: item.payload?.contractNo ?? item.payload?.references?.contractNo ?? null, + assetId: + item.payload?.assetId ?? + (item.payload?.entityType === 'asset_card' ? item.payload?.entityId ?? null : null), + assetNo: item.payload?.assetNo ?? null, + changeType: item.payload?.changeType ?? null, entityType: item.payload?.entityType ?? null, entityId: item.payload?.entityId ?? null, channelId: item.payload?.channelId ?? null, diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 5c83e6b4..9154dda8 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -4,6 +4,12 @@ const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString } = require("../utils/date"); const { issueDocumentNumber } = require("../services/documentSequence"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags'); +const { + ASSET_ERP_TARGET, + enqueueAssetLedgerSummaryExport, + resolveAssetLedgerSummaryChange, +} = require('../services/integrations/erpAsset'); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); @@ -557,6 +563,92 @@ async function ensureApprovedPurchaseRequest(tx, purchaseRequestId) { return record; } +async function handleAssetLedgerSummaryErpSync({ + tx, + config, + request, + actorId, + asset, + sourceEventName, + changeType, + changedFields, + evaluateGate = evaluateFeatureFlag, + enqueueExport = enqueueAssetLedgerSummaryExport, +}) { + const gate = await evaluateGate({ + prisma: tx, + config, + flagKey: FEATURE_FLAG_KEYS.erpSync, + departmentId: null, + strictDepartment: false, + }); + + if (!gate.enabled) { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId, + action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', + targetType: 'asset_card', + targetId: asset.id, + extra: { + erpTarget: ASSET_ERP_TARGET, + eventType: 'erp.asset.export', + assetId: asset.id, + assetNo: asset.asset_no, + changeType, + gateReason: gate.reason, + featureFlag: FEATURE_FLAG_KEYS.erpSync, + }, + }), + }); + + return { + skipped: true, + gate, + asset, + }; + } + + const enqueued = await enqueueExport(tx, config, { + asset, + sourceEventName, + changeType, + changedFields, + occurredAt: new Date(), + actorId, + requeueOnFailure: false, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId, + action: 'ERP_SYNC_ENQUEUED', + targetType: 'asset_card', + targetId: asset.id, + extra: { + erpTarget: ASSET_ERP_TARGET, + eventType: enqueued.eventType, + assetId: asset.id, + assetNo: asset.asset_no, + outboxId: enqueued.id, + duplicated: enqueued.duplicated, + requeued: enqueued.requeued, + changeType, + changedFields: Array.isArray(changedFields) ? changedFields : [], + gateReason: gate.reason, + featureFlag: FEATURE_FLAG_KEYS.erpSync, + }, + }), + }); + + return { + skipped: false, + gate, + asset, + enqueued, + }; +} + async function assetsRoutes(fastify) { fastify.get( "/asset-categories", @@ -843,6 +935,17 @@ async function assetsRoutes(fastify) { }), }); + await handleAssetLedgerSummaryErpSync({ + tx, + config: fastify.config, + request, + actorId: request.employee.id, + asset: created, + sourceEventName: 'asset.card.created', + changeType: 'created', + changedFields: [], + }); + return created; }); @@ -953,6 +1056,20 @@ async function assetsRoutes(fastify) { }), }); + const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated); + if (assetLedgerSummaryChange) { + await handleAssetLedgerSummaryErpSync({ + tx, + config: fastify.config, + request, + actorId: request.employee.id, + asset: updated, + sourceEventName: assetLedgerSummaryChange.sourceEventName, + changeType: assetLedgerSummaryChange.changeType, + changedFields: assetLedgerSummaryChange.changedFields, + }); + } + return updated; }); @@ -962,3 +1079,6 @@ async function assetsRoutes(fastify) { } module.exports = assetsRoutes; +module.exports.__testables = { + handleAssetLedgerSummaryErpSync, +}; diff --git a/server/src/services/integrations/dispatcher.js b/server/src/services/integrations/dispatcher.js index 14c54041..1ed15b31 100644 --- a/server/src/services/integrations/dispatcher.js +++ b/server/src/services/integrations/dispatcher.js @@ -1,5 +1,6 @@ const { pushPaidReimbursementToErp, + exportAssetToErp, exportJournalToErp, exportProcurementToErp, } = require('./erp'); @@ -136,6 +137,17 @@ function resolveBindingSpecs(eventType, payload, response) { return specs.filter(Boolean); } + if (eventType === 'erp.asset.export') { + return [ + createBindingSpec( + 'asset_erp_card', + payload?.entityId, + extractResponseValue(response, 'externalAssetId') ?? extractResponseValue(response, 'externalId'), + extractResponseValue(response, 'externalAssetNo') ?? extractResponseValue(response, 'externalCode') ?? payload?.assetNo ?? null, + ), + ].filter(Boolean); + } + return []; } @@ -179,9 +191,11 @@ async function dispatchErpEvent({ prisma, config, eventType, payload }) { const result = eventType === 'erp.procurement.export' ? await exportProcurementToErp(config, payload) - : eventType === 'erp.journal.export' - ? await exportJournalToErp(config, payload) - : await pushPaidReimbursementToErp(config, payload); + : eventType === 'erp.asset.export' + ? await exportAssetToErp(config, payload) + : eventType === 'erp.journal.export' + ? await exportJournalToErp(config, payload) + : await pushPaidReimbursementToErp(config, payload); if (!result.ok) { throw toDispatchError(result); @@ -200,7 +214,8 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) { if ( eventType === 'erp.reimbursement.paid' || eventType === 'erp.journal.export' || - eventType === 'erp.procurement.export' + eventType === 'erp.procurement.export' || + eventType === 'erp.asset.export' ) { return dispatchErpEvent({ prisma, config, eventType, payload }); } diff --git a/server/src/services/integrations/erp.js b/server/src/services/integrations/erp.js index d93c320c..5b2f69c6 100644 --- a/server/src/services/integrations/erp.js +++ b/server/src/services/integrations/erp.js @@ -41,6 +41,13 @@ function resolveProcurementExportEndpoint(config) { ); } +function resolveAssetExportEndpoint(config) { + return ( + String(config.erpAssetExportEndpoint ?? '').trim() || + String(config.erpSyncEndpoint ?? '').trim() + ); +} + async function parseResponseBody(response) { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { @@ -236,6 +243,55 @@ async function exportJournalToErp(config, payload) { }; } +async function exportAssetToErp(config, payload) { + if (!config.erpSyncEnabled) { + return { + ok: false, + code: 'erp_sync_disabled', + message: 'ERP sync disabled', + retryable: false, + }; + } + + const provider = normalizeProvider(config); + if (provider === 'mock') { + const identity = payload?.assetNo ?? payload?.entityId ?? 'UNKNOWN'; + return { + ok: true, + code: 'mock_synced', + message: 'mock ERP asset synced', + response: { + externalAssetId: `ERP-ASSET-${identity}`, + externalAssetNo: `ERP-ASSET-${identity}`, + }, + }; + } + + if (provider === 'http') { + return postJsonToErp(config, { + endpoint: resolveAssetExportEndpoint(config), + timeoutMs: coerceTimeout(config.erpAssetTimeoutMs, config.erpSyncTimeoutMs), + payload, + successCode: 'http_synced', + successMessage: 'ERP asset synced', + missingEndpointCode: 'erp_asset_endpoint_missing', + missingEndpointMessage: 'ERP asset endpoint missing', + http4xxCode: 'erp_asset_http_4xx', + http5xxCode: 'erp_asset_http_5xx', + timeoutCode: 'erp_asset_timeout', + networkCode: 'erp_asset_network_error', + messagePrefix: 'ERP asset sync', + }); + } + + return { + ok: false, + code: 'erp_sync_provider_invalid', + message: `Unsupported ERP sync provider: ${provider}`, + retryable: false, + }; +} + async function exportProcurementToErp(config, payload) { if (!config.erpSyncEnabled) { return { @@ -299,6 +355,7 @@ async function pushPaidReimbursementToErp(config, payload) { module.exports = { buildErpPayload, + exportAssetToErp, exportJournalToErp, exportProcurementToErp, normalizeProvider, diff --git a/server/src/services/integrations/erpAsset.js b/server/src/services/integrations/erpAsset.js new file mode 100644 index 00000000..ca5caf1f --- /dev/null +++ b/server/src/services/integrations/erpAsset.js @@ -0,0 +1,552 @@ +const crypto = require('node:crypto'); + +const { enqueueIntegrationEvent } = require('./outbox'); + +const ASSET_ERP_EVENT_TYPE = 'erp.asset.export'; +const ASSET_ERP_BINDING_ENTITY_TYPE = 'asset_erp_card'; +const ASSET_ERP_TARGET = 'asset_ledger_summary'; +const ASSET_TRACK_STATUSES = Object.freeze({ + notTriggered: 'not_triggered', + skipped: 'skipped', +}); +const ASSET_TRACK_ACTIONS = Object.freeze({ + none: 'none', + backfill: 'backfill', + retry: 'retry', + resync: 'resync', +}); +const ASSET_EXPORTABLE_STATUS_CHANGES = new Set(['retired', 'archived']); + +const ASSET_ERP_CONTEXT_SELECT = { + id: true, + asset_no: true, + name: true, + category_id: true, + legal_entity_id: true, + cost_center_id: true, + purchase_request_id: true, + serial_no: true, + location: true, + status: true, + assignee_employee_id: true, + activated_at: true, + retired_at: true, + created_at: true, + updated_at: true, + category: { + select: { + id: true, + code: true, + name: true, + document_category: { + select: { + code: true, + }, + }, + }, + }, + legal_entity: { + select: { + id: true, + code: true, + name: true, + }, + }, + cost_center: { + select: { + id: true, + code: true, + name: true, + }, + }, + purchase_request: { + select: { + id: true, + request_no: true, + title: true, + status: true, + }, + }, + assignee: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, +}; + +function normalizeString(value) { + const text = String(value ?? '').trim(); + return text || null; +} + +function toIsoString(value) { + if (!value) { + return null; + } + + try { + return new Date(value).toISOString(); + } catch { + return null; + } +} + +function toDateOnly(value) { + const iso = toIsoString(value); + return iso ? iso.slice(0, 10) : null; +} + +function stableNormalizeJson(value) { + if (Array.isArray(value)) { + return value.map((item) => stableNormalizeJson(item)); + } + + if (value && typeof value === 'object' && value.constructor === Object) { + return Object.keys(value) + .sort() + .reduce((result, key) => { + result[key] = stableNormalizeJson(value[key]); + return result; + }, {}); + } + + return value; +} + +function stableStringifyJson(value) { + return JSON.stringify(stableNormalizeJson(value)); +} + +function hashAssetPayload(payload) { + return crypto.createHash('sha256').update(stableStringifyJson(payload)).digest('hex'); +} + +function buildAssetLedgerSummaryDedupeKey(assetId) { + return `asset_card:${assetId}:ledger_summary`; +} + +function resolveAssetChangeType(status, changedFields) { + const normalizedStatus = normalizeString(status); + if (normalizedStatus === 'retired') { + return 'retired'; + } + if (normalizedStatus === 'archived') { + return 'archived'; + } + return changedFields?.length ? 'financial_dimensions_changed' : 'created'; +} + +function buildAssetLedgerSummaryPayload(input) { + const asset = input?.asset ?? {}; + const changedFields = Array.isArray(input?.changedFields) + ? Array.from(new Set(input.changedFields.filter(Boolean))) + : []; + const sourceEventName = normalizeString(input?.sourceEventName) ?? 'asset.card.created'; + const changeType = normalizeString(input?.changeType) ?? resolveAssetChangeType(asset.status, changedFields); + + return { + exportType: 'asset_ledger_summary', + sourceEventName, + changeType, + entityType: 'asset_card', + entityId: asset.id ?? null, + assetNo: asset.asset_no ?? null, + name: asset.name ?? null, + status: asset.status ?? null, + location: normalizeString(asset.location), + activatedAt: toDateOnly(asset.activated_at), + retiredAt: toDateOnly(asset.retired_at), + occurredAt: + toIsoString(input?.occurredAt) ?? + toIsoString(asset.updated_at) ?? + toIsoString(asset.created_at), + changedFields, + category: asset.category + ? { + id: asset.category.id, + code: asset.category.code ?? null, + name: asset.category.name ?? null, + documentCategoryCode: asset.category.document_category?.code ?? null, + } + : { + id: asset.category_id ?? null, + code: null, + name: null, + documentCategoryCode: null, + }, + legalEntity: asset.legal_entity + ? { + id: asset.legal_entity.id, + code: asset.legal_entity.code ?? null, + name: asset.legal_entity.name ?? null, + } + : { + id: asset.legal_entity_id ?? null, + code: null, + name: null, + }, + costCenter: asset.cost_center + ? { + id: asset.cost_center.id, + code: asset.cost_center.code ?? null, + name: asset.cost_center.name ?? null, + } + : { + id: asset.cost_center_id ?? null, + code: null, + name: null, + }, + purchaseRequest: asset.purchase_request + ? { + id: asset.purchase_request.id, + requestNo: asset.purchase_request.request_no ?? null, + title: asset.purchase_request.title ?? null, + } + : { + id: asset.purchase_request_id ?? null, + requestNo: null, + title: null, + }, + assignee: asset.assignee + ? { + id: asset.assignee.id, + employeeNo: asset.assignee.employee_no ?? null, + name: asset.assignee.name ?? null, + } + : { + id: asset.assignee_employee_id ?? null, + employeeNo: null, + name: null, + }, + }; +} + +function resolveAssetLedgerSummaryChange(previousAsset, nextAsset) { + if (!previousAsset || !nextAsset) { + return null; + } + + const changedFields = []; + if (previousAsset.legal_entity_id !== nextAsset.legal_entity_id) { + changedFields.push('legalEntityId'); + } + if (previousAsset.cost_center_id !== nextAsset.cost_center_id) { + changedFields.push('costCenterId'); + } + + const nextStatus = normalizeString(nextAsset.status); + if (previousAsset.status !== nextAsset.status && ASSET_EXPORTABLE_STATUS_CHANGES.has(nextStatus)) { + changedFields.push('status'); + } + + if (!changedFields.length) { + return null; + } + + return { + sourceEventName: 'asset.ledger.summary.updated', + changeType: resolveAssetChangeType(nextAsset.status, changedFields), + changedFields, + }; +} + +function resolveAssetLedgerSummaryDrift(asset, previousPayload) { + if (!asset || !previousPayload) { + return null; + } + + const changedFields = []; + const previousLegalEntityId = previousPayload.legalEntity?.id ?? null; + const previousCostCenterId = previousPayload.costCenter?.id ?? null; + const previousStatus = previousPayload.status ?? null; + + if ((asset.legal_entity_id ?? null) !== previousLegalEntityId) { + changedFields.push('legalEntityId'); + } + if ((asset.cost_center_id ?? null) !== previousCostCenterId) { + changedFields.push('costCenterId'); + } + + const assetStatus = normalizeString(asset.status); + if (ASSET_EXPORTABLE_STATUS_CHANGES.has(assetStatus) && assetStatus !== previousStatus) { + changedFields.push('status'); + } + + if (!changedFields.length) { + return null; + } + + return { + sourceEventName: 'asset.ledger.summary.updated', + changeType: resolveAssetChangeType(asset.status, changedFields), + changedFields, + }; +} + +function mapIntegrationBinding(record) { + if (!record) { + return null; + } + + return { + id: record.id, + provider: record.provider, + entityType: record.entity_type, + localId: record.local_id, + externalId: record.external_id ?? null, + externalCode: record.external_code ?? null, + lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, + }; +} + +function redactBinding(binding, canManage) { + if (!binding) { + return null; + } + + if (canManage) { + return binding; + } + + return { + ...binding, + externalId: null, + externalCode: null, + }; +} + +function mapIntegrationOutboxState(record) { + if (!record) { + return null; + } + + return { + id: record.id, + eventType: record.event_type, + status: record.status, + attempts: Number(record.attempts ?? 0), + lastError: record.last_error ?? null, + processedAt: record.processed_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + }; +} + +async function findOutboxByKey(prisma, eventType, dedupeKey) { + if (!prisma || !eventType || !dedupeKey) { + return null; + } + + return prisma.integrationOutbox.findUnique({ + where: { + event_type_dedupe_key: { + event_type: eventType, + dedupe_key: dedupeKey, + }, + }, + }); +} + +async function findBindingByLocalId(prisma, entityType, localId) { + if (!prisma || !entityType || !localId) { + return null; + } + + return prisma.integrationBinding.findUnique({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: entityType, + local_id: localId, + }, + }, + }); +} + +async function enqueueAssetLedgerSummaryExport(prisma, config, input) { + const payload = + input?.payload ?? + buildAssetLedgerSummaryPayload({ + asset: input?.asset, + sourceEventName: input?.sourceEventName, + changeType: input?.changeType, + occurredAt: input?.occurredAt, + changedFields: input?.changedFields, + }); + + const enqueued = await enqueueIntegrationEvent(prisma, { + eventType: ASSET_ERP_EVENT_TYPE, + payload, + dedupeKey: buildAssetLedgerSummaryDedupeKey(payload.entityId), + maxAttempts: config.integrationOutboxMaxAttempts, + actorId: input?.actorId ?? null, + requeueOnFailure: input?.requeueOnFailure === true, + resetOnPayloadChange: true, + }); + + return { + ...enqueued, + eventType: ASSET_ERP_EVENT_TYPE, + erpTarget: ASSET_ERP_TARGET, + payload, + }; +} + +async function loadAssetErpContext(prisma, assetId) { + const asset = await prisma.assetCard.findUnique({ + where: { id: assetId }, + select: ASSET_ERP_CONTEXT_SELECT, + }); + + if (!asset) { + return null; + } + + return { asset }; +} + +function buildAssetTrackSummary(context) { + const asset = context?.asset ?? {}; + return { + id: asset.id, + assetNo: asset.asset_no ?? null, + name: asset.name ?? null, + status: asset.status ?? null, + legalEntity: asset.legal_entity + ? { + id: asset.legal_entity.id, + code: asset.legal_entity.code ?? null, + name: asset.legal_entity.name ?? null, + } + : null, + costCenter: asset.cost_center + ? { + id: asset.cost_center.id, + code: asset.cost_center.code ?? null, + name: asset.cost_center.name ?? null, + } + : null, + purchaseRequest: asset.purchase_request + ? { + id: asset.purchase_request.id, + requestNo: asset.purchase_request.request_no ?? null, + title: asset.purchase_request.title ?? null, + } + : null, + }; +} + +function buildAssetTrackPreview(asset, options = {}) { + return { + changeType: options.changeType ?? 'created', + sourceEventName: options.sourceEventName ?? 'asset.card.created', + categoryCode: asset?.category?.code ?? null, + legalEntityCode: asset?.legal_entity?.code ?? null, + costCenterCode: asset?.cost_center?.code ?? null, + status: asset?.status ?? null, + }; +} + +function resolveTrackStatus({ outbox, binding, gateEnabled }) { + if (outbox?.status) { + return outbox.status; + } + if (binding) { + return 'succeeded'; + } + if (!gateEnabled) { + return ASSET_TRACK_STATUSES.skipped; + } + return ASSET_TRACK_STATUSES.notTriggered; +} + +function resolveTrackAction({ canManage, gateEnabled, outbox, binding, drift }) { + if (!canManage || !gateEnabled) { + return ASSET_TRACK_ACTIONS.none; + } + + if (outbox && ['failed', 'dead_letter'].includes(outbox.status)) { + return ASSET_TRACK_ACTIONS.retry; + } + + if (!outbox && !binding) { + return ASSET_TRACK_ACTIONS.backfill; + } + + if (drift) { + return ASSET_TRACK_ACTIONS.resync; + } + + return ASSET_TRACK_ACTIONS.none; +} + +async function buildAssetErpTrack(prisma, config, assetId, options = {}) { + const context = options.context ?? (await loadAssetErpContext(prisma, assetId)); + if (!context?.asset) { + return null; + } + + const asset = context.asset; + const canManage = Boolean(options.canManage); + const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true); + const dedupeKey = buildAssetLedgerSummaryDedupeKey(asset.id); + + const [outboxRow, bindingRow] = await Promise.all([ + findOutboxByKey(prisma, ASSET_ERP_EVENT_TYPE, dedupeKey), + findBindingByLocalId(prisma, ASSET_ERP_BINDING_ENTITY_TYPE, asset.id), + ]); + + const outbox = mapIntegrationOutboxState(outboxRow); + const binding = redactBinding(mapIntegrationBinding(bindingRow), canManage); + const drift = resolveAssetLedgerSummaryDrift(asset, outboxRow?.payload ?? null); + const preview = buildAssetTrackPreview(asset, { + changeType: drift?.changeType ?? outboxRow?.payload?.changeType ?? 'created', + sourceEventName: + drift?.sourceEventName ?? + outboxRow?.payload?.sourceEventName ?? + 'asset.card.created', + }); + + return { + asset: buildAssetTrackSummary(context), + stage: { + eligible: true, + reason: !gateEnabled && !outboxRow && !bindingRow ? (options.gate?.effectiveReason ?? options.gate?.reason ?? 'erp_sync_disabled') : null, + deliveryStatus: resolveTrackStatus({ outbox, binding: bindingRow, gateEnabled }), + actionHint: resolveTrackAction({ + canManage, + gateEnabled, + outbox, + binding: bindingRow, + drift, + }), + preview, + outbox, + binding, + }, + permissions: { + canRetry: Boolean(canManage && gateEnabled), + canSeeExternalRefs: canManage, + }, + }; +} + +module.exports = { + ASSET_ERP_BINDING_ENTITY_TYPE, + ASSET_ERP_CONTEXT_SELECT, + ASSET_ERP_EVENT_TYPE, + ASSET_ERP_TARGET, + ASSET_TRACK_ACTIONS, + ASSET_TRACK_STATUSES, + buildAssetErpTrack, + buildAssetLedgerSummaryDedupeKey, + buildAssetLedgerSummaryPayload, + buildAssetTrackPreview, + enqueueAssetLedgerSummaryExport, + hashAssetPayload, + loadAssetErpContext, + resolveAssetLedgerSummaryChange, + resolveAssetLedgerSummaryDrift, + stableStringifyJson, +}; diff --git a/server/tests/unit/integrations.dispatcher.test.js b/server/tests/unit/integrations.dispatcher.test.js index 7a37ca7c..9faa3d68 100644 --- a/server/tests/unit/integrations.dispatcher.test.js +++ b/server/tests/unit/integrations.dispatcher.test.js @@ -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: { diff --git a/server/tests/unit/routes/assets.erp.test.js b/server/tests/unit/routes/assets.erp.test.js new file mode 100644 index 00000000..8a543f83 --- /dev/null +++ b/server/tests/unit/routes/assets.erp.test.js @@ -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'], + }); + }); +}); diff --git a/server/tests/unit/services/erpAsset.test.js b/server/tests/unit/services/erpAsset.test.js new file mode 100644 index 00000000..36a4cfcc --- /dev/null +++ b/server/tests/unit/services/erpAsset.test.js @@ -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); + }); +}); diff --git a/server/tests/unit/services/integrations/erp.test.js b/server/tests/unit/services/integrations/erp.test.js index 616b5933..b23df1d0 100644 --- a/server/tests/unit/services/integrations/erp.test.js +++ b/server/tests/unit/services/integrations/erp.test.js @@ -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' },