feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户