const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); 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 { ensureCutoverAllowed } = require("../services/cutover"); const { buildPersistedRiskFlag, 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"); const { buildPendingTaskCopy, buildWorkflowEntityMeta, } = require("../services/notifications/workflowCopy"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const defaultStorage = require("../services/storage"); const { PurchaseAttachmentPolicyError, buildAttachmentContentDisposition, buildAttachmentStorageKey, validateAndNormalizePurchaseAttachment, } = require("../services/purchaseAttachmentPolicy"); const { dispatchNotificationBatch } = require("../services/notificationDispatcher"); const { ATTACHMENT_TYPE, DOWNLOAD_POLICY, SENSITIVITY_LEVEL, governanceCreateData, mapGovernanceFields, parseMultipartGovernanceField, requiresDownloadReason, sanitizeDownloadReason, } = require("../services/businessAttachmentGovernance"); const UUID_SCHEMA = z.string().uuid(); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/); const PURCHASE_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected", "cancelled"]); const PURCHASE_LIST_QUERY_SCHEMA = z.object({ status: PURCHASE_STATUS_SCHEMA.optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), mine: z.enum(["0", "1", "true", "false"]).optional(), supplierId: UUID_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), projectId: UUID_SCHEMA.optional(), }); const PURCHASE_PATCH_SCHEMA = z .object({ title: z.string().trim().max(200).optional(), reason: z.string().trim().max(2000).nullable().optional(), currencyCode: z.string().trim().min(1).max(8).optional(), legalEntityId: UUID_SCHEMA.nullable().optional(), costCenterId: UUID_SCHEMA.nullable().optional(), supplierId: UUID_SCHEMA.nullable().optional(), documentCategoryId: UUID_SCHEMA.nullable().optional(), budgetPoolId: UUID_SCHEMA.nullable().optional(), projectId: UUID_SCHEMA.nullable().optional(), expectedDeliveryDate: DATE_ONLY_SCHEMA.nullable().optional(), items: z .array( z .object({ description: z.string().trim().max(500), specification: z.string().trim().max(500).nullable().optional(), quantity: z.union([MONEY_STRING_SCHEMA, z.number().positive()]), unitPrice: z.union([MONEY_STRING_SCHEMA, z.number().min(0)]), isAssetCandidate: z.boolean().optional(), }) .strict(), ) .max(200) .optional(), }) .strict(); const PURCHASE_ARCHIVE_FILE_SCHEMA = z .object({ storagePath: z.string().trim().min(1).max(500), originalName: z.string().trim().min(1).max(255), mimeType: z.string().trim().min(1).max(100), fileSize: z.coerce.number().int().min(0), }) .strict(); const PURCHASE_ARCHIVE_BODY_SCHEMA = z .object({ poNo: z.string().trim().min(1).max(64), contractNo: z.string().trim().max(64).nullable().optional(), archivedFiles: z.array(PURCHASE_ARCHIVE_FILE_SCHEMA).max(20).optional(), }) .strict(); const PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA = z .object({ reason: z.string().optional(), }) .strict(); const PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA = z .object({ reason: z.string().trim().min(2).max(200), }) .strict(); const PURCHASE_SUMMARY_SELECT = { id: true, request_no: true, applicant_id: true, submitter_id: true, department_id: true, legal_entity_id: true, cost_center_id: true, supplier_id: true, document_category_id: true, budget_pool_id: true, project_id: true, title: true, reason: true, currency_code: true, total_amount: true, expected_delivery_date: true, status: true, created_at: true, updated_at: true, applicant: { select: { id: true, employee_no: true, name: true, }, }, submitter: { select: { id: true, employee_no: true, name: true, }, }, department: { select: { id: true, name: true, }, }, legal_entity: { select: { id: true, code: true, name: true, }, }, cost_center: { select: { id: true, code: true, name: true, legal_entity_id: true, }, }, supplier: { select: { id: true, code: true, name: true, }, }, document_category: { select: { id: true, code: true, name: true, entity_type: true, }, }, budget_pool: { select: { id: true, code: true, name: true, }, }, project: { select: { id: true, project_no: true, name: true, status: true, }, }, }; const PURCHASE_DETAIL_SELECT = { ...PURCHASE_SUMMARY_SELECT, items: { orderBy: { created_at: "asc" }, select: { id: true, description: true, specification: true, quantity: true, unit_price: true, line_amount: true, is_asset_candidate: true, created_at: true, updated_at: true, }, }, attachments: { orderBy: { created_at: "asc" }, select: { id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, governance_status: true, sensitivity_level: true, attachment_type: true, reviewed_by: true, reviewed_at: true, review_note: true, archived_by: true, archived_at: true, archive_note: true, download_policy: true, uploaded_by: true, created_at: true, uploader: { select: { id: true, employee_no: true, name: true, }, }, }, }, order_archive: { select: { id: true, po_no: true, contract_no: true, archived_files: true, archived_at: true, archived_by: true, archived_by_employee: { select: { id: true, employee_no: true, name: true, }, }, }, }, workflow_instances: { orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_step_id: true, current_status: true, workflow_tasks: { where: { status: "pending" }, orderBy: { created_at: "asc" }, take: 1, select: { id: true, step_id: true, step_name: true, assignee_id: true, assignee_role: true, }, }, workflow_history: { orderBy: { created_at: "desc" }, take: 8, select: { id: true, task_id: true, actor_id: true, action: true, comment: true, created_at: true, actor: { select: { employee_no: true, name: true, }, }, }, }, }, }, }; const PURCHASE_SUBMIT_SELECT = { id: true, applicant_id: true, department_id: true, legal_entity_id: true, cost_center_id: true, supplier_id: true, document_category_id: true, budget_pool_id: true, request_no: true, items: { select: { id: true, }, }, document_category: { select: { id: true, code: true, name: true, entity_type: true, enabled: true, archived_at: true, }, }, }; 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" }); } return parsed.data; } function parseAttachmentId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ attachmentId: "invalid_uuid" }); } return parsed.data; } function parseListQuery(query) { const parsed = PURCHASE_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { status: parsed.data.status, page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, mine: parsed.data.mine == null ? true : ["1", "true"].includes(parsed.data.mine), supplierId: parsed.data.supplierId, legalEntityId: parsed.data.legalEntityId, costCenterId: parsed.data.costCenterId, projectId: parsed.data.projectId, }; } function parsePatchBody(body) { const parsed = PURCHASE_PATCH_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parseArchiveBody(body) { const parsed = PURCHASE_ARCHIVE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function normalizeBudgetControlMode(config) { return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn"; } function buildPrecheckBlockedError(precheck) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { precheck: precheck.blockingReasons, }, }); } function ensurePurchaseReadPermission(fastify, request) { const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own"); const canViewAll = fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage"); if (!canViewOwn && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return { canViewOwn, canViewAll }; } function canReadPurchaseRequest(request, purchaseRequest) { if (!purchaseRequest) { return false; } if ( request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("procurement.view_all") || request.employee.permissionCodes.includes("procurement.manage") ) { return true; } return ( purchaseRequest.applicant_id === request.employee.id || purchaseRequest.submitter_id === request.employee.id ); } function ensureDraftOwner(request, purchaseRequest, detailsKey = "purchaseRequest") { if (purchaseRequest.applicant_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { [detailsKey]: "only_applicant" }, }); } } function toIsoDate(value) { return value?.toISOString?.()?.slice(0, 10) ?? null; } function toIsoDateTime(value) { return value?.toISOString?.() ?? null; } function moneyToFixed(value) { if (value == null) { return "0.00"; } const numeric = Number(value); return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00"; } function decimalInputToString(value, { min, field }) { const numeric = typeof value === "number" ? value : Number(value); if (!Number.isFinite(numeric)) { throw buildValidationError({ [field]: "invalid_decimal" }); } if (numeric < min) { throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" }); } return numeric.toFixed(2); } function computeLineAmount(quantity, unitPrice) { const quantityNumber = Number(quantity); const unitPriceNumber = Number(unitPrice); return (quantityNumber * unitPriceNumber).toFixed(2); } function mapEmployeeBasic(employee) { if (!employee) { return null; } return { id: employee.id, employeeNo: employee.employee_no, name: employee.name, }; } function mapPurchaseRequestSummary(item) { return { id: item.id, requestNo: item.request_no ?? null, applicantId: item.applicant_id, submitterId: item.submitter_id ?? null, departmentId: item.department_id, legalEntityId: item.legal_entity_id ?? null, costCenterId: item.cost_center_id ?? null, supplierId: item.supplier_id ?? null, documentCategoryId: item.document_category_id ?? null, budgetPoolId: item.budget_pool_id ?? null, projectId: item.project_id ?? null, title: item.title, reason: item.reason ?? null, currencyCode: item.currency_code, totalAmount: moneyToFixed(item.total_amount), expectedDeliveryDate: toIsoDate(item.expected_delivery_date), status: item.status, createdAt: toIsoDateTime(item.created_at), updatedAt: toIsoDateTime(item.updated_at), applicant: mapEmployeeBasic(item.applicant), submitter: mapEmployeeBasic(item.submitter), department: item.department ? { id: item.department.id, name: item.department.name, } : null, legalEntity: item.legal_entity ? { id: item.legal_entity.id, code: item.legal_entity.code, name: item.legal_entity.name, } : null, costCenter: item.cost_center ? { id: item.cost_center.id, code: item.cost_center.code, name: item.cost_center.name, legalEntityId: item.cost_center.legal_entity_id, } : null, supplier: item.supplier ? { id: item.supplier.id, code: item.supplier.code, name: item.supplier.name, } : null, documentCategory: item.document_category ? { id: item.document_category.id, code: item.document_category.code, name: item.document_category.name, entityType: item.document_category.entity_type, } : null, budgetPool: item.budget_pool ? { id: item.budget_pool.id, code: item.budget_pool.code, name: item.budget_pool.name, } : null, project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name, status: item.project.status, } : null, }; } function mapPurchaseOrderArchive(item) { if (!item) { return null; } return { id: item.id, poNo: item.po_no, contractNo: item.contract_no ?? null, archivedAt: toIsoDateTime(item.archived_at), archivedBy: item.archived_by, archivedByEmployee: mapEmployeeBasic(item.archived_by_employee), archivedFiles: Array.isArray(item.archived_files) ? item.archived_files.map((entry) => ({ storagePath: entry?.storagePath ?? null, originalName: entry?.originalName ?? null, mimeType: entry?.mimeType ?? null, fileSize: Number(entry?.fileSize ?? 0), })) : [], }; } function mapWorkflowHistoryItem(item) { return { id: item.id, taskId: item.task_id ?? null, actorId: item.actor_id, actorName: item.actor?.name ?? null, actorEmployeeNo: item.actor?.employee_no ?? null, action: item.action, comment: item.comment ?? null, createdAt: toIsoDateTime(item.created_at), }; } function mapPurchaseRequestDetail(item, request) { const summary = mapPurchaseRequestSummary(item); const latestWorkflow = item.workflow_instances?.[0] ?? null; const pendingWorkflow = latestWorkflow?.current_status === "pending" ? { instanceId: latestWorkflow.id, currentStatus: latestWorkflow.current_status, currentStepId: latestWorkflow.current_step_id, task: latestWorkflow.workflow_tasks?.[0] ? { id: latestWorkflow.workflow_tasks[0].id, stepId: latestWorkflow.workflow_tasks[0].step_id, stepName: latestWorkflow.workflow_tasks[0].step_name, assigneeId: latestWorkflow.workflow_tasks[0].assignee_id, assigneeRole: latestWorkflow.workflow_tasks[0].assignee_role, } : null, } : null; return { ...summary, items: (item.items ?? []).map((entry) => ({ id: entry.id, description: entry.description, specification: entry.specification ?? null, quantity: moneyToFixed(entry.quantity), unitPrice: moneyToFixed(entry.unit_price), lineAmount: moneyToFixed(entry.line_amount), isAssetCandidate: Boolean(entry.is_asset_candidate), createdAt: toIsoDateTime(entry.created_at), updatedAt: toIsoDateTime(entry.updated_at), })), attachments: (item.attachments ?? []).map((attachment) => ({ id: attachment.id, originalName: attachment.original_name, mimeType: attachment.mime_type, fileSize: attachment.file_size, fileSha256: attachment.file_sha256 ?? null, ...mapGovernanceFields(attachment, { governanceStatus: "approved", sensitivityLevel: SENSITIVITY_LEVEL.internal, attachmentType: ATTACHMENT_TYPE.other, downloadPolicy: DOWNLOAD_POLICY.standard, }), uploadedBy: attachment.uploaded_by, createdAt: toIsoDateTime(attachment.created_at), uploader: mapEmployeeBasic(attachment.uploader), })), archiveOrder: mapPurchaseOrderArchive(item.order_archive), pendingWorkflow, approvalHistory: (latestWorkflow?.workflow_history ?? []).map(mapWorkflowHistoryItem), permissions: { canEditDraft: item.status === "draft" && item.applicant_id === request.employee.id, canSubmit: item.status === "draft" && item.applicant_id === request.employee.id && request.employee.permissionCodes.includes("procurement.submit"), canWithdraw: item.status === "reviewing" && item.applicant_id === request.employee.id && request.employee.permissionCodes.includes("procurement.submit"), canArchiveOrder: item.status === "approved" && (request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("procurement.manage")), }, }; } async function ensureLegalEntityAvailable(tx, legalEntityId) { if (!legalEntityId) { return null; } const item = await tx.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true, enabled: true, archived_at: true }, }); if (!item) { throw buildValidationError({ legalEntityId: "not_found" }, 409); } if (!item.enabled || item.archived_at) { throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409); } return item; } async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) { if (!costCenterId) { return null; } const item = await tx.costCenter.findUnique({ where: { id: costCenterId }, select: { id: true, code: true, name: true, legal_entity_id: true, enabled: true, archived_at: true, }, }); if (!item) { throw buildValidationError({ costCenterId: "not_found" }, 409); } if (!item.enabled || item.archived_at) { throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409); } if (legalEntityId && item.legal_entity_id !== legalEntityId) { throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409); } return item; } async function ensureSupplierAvailable(tx, supplierId, legalEntityId) { if (!supplierId) { return null; } const item = await tx.supplier.findUnique({ where: { id: supplierId }, select: { id: true, code: true, name: true, enabled: true, archived_at: true, legal_entity_id: true, }, }); if (!item) { throw buildValidationError({ supplierId: "not_found" }, 409); } if (!item.enabled || item.archived_at) { throw buildValidationError({ supplierId: "disabled_or_archived" }, 409); } if (legalEntityId && item.legal_entity_id && item.legal_entity_id !== legalEntityId) { throw buildValidationError({ supplierId: "legalEntityId_mismatch" }, 409); } return item; } async function ensureDocumentCategoryAvailable(tx, documentCategoryId) { if (!documentCategoryId) { return null; } const item = await tx.documentCategory.findUnique({ where: { id: documentCategoryId }, select: { id: true, code: true, name: true, entity_type: true, enabled: true, archived_at: true, }, }); if (!item) { throw buildValidationError({ documentCategoryId: "not_found" }, 409); } if (item.entity_type !== "purchase_request") { throw buildValidationError({ documentCategoryId: "entity_type_mismatch" }, 409); } if (!item.enabled || item.archived_at) { throw buildValidationError({ documentCategoryId: "disabled_or_archived" }, 409); } return item; } async function ensureBudgetPoolAvailable(tx, budgetPoolId) { if (!budgetPoolId) { return null; } const item = await tx.budgetPool.findUnique({ where: { id: budgetPoolId }, select: { id: true, code: true, name: true, department_id: true, legal_entity_id: true, cost_center_id: true, status: true, archived_at: true, period_start: true, period_end: true, }, }); if (!item) { throw buildValidationError({ budgetPoolId: "not_found" }, 409); } if (item.status !== "active" || item.archived_at) { throw buildValidationError({ budgetPoolId: "disabled_or_archived" }, 409); } return item; } async function ensureProjectLinkingEnabled(fastify, tx, request) { const gate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildValidationError({ featureFlag: `${FEATURE_FLAG_KEYS.projectLinking}_disabled`, reason: gate.reason }, 409); } } function canReadProjectForLink(fastify, request, project) { if (!project) { return false; } if ( fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") ) { return true; } return ( project.owner_employee_id === request.employee.id || project.manager_employee_id === request.employee.id || (project.members ?? []).some((item) => item.employee_id === request.employee.id && item.left_at == null) ); } async function ensureProjectLinkable(fastify, tx, request, projectId) { if (!projectId) { return null; } await ensureProjectLinkingEnabled(fastify, tx, request); const project = await tx.project.findUnique({ where: { id: projectId }, select: { id: true, project_no: true, name: true, status: true, owner_employee_id: true, manager_employee_id: true, members: { where: { left_at: null }, select: { employee_id: true, left_at: true }, }, }, }); if (!project) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (!canReadProjectForLink(fastify, request, project)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.view_own" }, }); } if (project.status === "archived") { throw buildValidationError({ projectId: "archived" }, 409); } return project; } async function loadPurchaseRequestForUpdate(tx, purchaseRequestId) { const rows = await tx.$queryRaw` SELECT id, applicant_id, submitter_id, department_id, legal_entity_id, cost_center_id, supplier_id, document_category_id, budget_pool_id, project_id, request_no, currency_code, status FROM "purchase_requests" WHERE id = ${purchaseRequestId}::uuid FOR UPDATE `; if (!Array.isArray(rows) || rows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return rows[0]; } async function replacePurchaseRequestItems(tx, purchaseRequestId, items, actorId) { await tx.purchaseRequestItem.deleteMany({ where: { purchase_request_id: purchaseRequestId }, }); if (!items.length) { return "0.00"; } 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 lineAmount = computeLineAmount(quantity, unitPrice); return { purchase_request_id: purchaseRequestId, description: item.description.trim(), specification: item.specification?.trim() || null, quantity, unit_price: unitPrice, line_amount: lineAmount, is_asset_candidate: Boolean(item.isAssetCandidate), created_by: actorId, updated_by: actorId, }; }); await tx.purchaseRequestItem.createMany({ data: createData }); const totalAmount = createData .reduce((sum, item) => sum + Number(item.line_amount), 0) .toFixed(2); return totalAmount; } async function recomputePurchaseRequestTotal(tx, purchaseRequestId, actorId) { const aggregate = await tx.purchaseRequestItem.aggregate({ where: { purchase_request_id: purchaseRequestId }, _sum: { line_amount: true }, }); const nextTotal = moneyToFixed(aggregate._sum.line_amount ?? 0); await tx.purchaseRequest.update({ where: { id: purchaseRequestId }, data: { total_amount: nextTotal, updated_by: actorId, }, }); return nextTotal; } class PurchaseAttachmentSizeLimitError extends Error { constructor() { super("purchase_attachment_size_limit"); this.name = "PurchaseAttachmentSizeLimitError"; this.reasonCode = "size_limit"; } } function resolveAttachmentMaxBytes(config) { const configured = Number(config?.maxUploadSizeBytes); return Number.isSafeInteger(configured) && configured > 0 ? configured : 10 * 1024 * 1024; } function destroyStream(stream) { if (!stream) { return; } if (typeof stream.destroy === "function" && !stream.destroyed) { try { stream.destroy(); return; } catch { // Fall through to a Web Stream cancellation attempt. } } if (typeof stream.cancel === "function") { try { Promise.resolve(stream.cancel()).catch(() => {}); } catch { // The caller is already failing closed; cleanup must not replace the security error. } } } async function readBoundedStream(stream, maxBytes) { const chunks = []; let size = 0; try { for await (const chunk of stream) { const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); size += bufferChunk.length; if (size > maxBytes) { destroyStream(stream); throw new PurchaseAttachmentSizeLimitError(); } chunks.push(bufferChunk); } } catch (error) { if (error instanceof PurchaseAttachmentSizeLimitError) { throw error; } destroyStream(stream); throw error; } if (stream?.truncated) { destroyStream(stream); throw new PurchaseAttachmentSizeLimitError(); } return Buffer.concat(chunks, size); } async function discardMultipartFile(stream, maxBytes) { try { await readBoundedStream(stream, maxBytes); } catch { destroyStream(stream); } } function parsePurchaseAttachmentDownloadBody(body) { const parsed = PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parsePurchaseAttachmentDeleteBody(body) { const parsed = PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) { return new AppError({ code: "VALID_001", message, statusCode, details: { attachment: "security_validation_failed", reason: reasonCode, }, }); } async function auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode, downloadReason, }) { try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED", targetType: "purchase_attachment", targetId: attachmentId, extra: { purchaseRequestId, attachmentType: attachment?.attachment_type ?? ATTACHMENT_TYPE.quote, sensitivityLevel: attachment?.sensitivity_level ?? SENSITIVITY_LEVEL.internal, governanceStatus: attachment?.governance_status ?? "approved", reason: downloadReason, reasonCode, }, }), }); } catch { fastify.log.error( { requestId: request.id, purchaseRequestId, attachmentId, errorCode: "purchase_attachment_block_audit_failed", reasonCode, }, "Purchase attachment block audit failed", ); } } function throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId }) { fastify.log.error( { requestId: request.id, purchaseRequestId, attachmentId, errorCode: "purchase_attachment_storage_read_failed", }, "Purchase attachment storage read failed", ); throw new AppError({ code: "SYS_001", message: "附件暂时无法读取,请稍后重试", statusCode: 500, details: { attachment: "storage_read_failed" }, }); } async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) { const purchaseRequest = await tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, applicant_id: true, submitter_id: true, status: true, }, }); if (!purchaseRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (!canReadPurchaseRequest(request, purchaseRequest)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return purchaseRequest; } function buildSkippedNotificationResult(reason) { return { skipped: true, reason }; } function buildSkippedDispatchResult(reason) { return { deliveredChannels: [], skippedReason: reason, notificationCount: 0, eligibleRecipientCount: 0, matchedChannelCount: 0 }; } function buildSkippedEmailResult(reason, failed = 0) { return { skipped: true, reason, sent: 0, failed }; } async function loadPurchaseSubmitSummary(prisma, purchaseRequestId) { return prisma.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, request_no: true, total_amount: true, applicant: { select: { id: true, name: true, }, }, }, }); } async function finalizePurchaseSubmitSideEffects({ prisma, config, logger, requestId, purchaseRequestId, requestNo, workflowTaskId, assigneeId, assigneeRole, actorId, loadSummary = loadPurchaseSubmitSummary, resolveRecipients = resolveRecipientsForAssignee, createAppNotifications = createNotificationsWithRecords, dispatchNotifications = dispatchNotificationBatch, sendEmailSafe = sendNotificationEmailSafe, }) { let copy = buildPendingTaskCopy( buildWorkflowEntityMeta("purchase_request", { request_no: requestNo }, purchaseRequestId), ); try { const summary = await loadSummary(prisma, purchaseRequestId); copy = buildPendingTaskCopy( buildWorkflowEntityMeta( "purchase_request", { ...summary, request_no: summary?.request_no ?? requestNo, }, purchaseRequestId, ), ); } catch (error) { logger?.error?.( { err: error, requestId, purchaseRequestId, }, "Failed to load purchase submit notification summary", ); } const { title, body } = copy; const targetPath = `/workflow/tasks/${workflowTaskId}`; let recipientEmployeeIds = []; try { recipientEmployeeIds = await resolveRecipients(prisma, { assigneeId, assigneeRole, }); } catch (error) { logger?.error?.( { err: error, requestId, purchaseRequestId, workflowTaskId, }, "Failed to resolve purchase submit notification recipients", ); return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("recipient_resolution_failed"), dispatch: buildSkippedDispatchResult("recipient_resolution_failed"), email: buildSkippedEmailResult("recipient_resolution_failed"), }; } if (!recipientEmployeeIds.length) { return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("no_recipients"), dispatch: buildSkippedDispatchResult("no_recipients"), email: buildSkippedEmailResult("no_recipients"), }; } let notification = buildSkippedNotificationResult("not_attempted"); let notificationRecords = []; try { notificationRecords = await createAppNotifications(prisma, { recipientEmployeeIds, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, }); notification = { count: notificationRecords.length }; } catch (error) { logger?.error?.( { err: error, requestId, purchaseRequestId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to create purchase submit notifications", ); notification = buildSkippedNotificationResult("notification_failed"); } let dispatch = buildSkippedDispatchResult("not_attempted"); try { dispatch = await dispatchNotifications({ prisma, config, logger, requestId, notifications: notificationRecords, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, targetPath, actorId, }); } catch (error) { logger?.error?.( { err: error, requestId, purchaseRequestId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to dispatch purchase submit notifications", ); dispatch = buildSkippedDispatchResult("dispatch_failed"); } let email = buildSkippedEmailResult("not_attempted"); try { const emailResult = await sendEmailSafe({ prisma, config, logger, requestId, recipientEmployeeIds, subject: title, body, type: NOTIFICATION_TYPES.workflowTaskAssigned, targetPath, }); email = emailResult?.email ?? emailResult ?? buildSkippedEmailResult("empty_email_result"); } catch (error) { logger?.error?.( { err: error, requestId, purchaseRequestId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to send purchase submit notification email", ); email = buildSkippedEmailResult("email_failed", recipientEmployeeIds.length); } return { recipientEmployeeIds, notification, dispatch, email, }; } 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( "/purchase-requests", { schema: { tags: ["Purchase Requests"], summary: "List Purchase Requests" }, preHandler: fastify.authenticate, }, async (request, reply) => { const permissionState = ensurePurchaseReadPermission(fastify, request); const query = parseListQuery(request.query); const skip = (query.page - 1) * query.pageSize; const mineOnly = permissionState.canViewAll ? query.mine : true; const where = { ...(query.status ? { status: query.status } : {}), ...(query.supplierId ? { supplier_id: query.supplierId } : {}), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), ...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}), ...(query.projectId ? { project_id: query.projectId } : {}), ...(mineOnly ? { OR: [ { applicant_id: request.employee.id }, { submitter_id: request.employee.id }, ], } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.purchaseRequest.findMany({ where, orderBy: [{ updated_at: "desc" }, { created_at: "desc" }], skip, take: query.pageSize, select: PURCHASE_SUMMARY_SELECT, }), fastify.prisma.purchaseRequest.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, mine: mineOnly, items: items.map(mapPurchaseRequestSummary), }); }, ); fastify.post( "/purchase-requests", { schema: { tags: ["Purchase Requests"], summary: "Create Purchase Request Draft" }, preHandler: fastify.requirePermission("procurement.create"), }, async (request, reply) => { await ensureCutoverAllowed({ prisma: fastify.prisma, config: fastify.config, request, domain: "procurement", departmentId: request.employee.department_id ?? null, }); const purchaseRequest = await fastify.prisma.$transaction(async (tx) => { const created = await tx.purchaseRequest.create({ data: { applicant_id: request.employee.id, department_id: request.employee.department_id, title: "", reason: null, currency_code: "CNY", total_amount: "0.00", status: "draft", created_by: request.employee.id, updated_by: request.employee.id, }, select: PURCHASE_DETAIL_SELECT, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_REQUEST_CREATED", targetType: "purchase_request", targetId: created.id, extra: { applicantId: created.applicant_id, departmentId: created.department_id, }, }), }); return created; }); return sendData(reply, request, { purchaseRequest: mapPurchaseRequestDetail(purchaseRequest, request), }); }, ); fastify.get( "/purchase-requests/:id", { schema: { tags: ["Purchase Requests"], summary: "Get Purchase Request Detail" }, preHandler: fastify.authenticate, }, async (request, reply) => { ensurePurchaseReadPermission(fastify, request); const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: PURCHASE_DETAIL_SELECT, }); if (!purchaseRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (!canReadPurchaseRequest(request, purchaseRequest)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ where: { entity_type: "purchase_request", entity_id: purchaseRequestId, status: "open", }, orderBy: [{ created_at: "desc" }, { id: "desc" }], }); return sendData(reply, request, { ...mapPurchaseRequestDetail(purchaseRequest, request), riskFlags: riskFlags.map(buildPersistedRiskFlag), }); }, ); fastify.patch( "/purchase-requests/:id", { schema: { tags: ["Purchase Requests"], summary: "Update Purchase Request Draft" }, preHandler: fastify.requirePermission("procurement.create"), }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const payload = parsePatchBody(request.body); const purchaseRequest = 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); } if (payload.legalEntityId) { await ensureLegalEntityAvailable(tx, payload.legalEntityId); } if (payload.costCenterId) { await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId ?? locked.legal_entity_id ?? null); } if (payload.supplierId) { await ensureSupplierAvailable(tx, payload.supplierId, payload.legalEntityId ?? locked.legal_entity_id ?? null); } if (payload.documentCategoryId) { await ensureDocumentCategoryAvailable(tx, payload.documentCategoryId); } if (payload.budgetPoolId) { await ensureBudgetPoolAvailable(tx, payload.budgetPoolId); } if (payload.projectId && payload.projectId !== (locked.project_id ?? null)) { await ensureProjectLinkable(fastify, tx, request, payload.projectId); } const updateData = { ...(payload.title !== undefined ? { title: payload.title.trim() } : {}), ...(payload.reason !== undefined ? { reason: payload.reason?.trim() || null } : {}), ...(payload.currencyCode !== undefined ? { currency_code: payload.currencyCode.trim().toUpperCase() } : {}), ...(payload.legalEntityId !== undefined ? { legal_entity_id: payload.legalEntityId || null } : {}), ...(payload.costCenterId !== undefined ? { cost_center_id: payload.costCenterId || null } : {}), ...(payload.supplierId !== undefined ? { supplier_id: payload.supplierId || null } : {}), ...(payload.documentCategoryId !== undefined ? { document_category_id: payload.documentCategoryId || null } : {}), ...(payload.budgetPoolId !== undefined ? { budget_pool_id: payload.budgetPoolId || null } : {}), ...(payload.projectId !== undefined ? { project_id: payload.projectId || null } : {}), ...(payload.expectedDeliveryDate !== undefined ? { expected_delivery_date: payload.expectedDeliveryDate ? parseDateOnlyString(payload.expectedDeliveryDate) : null, } : {}), updated_by: request.employee.id, }; await tx.purchaseRequest.update({ where: { id: purchaseRequestId }, data: updateData, }); let totalAmount = null; if (payload.items) { totalAmount = await replacePurchaseRequestItems(tx, purchaseRequestId, payload.items, request.employee.id); } await tx.purchaseRequest.update({ where: { id: purchaseRequestId }, data: { total_amount: totalAmount ?? undefined, updated_by: request.employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_REQUEST_UPDATED", targetType: "purchase_request", targetId: purchaseRequestId, extra: { updatedFields: Object.keys(payload), replacedItems: Boolean(payload.items), }, }), }); return tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: PURCHASE_DETAIL_SELECT, }); }); return sendData(reply, request, { purchaseRequest: mapPurchaseRequestDetail(purchaseRequest, request), }); }, ); fastify.post( "/purchase-requests/:id/precheck", { preHandler: fastify.requirePermission("procurement.submit"), }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const precheck = await resolveEntityPrecheck({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, entityType: "purchase_request", entityId: purchaseRequestId, actorDepartmentId: request.employee.department_id ?? null, }); return sendData(reply, request, precheck); }, ); fastify.post( "/purchase-requests/:id/submit", { schema: { tags: ["Purchase Requests"], summary: "Submit Purchase Request" }, preHandler: fastify.requirePermission("procurement.submit"), }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const unlockedPurchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, applicant_id: true, department_id: true, }, }); if (!unlockedPurchaseRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } 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", is_active: true, }, orderBy: { version: "desc" }, select: { id: true, definition_json: true, }, }); if (!workflowDefinition) { throw buildValidationError({ workflow: "missing_definition" }, 409); } const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); const { actionable, skipped } = await resolveActionableStep({ tx: fastify.prisma, steps: workflowSteps, startIndex: 0, ownerId: unlockedPurchaseRequest.applicant_id, logger: fastify.log, blockOnUnresolvedAssignee: true, }); if (!actionable) { throw buildValidationError({ workflow: "no_actionable_approver" }, 409); } const submitResult = await fastify.prisma.$transaction(async (tx) => { const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); ensureDraftOwner(request, locked); if (locked.status !== "draft") { throw buildValidationError({ status: "only_draft_submittable" }, 409); } await ensureCutoverAllowed({ prisma: tx, config: fastify.config, request, domain: "procurement", departmentId: locked.department_id ?? request.employee.department_id ?? null, legalEntityId: locked.legal_entity_id ?? null, }); const purchaseRequest = await tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: PURCHASE_SUBMIT_SELECT, }); if (!purchaseRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (!purchaseRequest.legal_entity_id) { throw buildValidationError({ legalEntityId: "required" }); } if (!purchaseRequest.cost_center_id) { throw buildValidationError({ costCenterId: "required" }); } if (!purchaseRequest.supplier_id) { throw buildValidationError({ supplierId: "required" }); } if (!purchaseRequest.document_category_id) { throw buildValidationError({ documentCategoryId: "required" }); } if (!purchaseRequest.items?.length) { throw buildValidationError({ items: "at_least_one_required" }); } if (purchaseRequest.applicant_id !== unlockedPurchaseRequest.applicant_id) { throw buildValidationError({ workflow: "no_actionable_approver" }, 409); } const legalEntity = await ensureLegalEntityAvailable(tx, purchaseRequest.legal_entity_id); const costCenter = await ensureCostCenterAvailable(tx, purchaseRequest.cost_center_id, purchaseRequest.legal_entity_id); const supplier = await ensureSupplierAvailable(tx, purchaseRequest.supplier_id, purchaseRequest.legal_entity_id); const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id); const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id); const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { entityType: "purchase_request", entityId: purchaseRequestId, actorId: request.employee.id, 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, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null, strictDepartment: false, }); const fallbackBudgetControlMode = normalizeBudgetControlMode(fastify.config); const budgetControlMode = budgetPrecheckGate.enabled ? authoritativePrecheck.matchedBudgetControlRule?.controlMode ?? authoritativePrecheck?.budgetHint?.controlMode ?? fallbackBudgetControlMode : fallbackBudgetControlMode; if (!selectedBudgetPoolId) { if (!budgetPrecheckGate.enabled && budgetControlMode === "block") { throw buildValidationError({ budget: "missing_budget_pool" }, 409); } if (!budgetPrecheckGate.enabled) { warnings.push("missing_budget_pool"); } } let requestNo = purchaseRequest.request_no; if (!requestNo) { const issued = await issueDocumentNumber(tx, { documentType: "purchase_request", documentCategoryCode: documentCategory.code, actorId: request.employee.id, timeZone: fastify.config.appTimezone, }); requestNo = issued.documentNumber; } await tx.purchaseRequest.update({ where: { id: purchaseRequestId }, data: { request_no: requestNo, submitter_id: request.employee.id, budget_pool_id: selectedBudgetPoolId, total_amount: totalAmount, status: "reviewing", updated_by: request.employee.id, }, }); const budgetReservation = await reserveBudgetForEntity(tx, { budgetPoolId: selectedBudgetPoolId, entityType: "purchase_request", entityId: purchaseRequestId, amount: totalAmount, actorId: request.employee.id, note: `submit_reserve:${requestNo ?? purchaseRequestId}`, }); const workflowInstance = await tx.workflowInstance.create({ data: { workflow_id: workflowDefinition.id, entity_type: "purchase_request", entity_id: purchaseRequestId, purchase_request_id: purchaseRequestId, submitter_id: request.employee.id, current_status: "pending", current_step_id: actionable.step.id, context_data: { owner_id: purchaseRequest.applicant_id, department_id: purchaseRequest.department_id, workflow_steps_snapshot: workflowSteps, document_category_id: purchaseRequest.document_category_id, legal_entity_id: legalEntity.id, cost_center_id: costCenter.id, supplier_id: supplier.id, }, context_department_id: purchaseRequest.department_id, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, }, }); for (const skippedStep of skipped) { await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, actor_id: request.employee.id, action: "skip", payload: { step_id: skippedStep.step.id, reason: skippedStep.reason, owner_id: purchaseRequest.applicant_id, depth_checked: skippedStep.depthChecked, }, }, }); } const workflowTask = await tx.workflowTask.create({ data: { instance_id: workflowInstance.id, step_id: actionable.step.id, step_name: actionable.step.name ?? actionable.step.id, assignee_id: actionable.assigneeId, assignee_role: actionable.assigneeRole, }, select: { id: true, assignee_id: true, assignee_role: true, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: workflowTask.id, actor_id: request.employee.id, action: "start", payload: { step_id: actionable.step.id, skipped_step_count: skipped.length, }, }, }); return { response: { purchaseRequestId, requestNo, status: "reviewing", workflowInstanceId: workflowInstance.id, currentTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, warnings, budgetReservation, }, sideEffectsContext: { purchaseRequestId, requestNo, workflowTaskId: workflowTask.id, assigneeId: workflowTask.assignee_id, assigneeRole: workflowTask.assignee_role, actorId: request.employee.id, totalAmount, warnings, budgetReservation, }, }; }); void fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_REQUEST_SUBMITTED", targetType: "purchase_request", targetId: submitResult.response.purchaseRequestId, extra: { requestNo: submitResult.response.requestNo, totalAmount: submitResult.sideEffectsContext.totalAmount, workflowInstanceId: submitResult.response.workflowInstanceId, workflowTaskId: submitResult.response.currentTaskId, budgetPoolId: submitResult.response.budgetPoolId, warnings: submitResult.response.warnings, budgetReservation: submitResult.response.budgetReservation, }, }), }).catch((error) => { fastify.log.error( { err: error, purchaseRequestId: submitResult.response.purchaseRequestId }, "Failed to write purchase submit audit log", ); }); await finalizePurchaseSubmitSideEffects({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...submitResult.sideEffectsContext, }); return sendData(reply, request, submitResult.response); }, ); fastify.post( "/purchase-requests/:id/withdraw", { schema: { tags: ["Purchase Requests"], summary: "Withdraw Purchase Request" }, preHandler: fastify.requirePermission("procurement.submit"), }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); let response = null; await fastify.prisma.$transaction(async (tx) => { const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); ensureDraftOwner(request, locked); if (locked.status !== "reviewing") { throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409); } const workflowInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "purchase_request", entity_id: purchaseRequestId, current_status: "pending", }, orderBy: { created_at: "desc" }, select: { id: true }, }); if (workflowInstance) { await tx.workflowTask.updateMany({ where: { instance_id: workflowInstance.id, status: "pending", }, data: { status: "cancelled", completed_at: new Date(), completed_by: request.employee.id, }, }); await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_status: "terminated", completed_at: new Date(), updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, actor_id: request.employee.id, action: "withdraw", payload: { source: "purchase_request_withdraw", }, }, }); } const budgetRelease = await releaseReservedBudgetIfAny(tx, { entityType: "purchase_request", entityId: purchaseRequestId, actorId: request.employee.id, note: "withdraw_release_purchase_budget", }); await tx.purchaseRequest.update({ where: { id: purchaseRequestId }, data: { status: "draft", updated_by: request.employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_REQUEST_WITHDRAWN", targetType: "purchase_request", targetId: purchaseRequestId, extra: { workflowInstanceId: workflowInstance?.id ?? null, budgetRelease, }, }), }); response = { purchaseRequestId, status: "draft", workflowInstanceId: workflowInstance?.id ?? null, budgetRelease, }; }); return sendData(reply, request, response); }, ); fastify.get( "/purchase-requests/:id/archive-order", { schema: { tags: ["Purchase Requests"], summary: "Get Purchase Order Archive" }, preHandler: fastify.authenticate, }, async (request, reply) => { ensurePurchaseReadPermission(fastify, request); const purchaseRequestId = parsePurchaseRequestId(request.params?.id); await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request); const archive = await fastify.prisma.purchaseOrderArchive.findUnique({ where: { purchase_request_id: purchaseRequestId }, select: { id: true, po_no: true, contract_no: true, archived_files: true, archived_at: true, archived_by: true, archived_by_employee: { select: { id: true, employee_no: true, name: true, }, }, }, }); return sendData(reply, request, { archiveOrder: mapPurchaseOrderArchive(archive), }); }, ); fastify.post( "/purchase-requests/:id/archive-order", { schema: { tags: ["Purchase Requests"], summary: "Archive Purchase Order" }, preHandler: fastify.requirePermission("procurement.manage"), }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const payload = parseArchiveBody(request.body); let response = null; await fastify.prisma.$transaction(async (tx) => { await loadReadablePurchaseRequest(tx, purchaseRequestId, request); const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); if (locked.status !== "approved") { 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: { po_no: payload.poNo, contract_no: payload.contractNo?.trim() || null, archived_files: payload.archivedFiles ?? [], archived_at: new Date(), archived_by: request.employee.id, }, create: { purchase_request_id: purchaseRequestId, po_no: payload.poNo, contract_no: payload.contractNo?.trim() || null, archived_files: payload.archivedFiles ?? [], archived_by: request.employee.id, }, select: { id: true, po_no: true, contract_no: true, archived_files: true, archived_at: true, archived_by: true, archived_by_employee: { select: { id: true, employee_no: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_ORDER_ARCHIVED", targetType: "purchase_order_archive", targetId: archive.id, extra: { purchaseRequestId, poNo: archive.po_no, contractNo: archive.contract_no ?? null, archivedFileCount: Array.isArray(archive.archived_files) ? archive.archived_files.length : 0, }, }), }); 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), }; }); return sendData(reply, request, response); }, ); fastify.post( "/purchase-requests/:id/attachments", { schema: { tags: ["Purchase Requests"], summary: "Upload Purchase Attachment" }, preHandler: fastify.requirePermission("procurement.create"), }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, applicant_id: true, status: true, }, }); if (!purchaseRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } ensureDraftOwner(request, purchaseRequest); if (purchaseRequest.status !== "draft") { throw buildValidationError({ status: "only_draft_editable" }, 409); } if (typeof request.isMultipart !== "function" || !request.isMultipart()) { throw buildValidationError({ file: "multipart_required" }); } const maxBytes = resolveAttachmentMaxBytes(fastify.config); let normalizedFile = null; let governanceFields = {}; try { for await (const part of request.parts({ preservePath: true })) { if (part.type === "field") { governanceFields = parseMultipartGovernanceField(part, governanceFields); continue; } if (part.type === "file") { if (normalizedFile) { await discardMultipartFile(part.file, maxBytes); throw buildValidationError({ file: "only_single_file_supported" }); } const fileBuffer = await readBoundedStream(part.file, maxBytes); if (part.fieldname !== "file") { throw buildValidationError({ file: "invalid_field" }); } normalizedFile = await validateAndNormalizePurchaseAttachment({ buffer: fileBuffer, fileName: part.filename, declaredMimeType: part.mimetype, maxBytes, normalizeImages: true, }); } } } catch (error) { if ( error instanceof PurchaseAttachmentSizeLimitError || error?.code === "FST_REQ_FILE_TOO_LARGE" ) { throw buildValidationError({ file: "file_too_large" }); } if (error?.code === "FST_FILES_LIMIT") { throw buildValidationError({ file: "only_single_file_supported" }); } if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") { throw buildValidationError({ file: "multipart_limit_exceeded" }); } if (error instanceof PurchaseAttachmentPolicyError) { throw buildValidationError(error.details ?? { file: error.reasonCode }); } throw error; } if (!normalizedFile) { throw buildValidationError({ file: "required" }); } let storagePath; try { storagePath = await attachmentStorage.writeBuffer({ config: fastify.config, key: buildAttachmentStorageKey(purchaseRequestId, normalizedFile.extension), buffer: normalizedFile.buffer, contentType: normalizedFile.mimeType, }); } catch { fastify.log.error( { requestId: request.id, purchaseRequestId, errorCode: "purchase_attachment_storage_write_failed", }, "Purchase attachment storage write failed", ); throw new AppError({ code: "SYS_001", message: "附件暂时无法保存,请稍后重试", statusCode: 500, details: { attachment: "storage_write_failed" }, }); } const stored = { storagePath, originalName: normalizedFile.originalName, mimeType: normalizedFile.mimeType, fileSize: normalizedFile.fileSize, fileSha256: normalizedFile.fileSha256, }; const attachment = await fastify.prisma.$transaction(async (tx) => { const created = await tx.purchaseAttachment.create({ data: { purchase_request_id: purchaseRequestId, storage_path: stored.storagePath, original_name: stored.originalName, mime_type: stored.mimeType, file_size: stored.fileSize, file_sha256: stored.fileSha256, uploaded_by: request.employee.id, ...governanceCreateData(governanceFields, { governanceStatus: "approved", sensitivityLevel: SENSITIVITY_LEVEL.internal, attachmentType: ATTACHMENT_TYPE.quote, downloadPolicy: DOWNLOAD_POLICY.standard, }), }, select: { id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, governance_status: true, sensitivity_level: true, attachment_type: true, reviewed_by: true, reviewed_at: true, review_note: true, archived_by: true, archived_at: true, archive_note: true, download_policy: true, uploaded_by: true, created_at: true, uploader: { select: { id: true, employee_no: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_ATTACHMENT_UPLOADED", targetType: "purchase_attachment", targetId: created.id, extra: { purchaseRequestId, originalName: stored.originalName, fileSize: stored.fileSize, attachmentType: created.attachment_type, sensitivityLevel: created.sensitivity_level, }, }), }); return created; }); return sendData(reply, request, { attachment: { id: attachment.id, originalName: attachment.original_name, mimeType: attachment.mime_type, fileSize: attachment.file_size, fileSha256: attachment.file_sha256 ?? null, ...mapGovernanceFields(attachment, { governanceStatus: "approved", sensitivityLevel: SENSITIVITY_LEVEL.internal, attachmentType: ATTACHMENT_TYPE.quote, downloadPolicy: DOWNLOAD_POLICY.standard, }), uploadedBy: attachment.uploaded_by, createdAt: toIsoDateTime(attachment.created_at), uploader: mapEmployeeBasic(attachment.uploader), }, }); }, ); fastify.delete( "/purchase-requests/:id/attachments/:attachmentId", { schema: { tags: ["Purchase Requests"], summary: "Delete Purchase Attachment" }, preHandler: fastify.requirePermission("procurement.create"), }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const attachmentId = parseAttachmentId(request.params?.attachmentId); const { reason } = parsePurchaseAttachmentDeleteBody(request.body); const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, applicant_id: true, status: true, }, }); if (!purchaseRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } ensureDraftOwner(request, purchaseRequest); if (purchaseRequest.status !== "draft") { throw buildValidationError({ status: "only_draft_editable" }, 409); } const attachment = await fastify.prisma.purchaseAttachment.findFirst({ where: { id: attachmentId, purchase_request_id: purchaseRequestId, }, select: { id: true }, }); if (!attachment) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } await fastify.prisma.$transaction(async (tx) => { await tx.purchaseAttachment.delete({ where: { id: attachmentId } }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_ATTACHMENT_DELETED", targetType: "purchase_attachment", targetId: attachmentId, extra: { purchaseRequestId, reason, }, }), }); }); return sendData(reply, request, { outcome: "deleted", attachmentId, }); }, ); fastify.post( "/purchase-requests/:id/attachments/:attachmentId/download", { schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment" }, preHandler: fastify.authenticate, }, async (request, reply) => { ensurePurchaseReadPermission(fastify, request); const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const attachmentId = parseAttachmentId(request.params?.attachmentId); const body = parsePurchaseAttachmentDownloadBody(request.body); const downloadReason = sanitizeDownloadReason(body.reason); const maxBytes = resolveAttachmentMaxBytes(fastify.config); await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request); const attachment = await fastify.prisma.purchaseAttachment.findFirst({ where: { id: attachmentId, purchase_request_id: purchaseRequestId, }, select: { id: true, storage_path: true, mime_type: true, original_name: true, file_size: true, governance_status: true, sensitivity_level: true, attachment_type: true, download_policy: true, }, }); if (!attachment) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (requiresDownloadReason(attachment) && !downloadReason) { throw buildValidationError({ reason: "required" }); } if (attachment.governance_status === "quarantined") { await auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode: "quarantined", downloadReason, }); throw buildAttachmentBlockedError("quarantined", "附件已隔离,无法下载"); } const recordedSize = Number(attachment.file_size); if (Number.isFinite(recordedSize) && recordedSize > maxBytes) { await auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode: "size_limit", downloadReason, }); throw buildAttachmentBlockedError("size_limit"); } let opened; try { opened = await attachmentStorage.openReadStream({ config: fastify.config, storagePath: attachment.storage_path, }); } catch (error) { if ( (typeof attachmentStorage.StorageNotFoundError === "function" && error instanceof attachmentStorage.StorageNotFoundError) || error?.name === "StorageNotFoundError" ) { await auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode: "missing_object", downloadReason, }); throw new AppError({ code: "SYS_404", message: "附件不存在", statusCode: 404, }); } await auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode: "storage_error", downloadReason, }); throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId }); } let storedBuffer; try { storedBuffer = await readBoundedStream(opened.stream, maxBytes); } catch (error) { if (error instanceof PurchaseAttachmentSizeLimitError) { await auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode: "size_limit", downloadReason, }); throw buildAttachmentBlockedError("size_limit"); } await auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode: "storage_error", downloadReason, }); throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId }); } let validated; try { validated = await validateAndNormalizePurchaseAttachment({ buffer: storedBuffer, fileName: attachment.original_name, declaredMimeType: attachment.mime_type, maxBytes, normalizeImages: false, }); } catch (error) { if (!(error instanceof PurchaseAttachmentPolicyError)) { throw error; } await auditBlockedAttachmentDownload({ fastify, request, purchaseRequestId, attachmentId, attachment, reasonCode: error.reasonCode, downloadReason, }); throw buildAttachmentBlockedError(error.reasonCode); } try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_ATTACHMENT_DOWNLOADED", targetType: "purchase_attachment", targetId: attachmentId, extra: { purchaseRequestId, attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote, sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal, governanceStatus: attachment.governance_status ?? "approved", reason: downloadReason, }, }), }); } catch { fastify.log.error( { requestId: request.id, purchaseRequestId, attachmentId, errorCode: "purchase_attachment_download_audit_failed", }, "Purchase attachment download audit failed", ); throw new AppError({ code: "SYS_001", message: "附件下载留痕失败,请稍后重试", statusCode: 500, details: { attachment: "audit_failed" }, }); } reply.type("application/octet-stream"); reply.header( "content-disposition", buildAttachmentContentDisposition(validated.originalName), ); reply.header("x-content-type-options", "nosniff"); reply.header("cache-control", "private, no-store, max-age=0"); reply.header("pragma", "no-cache"); reply.header("referrer-policy", "no-referrer"); reply.header( "content-security-policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'", ); reply.header("cross-origin-resource-policy", "same-origin"); return reply.send(validated.buffer); }, ); } module.exports = purchaseRequestRoutes; module.exports.__testables = { finalizePurchaseSubmitSideEffects, handlePurchaseArchiveErpSync, parseArchiveBody, };