feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 539c2cc4dd
当前提交 dc74d9e53f
共修改 23 个文件,包含 4265 行新增和 218 行删除
@@ -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') {
+177 -11
查看文件
@@ -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,
};
+106 -8
查看文件
@@ -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,
};