const { AppError } = require("../utils/AppError"); const { toMoneyString } = require("../utils/workflow"); const { summarizeBudgetLedgerByPool } = require("./purchaseBudget"); const { buildTravelOrderPaymentJournalPreview } = require("./integrations/erpJournal"); function buildTravelPaymentValidationError(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 appendCorporateSummary(summary, entry) { const poolId = entry?.budget_pool_id; if (!poolId) { return; } if (!summary.has(poolId)) { summary.set(poolId, { budgetPoolId: poolId, authorize: 0, settle: 0, reverse: 0, }); } const current = summary.get(poolId); const amount = toMoneyNumber(entry.amount); if (entry.kind === "authorize") { current.authorize = toMoneyNumber(current.authorize + amount); } else if (entry.kind === "settle") { current.settle = toMoneyNumber(current.settle + amount); } else if (entry.kind === "reverse") { current.reverse = toMoneyNumber(current.reverse + amount); } } function summarizeCorporateBudgetEntries(entries = []) { const summary = new Map(); for (const entry of entries) { appendCorporateSummary(summary, entry); } return Array.from(summary.values()).map((item) => ({ ...item, outstanding: toMoneyNumber(Math.max(0, item.authorize - item.reverse)), settlePending: toMoneyNumber(Math.max(0, item.authorize - item.reverse - item.settle)), })); } async function listEntityBudgetEntries(tx, { entityType, entityId }) { return tx.budgetLedgerEntry.findMany({ where: { entity_type: entityType, entity_id: entityId, }, orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, budget_pool_id: true, entity_type: true, entity_id: true, kind: true, amount: true, created_at: true, }, }); } async function listCorporateBudgetEntries(tx, { authorizationIds = [], transactionIds = [] } = {}) { const filters = []; if (authorizationIds.length) { filters.push({ entity_type: "card_authorization", entity_id: { in: authorizationIds }, }); } if (transactionIds.length) { filters.push({ entity_type: "card_transaction", entity_id: { in: transactionIds }, }); } if (!filters.length) { return []; } return tx.budgetLedgerEntry.findMany({ where: { OR: filters }, orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, budget_pool_id: true, entity_type: true, entity_id: true, kind: true, amount: true, created_at: true, }, }); } async function appendBudgetLedgerEntry(tx, { budgetPoolId, entityType, entityId, kind, amount, actorId, note, }) { return tx.budgetLedgerEntry.create({ data: { budget_pool_id: budgetPoolId, entity_type: entityType, entity_id: entityId, kind, amount: toMoneyFixed(amount), note, created_by: actorId, }, }); } async function applyBudgetPoolUsageDelta(tx, { budgetPoolId, deltaAmount, actorId }) { if (!budgetPoolId || !deltaAmount) { return; } const budgetPool = await tx.budgetPool.findUnique({ where: { id: budgetPoolId }, select: { id: true, used_amount: true }, }); if (!budgetPool) { throw buildTravelPaymentValidationError({ budgetPoolId: "not_found" }); } const nextUsedAmount = Math.max(0, toMoneyNumber(budgetPool.used_amount) + toMoneyNumber(deltaAmount)); await tx.budgetPool.update({ where: { id: budgetPoolId }, data: { used_amount: toMoneyFixed(nextUsedAmount), updated_by: actorId, }, }); } async function authorizeBudgetForEntity(tx, { budgetPoolId, entityId, amount, actorId, note, }) { if (!budgetPoolId) { return { applied: false, authorizedAmount: "0.00", duplicated: false }; } const normalizedAmount = toMoneyNumber(amount); if (normalizedAmount <= 0) { return { applied: false, authorizedAmount: "0.00", duplicated: false }; } const existingEntries = await listEntityBudgetEntries(tx, { entityType: "card_authorization", entityId, }); if (existingEntries.some((item) => item.kind === "authorize")) { const current = summarizeCorporateBudgetEntries(existingEntries)[0]; return { applied: false, duplicated: true, authorizedAmount: toMoneyFixed(current?.authorize ?? normalizedAmount), }; } await appendBudgetLedgerEntry(tx, { budgetPoolId, entityType: "card_authorization", entityId, kind: "authorize", amount: normalizedAmount, actorId, note, }); await applyBudgetPoolUsageDelta(tx, { budgetPoolId, deltaAmount: normalizedAmount, actorId, }); return { applied: true, duplicated: false, authorizedAmount: toMoneyFixed(normalizedAmount), }; } async function settleBudgetForEntity(tx, { budgetPoolId, entityId, amount, actorId, note, }) { if (!budgetPoolId) { return { applied: false, settledAmount: "0.00", duplicated: false }; } const normalizedAmount = toMoneyNumber(amount); if (normalizedAmount <= 0) { return { applied: false, settledAmount: "0.00", duplicated: false }; } const existingEntries = await listEntityBudgetEntries(tx, { entityType: "card_transaction", entityId, }); if (existingEntries.some((item) => item.kind === "settle")) { const current = summarizeCorporateBudgetEntries(existingEntries)[0]; return { applied: false, duplicated: true, settledAmount: toMoneyFixed(current?.settle ?? normalizedAmount), }; } await appendBudgetLedgerEntry(tx, { budgetPoolId, entityType: "card_transaction", entityId, kind: "settle", amount: normalizedAmount, actorId, note, }); return { applied: true, duplicated: false, settledAmount: toMoneyFixed(normalizedAmount), }; } async function reverseBudgetForEntity(tx, { budgetPoolId, entityId, amount, actorId, note, }) { if (!budgetPoolId) { return { applied: false, reversedAmount: "0.00", duplicated: false }; } const normalizedAmount = toMoneyNumber(amount); if (normalizedAmount <= 0) { return { applied: false, reversedAmount: "0.00", duplicated: false }; } const existingEntries = await listEntityBudgetEntries(tx, { entityType: "card_transaction", entityId, }); if (existingEntries.some((item) => item.kind === "reverse")) { const current = summarizeCorporateBudgetEntries(existingEntries)[0]; return { applied: false, duplicated: true, reversedAmount: toMoneyFixed(current?.reverse ?? normalizedAmount), }; } await appendBudgetLedgerEntry(tx, { budgetPoolId, entityType: "card_transaction", entityId, kind: "reverse", amount: normalizedAmount, actorId, note, }); await applyBudgetPoolUsageDelta(tx, { budgetPoolId, deltaAmount: -normalizedAmount, actorId, }); return { applied: true, duplicated: false, reversedAmount: toMoneyFixed(normalizedAmount), }; } function mapBudgetPoolSummary(summaryItems, budgetPoolMap, mode) { return summaryItems.map((item) => { const pool = budgetPoolMap.get(item.budgetPoolId); const base = { budgetPoolId: item.budgetPoolId, budgetPoolCode: pool?.code ?? null, budgetPoolName: pool?.name ?? null, outstanding: toMoneyFixed(item.outstanding), }; if (mode === "request") { return { ...base, reserve: toMoneyFixed(item.reserve), confirm: toMoneyFixed(item.confirm), release: toMoneyFixed(item.release), confirmPending: toMoneyFixed(item.confirmPending), }; } return { ...base, authorize: toMoneyFixed(item.authorize), settle: toMoneyFixed(item.settle), reverse: toMoneyFixed(item.reverse), settlePending: toMoneyFixed(item.settlePending), }; }); } function buildTransactionAnomalies(transaction, order) { const anomalies = []; if (!transaction.travel_order_id) { anomalies.push({ code: "missing_order", level: "medium", title: "未匹配差旅订单", detail: `交易 ${transaction.external_txn_id} 尚未关联 travel_order。`, transactionId: transaction.id, travelOrderId: null, }); } if (order) { if (String(order.currency ?? "") !== String(transaction.currency ?? "")) { anomalies.push({ code: "currency_mismatch", level: "high", title: "币种不一致", detail: `订单 ${order.external_order_no} 与交易 ${transaction.external_txn_id} 币种不一致。`, transactionId: transaction.id, travelOrderId: order.id, }); } if (toMoneyFixed(order.amount) !== toMoneyFixed(transaction.amount)) { anomalies.push({ code: "amount_mismatch", level: "high", title: "金额不一致", detail: `订单 ${order.external_order_no} 与交易 ${transaction.external_txn_id} 金额不一致。`, transactionId: transaction.id, travelOrderId: order.id, }); } } if (transaction.freeze_reason_code) { anomalies.push({ code: transaction.freeze_reason_code, level: "medium", title: "已标记人工冻结建议", detail: transaction.freeze_reason_note || `交易 ${transaction.external_txn_id} 已被标记人工复核。`, transactionId: transaction.id, travelOrderId: transaction.travel_order_id ?? null, }); } return anomalies; } function isOrderAmountAndCurrencyMatched(order, transaction) { if (!order || !transaction) { return false; } return ( toMoneyFixed(order.amount) === toMoneyFixed(transaction.amount) && String(order.currency ?? "").toUpperCase() === String(transaction.currency ?? "").toUpperCase() ); } function mapAuthorization(item) { return { id: item.id, source: item.source, externalAuthNo: item.external_auth_no, employeeId: item.employee_id, departmentId: item.department_id ?? null, travelRequestId: item.travel_request_id, budgetPoolId: item.budget_pool_id ?? null, legalEntityId: item.legal_entity_id, costCenterId: item.cost_center_id, authorizedAmount: toMoneyFixed(item.authorized_amount), settledAmount: toMoneyFixed(item.settled_amount), reversedAmount: toMoneyFixed(item.reversed_amount), currency: item.currency, occurredAt: item.occurred_at?.toISOString?.() ?? null, expiresAt: item.expires_at?.toISOString?.() ?? null, status: item.status, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } function mapTransaction(item, ordersById) { const order = item.travel_order_id ? ordersById.get(item.travel_order_id) ?? null : null; return { id: item.id, source: item.source, externalTxnId: item.external_txn_id, cardAuthorizationId: item.card_authorization_id ?? null, employeeId: item.employee_id, departmentId: item.department_id ?? null, travelRequestId: item.travel_request_id, travelOrderId: item.travel_order_id ?? null, amount: toMoneyFixed(item.amount), currency: item.currency, txnType: item.txn_type, status: item.status, occurredAt: item.occurred_at?.toISOString?.() ?? null, freezeSuggestedAt: item.freeze_suggested_at?.toISOString?.() ?? null, freezeSuggestedBy: item.freeze_suggested_by ?? null, freezeReasonCode: item.freeze_reason_code ?? null, freezeReasonNote: item.freeze_reason_note ?? null, matchedOrder: order ? { id: order.id, externalOrderNo: order.external_order_no, status: order.status, amount: toMoneyFixed(order.amount), currency: order.currency, exactMatch: isOrderAmountAndCurrencyMatched(order, item), } : null, }; } function mapInvoice(item) { return { id: item.id, status: item.status, originalName: item.original_name ?? null, totalAmount: toMoneyString(item.total_amount), invoiceCode: item.invoice_code ?? null, invoiceNumber: item.invoice_number ?? null, invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, linkedReimbursementId: item.linked_reimbursement_id ?? null, linkedTravelOrderId: item.travel_order_id ?? null, }; } async function buildTravelPaymentTrack(prisma, travelRequestId, { canManagePayments = false } = {}) { const request = await prisma.travelRequest.findUnique({ where: { id: travelRequestId }, select: { id: true, request_no: true, budget_pool_id: true, travel_orders: { orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], select: { id: true, source: true, external_order_no: true, employee_id: true, department_id: true, linked_reimbursement_id: true, travel_request_id: true, occurred_at: true, amount: true, currency: true, status: true, payment_voucher_no: true, payment_date: true, payment_channel: true, paid_at: true, paid_by: true, created_at: true, updated_at: true, }, }, card_authorizations: { orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], select: { id: true, source: true, external_auth_no: true, employee_id: true, department_id: true, travel_request_id: true, budget_pool_id: true, legal_entity_id: true, cost_center_id: true, authorized_amount: true, settled_amount: true, reversed_amount: true, currency: true, occurred_at: true, expires_at: true, status: true, created_at: true, updated_at: true, }, }, card_transactions: { orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], select: { id: true, source: true, external_txn_id: true, card_authorization_id: true, employee_id: true, department_id: true, travel_request_id: true, travel_order_id: true, amount: true, currency: true, txn_type: true, status: true, occurred_at: true, freeze_suggested_at: true, freeze_suggested_by: true, freeze_reason_code: true, freeze_reason_note: true, created_at: true, updated_at: true, }, }, }, }); if (!request) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const orderIds = request.travel_orders.map((item) => item.id); const authorizationIds = request.card_authorizations.map((item) => item.id); const transactionIds = request.card_transactions.map((item) => item.id); const [boundInvoices, requestBudgetEntries, corporateBudgetEntries] = await Promise.all([ orderIds.length ? prisma.invoicePool.findMany({ where: { travel_order_id: { in: orderIds } }, orderBy: [{ created_at: "desc" }], select: { id: true, status: true, original_name: true, total_amount: true, invoice_code: true, invoice_number: true, invoice_date: true, linked_reimbursement_id: true, travel_order_id: true, }, }) : Promise.resolve([]), prisma.budgetLedgerEntry.findMany({ where: { entity_type: "travel_request", entity_id: travelRequestId, }, orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, budget_pool_id: true, kind: true, amount: true, created_at: true, }, }), listCorporateBudgetEntries(prisma, { authorizationIds, transactionIds }), ]); const budgetPoolIds = Array.from(new Set([ ...requestBudgetEntries.map((item) => item.budget_pool_id).filter(Boolean), ...corporateBudgetEntries.map((item) => item.budget_pool_id).filter(Boolean), ...request.card_authorizations.map((item) => item.budget_pool_id).filter(Boolean), ])); const budgetPools = budgetPoolIds.length ? await prisma.budgetPool.findMany({ where: { id: { in: budgetPoolIds } }, select: { id: true, code: true, name: true }, }) : []; const budgetPoolMap = new Map(budgetPools.map((item) => [item.id, item])); const orderInvoicesMap = new Map(); for (const invoice of boundInvoices) { if (!invoice.travel_order_id) { continue; } const items = orderInvoicesMap.get(invoice.travel_order_id) ?? []; items.push(mapInvoice(invoice)); orderInvoicesMap.set(invoice.travel_order_id, items); } const ordersById = new Map(request.travel_orders.map((item) => [item.id, item])); const transactionsByOrderId = new Map(); for (const transaction of request.card_transactions) { if (!transaction.travel_order_id) { continue; } const items = transactionsByOrderId.get(transaction.travel_order_id) ?? []; items.push(transaction); transactionsByOrderId.set(transaction.travel_order_id, items); } const orderStages = await Promise.all( request.travel_orders.map(async (order) => { const preview = await buildTravelOrderPaymentJournalPreview(prisma, order); return [order.id, preview]; }), ); const orderStageMap = new Map(orderStages); const anomalies = request.card_transactions.flatMap((transaction) => buildTransactionAnomalies(transaction, transaction.travel_order_id ? ordersById.get(transaction.travel_order_id) : null), ); return { travelRequestId: request.id, requestNo: request.request_no ?? null, requestBudgetTrack: { items: mapBudgetPoolSummary(summarizeBudgetLedgerByPool(requestBudgetEntries), budgetPoolMap, "request"), }, corporateBudgetTrack: { items: mapBudgetPoolSummary(summarizeCorporateBudgetEntries(corporateBudgetEntries), budgetPoolMap, "corporate"), }, authorizations: request.card_authorizations.map(mapAuthorization), transactions: request.card_transactions.map((item) => mapTransaction(item, ordersById)), travelOrders: request.travel_orders.map((order) => { const matchedTransactions = (transactionsByOrderId.get(order.id) ?? []).map((item) => mapTransaction(item, ordersById)); const stage = orderStageMap.get(order.id) ?? { journal: null, exportState: { externalId: null, lastOutboxId: null, lastOutboxStatus: null, lastError: null, exportedAt: null, }, binding: null, outbox: null, }; return { id: order.id, externalOrderNo: order.external_order_no, source: order.source, amount: toMoneyFixed(order.amount), currency: order.currency, status: order.status, paymentVoucherNo: order.payment_voucher_no ?? null, paymentDate: order.payment_date?.toISOString?.()?.slice(0, 10) ?? null, paymentChannel: order.payment_channel ?? null, paidAt: order.paid_at?.toISOString?.() ?? null, paidBy: order.paid_by ?? null, boundInvoices: orderInvoicesMap.get(order.id) ?? [], matchedTransactions, erpStage: { binding: stage.binding, outbox: stage.outbox, exportState: stage.exportState, }, actions: { canMarkPaid: canManagePayments && order.status === "confirmed", canBindInvoice: canManagePayments && ["confirmed", "paid"].includes(order.status), }, }; }), anomalies, permissions: { canManagePayments, }, }; } module.exports = { authorizeBudgetForEntity, settleBudgetForEntity, reverseBudgetForEntity, summarizeCorporateBudgetEntries, buildTravelPaymentTrack, buildTravelPaymentValidationError, isOrderAmountAndCurrencyMatched, };