const { enqueueIntegrationEvent } = require('./outbox'); const ERP_JOURNAL_EVENT_TYPES = Object.freeze(['erp.journal.export', 'erp.reimbursement.paid']); const REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX = 'reimbursement'; const TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX = 'travel_order'; function toDateOnly(value) { if (!value) { return null; } try { return new Date(value).toISOString().slice(0, 10); } catch { return null; } } function toIsoString(value) { if (!value) { return null; } try { return new Date(value).toISOString(); } catch { return null; } } function resolveReimbursementLegalEntityCode(reimbursement) { return reimbursement?.owner?.legal_entity?.code ?? reimbursement?.submitter?.legal_entity?.code ?? null; } function resolveTravelRequestLegalEntityCode(travelOrder) { return travelOrder?.travel_request?.legal_entity?.code ?? null; } function resolveTravelRequestCostCenterCode(travelOrder) { return travelOrder?.travel_request?.cost_center?.code ?? null; } function resolveTravelRequestBudgetPoolCode(travelOrder) { return travelOrder?.travel_request?.budget_pool?.code ?? null; } function buildReimbursementJournalPayload(input) { const reimbursement = input?.reimbursement ?? {}; const occurredAt = toIsoString(reimbursement.paid_at) ?? (reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null); return { journalType: 'reimbursement_paid', sourceEventName: 'reimbursement.paid', entityType: 'reimbursement_form', entityId: reimbursement.id, documentNo: reimbursement.form_number ?? null, currencyCode: 'CNY', totalAmount: reimbursement.total_amount?.toString?.() ?? null, occurredAt, paymentVoucherNo: reimbursement.payment_voucher_no ?? null, paymentDate: toDateOnly(reimbursement.payment_date), dimensions: { departmentId: reimbursement.department_id ?? null, ownerEmployeeId: reimbursement.owner_id ?? null, submitterEmployeeId: reimbursement.submitter_id ?? null, legalEntityCode: resolveReimbursementLegalEntityCode(reimbursement), costCenterCode: null, }, reimbursementId: reimbursement.id, formNumber: reimbursement.form_number ?? null, }; } function buildTravelOrderPaymentJournalPayload(input) { const travelOrder = input?.travelOrder ?? {}; const travelRequest = travelOrder?.travel_request ?? {}; const occurredAt = toIsoString(travelOrder.paid_at) ?? (travelOrder.payment_date ? `${toDateOnly(travelOrder.payment_date)}T00:00:00.000Z` : null) ?? toIsoString(travelOrder.updated_at) ?? toIsoString(travelOrder.created_at); return { journalType: 'travel_order_payment', sourceEventName: 'travel_order.paid', entityType: 'travel_order', entityId: travelOrder.id ?? null, documentNo: travelOrder.external_order_no ?? null, currencyCode: travelOrder.currency ?? 'CNY', totalAmount: travelOrder.amount?.toString?.() ?? null, occurredAt, paymentVoucherNo: travelOrder.payment_voucher_no ?? null, paymentDate: toDateOnly(travelOrder.payment_date), paymentChannel: travelOrder.payment_channel ?? null, dimensions: { departmentId: travelOrder.department_id ?? travelRequest.department_id ?? null, employeeId: travelOrder.employee_id ?? null, applicantEmployeeId: travelRequest.applicant_employee_id ?? null, submitterEmployeeId: travelRequest.submitter_id ?? null, legalEntityCode: resolveTravelRequestLegalEntityCode(travelOrder), costCenterCode: resolveTravelRequestCostCenterCode(travelOrder), budgetPoolCode: resolveTravelRequestBudgetPoolCode(travelOrder), }, references: { travelOrderId: travelOrder.id ?? null, travelRequestId: travelOrder.travel_request_id ?? null, requestNo: travelRequest.request_no ?? null, externalOrderNo: travelOrder.external_order_no ?? null, source: travelOrder.source ?? null, linkedReimbursementId: travelOrder.linked_reimbursement_id ?? null, }, }; } function buildReimbursementJournalDedupeKey(reimbursementId) { return `${REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX}:${reimbursementId}:paid`; } function buildTravelOrderPaymentJournalDedupeKey(travelOrderId) { return `${TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX}:${travelOrderId}:paid`; } async function enqueueReimbursementJournalExport(prisma, config, input) { const payload = input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement }); const enqueued = await enqueueIntegrationEvent(prisma, { eventType: 'erp.journal.export', payload, dedupeKey: buildReimbursementJournalDedupeKey(payload.entityId), maxAttempts: config.integrationOutboxMaxAttempts, actorId: input?.actorId ?? null, requeueOnFailure: true, }); return { ...enqueued, eventType: 'erp.journal.export', payload, }; } async function enqueueTravelOrderPaymentJournalExport(prisma, config, input) { const payload = input?.payload ?? buildTravelOrderPaymentJournalPayload({ travelOrder: input?.travelOrder }); const enqueued = await enqueueIntegrationEvent(prisma, { eventType: 'erp.journal.export', payload, dedupeKey: buildTravelOrderPaymentJournalDedupeKey(payload.entityId), maxAttempts: config.integrationOutboxMaxAttempts, actorId: input?.actorId ?? null, requeueOnFailure: true, }); return { ...enqueued, eventType: 'erp.journal.export', payload, }; } 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 mapIntegrationOutboxState(record) { if (!record) { return null; } return { id: record.id, eventType: record.event_type, status: record.status, lastError: record.last_error ?? null, processedAt: record.processed_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) { const current = await prisma.integrationOutbox.findFirst({ where: { event_type: 'erp.journal.export', dedupe_key: buildReimbursementJournalDedupeKey(reimbursementId), }, orderBy: { updated_at: 'desc' }, }); if (current) { return current; } return prisma.integrationOutbox.findFirst({ where: { event_type: 'erp.reimbursement.paid', dedupe_key: reimbursementId, }, orderBy: { updated_at: 'desc' }, }); } async function findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrderId) { return prisma.integrationOutbox.findFirst({ where: { event_type: 'erp.journal.export', dedupe_key: buildTravelOrderPaymentJournalDedupeKey(travelOrderId), }, orderBy: { updated_at: 'desc' }, }); } async function buildReimbursementJournalPreview(prisma, reimbursement) { const [binding, outbox] = await Promise.all([ prisma.integrationBinding.findUnique({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'reimbursement_journal', local_id: reimbursement.id, }, }, }), findLatestReimbursementJournalOutbox(prisma, reimbursement.id), ]); const journal = buildReimbursementJournalPayload({ reimbursement }); const exportState = { externalId: binding?.external_id ?? null, lastOutboxId: outbox?.id ?? null, lastOutboxStatus: outbox?.status ?? null, lastError: outbox?.last_error ?? null, exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null, }; return { journal, exportState, binding: mapIntegrationBinding(binding), outbox: mapIntegrationOutboxState(outbox), }; } async function buildTravelOrderPaymentJournalPreview(prisma, travelOrderInput) { const travelOrder = travelOrderInput?.travel_request ? travelOrderInput : await prisma.travelOrder.findUnique({ where: { id: travelOrderInput.id }, select: { id: true, source: true, external_order_no: true, employee_id: true, department_id: true, linked_reimbursement_id: true, travel_request_id: true, amount: true, currency: true, payment_voucher_no: true, payment_date: true, payment_channel: true, paid_at: true, created_at: true, updated_at: true, travel_request: { select: { id: true, request_no: true, department_id: true, applicant_employee_id: true, submitter_id: true, legal_entity: { select: { code: true } }, cost_center: { select: { code: true } }, budget_pool: { select: { code: true } }, }, }, }, }); if (!travelOrder) { return { journal: null, exportState: { externalId: null, lastOutboxId: null, lastOutboxStatus: null, lastError: null, exportedAt: null, }, binding: null, outbox: null, }; } const [binding, outbox] = await Promise.all([ prisma.integrationBinding.findUnique({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'travel_order_payment_journal', local_id: travelOrder.id, }, }, }), findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrder.id), ]); const journal = buildTravelOrderPaymentJournalPayload({ travelOrder }); const exportState = { externalId: binding?.external_id ?? null, lastOutboxId: outbox?.id ?? null, lastOutboxStatus: outbox?.status ?? null, lastError: outbox?.last_error ?? null, exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null, }; return { journal, exportState, binding: mapIntegrationBinding(binding), outbox: mapIntegrationOutboxState(outbox), }; } module.exports = { ERP_JOURNAL_EVENT_TYPES, buildReimbursementJournalDedupeKey, buildReimbursementJournalPayload, buildReimbursementJournalPreview, buildTravelOrderPaymentJournalDedupeKey, buildTravelOrderPaymentJournalPayload, buildTravelOrderPaymentJournalPreview, enqueueReimbursementJournalExport, enqueueTravelOrderPaymentJournalExport, findLatestReimbursementJournalOutbox, findLatestTravelOrderPaymentJournalOutbox, };