feat(P19ERP2-1,P19ERP2-2): 实现采购ERP凭证与摘要出站服务
这个提交包含在:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
||||
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
||||
const {
|
||||
PROCUREMENT_ERP_TARGETS,
|
||||
enqueuePurchaseCommitmentJournalExport,
|
||||
loadPurchaseRequestErpContext,
|
||||
} = require("../services/integrations/erpProcurement");
|
||||
const {
|
||||
createNotifications,
|
||||
resolveRecipientsForAssignee,
|
||||
@@ -97,6 +104,89 @@ function parseTaskId(taskId) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function handlePurchaseApprovalErpSync({
|
||||
tx,
|
||||
config,
|
||||
request,
|
||||
actorId,
|
||||
purchaseRequestId,
|
||||
loadContext = loadPurchaseRequestErpContext,
|
||||
evaluateGate = evaluateFeatureFlag,
|
||||
enqueueCommitment = enqueuePurchaseCommitmentJournalExport,
|
||||
}) {
|
||||
const context = await loadContext(tx, purchaseRequestId);
|
||||
if (!context?.purchaseRequest) {
|
||||
return { skipped: true, reason: 'purchase_request_not_found' };
|
||||
}
|
||||
|
||||
const purchaseRequest = context.purchaseRequest;
|
||||
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) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId,
|
||||
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
|
||||
targetType: 'purchase_request',
|
||||
targetId: purchaseRequest.id,
|
||||
extra: {
|
||||
erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal,
|
||||
eventType: 'erp.journal.export',
|
||||
purchaseRequestId: purchaseRequest.id,
|
||||
purchaseOrderArchiveId: null,
|
||||
gateReason: gate.reason,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { skipped: true, gate, purchaseRequest };
|
||||
}
|
||||
|
||||
const enqueued = await enqueueCommitment(tx, config, {
|
||||
purchaseRequest,
|
||||
occurredAt: context.approvalOccurredAt ?? new Date(),
|
||||
actorId,
|
||||
requeueOnFailure: false,
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId,
|
||||
action: 'ERP_SYNC_ENQUEUED',
|
||||
targetType: 'purchase_request',
|
||||
targetId: purchaseRequest.id,
|
||||
extra: {
|
||||
erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal,
|
||||
eventType: enqueued.eventType,
|
||||
purchaseRequestId: purchaseRequest.id,
|
||||
purchaseOrderArchiveId: null,
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
requeued: enqueued.requeued,
|
||||
gateReason: gate.reason,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
gate,
|
||||
purchaseRequest,
|
||||
enqueued,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTaskListQuery(query) {
|
||||
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -1372,6 +1462,21 @@ async function workflowRoutes(fastify) {
|
||||
actorId: request.employee.id,
|
||||
note: "approved_confirm_purchase_budget",
|
||||
});
|
||||
|
||||
try {
|
||||
await handlePurchaseApprovalErpSync({
|
||||
tx,
|
||||
config: fastify.config,
|
||||
request,
|
||||
actorId: request.employee.id,
|
||||
purchaseRequestId: workflowInstance.entity_id,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, purchaseRequestId: workflowInstance.entity_id },
|
||||
"Failed to enqueue purchase approval ERP events",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await tx.workflowHistory.create({
|
||||
@@ -1905,5 +2010,6 @@ async function workflowRoutes(fastify) {
|
||||
module.exports = workflowRoutes;
|
||||
|
||||
module.exports.__testables = {
|
||||
handlePurchaseApprovalErpSync,
|
||||
listAllowedActionsForTask,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户