feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端

这个提交包含在:
2026-03-11 16:55:04 +08:00
父节点 41a169ea41
当前提交 fc0295249e
共修改 15 个文件,包含 3733 行新增和 28 行删除
+25 -4
查看文件
@@ -3,6 +3,7 @@ const {
exportAssetToErp,
exportJournalToErp,
exportProcurementToErp,
exportTravelToErp,
} = require('./erp');
const { sendInstantMessageByChannel } = require('../notifications/channels');
@@ -159,6 +160,23 @@ function resolveBindingSpecs(eventType, payload, response) {
].filter(Boolean);
}
if (eventType === 'erp.travel.export') {
return [
createBindingSpec(
'travel_payable_summary',
payload?.entityId,
extractResponseValue(response, 'externalSummaryId'),
extractResponseValue(response, 'externalSummaryNo') ?? payload?.batchNo ?? null,
),
createBindingSpec(
'travel_settlement_batch',
payload?.entityId,
extractResponseValue(response, 'externalBatchId'),
extractResponseValue(response, 'externalBatchNo') ?? payload?.batchNo ?? null,
),
].filter(Boolean);
}
return [];
}
@@ -204,9 +222,11 @@ async function dispatchErpEvent({ prisma, config, eventType, payload }) {
? await exportProcurementToErp(config, payload)
: eventType === 'erp.asset.export'
? await exportAssetToErp(config, payload)
: eventType === 'erp.journal.export'
? await exportJournalToErp(config, payload)
: await pushPaidReimbursementToErp(config, payload);
: eventType === 'erp.travel.export'
? await exportTravelToErp(config, payload)
: eventType === 'erp.journal.export'
? await exportJournalToErp(config, payload)
: await pushPaidReimbursementToErp(config, payload);
if (!result.ok) {
throw toDispatchError(result);
@@ -226,7 +246,8 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
eventType === 'erp.reimbursement.paid' ||
eventType === 'erp.journal.export' ||
eventType === 'erp.procurement.export' ||
eventType === 'erp.asset.export'
eventType === 'erp.asset.export' ||
eventType === 'erp.travel.export'
) {
return dispatchErpEvent({ prisma, config, eventType, payload });
}
@@ -0,0 +1,333 @@
const { enqueueIntegrationEvent } = require("./outbox");
const { normalizeSummaryJson, toMoneyFixed } = require("../travelReconciliation");
const TRAVEL_ERP_EVENT_TYPE = "erp.travel.export";
const TRAVEL_ERP_EXPORT_TYPE = "travel_reconciliation_summary";
const TRAVEL_ERP_BINDING_TYPES = Object.freeze({
payableSummary: "travel_payable_summary",
settlementBatch: "travel_settlement_batch",
});
const TRAVEL_ERP_TRACK_STATUSES = Object.freeze({
notTriggered: "not_triggered",
skipped: "skipped",
});
const TRAVEL_RECONCILIATION_ERP_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,
created_at: true,
updated_at: true,
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
},
},
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: 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,
snapshot_json: true,
},
},
};
function toDateOnly(value) {
if (!value) {
return null;
}
try {
return new Date(value).toISOString().slice(0, 10);
} catch {
return null;
}
}
function toIsoString(value) {
if (!value) {
return null;
}
try {
return new Date(value).toISOString();
} catch {
return null;
}
}
function mapIntegrationBinding(record) {
if (!record) {
return null;
}
return {
id: record.id,
provider: record.provider,
entityType: record.entity_type,
localId: record.local_id,
externalId: record.external_id ?? null,
externalCode: record.external_code ?? null,
lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null,
};
}
function redactBinding(binding, canManage) {
if (!binding) {
return null;
}
if (canManage) {
return binding;
}
return {
...binding,
externalId: null,
externalCode: null,
};
}
function mapIntegrationOutboxState(record) {
if (!record) {
return null;
}
return {
id: record.id,
eventType: record.event_type,
status: record.status,
attempts: Number(record.attempts ?? 0),
lastError: record.last_error ?? null,
processedAt: record.processed_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function buildTravelReconciliationSummaryDedupeKey(batchId) {
return `travel_reconciliation_batch:${batchId}:summary`;
}
function mapPayloadItem(item) {
const snapshot = item.snapshot_json && typeof item.snapshot_json === "object" ? item.snapshot_json : {};
return {
travelRequestId: item.travel_request_id ?? null,
requestNo: snapshot.requestNo ?? null,
travelOrderId: item.travel_order_id ?? null,
externalOrderNo: snapshot.externalOrderNo ?? null,
cardTransactionId: item.card_transaction_id ?? null,
externalTxnId: snapshot.externalTxnId ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
orderAmount: item.order_amount?.toString?.() ?? null,
transactionAmount: item.transaction_amount?.toString?.() ?? null,
currency: item.currency ?? "CNY",
linkedReimbursementId: snapshot.linkedReimbursementId ?? null,
boundInvoiceCount: Number(snapshot.boundInvoiceCount ?? 0),
};
}
function buildTravelReconciliationSummaryPayload(input) {
const batch = input?.batch ?? {};
const summary = normalizeSummaryJson(batch.summary_json);
const items = Array.isArray(batch.items) ? batch.items.map(mapPayloadItem) : [];
return {
exportType: TRAVEL_ERP_EXPORT_TYPE,
entityType: "travel_reconciliation_batch",
entityId: batch.id ?? null,
batchNo: batch.batch_no ?? null,
occurredAt:
toIsoString(batch.confirmed_at) ??
toIsoString(batch.updated_at) ??
toIsoString(batch.created_at),
periodStart: toDateOnly(batch.period_start),
periodEnd: toDateOnly(batch.period_end),
dimensions: {
legalEntityCode: batch.legal_entity?.code ?? null,
costCenterCode: batch.cost_center?.code ?? null,
},
summary: {
totalOrders: Number(summary.totalOrders ?? 0),
totalTransactions: Number(summary.totalTransactions ?? 0),
anomalyItems: Number(summary.anomalyItems ?? 0),
matchRate: toMoneyFixed(summary.matchRate),
totalOrderAmount: toMoneyFixed(summary.totalOrderAmount),
totalSettledAmount: toMoneyFixed(summary.totalSettledAmount),
totalReversedAmount: toMoneyFixed(summary.totalReversedAmount),
},
items,
};
}
async function enqueueTravelReconciliationSummaryExport(prisma, config, input) {
const payload =
input?.payload ?? buildTravelReconciliationSummaryPayload({
batch: input?.batch,
});
const enqueued = await enqueueIntegrationEvent(prisma, {
eventType: TRAVEL_ERP_EVENT_TYPE,
payload,
dedupeKey: buildTravelReconciliationSummaryDedupeKey(payload.entityId),
maxAttempts: config.integrationOutboxMaxAttempts,
actorId: input?.actorId ?? null,
requeueOnFailure: input?.requeueOnFailure === true,
});
return {
...enqueued,
eventType: TRAVEL_ERP_EVENT_TYPE,
payload,
};
}
async function findOutboxByKey(prisma, eventType, dedupeKey) {
if (!eventType || !dedupeKey) {
return null;
}
return prisma.integrationOutbox.findUnique({
where: {
event_type_dedupe_key: {
event_type: eventType,
dedupe_key: dedupeKey,
},
},
});
}
async function findBindingByLocalId(prisma, entityType, localId) {
return prisma.integrationBinding.findUnique({
where: {
provider_entity_type_local_id: {
provider: "erp",
entity_type: entityType,
local_id: localId,
},
},
});
}
async function loadTravelReconciliationErpContext(prisma, batchId) {
return prisma.travelReconciliationBatch.findUnique({
where: { id: batchId },
select: TRAVEL_RECONCILIATION_ERP_SELECT,
});
}
function resolveTrackStatus({ eligible, outbox, payableBinding, settlementBinding, gateEnabled }) {
if (outbox?.status) {
return outbox.status;
}
if (payableBinding && settlementBinding) {
return "succeeded";
}
if (!eligible) {
return TRAVEL_ERP_TRACK_STATUSES.notTriggered;
}
if (!gateEnabled) {
return TRAVEL_ERP_TRACK_STATUSES.skipped;
}
return TRAVEL_ERP_TRACK_STATUSES.notTriggered;
}
async function buildTravelReconciliationErpTrack(prisma, config, batchId, options = {}) {
const batch = options.context ?? (await loadTravelReconciliationErpContext(prisma, batchId));
if (!batch) {
return null;
}
const canManage = Boolean(options.canManage);
const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true);
const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id);
const [outboxRow, payableBindingRow, settlementBindingRow] = await Promise.all([
findOutboxByKey(prisma, TRAVEL_ERP_EVENT_TYPE, dedupeKey),
findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.payableSummary, batch.id),
findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.settlementBatch, batch.id),
]);
const eligible = batch.status === "reconciled";
const reason = eligible ? null : "travel_reconciliation_batch_not_reconciled";
const outbox = mapIntegrationOutboxState(outboxRow);
const payableBinding = mapIntegrationBinding(payableBindingRow);
const settlementBinding = mapIntegrationBinding(settlementBindingRow);
return {
batch: {
id: batch.id,
batchNo: batch.batch_no,
status: batch.status,
periodStart: toDateOnly(batch.period_start),
periodEnd: toDateOnly(batch.period_end),
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,
}
: null,
},
stage: {
summaryExport: {
eligible,
reason,
status: resolveTrackStatus({
eligible,
outbox,
payableBinding: payableBindingRow,
settlementBinding: settlementBindingRow,
gateEnabled,
}),
preview: buildTravelReconciliationSummaryPayload({ batch }),
outbox,
bindings: {
payableSummary: redactBinding(payableBinding, canManage),
settlementBatch: redactBinding(settlementBinding, canManage),
},
},
},
permissions: {
canRetry: Boolean(canManage && gateEnabled && eligible),
canSeeExternalRefs: canManage,
},
};
}
module.exports = {
TRAVEL_ERP_BINDING_TYPES,
TRAVEL_ERP_EVENT_TYPE,
TRAVEL_ERP_EXPORT_TYPE,
TRAVEL_ERP_TRACK_STATUSES,
TRAVEL_RECONCILIATION_ERP_SELECT,
buildTravelReconciliationErpTrack,
buildTravelReconciliationSummaryDedupeKey,
buildTravelReconciliationSummaryPayload,
enqueueTravelReconciliationSummaryExport,
loadTravelReconciliationErpContext,
};