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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户