feat(P19ERP2-1,P19ERP2-2): 实现采购ERP凭证与摘要出站服务

这个提交包含在:
2026-03-08 23:50:02 +08:00
父节点 8f4a50cdd7
当前提交 486660df06
共修改 15 个文件,包含 1912 行新增和 181 行删除
+144
查看文件
@@ -11,6 +11,13 @@ const { signPayload, verifyToken } = require("../utils/signedUrl");
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { issueDocumentNumber } = require("../services/documentSequence");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
PROCUREMENT_ERP_TARGETS,
enqueuePurchaseAccrualJournalExport,
enqueuePurchaseArchiveSummaryExport,
loadPurchaseRequestErpContext,
} = require("../services/integrations/erpProcurement");
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
const { sendNotificationEmailSafe } = require("../services/email");
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
@@ -837,6 +844,116 @@ async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) {
return purchaseRequest;
}
async function handlePurchaseArchiveErpSync({
tx,
config,
request,
actorId,
purchaseRequestId,
archiveId,
isFirstArchive,
loadContext = loadPurchaseRequestErpContext,
evaluateGate = evaluateFeatureFlag,
enqueueAccrual = enqueuePurchaseAccrualJournalExport,
enqueueSummary = enqueuePurchaseArchiveSummaryExport,
}) {
const context = await loadContext(tx, purchaseRequestId);
if (!context?.purchaseRequest?.order_archive) {
return { skipped: true, reason: 'purchase_order_archive_missing' };
}
const purchaseRequest = context.purchaseRequest;
const archive = purchaseRequest.order_archive;
const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null;
const gate = await evaluateGate({
prisma: tx,
config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
if (!gate.enabled) {
const skipTargets = isFirstArchive
? [PROCUREMENT_ERP_TARGETS.accrualJournal, PROCUREMENT_ERP_TARGETS.archiveSummary]
: [PROCUREMENT_ERP_TARGETS.archiveSummary];
for (const erpTarget of skipTargets) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
targetType: 'purchase_order_archive',
targetId: archiveId,
extra: {
erpTarget,
eventType: erpTarget === PROCUREMENT_ERP_TARGETS.archiveSummary ? 'erp.procurement.export' : 'erp.journal.export',
purchaseRequestId,
purchaseOrderArchiveId: archiveId,
gateReason: gate.reason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
},
}),
});
}
return { skipped: true, gate, purchaseRequest, archive };
}
const enqueuedItems = [];
if (isFirstArchive) {
const accrual = await enqueueAccrual(tx, config, {
purchaseRequest,
archive,
occurredAt: archive.archived_at ?? new Date(),
actorId,
requeueOnFailure: false,
});
enqueuedItems.push(accrual);
}
const summary = await enqueueSummary(tx, config, {
purchaseRequest,
archive,
archivedAt: archive.archived_at ?? new Date(),
actorId,
requeueOnFailure: false,
});
enqueuedItems.push(summary);
for (const item of enqueuedItems) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: 'ERP_SYNC_ENQUEUED',
targetType: 'purchase_order_archive',
targetId: archiveId,
extra: {
erpTarget: item.erpTarget,
eventType: item.eventType,
purchaseRequestId,
purchaseOrderArchiveId: archiveId,
outboxId: item.id,
duplicated: item.duplicated,
requeued: item.requeued,
gateReason: gate.reason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
},
}),
});
}
return {
skipped: false,
gate,
purchaseRequest,
archive,
items: enqueuedItems,
};
}
async function purchaseRequestRoutes(fastify) {
fastify.get(
"/purchase-requests",
@@ -1527,6 +1644,11 @@ async function purchaseRequestRoutes(fastify) {
throw buildValidationError({ status: "only_approved_archivable" }, 409);
}
const existingArchive = await tx.purchaseOrderArchive.findUnique({
where: { purchase_request_id: purchaseRequestId },
select: { id: true },
});
const archive = await tx.purchaseOrderArchive.upsert({
where: { purchase_request_id: purchaseRequestId },
update: {
@@ -1575,6 +1697,23 @@ async function purchaseRequestRoutes(fastify) {
}),
});
try {
await handlePurchaseArchiveErpSync({
tx,
config: fastify.config,
request,
actorId: request.employee.id,
purchaseRequestId,
archiveId: archive.id,
isFirstArchive: !existingArchive,
});
} catch (error) {
fastify.log.error(
{ err: error, purchaseRequestId, purchaseOrderArchiveId: archive.id },
"Failed to enqueue purchase archive ERP events",
);
}
response = {
archiveOrder: mapPurchaseOrderArchive(archive),
};
@@ -1942,3 +2081,8 @@ async function purchaseRequestRoutes(fastify) {
}
module.exports = purchaseRequestRoutes;
module.exports.__testables = {
handlePurchaseArchiveErpSync,
parseArchiveBody,
};