const crypto = require('node:crypto'); const { enqueueIntegrationEvent } = require('./outbox'); const PROCUREMENT_TRACK_STATUSES = Object.freeze({ notTriggered: 'not_triggered', skipped: 'skipped', }); const PROCUREMENT_ERP_TARGETS = Object.freeze({ commitmentJournal: 'commitment_journal', accrualJournal: 'accrual_journal', archiveSummary: 'archive_summary', }); 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 normalizeMoney(value) { if (value == null) { return '0.00'; } if (typeof value === 'string') { const trimmed = value.trim(); return trimmed || '0.00'; } if (typeof value?.toString === 'function') { return value.toString(); } return '0.00'; } function resolveCurrencyCode(purchaseRequest) { return normalizeString(purchaseRequest?.currency_code) ?? 'CNY'; } function resolveLegalEntityCode(purchaseRequest) { return normalizeString(purchaseRequest?.legal_entity?.code); } function resolveCostCenterCode(purchaseRequest) { return normalizeString(purchaseRequest?.cost_center?.code); } function resolveSupplierCode(purchaseRequest) { return normalizeString(purchaseRequest?.supplier?.code); } function resolveBudgetPoolCode(purchaseRequest) { return normalizeString(purchaseRequest?.budget_pool?.code); } function resolveDocumentCategoryCode(purchaseRequest) { return normalizeString(purchaseRequest?.document_category?.code); } 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 hashProcurementPayload(value) { return crypto.createHash('sha256').update(stableStringifyJson(value)).digest('hex'); } function buildPurchaseCommitmentJournalPayload(input) { const purchaseRequest = input?.purchaseRequest ?? {}; return { journalType: 'purchase_request_commitment', sourceEventName: 'purchase_request.approved', entityType: 'purchase_request', entityId: purchaseRequest.id ?? null, documentNo: purchaseRequest.request_no ?? null, currencyCode: resolveCurrencyCode(purchaseRequest), totalAmount: normalizeMoney(purchaseRequest.total_amount), occurredAt: toIsoString(input?.occurredAt) ?? toIsoString(purchaseRequest.updated_at) ?? toIsoString(purchaseRequest.created_at), dimensions: { departmentId: purchaseRequest.department_id ?? null, applicantEmployeeId: purchaseRequest.applicant_id ?? null, submitterEmployeeId: purchaseRequest.submitter_id ?? null, legalEntityCode: resolveLegalEntityCode(purchaseRequest), costCenterCode: resolveCostCenterCode(purchaseRequest), supplierCode: resolveSupplierCode(purchaseRequest), budgetPoolCode: resolveBudgetPoolCode(purchaseRequest), }, references: { purchaseRequestId: purchaseRequest.id ?? null, documentCategoryCode: resolveDocumentCategoryCode(purchaseRequest), requestNo: purchaseRequest.request_no ?? null, }, }; } function buildPurchaseAccrualJournalPayload(input) { const purchaseRequest = input?.purchaseRequest ?? {}; const archive = input?.archive ?? purchaseRequest.order_archive ?? {}; return { journalType: 'purchase_order_accrual', sourceEventName: 'purchase_order.archived', entityType: 'purchase_order_archive', entityId: archive.id ?? null, documentNo: archive.po_no ?? null, currencyCode: resolveCurrencyCode(purchaseRequest), totalAmount: normalizeMoney(purchaseRequest.total_amount), occurredAt: toIsoString(input?.occurredAt) ?? toIsoString(archive.archived_at) ?? toIsoString(purchaseRequest.updated_at) ?? toIsoString(purchaseRequest.created_at), dimensions: { departmentId: purchaseRequest.department_id ?? null, applicantEmployeeId: purchaseRequest.applicant_id ?? null, submitterEmployeeId: purchaseRequest.submitter_id ?? null, legalEntityCode: resolveLegalEntityCode(purchaseRequest), costCenterCode: resolveCostCenterCode(purchaseRequest), supplierCode: resolveSupplierCode(purchaseRequest), budgetPoolCode: resolveBudgetPoolCode(purchaseRequest), }, references: { purchaseRequestId: purchaseRequest.id ?? null, purchaseOrderArchiveId: archive.id ?? null, requestNo: purchaseRequest.request_no ?? null, poNo: archive.po_no ?? null, contractNo: archive.contract_no ?? null, }, }; } function buildPurchaseArchiveSummaryPayload(input) { const purchaseRequest = input?.purchaseRequest ?? {}; const archive = input?.archive ?? purchaseRequest.order_archive ?? {}; const archivedFiles = Array.isArray(archive.archived_files) ? archive.archived_files.map((file) => ({ storagePath: file?.storagePath ?? null, originalName: file?.originalName ?? null, mimeType: file?.mimeType ?? null, fileSize: Number(file?.fileSize ?? 0), })) : []; return { exportType: 'purchase_order_archive', sourceEventName: 'purchase_order.archived', entityType: 'purchase_order_archive', entityId: archive.id ?? null, purchaseRequestId: purchaseRequest.id ?? null, requestNo: purchaseRequest.request_no ?? null, poNo: archive.po_no ?? null, contractNo: archive.contract_no ?? null, currencyCode: resolveCurrencyCode(purchaseRequest), totalAmount: normalizeMoney(purchaseRequest.total_amount), archivedAt: toIsoString(input?.archivedAt) ?? toIsoString(archive.archived_at) ?? toIsoString(purchaseRequest.updated_at) ?? toIsoString(purchaseRequest.created_at), supplier: purchaseRequest.supplier ? { id: purchaseRequest.supplier.id, code: purchaseRequest.supplier.code ?? null, name: purchaseRequest.supplier.name ?? null, } : { id: purchaseRequest.supplier_id ?? null, code: null, name: null, }, dimensions: { legalEntityCode: resolveLegalEntityCode(purchaseRequest), costCenterCode: resolveCostCenterCode(purchaseRequest), departmentId: purchaseRequest.department_id ?? null, }, files: archivedFiles, }; } function buildPurchaseCommitmentJournalDedupeKey(purchaseRequestId) { return `purchase_request:${purchaseRequestId}:commitment`; } function buildPurchaseAccrualJournalDedupeKey(archiveId) { return `purchase_order_archive:${archiveId}:accrual`; } function buildPurchaseArchiveSummaryDedupeKey(archiveId) { return `purchase_order_archive:${archiveId}:summary`; } 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 enqueuePurchaseCommitmentJournalExport(prisma, config, input) { const payload = input?.payload ?? buildPurchaseCommitmentJournalPayload({ purchaseRequest: input?.purchaseRequest, occurredAt: input?.occurredAt, }); const enqueued = await enqueueIntegrationEvent(prisma, { eventType: 'erp.journal.export', payload, dedupeKey: buildPurchaseCommitmentJournalDedupeKey(payload.entityId), maxAttempts: config.integrationOutboxMaxAttempts, actorId: input?.actorId ?? null, requeueOnFailure: input?.requeueOnFailure === true, }); return { ...enqueued, eventType: 'erp.journal.export', erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal, payload, }; } async function enqueuePurchaseAccrualJournalExport(prisma, config, input) { const payload = input?.payload ?? buildPurchaseAccrualJournalPayload({ purchaseRequest: input?.purchaseRequest, archive: input?.archive, occurredAt: input?.occurredAt, }); const enqueued = await enqueueIntegrationEvent(prisma, { eventType: 'erp.journal.export', payload, dedupeKey: buildPurchaseAccrualJournalDedupeKey(payload.entityId), maxAttempts: config.integrationOutboxMaxAttempts, actorId: input?.actorId ?? null, requeueOnFailure: input?.requeueOnFailure === true, }); return { ...enqueued, eventType: 'erp.journal.export', erpTarget: PROCUREMENT_ERP_TARGETS.accrualJournal, payload, }; } async function enqueuePurchaseArchiveSummaryExport(prisma, config, input) { const payload = input?.payload ?? buildPurchaseArchiveSummaryPayload({ purchaseRequest: input?.purchaseRequest, archive: input?.archive, archivedAt: input?.archivedAt, }); const enqueued = await enqueueIntegrationEvent(prisma, { eventType: 'erp.procurement.export', payload, dedupeKey: buildPurchaseArchiveSummaryDedupeKey(payload.entityId), maxAttempts: config.integrationOutboxMaxAttempts, actorId: input?.actorId ?? null, requeueOnFailure: input?.requeueOnFailure === true, resetOnPayloadChange: true, }); return { ...enqueued, eventType: 'erp.procurement.export', erpTarget: PROCUREMENT_ERP_TARGETS.archiveSummary, payload, }; } async function loadPurchaseRequestErpContext(prisma, purchaseRequestId) { const purchaseRequest = await prisma.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, request_no: true, applicant_id: true, submitter_id: true, department_id: true, legal_entity_id: true, cost_center_id: true, supplier_id: true, document_category_id: true, budget_pool_id: true, status: true, currency_code: true, total_amount: true, created_at: true, updated_at: true, applicant: { select: { id: true, employee_no: true, name: true, }, }, submitter: { select: { id: true, employee_no: true, name: true, }, }, legal_entity: { select: { id: true, code: true, name: true, }, }, cost_center: { select: { id: true, code: true, name: true, }, }, supplier: { select: { id: true, code: true, name: true, }, }, document_category: { select: { id: true, code: true, name: true, }, }, budget_pool: { select: { id: true, code: true, name: true, }, }, order_archive: { select: { id: true, po_no: true, contract_no: true, archived_files: true, archived_at: true, archived_by: true, }, }, workflow_instances: { where: { entity_type: 'purchase_request' }, orderBy: { created_at: 'desc' }, take: 1, select: { id: true }, }, }, }); if (!purchaseRequest) { return null; } const workflowInstanceId = purchaseRequest.workflow_instances?.[0]?.id ?? null; const approvalHistory = workflowInstanceId ? await prisma.workflowHistory.findFirst({ where: { instance_id: workflowInstanceId, action: 'approve', }, orderBy: { created_at: 'desc' }, select: { created_at: true }, }) : null; return { purchaseRequest, approvalOccurredAt: approvalHistory?.created_at ?? purchaseRequest.updated_at ?? purchaseRequest.created_at ?? null, }; } function hasAnyBinding(binding) { if (!binding) { return false; } return Boolean(binding.externalId || binding.externalCode || binding.lastSyncedAt); } function resolveTrackStageStatus({ eligible, outbox, binding, bindings, gateEnabled }) { if (outbox?.status) { return outbox.status; } if (binding && hasAnyBinding(binding)) { return 'succeeded'; } if (bindings && Object.values(bindings).some((item) => hasAnyBinding(item))) { return 'succeeded'; } if (!eligible) { return PROCUREMENT_TRACK_STATUSES.notTriggered; } if (!gateEnabled) { return PROCUREMENT_TRACK_STATUSES.skipped; } return PROCUREMENT_TRACK_STATUSES.notTriggered; } function buildPurchaseTrackSummary(context) { const purchaseRequest = context?.purchaseRequest ?? {}; const archive = purchaseRequest.order_archive ?? null; return { id: purchaseRequest.id, requestNo: purchaseRequest.request_no ?? null, status: purchaseRequest.status, totalAmount: normalizeMoney(purchaseRequest.total_amount), supplier: purchaseRequest.supplier ? { id: purchaseRequest.supplier.id, code: purchaseRequest.supplier.code ?? null, name: purchaseRequest.supplier.name ?? null, } : null, archiveOrder: archive ? { id: archive.id, poNo: archive.po_no, contractNo: archive.contract_no ?? null, archivedAt: toIsoString(archive.archived_at), } : null, }; } async function buildPurchaseRequestErpTrack(prisma, config, purchaseRequestId, options = {}) { const context = options.context ?? (await loadPurchaseRequestErpContext(prisma, purchaseRequestId)); if (!context) { return null; } const purchaseRequest = context.purchaseRequest; const archive = purchaseRequest.order_archive ?? null; const canManage = Boolean(options.canManage); const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true); const [ commitmentOutboxRow, accrualOutboxRow, summaryOutboxRow, commitmentBindingRow, accrualBindingRow, poBindingRow, contractBindingRow, ] = await Promise.all([ findOutboxByKey( prisma, 'erp.journal.export', buildPurchaseCommitmentJournalDedupeKey(purchaseRequest.id), ), archive ? findOutboxByKey( prisma, 'erp.journal.export', buildPurchaseAccrualJournalDedupeKey(archive.id), ) : Promise.resolve(null), archive ? findOutboxByKey( prisma, 'erp.procurement.export', buildPurchaseArchiveSummaryDedupeKey(archive.id), ) : Promise.resolve(null), findBindingByLocalId(prisma, 'purchase_request_commitment_journal', purchaseRequest.id), archive ? findBindingByLocalId(prisma, 'purchase_order_accrual_journal', archive.id) : Promise.resolve(null), archive ? findBindingByLocalId(prisma, 'purchase_order_po', archive.id) : Promise.resolve(null), archive ? findBindingByLocalId(prisma, 'purchase_order_contract', archive.id) : Promise.resolve(null), ]); const commitmentPreview = buildPurchaseCommitmentJournalPayload({ purchaseRequest, occurredAt: context.approvalOccurredAt, }); const accrualPreview = archive ? buildPurchaseAccrualJournalPayload({ purchaseRequest, archive, occurredAt: archive.archived_at, }) : {}; const summaryPreview = archive ? buildPurchaseArchiveSummaryPayload({ purchaseRequest, archive, archivedAt: archive.archived_at, }) : {}; const commitmentBinding = redactBinding(mapIntegrationBinding(commitmentBindingRow), canManage); const accrualBinding = redactBinding(mapIntegrationBinding(accrualBindingRow), canManage); const poBinding = redactBinding(mapIntegrationBinding(poBindingRow), canManage); const contractBinding = redactBinding(mapIntegrationBinding(contractBindingRow), canManage); const commitmentOutbox = mapIntegrationOutboxState(commitmentOutboxRow); const accrualOutbox = mapIntegrationOutboxState(accrualOutboxRow); const summaryOutbox = mapIntegrationOutboxState(summaryOutboxRow); const commitmentEligible = purchaseRequest.status === 'approved'; const archiveEligible = Boolean(archive); return { purchaseRequest: buildPurchaseTrackSummary(context), stages: { commitmentJournal: { eligible: commitmentEligible, reason: commitmentEligible ? null : 'purchase_request_not_approved', status: resolveTrackStageStatus({ eligible: commitmentEligible, outbox: commitmentOutbox, binding: commitmentBinding, gateEnabled, }), preview: commitmentPreview, outbox: commitmentOutbox, binding: commitmentBinding, }, accrualJournal: { eligible: archiveEligible, reason: archiveEligible ? null : 'purchase_order_archive_missing', status: resolveTrackStageStatus({ eligible: archiveEligible, outbox: accrualOutbox, binding: accrualBinding, gateEnabled, }), preview: accrualPreview, outbox: accrualOutbox, binding: accrualBinding, }, archiveSummary: { eligible: archiveEligible, reason: archiveEligible ? null : 'purchase_order_archive_missing', status: resolveTrackStageStatus({ eligible: archiveEligible, outbox: summaryOutbox, bindings: { po: poBinding, contract: contractBinding, }, gateEnabled, }), preview: summaryPreview, outbox: summaryOutbox, bindings: { po: poBinding, contract: contractBinding, }, }, }, permissions: { canRetry: Boolean(canManage && gateEnabled), }, }; } module.exports = { PROCUREMENT_ERP_TARGETS, PROCUREMENT_TRACK_STATUSES, buildPurchaseAccrualJournalDedupeKey, buildPurchaseAccrualJournalPayload, buildPurchaseArchiveSummaryDedupeKey, buildPurchaseArchiveSummaryPayload, buildPurchaseCommitmentJournalDedupeKey, buildPurchaseCommitmentJournalPayload, buildPurchaseRequestErpTrack, enqueuePurchaseAccrualJournalExport, enqueuePurchaseArchiveSummaryExport, enqueuePurchaseCommitmentJournalExport, hashProcurementPayload, loadPurchaseRequestErpContext, stableStringifyJson, };