feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端
这个提交包含在:
@@ -0,0 +1,738 @@
|
||||
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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户