feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端
这个提交包含在:
@@ -106,6 +106,17 @@ function resolveBindingSpecs(eventType, payload, response) {
|
||||
),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
if (journalType === 'travel_order_payment') {
|
||||
return [
|
||||
createBindingSpec(
|
||||
'travel_order_payment_journal',
|
||||
payload?.references?.travelOrderId ?? payload?.entityId,
|
||||
extractResponseValue(response, 'externalId'),
|
||||
extractResponseValue(response, 'externalCode') ?? payload?.references?.externalOrderNo ?? payload?.documentNo ?? null,
|
||||
),
|
||||
].filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'erp.procurement.export') {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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) {
|
||||
@@ -25,12 +26,20 @@ function toIsoString(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegalEntityCode(reimbursement) {
|
||||
return (
|
||||
reimbursement?.owner?.legal_entity?.code ??
|
||||
reimbursement?.submitter?.legal_entity?.code ??
|
||||
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) {
|
||||
@@ -39,7 +48,7 @@ function buildReimbursementJournalPayload(input) {
|
||||
toIsoString(reimbursement.paid_at) ??
|
||||
(reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null);
|
||||
|
||||
const payload = {
|
||||
return {
|
||||
journalType: 'reimbursement_paid',
|
||||
sourceEventName: 'reimbursement.paid',
|
||||
entityType: 'reimbursement_form',
|
||||
@@ -54,23 +63,65 @@ function buildReimbursementJournalPayload(input) {
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
ownerEmployeeId: reimbursement.owner_id ?? null,
|
||||
submitterEmployeeId: reimbursement.submitter_id ?? null,
|
||||
legalEntityCode: resolveLegalEntityCode(reimbursement),
|
||||
legalEntityCode: resolveReimbursementLegalEntityCode(reimbursement),
|
||||
costCenterCode: null,
|
||||
},
|
||||
reimbursementId: reimbursement.id,
|
||||
formNumber: reimbursement.form_number ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
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 payload = input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement });
|
||||
|
||||
const enqueued = await enqueueIntegrationEvent(prisma, {
|
||||
eventType: 'erp.journal.export',
|
||||
@@ -88,6 +139,25 @@ async function enqueueReimbursementJournalExport(prisma, config, input) {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -141,6 +211,16 @@ async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) {
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -172,11 +252,97 @@ async function buildReimbursementJournalPreview(prisma, reimbursement) {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
function toDateOnly(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(`${String(value).slice(0, 10)}T00:00:00.000Z`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function toDateTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function toMoneyString(value) {
|
||||
if (value == null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric.toFixed(2) : null;
|
||||
}
|
||||
|
||||
function normalizeTravelImportRows(rows) {
|
||||
if (!Array.isArray(rows)) {
|
||||
return [];
|
||||
@@ -12,12 +39,6 @@ function normalizeTravelImportRows(rows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amountRaw = item?.amount;
|
||||
const amount = amountRaw == null || amountRaw === "" ? null : Number(amountRaw);
|
||||
const occurredAt = item?.occurredAt
|
||||
? new Date(`${String(item.occurredAt).slice(0, 10)}T00:00:00.000Z`)
|
||||
: null;
|
||||
|
||||
const hasTravelRequestId = Object.prototype.hasOwnProperty.call(item ?? {}, "travelRequestId");
|
||||
const rawTravelRequestId = String(item?.travelRequestId ?? "").trim();
|
||||
|
||||
@@ -31,9 +52,84 @@ function normalizeTravelImportRows(rows) {
|
||||
: null,
|
||||
travelRequestId: rawTravelRequestId || null,
|
||||
hasTravelRequestId,
|
||||
amount: Number.isFinite(amount) ? amount.toFixed(2) : null,
|
||||
amount: toMoneyString(item?.amount),
|
||||
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
|
||||
occurredAt,
|
||||
occurredAt: toDateOnly(item?.occurredAt),
|
||||
rawPayload: item ?? null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTravelAuthorizationRows(rows) {
|
||||
if (!Array.isArray(rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((item) => {
|
||||
const source = String(item?.source ?? "manual").trim().toLowerCase();
|
||||
const externalAuthNo = String(item?.externalAuthNo ?? item?.externalAuthNumber ?? "").trim();
|
||||
const employeeId = String(item?.employeeId ?? "").trim();
|
||||
const travelRequestId = String(item?.travelRequestId ?? "").trim();
|
||||
if (!externalAuthNo || !employeeId || !travelRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
externalAuthNo,
|
||||
employeeId,
|
||||
travelRequestId,
|
||||
budgetPoolId:
|
||||
item?.budgetPoolId && String(item.budgetPoolId).trim()
|
||||
? String(item.budgetPoolId).trim()
|
||||
: null,
|
||||
authorizedAmount: toMoneyString(item?.authorizedAmount),
|
||||
settledAmount: toMoneyString(item?.settledAmount) ?? "0.00",
|
||||
reversedAmount: toMoneyString(item?.reversedAmount) ?? "0.00",
|
||||
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
|
||||
occurredAt: toDateTime(item?.occurredAt),
|
||||
expiresAt: toDateTime(item?.expiresAt),
|
||||
status: String(item?.status ?? "authorized").trim().toLowerCase() || "authorized",
|
||||
rawPayload: item ?? null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTravelTransactionRows(rows) {
|
||||
if (!Array.isArray(rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((item) => {
|
||||
const source = String(item?.source ?? "manual").trim().toLowerCase();
|
||||
const externalTxnId = String(item?.externalTxnId ?? item?.externalTransactionId ?? "").trim();
|
||||
const employeeId = String(item?.employeeId ?? "").trim();
|
||||
const travelRequestId = String(item?.travelRequestId ?? "").trim();
|
||||
if (!externalTxnId || !employeeId || !travelRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawCardAuthorizationId = String(item?.cardAuthorizationId ?? "").trim();
|
||||
const rawTravelOrderId = String(item?.travelOrderId ?? "").trim();
|
||||
|
||||
return {
|
||||
source,
|
||||
externalTxnId,
|
||||
employeeId,
|
||||
travelRequestId,
|
||||
cardAuthorizationId: rawCardAuthorizationId || null,
|
||||
hasCardAuthorizationId: Object.prototype.hasOwnProperty.call(item ?? {}, "cardAuthorizationId"),
|
||||
travelOrderId: rawTravelOrderId || null,
|
||||
hasTravelOrderId: Object.prototype.hasOwnProperty.call(item ?? {}, "travelOrderId"),
|
||||
amount: toMoneyString(item?.amount),
|
||||
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
|
||||
txnType: String(item?.txnType ?? "charge").trim().toLowerCase() || "charge",
|
||||
status: String(item?.status ?? "pending").trim().toLowerCase() || "pending",
|
||||
occurredAt: toDateTime(item?.occurredAt),
|
||||
rawPayload: item ?? null,
|
||||
};
|
||||
})
|
||||
@@ -42,4 +138,6 @@ function normalizeTravelImportRows(rows) {
|
||||
|
||||
module.exports = {
|
||||
normalizeTravelImportRows,
|
||||
normalizeTravelAuthorizationRows,
|
||||
normalizeTravelTransactionRows,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户