完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)

汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
2026-09-19 13:11:04 +08:00
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
+128 -248
查看文件
@@ -1,9 +1,12 @@
const { resolveNumberingContext } = require("../services/businessNumbering");
const { getErrorReason } = require("../constants/errorReasons");
const { z } = require("zod");
const { Prisma } = require("@prisma/client");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
@@ -15,12 +18,6 @@ const {
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const {
PROCUREMENT_ERP_TARGETS,
enqueuePurchaseAccrualJournalExport,
enqueuePurchaseArchiveSummaryExport,
loadPurchaseRequestErpContext,
} = require("../services/integrations/erpProcurement");
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
const { sendNotificationEmailSafe } = require("../services/email");
const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications");
@@ -48,8 +45,18 @@ const {
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const PURCHASE_ATTACHMENT_POLICY_REASONS = Object.freeze({
"dangerous_filename": "purchase_attachment.dangerous_filename",
"corrupt_content": "purchase_attachment.corrupt_content",
"multiple_pages": "purchase_attachment.multiple_pages",
"pixel_limit": "purchase_attachment.pixel_limit",
"type_mismatch": "purchase_attachment.type_mismatch",
"empty_file": "purchase_attachment.empty_file",
"size_limit": "purchase_attachment.too_large"
});
const UUID_SCHEMA = z.string().uuid();
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" });
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/);
const PURCHASE_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected", "cancelled"]);
@@ -313,6 +320,7 @@ const PURCHASE_DETAIL_SELECT = {
};
const PURCHASE_SUBMIT_SELECT = {
numbering_version: true,
id: true,
applicant_id: true,
department_id: true,
@@ -339,19 +347,10 @@ const PURCHASE_SUBMIT_SELECT = {
},
};
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parsePurchaseRequestId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
throw buildBusinessValidationError("purchase_request.id_invalid", { id: "invalid_uuid" });
}
return parsed.data;
}
@@ -359,7 +358,7 @@ function parsePurchaseRequestId(value) {
function parseAttachmentId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ attachmentId: "invalid_uuid" });
throw buildBusinessValidationError("purchase_attachment.id_invalid", { attachmentId: "invalid_uuid" });
}
return parsed.data;
}
@@ -367,7 +366,7 @@ function parseAttachmentId(value) {
function parseListQuery(query) {
const parsed = PURCHASE_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("purchase_request.query_invalid", parsed.error.flatten());
}
return {
@@ -388,7 +387,7 @@ function parseListQuery(query) {
function parsePatchBody(body) {
const parsed = PURCHASE_PATCH_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("purchase_request.update_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
@@ -396,7 +395,7 @@ function parsePatchBody(body) {
function parseArchiveBody(body) {
const parsed = PURCHASE_ARCHIVE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("purchase_request.archive_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
@@ -406,14 +405,9 @@ function normalizeBudgetControlMode(config) {
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
return buildBusinessValidationError("purchase_request.precheck_blocked", {
precheck: precheck.blockingReasons,
},
});
}, 409);
}
function ensurePurchaseReadPermission(fastify, request) {
@@ -479,13 +473,13 @@ function moneyToFixed(value) {
return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00";
}
function decimalInputToString(value, { min, field }) {
function decimalInputToString(value, { min, field }, invalidReason, rangeReason) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) {
throw buildValidationError({ [field]: "invalid_decimal" });
throw buildBusinessValidationError(getErrorReason(invalidReason).code, { [field]: "invalid_decimal" });
}
if (numeric < min) {
throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" });
throw buildBusinessValidationError(getErrorReason(rangeReason).code, { [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" });
}
return numeric.toFixed(2);
}
@@ -695,11 +689,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) {
});
if (!item) {
throw buildValidationError({ legalEntityId: "not_found" }, 409);
throw buildBusinessValidationError("purchase_request.company_not_found", { legalEntityId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409);
throw buildBusinessValidationError("purchase_request.company_inactive", { legalEntityId: "disabled_or_archived" }, 409);
}
return item;
@@ -723,15 +717,15 @@ async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) {
});
if (!item) {
throw buildValidationError({ costCenterId: "not_found" }, 409);
throw buildBusinessValidationError("purchase_request.cost_center_not_found", { costCenterId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409);
throw buildBusinessValidationError("purchase_request.cost_center_inactive", { costCenterId: "disabled_or_archived" }, 409);
}
if (legalEntityId && item.legal_entity_id !== legalEntityId) {
throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409);
throw buildBusinessValidationError("purchase_request.cost_center_company_mismatch", { costCenterId: "legalEntityId_mismatch" }, 409);
}
return item;
@@ -755,15 +749,15 @@ async function ensureSupplierAvailable(tx, supplierId, legalEntityId) {
});
if (!item) {
throw buildValidationError({ supplierId: "not_found" }, 409);
throw buildBusinessValidationError("purchase_request.supplier_not_found", { supplierId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ supplierId: "disabled_or_archived" }, 409);
throw buildBusinessValidationError("purchase_request.supplier_inactive", { supplierId: "disabled_or_archived" }, 409);
}
if (legalEntityId && item.legal_entity_id && item.legal_entity_id !== legalEntityId) {
throw buildValidationError({ supplierId: "legalEntityId_mismatch" }, 409);
throw buildBusinessValidationError("purchase_request.supplier_company_mismatch", { supplierId: "legalEntityId_mismatch" }, 409);
}
return item;
@@ -787,15 +781,15 @@ async function ensureDocumentCategoryAvailable(tx, documentCategoryId) {
});
if (!item) {
throw buildValidationError({ documentCategoryId: "not_found" }, 409);
throw buildBusinessValidationError("purchase_request.document_category_not_found", { documentCategoryId: "not_found" }, 409);
}
if (item.entity_type !== "purchase_request") {
throw buildValidationError({ documentCategoryId: "entity_type_mismatch" }, 409);
throw buildBusinessValidationError("purchase_request.document_category_type_invalid", { documentCategoryId: "entity_type_mismatch" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ documentCategoryId: "disabled_or_archived" }, 409);
throw buildBusinessValidationError("purchase_request.document_category_inactive", { documentCategoryId: "disabled_or_archived" }, 409);
}
return item;
@@ -823,11 +817,11 @@ async function ensureBudgetPoolAvailable(tx, budgetPoolId) {
});
if (!item) {
throw buildValidationError({ budgetPoolId: "not_found" }, 409);
throw buildBusinessValidationError("purchase_request.budget_pool_not_found", { budgetPoolId: "not_found" }, 409);
}
if (item.status !== "active" || item.archived_at) {
throw buildValidationError({ budgetPoolId: "disabled_or_archived" }, 409);
throw buildBusinessValidationError("purchase_request.budget_pool_inactive", { budgetPoolId: "disabled_or_archived" }, 409);
}
return item;
@@ -843,7 +837,7 @@ async function ensureProjectLinkingEnabled(fastify, tx, request) {
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: `${FEATURE_FLAG_KEYS.projectLinking}_disabled`, reason: gate.reason }, 409);
throw buildBusinessValidationError("purchase_request.project_link_disabled", { featureFlag: `${FEATURE_FLAG_KEYS.projectLinking}_disabled`, reason: gate.reason }, 409);
}
}
@@ -907,7 +901,7 @@ async function ensureProjectLinkable(fastify, tx, request, projectId) {
}
if (project.status === "archived") {
throw buildValidationError({ projectId: "archived" }, 409);
throw buildBusinessValidationError("purchase_request.project_archived", { projectId: "archived" }, 409);
}
return project;
@@ -943,8 +937,8 @@ async function replacePurchaseRequestItems(tx, purchaseRequestId, items, actorId
}
const createData = items.map((item) => {
const quantity = decimalInputToString(item.quantity, { min: 0.01, field: "quantity" });
const unitPrice = decimalInputToString(item.unitPrice, { min: 0, field: "unitPrice" });
const quantity = decimalInputToString(item.quantity, { min: 0.01, field: "quantity" }, "purchase_request.quantity_invalid", "purchase_request.quantity_too_small");
const unitPrice = decimalInputToString(item.unitPrice, { min: 0, field: "unitPrice" }, "purchase_request.unit_price_invalid", "purchase_request.unit_price_negative");
const lineAmount = computeLineAmount(quantity, unitPrice);
return {
@@ -1064,7 +1058,7 @@ async function discardMultipartFile(stream, maxBytes) {
function parsePurchaseAttachmentDownloadBody(body) {
const parsed = PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("purchase_attachment.download_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
@@ -1072,21 +1066,18 @@ function parsePurchaseAttachmentDownloadBody(body) {
function parsePurchaseAttachmentDeleteBody(body) {
const parsed = PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("purchase_attachment.delete_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) {
return new AppError({
code: "VALID_001",
message,
statusCode,
details: {
const error = buildBusinessValidationError("purchase_attachment.download_blocked", {
attachment: "security_validation_failed",
reason: reasonCode,
},
});
}, statusCode);
error.message = message;
return error;
}
async function auditBlockedAttachmentDownload({
@@ -1377,116 +1368,6 @@ async function finalizePurchaseSubmitSideEffects({
};
}
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, options = {}) {
const attachmentStorage = options.attachmentStorage ?? defaultStorage;
fastify.get(
@@ -1552,9 +1433,11 @@ async function purchaseRequestRoutes(fastify, options = {}) {
domain: "procurement",
departmentId: request.employee.department_id ?? null,
});
const purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
const createdId = await fastify.prisma.$transaction(async (tx) => {
const numbering = await resolveNumberingContext(tx);
const created = await tx.purchaseRequest.create({
data: {
numbering_version: numbering.version,
applicant_id: request.employee.id,
department_id: request.employee.department_id,
title: "",
@@ -1565,7 +1448,8 @@ async function purchaseRequestRoutes(fastify, options = {}) {
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: PURCHASE_DETAIL_SELECT,
// Keep response-only relation hydration outside the write transaction.
select: { id: true, applicant_id: true, department_id: true },
});
await tx.auditLog.create({
@@ -1581,9 +1465,13 @@ async function purchaseRequestRoutes(fastify, options = {}) {
}),
});
return created;
return created.id;
});
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: createdId },
select: PURCHASE_DETAIL_SELECT,
});
return sendData(reply, request, {
purchaseRequest: mapPurchaseRequestDetail(purchaseRequest, request),
});
@@ -1646,12 +1534,12 @@ async function purchaseRequestRoutes(fastify, options = {}) {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const payload = parsePatchBody(request.body);
const purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
throw buildBusinessValidationError("purchase_request.not_editable", { status: "only_draft_editable" }, 409);
}
if (payload.legalEntityId) {
@@ -1724,10 +1612,13 @@ async function purchaseRequestRoutes(fastify, options = {}) {
}),
});
return tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: PURCHASE_DETAIL_SELECT,
});
});
// Only response hydration belongs outside the write transaction. All
// validation, item/amount changes and their audit still commit together.
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: PURCHASE_DETAIL_SELECT,
});
return sendData(reply, request, {
@@ -1780,18 +1671,6 @@ async function purchaseRequestRoutes(fastify, options = {}) {
});
}
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const workflowDefinition = await fastify.prisma.workflowDefinition.findFirst({
where: {
entity_type: "purchase_request",
@@ -1805,7 +1684,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" }, 409);
throw buildBusinessValidationError("purchase_request.workflow_missing", { workflow: "missing_definition" }, 409);
}
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
@@ -1819,7 +1698,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409);
}
const submitResult = await fastify.prisma.$transaction(async (tx) => {
@@ -1827,7 +1706,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
ensureDraftOwner(request, locked);
if (locked.status !== "draft") {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
throw buildBusinessValidationError("purchase_request.not_submittable", { status: "only_draft_submittable" }, 409);
}
await ensureCutoverAllowed({
@@ -1853,22 +1732,22 @@ async function purchaseRequestRoutes(fastify, options = {}) {
}
if (!purchaseRequest.legal_entity_id) {
throw buildValidationError({ legalEntityId: "required" });
throw buildBusinessValidationError("purchase_request.company_required", { legalEntityId: "required" });
}
if (!purchaseRequest.cost_center_id) {
throw buildValidationError({ costCenterId: "required" });
throw buildBusinessValidationError("purchase_request.cost_center_required", { costCenterId: "required" });
}
if (!purchaseRequest.supplier_id) {
throw buildValidationError({ supplierId: "required" });
throw buildBusinessValidationError("purchase_request.supplier_required", { supplierId: "required" });
}
if (!purchaseRequest.document_category_id) {
throw buildValidationError({ documentCategoryId: "required" });
throw buildBusinessValidationError("purchase_request.document_category_required", { documentCategoryId: "required" });
}
if (!purchaseRequest.items?.length) {
throw buildValidationError({ items: "at_least_one_required" });
throw buildBusinessValidationError("purchase_request.items_required", { items: "at_least_one_required" });
}
if (purchaseRequest.applicant_id !== unlockedPurchaseRequest.applicant_id) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409);
}
const legalEntity = await ensureLegalEntityAvailable(tx, purchaseRequest.legal_entity_id);
@@ -1877,6 +1756,37 @@ async function purchaseRequestRoutes(fastify, options = {}) {
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id);
const effectiveDate = new Date();
const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
legalEntityId: purchaseRequest.legal_entity_id,
costCenterId: purchaseRequest.cost_center_id,
effectiveDate,
}, { forUpdate: true, additionalPoolIds: [purchaseRequest.budget_pool_id] });
let selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? null;
if (selectedBudgetPoolId) {
await ensureBudgetPoolAvailable(tx, selectedBudgetPoolId);
if (!resolvedBudgetPool || resolvedBudgetPool.id !== selectedBudgetPoolId) {
throw buildBusinessValidationError("purchase_request.budget_context_mismatch", { budgetPoolId: "context_mismatch" }, 409);
}
} else if (resolvedBudgetPool) {
selectedBudgetPoolId = resolvedBudgetPool.id;
}
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
logger: fastify.log,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorDepartmentId: request.employee.department_id ?? null,
budgetPoolForWrite: resolvedBudgetPool,
});
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "purchase_request",
entityId: purchaseRequestId,
@@ -1884,24 +1794,6 @@ async function purchaseRequestRoutes(fastify, options = {}) {
precheck: authoritativePrecheck,
});
const effectiveDate = new Date();
let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
legalEntityId: purchaseRequest.legal_entity_id,
costCenterId: purchaseRequest.cost_center_id,
effectiveDate,
});
let selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? null;
if (selectedBudgetPoolId) {
await ensureBudgetPoolAvailable(tx, selectedBudgetPoolId);
if (!resolvedBudgetPool || resolvedBudgetPool.id !== selectedBudgetPoolId) {
throw buildValidationError({ budgetPoolId: "context_mismatch" }, 409);
}
} else if (resolvedBudgetPool) {
selectedBudgetPoolId = resolvedBudgetPool.id;
}
const warnings = [];
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
@@ -1918,17 +1810,23 @@ async function purchaseRequestRoutes(fastify, options = {}) {
: fallbackBudgetControlMode;
if (!selectedBudgetPoolId) {
if (!budgetPrecheckGate.enabled && budgetControlMode === "block") {
throw buildValidationError({ budget: "missing_budget_pool" }, 409);
throw buildBusinessValidationError("purchase_request.budget_required", { budget: "missing_budget_pool" }, 409);
}
if (!budgetPrecheckGate.enabled) {
warnings.push("missing_budget_pool");
}
}
if (!budgetPrecheckGate.enabled && budgetControlMode === "block" && resolvedBudgetPool
&& new Prisma.Decimal(resolvedBudgetPool.used_amount).plus(totalAmount).gt(resolvedBudgetPool.allocated_amount)) {
throw buildPrecheckBlockedError({ blockingReasons: [{ code: "budget_exceeded", title: "预算将被超用", detail: "当前采购金额会导致预算池超出已分配额度。" }] });
}
let requestNo = purchaseRequest.request_no;
if (!requestNo) {
const issued = await issueDocumentNumber(tx, {
documentType: "purchase_request",
numberingVersion: purchaseRequest.numbering_version,
documentCategoryCode: documentCategory.code,
actorId: request.employee.id,
timeZone: fastify.config.appTimezone,
@@ -2103,7 +2001,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
ensureDraftOwner(request, locked);
if (locked.status !== "reviewing") {
throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409);
throw buildBusinessValidationError("purchase_request.not_withdrawable", { status: "only_reviewing_withdrawable" }, 409);
}
const workflowInstance = await tx.workflowInstance.findFirst({
@@ -2242,7 +2140,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
if (locked.status !== "approved") {
throw buildValidationError({ status: "only_approved_archivable" }, 409);
throw buildBusinessValidationError("purchase_request.not_archivable", { status: "only_approved_archivable" }, 409);
}
const existingArchive = await tx.purchaseOrderArchive.findUnique({
@@ -2298,23 +2196,6 @@ async function purchaseRequestRoutes(fastify, options = {}) {
}),
});
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),
};
@@ -2351,11 +2232,11 @@ async function purchaseRequestRoutes(fastify, options = {}) {
ensureDraftOwner(request, purchaseRequest);
if (purchaseRequest.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
throw buildBusinessValidationError("purchase_attachment.not_editable", { status: "only_draft_editable" }, 409);
}
if (typeof request.isMultipart !== "function" || !request.isMultipart()) {
throw buildValidationError({ file: "multipart_required" });
throw buildBusinessValidationError("purchase_attachment.multipart_required", { file: "multipart_required" });
}
const maxBytes = resolveAttachmentMaxBytes(fastify.config);
@@ -2370,12 +2251,12 @@ async function purchaseRequestRoutes(fastify, options = {}) {
if (part.type === "file") {
if (normalizedFile) {
await discardMultipartFile(part.file, maxBytes);
throw buildValidationError({ file: "only_single_file_supported" });
throw buildBusinessValidationError("purchase_attachment.single_file_required", { file: "only_single_file_supported" });
}
const fileBuffer = await readBoundedStream(part.file, maxBytes);
if (part.fieldname !== "file") {
throw buildValidationError({ file: "invalid_field" });
throw buildBusinessValidationError("purchase_attachment.field_invalid", { file: "invalid_field" });
}
normalizedFile = await validateAndNormalizePurchaseAttachment({
@@ -2392,23 +2273,23 @@ async function purchaseRequestRoutes(fastify, options = {}) {
error instanceof PurchaseAttachmentSizeLimitError ||
error?.code === "FST_REQ_FILE_TOO_LARGE"
) {
throw buildValidationError({ file: "file_too_large" });
throw buildBusinessValidationError("purchase_attachment.too_large", { file: "file_too_large" });
}
if (error?.code === "FST_FILES_LIMIT") {
throw buildValidationError({ file: "only_single_file_supported" });
throw buildBusinessValidationError("purchase_attachment.single_file_required", { file: "only_single_file_supported" });
}
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
throw buildValidationError({ file: "multipart_limit_exceeded" });
throw buildBusinessValidationError("purchase_attachment.multipart_limit", { file: "multipart_limit_exceeded" });
}
if (error instanceof PurchaseAttachmentPolicyError) {
if (error.reasonCode === "unsupported_type") throw buildBusinessValidationError("purchase_attachment.unsupported_type", error.details ?? { file: error.reasonCode });
throw buildValidationError(error.details ?? { file: error.reasonCode });
throw buildBusinessValidationError(getErrorReason(PURCHASE_ATTACHMENT_POLICY_REASONS[error.reasonCode] ?? "purchase_attachment.validation_failed").code, error.details ?? { file: error.reasonCode });
}
throw error;
}
if (!normalizedFile) {
throw buildValidationError({ file: "required" });
throw buildBusinessValidationError("purchase_attachment.file_required", { file: "required" });
}
let storagePath;
@@ -2558,7 +2439,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
ensureDraftOwner(request, purchaseRequest);
if (purchaseRequest.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
throw buildBusinessValidationError("purchase_attachment.not_editable", { status: "only_draft_editable" }, 409);
}
const attachment = await fastify.prisma.purchaseAttachment.findFirst({
@@ -2642,7 +2523,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
});
}
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
throw buildBusinessValidationError("purchase_attachment.download_reason_required", { reason: "required" });
}
if (attachment.governance_status === "quarantined") {
@@ -2821,6 +2702,5 @@ module.exports = purchaseRequestRoutes;
module.exports.__testables = {
finalizePurchaseSubmitSideEffects,
handlePurchaseArchiveErpSync,
parseArchiveBody,
};