feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端
这个提交包含在:
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户