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

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