文件
hyb-oa/server/src/services/travelPayments.js
T

726 行
20 KiB
JavaScript

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,
};