feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口

这个提交包含在:
2026-03-08 16:29:05 +08:00
父节点 a1b1d7cfc9
当前提交 855bcdffeb
共修改 14 个文件,包含 1361 行新增和 145 行删除
@@ -0,0 +1,182 @@
const { enqueueIntegrationEvent } = require('./outbox');
const ERP_JOURNAL_EVENT_TYPES = Object.freeze(['erp.journal.export', 'erp.reimbursement.paid']);
const REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX = 'reimbursement';
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 resolveLegalEntityCode(reimbursement) {
return (
reimbursement?.owner?.legal_entity?.code ??
reimbursement?.submitter?.legal_entity?.code ??
null
);
}
function buildReimbursementJournalPayload(input) {
const reimbursement = input?.reimbursement ?? {};
const occurredAt =
toIsoString(reimbursement.paid_at) ??
(reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null);
const payload = {
journalType: 'reimbursement_paid',
sourceEventName: 'reimbursement.paid',
entityType: 'reimbursement_form',
entityId: reimbursement.id,
documentNo: reimbursement.form_number ?? null,
currencyCode: 'CNY',
totalAmount: reimbursement.total_amount?.toString?.() ?? null,
occurredAt,
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
paymentDate: toDateOnly(reimbursement.payment_date),
dimensions: {
departmentId: reimbursement.department_id ?? null,
ownerEmployeeId: reimbursement.owner_id ?? null,
submitterEmployeeId: reimbursement.submitter_id ?? null,
legalEntityCode: resolveLegalEntityCode(reimbursement),
costCenterCode: null,
},
reimbursementId: reimbursement.id,
formNumber: reimbursement.form_number ?? null,
};
return payload;
}
function buildReimbursementJournalDedupeKey(reimbursementId) {
return `${REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX}:${reimbursementId}:paid`;
}
async function enqueueReimbursementJournalExport(prisma, config, input) {
const payload =
input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement });
const enqueued = await enqueueIntegrationEvent(prisma, {
eventType: 'erp.journal.export',
payload,
dedupeKey: buildReimbursementJournalDedupeKey(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;
}
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 mapIntegrationOutboxState(record) {
if (!record) {
return null;
}
return {
id: record.id,
eventType: record.event_type,
status: record.status,
lastError: record.last_error ?? null,
processedAt: record.processed_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) {
const current = await prisma.integrationOutbox.findFirst({
where: {
event_type: 'erp.journal.export',
dedupe_key: buildReimbursementJournalDedupeKey(reimbursementId),
},
orderBy: { updated_at: 'desc' },
});
if (current) {
return current;
}
return prisma.integrationOutbox.findFirst({
where: {
event_type: 'erp.reimbursement.paid',
dedupe_key: reimbursementId,
},
orderBy: { updated_at: 'desc' },
});
}
async function buildReimbursementJournalPreview(prisma, reimbursement) {
const [binding, outbox] = await Promise.all([
prisma.integrationBinding.findUnique({
where: {
provider_entity_type_local_id: {
provider: 'erp',
entity_type: 'reimbursement_journal',
local_id: reimbursement.id,
},
},
}),
findLatestReimbursementJournalOutbox(prisma, reimbursement.id),
]);
const journal = buildReimbursementJournalPayload({ reimbursement });
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,
enqueueReimbursementJournalExport,
findLatestReimbursementJournalOutbox,
};