const { AppError } = require("../utils/AppError"); const TRAVEL_RECONCILIATION_BATCH_STATUSES = Object.freeze({ draft: "draft", reconciled: "reconciled", }); const TRAVEL_RECONCILIATION_ITEM_KINDS = Object.freeze({ order: "order", orphanTransaction: "orphan_transaction", }); const TRAVEL_RECONCILIATION_MATCH_STATUSES = Object.freeze({ matched: "matched", amountMismatch: "amount_mismatch", currencyMismatch: "currency_mismatch", missingTransaction: "missing_transaction", duplicateCharge: "duplicate_charge", orphanTransaction: "orphan_transaction", reversedAfterPayment: "reversed_after_payment", }); const TRAVEL_RECONCILIATION_RESOLUTION_STATUSES = Object.freeze({ pending: "pending", confirmed: "confirmed", }); function buildValidationError(details, statusCode = 409) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function toMoneyNumber(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { return 0; } return Number(numeric.toFixed(2)); } function toMoneyFixed(value) { return toMoneyNumber(value).toFixed(2); } function toDateOnlyString(value) { if (!value) { return null; } try { return new Date(value).toISOString().slice(0, 10); } catch { return null; } } function normalizeSummaryJson(value) { const summary = value && typeof value === "object" ? value : {}; return { totalOrders: Number(summary.totalOrders ?? 0), totalTransactions: Number(summary.totalTransactions ?? 0), matchedItems: Number(summary.matchedItems ?? 0), pendingItems: Number(summary.pendingItems ?? 0), anomalyItems: Number(summary.anomalyItems ?? 0), totalOrderAmount: toMoneyFixed(summary.totalOrderAmount), totalSettledAmount: toMoneyFixed(summary.totalSettledAmount), totalReversedAmount: toMoneyFixed(summary.totalReversedAmount), matchRate: toMoneyFixed(summary.matchRate), }; } function isSettledCharge(transaction) { return transaction?.txn_type === "charge" && transaction?.status === "settled"; } function isReversedTransaction(transaction) { return ["refund", "reversal"].includes(String(transaction?.txn_type ?? "")) || transaction?.status === "reversed"; } function sumTransactionAmounts(items, predicate) { return items.reduce((total, item) => { if (!predicate(item)) { return total; } return toMoneyNumber(total + toMoneyNumber(item.amount)); }, 0); } function buildReasonDetail(matchStatus, order, settledCharges, reversedTransactions) { if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched) { return `订单 ${order.external_order_no} 与已结算交易精确匹配。`; } if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction) { return `订单 ${order.external_order_no} 未找到已结算的 charge 交易。`; } if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge) { return `订单 ${order.external_order_no} 关联到 ${settledCharges.length} 条已结算 charge 交易,无法判定唯一匹配。`; } if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch) { return `订单 ${order.external_order_no} 与交易 ${settledCharges[0]?.external_txn_id ?? "-"} 币种不一致。`; } if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch) { return `订单 ${order.external_order_no} 与交易 ${settledCharges[0]?.external_txn_id ?? "-"} 金额不一致。`; } if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment) { return `订单 ${order.external_order_no} 在支付后存在 ${reversedTransactions.length} 条 refund/reversal 交易。`; } return `订单 ${order.external_order_no} 存在待人工确认的差异。`; } function analyzeOrderTransactions(order, orderTransactions = []) { const settledCharges = orderTransactions.filter(isSettledCharge); const reversedTransactions = orderTransactions.filter(isReversedTransaction); const settledAmount = sumTransactionAmounts(settledCharges, () => true); const primaryTransaction = settledCharges[0] ?? reversedTransactions[0] ?? null; const exactMatch = settledCharges.length === 1 && String(order.currency ?? "").toUpperCase() === String(primaryTransaction?.currency ?? "").toUpperCase() && toMoneyFixed(order.amount) === toMoneyFixed(primaryTransaction?.amount); let matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction; if (reversedTransactions.length > 0) { matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment; } else if (settledCharges.length === 0) { matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction; } else if (settledCharges.length > 1) { matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge; } else if (String(order.currency ?? "").toUpperCase() !== String(primaryTransaction?.currency ?? "").toUpperCase()) { matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch; } else if (!exactMatch) { matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch; } else { matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.matched; } const anomalyCode = matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched ? null : matchStatus; const resolutionStatus = matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched ? TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed : TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending; return { primaryTransaction, matchStatus, anomalyCode, resolutionStatus, transactionAmount: toMoneyFixed(settledAmount || primaryTransaction?.amount), matchedTransactionIds: orderTransactions.map((item) => item.id), reasonDetail: buildReasonDetail(matchStatus, order, settledCharges, reversedTransactions), }; } function buildInvoiceStatsMap(invoices = []) { const stats = new Map(); for (const invoice of invoices) { if (!invoice.travel_order_id) { continue; } const current = stats.get(invoice.travel_order_id) ?? { count: 0, amount: 0, }; current.count += 1; current.amount = toMoneyNumber(current.amount + toMoneyNumber(invoice.total_amount)); stats.set(invoice.travel_order_id, current); } return stats; } function buildOrderItem({ order, orderTransactions = [], invoiceStatsMap, actorId, now }) { const analysis = analyzeOrderTransactions(order, orderTransactions); const invoiceStats = invoiceStatsMap.get(order.id) ?? { count: 0, amount: 0 }; const primaryTransaction = analysis.primaryTransaction; const autoConfirmed = analysis.resolutionStatus === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed; return { item_kind: TRAVEL_RECONCILIATION_ITEM_KINDS.order, travel_request_id: order.travel_request_id ?? null, travel_order_id: order.id, card_transaction_id: primaryTransaction?.id ?? null, match_status: analysis.matchStatus, resolution_status: analysis.resolutionStatus, order_amount: toMoneyFixed(order.amount), transaction_amount: analysis.transactionAmount, currency: order.currency ?? primaryTransaction?.currency ?? "CNY", anomaly_code: analysis.anomalyCode, snapshot_json: { requestNo: order.travel_request?.request_no ?? null, externalOrderNo: order.external_order_no, externalTxnId: primaryTransaction?.external_txn_id ?? null, travelOrderStatus: order.status, transactionStatus: primaryTransaction?.status ?? null, linkedReimbursementId: order.linked_reimbursement_id ?? null, boundInvoiceCount: invoiceStats.count, boundInvoiceAmount: toMoneyFixed(invoiceStats.amount), matchedTransactionIds: analysis.matchedTransactionIds, reasonDetail: analysis.reasonDetail, }, confirmed_at: autoConfirmed ? now : null, confirmed_by: autoConfirmed ? actorId : null, confirm_note: null, }; } function buildOrphanTransactionItem({ transaction, actorId, now }) { return { item_kind: TRAVEL_RECONCILIATION_ITEM_KINDS.orphanTransaction, travel_request_id: transaction.travel_request_id ?? null, travel_order_id: transaction.travel_order_id ?? null, card_transaction_id: transaction.id, match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction, resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, order_amount: null, transaction_amount: toMoneyFixed(transaction.amount), currency: transaction.currency ?? "CNY", anomaly_code: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction, snapshot_json: { requestNo: transaction.travel_request?.request_no ?? null, externalOrderNo: transaction.travel_order?.external_order_no ?? null, externalTxnId: transaction.external_txn_id, travelOrderStatus: transaction.travel_order?.status ?? null, transactionStatus: transaction.status, linkedReimbursementId: transaction.travel_order?.linked_reimbursement_id ?? null, boundInvoiceCount: 0, boundInvoiceAmount: toMoneyFixed(0), matchedTransactionIds: [transaction.id], reasonDetail: `交易 ${transaction.external_txn_id} 未匹配到本批次范围内的差旅订单。`, }, confirmed_at: null, confirmed_by: null, confirm_note: null, }; } function buildTravelReconciliationSummary({ items, orders, transactions }) { const matchedItems = items.filter((item) => item.match_status === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched).length; const pendingItems = items.filter((item) => item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending).length; const anomalyItems = items.length - matchedItems; const totalOrderAmount = orders.reduce((total, item) => toMoneyNumber(total + toMoneyNumber(item.amount)), 0); const totalSettledAmount = transactions.reduce((total, item) => { if (!isSettledCharge(item)) { return total; } return toMoneyNumber(total + toMoneyNumber(item.amount)); }, 0); const totalReversedAmount = transactions.reduce((total, item) => { if (!isReversedTransaction(item)) { return total; } return toMoneyNumber(total + toMoneyNumber(item.amount)); }, 0); const matchRate = items.length ? (matchedItems / items.length) * 100 : 0; return { totalOrders: orders.length, totalTransactions: transactions.length, matchedItems, pendingItems, anomalyItems, totalOrderAmount: toMoneyFixed(totalOrderAmount), totalSettledAmount: toMoneyFixed(totalSettledAmount), totalReversedAmount: toMoneyFixed(totalReversedAmount), matchRate: toMoneyFixed(matchRate), }; } function buildReconciliationItemsSnapshot({ orders = [], transactions = [], invoices = [], actorId = null, now = new Date() }) { const invoiceStatsMap = buildInvoiceStatsMap(invoices); const ordersById = new Map(orders.map((item) => [item.id, item])); const transactionsByOrderId = new Map(); for (const transaction of transactions) { if (!transaction.travel_order_id) { continue; } const list = transactionsByOrderId.get(transaction.travel_order_id) ?? []; list.push(transaction); transactionsByOrderId.set(transaction.travel_order_id, list); } const items = orders.map((order) => buildOrderItem({ order, orderTransactions: transactionsByOrderId.get(order.id) ?? [], invoiceStatsMap, actorId, now, }), ); const orphanTransactions = transactions.filter( (transaction) => !transaction.travel_order_id || !ordersById.has(transaction.travel_order_id), ); for (const transaction of orphanTransactions) { items.push(buildOrphanTransactionItem({ transaction, actorId, now })); } return { items, summary: buildTravelReconciliationSummary({ items, orders, transactions }), }; } function buildOrderWhere({ legalEntityId, costCenterId, periodStart, periodEnd, periodEndExclusive }) { return { status: "paid", travel_request: { legal_entity_id: legalEntityId, ...(costCenterId ? { cost_center_id: costCenterId } : {}), }, OR: [ { payment_date: { gte: periodStart, lte: periodEnd, }, }, { AND: [ { payment_date: null }, { paid_at: { gte: periodStart, lt: periodEndExclusive } }, ], }, ], }; } function buildTransactionWhere({ legalEntityId, costCenterId, periodStart, periodEndExclusive }) { return { status: { in: ["settled", "reversed"] }, travel_request: { legal_entity_id: legalEntityId, ...(costCenterId ? { cost_center_id: costCenterId } : {}), }, occurred_at: { gte: periodStart, lt: periodEndExclusive, }, }; } const ORDER_SCOPE_SELECT = { id: true, source: true, external_order_no: true, linked_reimbursement_id: true, travel_request_id: true, amount: true, currency: true, status: true, payment_date: true, paid_at: true, travel_request: { select: { id: true, request_no: true, legal_entity_id: true, cost_center_id: true, }, }, }; const TRANSACTION_SCOPE_SELECT = { id: true, source: true, external_txn_id: true, travel_request_id: true, travel_order_id: true, amount: true, currency: true, txn_type: true, status: true, occurred_at: true, travel_request: { select: { id: true, request_no: true, }, }, travel_order: { select: { id: true, external_order_no: true, linked_reimbursement_id: true, status: true, }, }, }; async function loadReconciliationScope(prisma, { legalEntityId, costCenterId = null, periodStart, periodEnd }) { const normalizedStart = new Date(`${toDateOnlyString(periodStart)}T00:00:00.000Z`); const normalizedEnd = new Date(`${toDateOnlyString(periodEnd)}T00:00:00.000Z`); const periodEndExclusive = new Date(normalizedEnd.getTime() + 24 * 60 * 60 * 1000); const [orders, transactions] = await Promise.all([ prisma.travelOrder.findMany({ where: buildOrderWhere({ legalEntityId, costCenterId, periodStart: normalizedStart, periodEnd: normalizedEnd, periodEndExclusive, }), orderBy: [{ payment_date: "asc" }, { paid_at: "asc" }, { created_at: "asc" }], select: ORDER_SCOPE_SELECT, }), prisma.cardTransaction.findMany({ where: buildTransactionWhere({ legalEntityId, costCenterId, periodStart: normalizedStart, periodEndExclusive, }), orderBy: [{ occurred_at: "asc" }, { created_at: "asc" }], select: TRANSACTION_SCOPE_SELECT, }), ]); const orderIds = orders.map((item) => item.id); const invoices = orderIds.length ? await prisma.invoicePool.findMany({ where: { travel_order_id: { in: orderIds } }, select: { id: true, travel_order_id: true, total_amount: true, }, }) : []; return { orders, transactions, invoices, periodStart: normalizedStart, periodEnd: normalizedEnd, }; } function mapBatchSummary(batch) { return { id: batch.id, batchNo: batch.batch_no, legalEntityId: batch.legal_entity_id, costCenterId: batch.cost_center_id ?? null, periodStart: toDateOnlyString(batch.period_start), periodEnd: toDateOnlyString(batch.period_end), status: batch.status, summary: normalizeSummaryJson(batch.summary_json), confirmedAt: batch.confirmed_at?.toISOString?.() ?? null, confirmedBy: batch.confirmed_by ?? null, createdAt: batch.created_at?.toISOString?.() ?? null, updatedAt: batch.updated_at?.toISOString?.() ?? null, legalEntity: batch.legal_entity ? { id: batch.legal_entity.id, code: batch.legal_entity.code, name: batch.legal_entity.name, } : null, costCenter: batch.cost_center ? { id: batch.cost_center.id, code: batch.cost_center.code, name: batch.cost_center.name, legalEntityId: batch.cost_center.legal_entity_id, } : null, confirmedByEmployee: batch.confirmed_by_employee ? { id: batch.confirmed_by_employee.id, employeeNo: batch.confirmed_by_employee.employee_no, name: batch.confirmed_by_employee.name, } : null, }; } function mapBatchItem(item, { canManagePayments = false, batchStatus = TRAVEL_RECONCILIATION_BATCH_STATUSES.draft } = {}) { const snapshot = item.snapshot_json && typeof item.snapshot_json === "object" ? item.snapshot_json : {}; return { id: item.id, itemKind: item.item_kind, travelRequestId: item.travel_request_id ?? null, travelOrderId: item.travel_order_id ?? null, cardTransactionId: item.card_transaction_id ?? null, matchStatus: item.match_status, resolutionStatus: item.resolution_status, orderAmount: item.order_amount?.toString?.() ?? null, transactionAmount: item.transaction_amount?.toString?.() ?? null, currency: item.currency, anomalyCode: item.anomaly_code ?? null, confirmedAt: item.confirmed_at?.toISOString?.() ?? null, confirmedBy: item.confirmed_by ?? null, confirmNote: item.confirm_note ?? null, snapshot: { requestNo: snapshot.requestNo ?? null, externalOrderNo: snapshot.externalOrderNo ?? null, externalTxnId: snapshot.externalTxnId ?? null, travelOrderStatus: snapshot.travelOrderStatus ?? null, transactionStatus: snapshot.transactionStatus ?? null, linkedReimbursementId: snapshot.linkedReimbursementId ?? null, boundInvoiceCount: Number(snapshot.boundInvoiceCount ?? 0), boundInvoiceAmount: toMoneyFixed(snapshot.boundInvoiceAmount), matchedTransactionIds: Array.isArray(snapshot.matchedTransactionIds) ? snapshot.matchedTransactionIds : [], reasonDetail: snapshot.reasonDetail ?? null, }, travelRequest: item.travel_request ? { id: item.travel_request.id, requestNo: item.travel_request.request_no ?? null, } : null, travelOrder: item.travel_order ? { id: item.travel_order.id, externalOrderNo: item.travel_order.external_order_no, status: item.travel_order.status, } : null, cardTransaction: item.card_transaction ? { id: item.card_transaction.id, externalTxnId: item.card_transaction.external_txn_id, status: item.card_transaction.status, txnType: item.card_transaction.txn_type, } : null, actions: { canConfirm: canManagePayments && batchStatus === TRAVEL_RECONCILIATION_BATCH_STATUSES.draft && item.match_status !== TRAVEL_RECONCILIATION_MATCH_STATUSES.matched && item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, canReopen: canManagePayments && batchStatus === TRAVEL_RECONCILIATION_BATCH_STATUSES.draft && item.match_status !== TRAVEL_RECONCILIATION_MATCH_STATUSES.matched && item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed, }, }; } const BATCH_SELECT = { id: true, batch_no: true, legal_entity_id: true, cost_center_id: true, period_start: true, period_end: true, status: true, summary_json: true, confirmed_at: true, confirmed_by: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, }, }, cost_center: { select: { id: true, code: true, name: true, legal_entity_id: true, }, }, confirmed_by_employee: { select: { id: true, employee_no: true, name: true, }, }, }; const BATCH_DETAIL_SELECT = { ...BATCH_SELECT, items: { orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, item_kind: true, travel_request_id: true, travel_order_id: true, card_transaction_id: true, match_status: true, resolution_status: true, order_amount: true, transaction_amount: true, currency: true, anomaly_code: true, snapshot_json: true, confirmed_at: true, confirmed_by: true, confirm_note: true, created_at: true, updated_at: true, travel_request: { select: { id: true, request_no: true, }, }, travel_order: { select: { id: true, external_order_no: true, status: true, }, }, card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true, }, }, }, }, }; async function listTravelReconciliationBatches(prisma, query) { const skip = (query.page - 1) * query.pageSize; const where = { ...(query.status ? { status: query.status } : {}), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), ...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}), ...(query.periodStart ? { period_end: { gte: query.periodStart } } : {}), ...(query.periodEnd ? { period_start: { lte: query.periodEnd } } : {}), }; const [items, total] = await Promise.all([ prisma.travelReconciliationBatch.findMany({ where, orderBy: [{ created_at: "desc" }, { id: "desc" }], skip, take: query.pageSize, select: BATCH_SELECT, }), prisma.travelReconciliationBatch.count({ where }), ]); return { page: query.page, pageSize: query.pageSize, total, items: items.map(mapBatchSummary), }; } async function loadTravelReconciliationBatchDetail(prisma, batchId, options = {}) { const batch = await prisma.travelReconciliationBatch.findUnique({ where: { id: batchId }, select: BATCH_DETAIL_SELECT, }); if (!batch) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return { ...mapBatchSummary(batch), items: batch.items.map((item) => mapBatchItem(item, { canManagePayments: options.canManagePayments, batchStatus: batch.status })), permissions: { canManagePayments: Boolean(options.canManagePayments), canRetryErp: Boolean(options.canRetryErp), }, }; } function ensureDraftBatch(batch) { if (batch.status !== TRAVEL_RECONCILIATION_BATCH_STATUSES.draft) { throw buildValidationError({ batch: "reconciliation_batch_not_editable" }); } } function ensurePendingReconciliationItem(item) { if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending) { throw buildValidationError({ item: "reconciliation_item_not_pending" }); } } function ensureConfirmedReconciliationItem(item) { if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed) { throw buildValidationError({ item: "reconciliation_item_not_confirmed" }); } } function ensureReopenableReconciliationItem(item) { if (item.match_status === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched) { throw buildValidationError({ item: "matched_item_not_reopenable" }); } } function ensureBatchFinalizable(items = []) { if (items.some((item) => item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed)) { throw buildValidationError({ batch: "reconciliation_batch_has_pending_items" }); } } module.exports = { TRAVEL_RECONCILIATION_BATCH_STATUSES, TRAVEL_RECONCILIATION_ITEM_KINDS, TRAVEL_RECONCILIATION_MATCH_STATUSES, TRAVEL_RECONCILIATION_RESOLUTION_STATUSES, BATCH_SELECT, BATCH_DETAIL_SELECT, buildReconciliationItemsSnapshot, buildTravelReconciliationSummary, buildValidationError, ensureBatchFinalizable, ensureConfirmedReconciliationItem, ensureDraftBatch, ensurePendingReconciliationItem, ensureReopenableReconciliationItem, listTravelReconciliationBatches, loadReconciliationScope, loadTravelReconciliationBatchDetail, mapBatchItem, mapBatchSummary, normalizeSummaryJson, toMoneyFixed, toMoneyNumber, };