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, };