feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口
这个提交包含在:
@@ -1,4 +1,4 @@
|
||||
const { pushPaidReimbursementToErp } = require('./erp');
|
||||
const { pushPaidReimbursementToErp, exportJournalToErp } = require('./erp');
|
||||
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
||||
|
||||
class IntegrationDispatchError extends Error {
|
||||
@@ -22,18 +22,82 @@ function toDispatchError(result) {
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||
if (eventType === 'erp.reimbursement.paid') {
|
||||
const result = await pushPaidReimbursementToErp(config, payload);
|
||||
if (!result.ok) {
|
||||
throw toDispatchError(result);
|
||||
}
|
||||
function extractExternalId(response) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channel: 'erp',
|
||||
code: result.code,
|
||||
response: result.response ?? null,
|
||||
};
|
||||
if (typeof response.externalId === 'string' && response.externalId.trim()) {
|
||||
return response.externalId.trim();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof response.body?.externalId === 'string' &&
|
||||
response.body.externalId.trim()
|
||||
) {
|
||||
return response.body.externalId.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function upsertErpBindingForPayload(prisma, payload, response) {
|
||||
const entityType = String(payload?.entityType ?? '').trim();
|
||||
const entityId = String(payload?.entityId ?? '').trim();
|
||||
if (!prisma || !entityType || !entityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType !== 'reimbursement_form') {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.integrationBinding.upsert({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: 'erp',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: entityId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
provider: 'erp',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: entityId,
|
||||
external_id: extractExternalId(response),
|
||||
external_code: payload?.documentNo ?? payload?.formNumber ?? null,
|
||||
last_synced_at: new Date(),
|
||||
},
|
||||
update: {
|
||||
external_id: extractExternalId(response),
|
||||
external_code: payload?.documentNo ?? payload?.formNumber ?? null,
|
||||
last_synced_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchErpEvent({ prisma, config, eventType, payload }) {
|
||||
const result =
|
||||
eventType === 'erp.journal.export'
|
||||
? await exportJournalToErp(config, payload)
|
||||
: await pushPaidReimbursementToErp(config, payload);
|
||||
|
||||
if (!result.ok) {
|
||||
throw toDispatchError(result);
|
||||
}
|
||||
|
||||
await upsertErpBindingForPayload(prisma, payload, result.response ?? null);
|
||||
|
||||
return {
|
||||
channel: 'erp',
|
||||
code: result.code,
|
||||
response: result.response ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||
if (eventType === 'erp.reimbursement.paid' || eventType === 'erp.journal.export') {
|
||||
return dispatchErpEvent({ prisma, config, eventType, payload });
|
||||
}
|
||||
|
||||
if (eventType === 'notification.im') {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
const OUTBOX_STATUSES = {
|
||||
pending: "pending",
|
||||
processing: "processing",
|
||||
succeeded: "succeeded",
|
||||
failed: "failed",
|
||||
dead_letter: "dead_letter",
|
||||
pending: 'pending',
|
||||
processing: 'processing',
|
||||
succeeded: 'succeeded',
|
||||
failed: 'failed',
|
||||
dead_letter: 'dead_letter',
|
||||
};
|
||||
|
||||
function nextRetryAt(config, attempts) {
|
||||
@@ -15,7 +15,7 @@ function nextRetryAt(config, attempts) {
|
||||
function formatDispatchError(error) {
|
||||
const code = error?.code ? String(error.code) : null;
|
||||
const statusCode = Number.isFinite(error?.statusCode) ? Number(error.statusCode) : null;
|
||||
const message = String(error?.message ?? error ?? "integration_outbox_error").trim();
|
||||
const message = String(error?.message ?? error ?? 'integration_outbox_error').trim();
|
||||
|
||||
const parts = [];
|
||||
if (code) {
|
||||
@@ -29,10 +29,10 @@ function formatDispatchError(error) {
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return "integration_outbox_error";
|
||||
return 'integration_outbox_error';
|
||||
}
|
||||
|
||||
return parts.join(" | ");
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
async function enqueueIntegrationEvent(prisma, input) {
|
||||
@@ -52,10 +52,42 @@ async function enqueueIntegrationEvent(prisma, input) {
|
||||
: null;
|
||||
|
||||
if (existed) {
|
||||
if (
|
||||
input.requeueOnFailure &&
|
||||
[OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)
|
||||
) {
|
||||
const reset = await prisma.integrationOutbox.update({
|
||||
where: { id: existed.id },
|
||||
data: {
|
||||
status: OUTBOX_STATUSES.pending,
|
||||
payload: input.payload,
|
||||
attempts: 0,
|
||||
max_attempts: input.maxAttempts,
|
||||
last_error: null,
|
||||
next_retry_at: null,
|
||||
processed_at: null,
|
||||
dead_letter_at: null,
|
||||
updated_by: input.actorId ?? null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: reset.id,
|
||||
status: reset.status,
|
||||
duplicated: false,
|
||||
requeued: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: existed.id,
|
||||
status: existed.status,
|
||||
duplicated: true,
|
||||
requeued: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +111,7 @@ async function enqueueIntegrationEvent(prisma, input) {
|
||||
id: created.id,
|
||||
status: created.status,
|
||||
duplicated: false,
|
||||
requeued: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +126,7 @@ async function fetchPendingOutboxEvents(prisma, options) {
|
||||
},
|
||||
OR: [{ next_retry_at: null }, { next_retry_at: { lte: now } }],
|
||||
},
|
||||
orderBy: [{ created_at: "asc" }],
|
||||
orderBy: [{ created_at: 'asc' }],
|
||||
take: batchSize,
|
||||
});
|
||||
}
|
||||
@@ -128,9 +161,9 @@ async function markOutboxFailed(prisma, config, item, error, actorId, options =
|
||||
);
|
||||
|
||||
const retryable =
|
||||
typeof options.retryable === "boolean"
|
||||
typeof options.retryable === 'boolean'
|
||||
? options.retryable
|
||||
: typeof error?.retryable === "boolean"
|
||||
: typeof error?.retryable === 'boolean'
|
||||
? error.retryable
|
||||
: true;
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户