完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
+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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户