const crypto = require("node:crypto"); const path = require("node:path"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { compactDateString, parseDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { issueDocumentNumber } = require("../services/documentSequence"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { ensureCutoverAllowed } = require("../services/cutover"); const { buildPersistedRiskFlag, buildPurchaseRequestPrecheck, createRiskFlagsFromPrecheck, } = 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 { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); 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(), }); 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(), 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_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, 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, }, }, }; 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, 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: { where: { entity_type: "purchase_request", current_status: "pending", }, 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, }, }, }, }, }; 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, }; } 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 buildDisabledPrecheck(entityType, entityId) { return { entityType, entityId, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["功能未开启,沿用当前提交流程"], }; } 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, 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, }; } 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 mapPurchaseRequestDetail(item, request) { const summary = mapPurchaseRequestSummary(item); const pendingWorkflow = item.workflow_instances?.[0] ? { instanceId: item.workflow_instances[0].id, currentStatus: item.workflow_instances[0].current_status, currentStepId: item.workflow_instances[0].current_step_id, task: item.workflow_instances[0].workflow_tasks?.[0] ? { id: item.workflow_instances[0].workflow_tasks[0].id, stepId: item.workflow_instances[0].workflow_tasks[0].step_id, stepName: item.workflow_instances[0].workflow_tasks[0].step_name, assigneeId: item.workflow_instances[0].workflow_tasks[0].assignee_id, assigneeRole: item.workflow_instances[0].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, uploadedBy: attachment.uploaded_by, createdAt: toIsoDateTime(attachment.created_at), uploader: mapEmployeeBasic(attachment.uploader), })), archiveOrder: mapPurchaseOrderArchive(item.order_archive), pendingWorkflow, 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 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, 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; } async function persistAttachment({ config, purchaseRequestId, filePart }) { const chunks = []; let fileSize = 0; for await (const chunk of filePart.file) { fileSize += chunk.length; if (fileSize > config.maxUploadSizeBytes) { throw buildValidationError({ file: "file_too_large" }); } chunks.push(chunk); } if (fileSize <= 0) { throw buildValidationError({ file: "empty" }); } const buffer = Buffer.concat(chunks); const safeName = (filePart.filename || "attachment") .replace(/[^a-zA-Z0-9._-]+/g, "_") .replace(/^_+|_+$/g, "") || "attachment"; const extension = path.extname(safeName); const basename = extension ? safeName.slice(0, -extension.length) : safeName; const key = path.posix.join( "purchase-requests", purchaseRequestId, `${compactDateString(config.appTimezone)}-${basename}-${crypto.randomUUID()}${extension}`, ); const storagePath = await writeBuffer({ config, key, buffer, contentType: filePart.mimetype, }); return { storagePath, originalName: filePart.filename || safeName, mimeType: filePart.mimetype || "application/octet-stream", fileSize, }; } function sanitizeFilename(fileName) { if (!fileName) return "file"; return fileName.replace(/[\r\n"]/g, "_"); } 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; } async function handlePurchaseArchiveErpSync({ tx, config, request, actorId, purchaseRequestId, archiveId, isFirstArchive, loadContext = loadPurchaseRequestErpContext, evaluateGate = evaluateFeatureFlag, enqueueAccrual = enqueuePurchaseAccrualJournalExport, enqueueSummary = enqueuePurchaseArchiveSummaryExport, }) { const context = await loadContext(tx, purchaseRequestId); if (!context?.purchaseRequest?.order_archive) { return { skipped: true, reason: 'purchase_order_archive_missing' }; } const purchaseRequest = context.purchaseRequest; const archive = purchaseRequest.order_archive; const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null; const gate = await evaluateGate({ prisma: tx, config, flagKey: FEATURE_FLAG_KEYS.erpSync, departmentId, strictDepartment: true, }); if (!gate.enabled) { const skipTargets = isFirstArchive ? [PROCUREMENT_ERP_TARGETS.accrualJournal, PROCUREMENT_ERP_TARGETS.archiveSummary] : [PROCUREMENT_ERP_TARGETS.archiveSummary]; for (const erpTarget of skipTargets) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId, action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', targetType: 'purchase_order_archive', targetId: archiveId, extra: { erpTarget, eventType: erpTarget === PROCUREMENT_ERP_TARGETS.archiveSummary ? 'erp.procurement.export' : 'erp.journal.export', purchaseRequestId, purchaseOrderArchiveId: archiveId, gateReason: gate.reason, featureFlag: FEATURE_FLAG_KEYS.erpSync, departmentId, }, }), }); } return { skipped: true, gate, purchaseRequest, archive }; } const enqueuedItems = []; if (isFirstArchive) { const accrual = await enqueueAccrual(tx, config, { purchaseRequest, archive, occurredAt: archive.archived_at ?? new Date(), actorId, requeueOnFailure: false, }); enqueuedItems.push(accrual); } const summary = await enqueueSummary(tx, config, { purchaseRequest, archive, archivedAt: archive.archived_at ?? new Date(), actorId, requeueOnFailure: false, }); enqueuedItems.push(summary); for (const item of enqueuedItems) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId, action: 'ERP_SYNC_ENQUEUED', targetType: 'purchase_order_archive', targetId: archiveId, extra: { erpTarget: item.erpTarget, eventType: item.eventType, purchaseRequestId, purchaseOrderArchiveId: archiveId, outboxId: item.id, duplicated: item.duplicated, requeued: item.requeued, gateReason: gate.reason, featureFlag: FEATURE_FLAG_KEYS.erpSync, departmentId, }, }), }); } return { skipped: false, gate, purchaseRequest, archive, items: enqueuedItems, }; } async function purchaseRequestRoutes(fastify) { fastify.get( "/purchase-requests", { 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 } : {}), ...(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); } 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.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 purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, department_id: true }, }); if (!purchaseRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId)); } const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, { purchaseRequestId, config: fastify.config, logger: fastify.log, }); 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); let response = null; const emailTasks = []; 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_DETAIL_SELECT, document_category: { select: { id: true, code: true, name: true, entity_type: true, enabled: true, archived_at: true, }, }, }, }); 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" }); } 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 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 authoritativePrecheck = budgetPrecheckGate.enabled ? await buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config: fastify.config, logger: fastify.log, }) : buildDisabledPrecheck("purchase_request", purchaseRequestId); if (authoritativePrecheck.decision === "block") { throw buildPrecheckBlockedError(authoritativePrecheck); } 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 budgetControlMode = authoritativePrecheck?.budgetHint?.controlMode ?? normalizeBudgetControlMode(fastify.config); if (!selectedBudgetPoolId) { if (!budgetPrecheckGate.enabled && budgetControlMode === "block") { throw buildValidationError({ budget: "missing_budget_pool" }, 409); } if (!budgetPrecheckGate.enabled) { warnings.push("missing_budget_pool"); } } const workflowDefinition = await tx.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, steps: workflowSteps, startIndex: 0, ownerId: purchaseRequest.applicant_id, logger: fastify.log, }); if (!actionable) { throw buildValidationError({ workflow: "no_actionable_approver" }, 409); } 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, }, }, }); const recipients = await resolveRecipientsForAssignee(tx, { assigneeId: workflowTask.assignee_id, assigneeRole: workflowTask.assignee_role, }); const title = `【待办】采购申请 ${requestNo} 待审批`; const body = `采购申请 ${requestNo} 已提交,请及时处理。`; await createNotifications(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTask.id, }); await enqueueImNotifications(tx, { config: fastify.config, logger: fastify.log, recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTask.id, targetPath: `/workflow/tasks/${workflowTask.id}`, entityType: "purchase_request", entityId: purchaseRequestId, actorId: request.employee.id, }); emailTasks.push({ type: NOTIFICATION_TYPES.workflowTaskAssigned, recipientEmployeeIds: recipients, subject: title, body, targetPath: `/workflow/tasks/${workflowTask.id}`, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_REQUEST_SUBMITTED", targetType: "purchase_request", targetId: purchaseRequestId, extra: { requestNo, totalAmount, workflowInstanceId: workflowInstance.id, workflowTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, warnings, budgetReservation, }, }), }); response = { purchaseRequestId, requestNo, status: "reviewing", workflowInstanceId: workflowInstance.id, currentTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, warnings, budgetReservation, }; }); for (const emailTask of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...emailTask, }); } return sendData(reply, request, 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); } let filePart = null; try { for await (const part of request.parts()) { if (part.type === "file") { if (filePart) { throw buildValidationError({ file: "only_single_file_supported" }); } filePart = part; } } } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { throw buildValidationError({ file: "file_too_large" }); } throw error; } if (!filePart) { throw buildValidationError({ file: "required" }); } const stored = await persistAttachment({ config: fastify.config, purchaseRequestId, filePart, }); 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, uploaded_by: request.employee.id, }, select: { id: true, original_name: true, mime_type: true, file_size: 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, }, }), }); return created; }); return sendData(reply, request, { attachment: { id: attachment.id, originalName: attachment.original_name, mimeType: attachment.mime_type, fileSize: attachment.file_size, 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 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, }, }), }); }); return sendData(reply, request, { outcome: "deleted", attachmentId, }); }, ); fastify.post( "/purchase-requests/:id/attachments/:attachmentId/signed-url", { schema: { tags: ["Purchase Requests"], summary: "Issue Purchase Attachment Signed URL" }, preHandler: fastify.authenticate, }, async (request, reply) => { ensurePurchaseReadPermission(fastify, request); const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const attachmentId = parseAttachmentId(request.params?.attachmentId); await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request); 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, }); } const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000; const token = signPayload( { purchaseRequestId, attachmentId, actorId: request.employee.id, exp: expiresAt, }, fastify.config.jwtSecret, ); const origin = fastify.config.appOrigin || `${request.protocol}://${request.headers.host ?? "localhost:3000"}`; const signedUrl = `${origin}/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/file?token=${encodeURIComponent(token)}`; await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PURCHASE_ATTACHMENT_SIGNED_URL_ISSUED", targetType: "purchase_attachment", targetId: attachmentId, extra: { purchaseRequestId, expiresAt, }, }), }); return sendData(reply, request, { signedUrl, expiresAt: new Date(expiresAt).toISOString(), }); }, ); fastify.get( "/purchase-requests/:id/attachments/:attachmentId/file", { schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment By Token" }, }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const attachmentId = parseAttachmentId(request.params?.attachmentId); const token = request.query?.token; if (typeof token !== "string") { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const verified = verifyToken(token, fastify.config.jwtSecret); if (!verified.ok) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const actorId = UUID_SCHEMA.safeParse(verified.payload?.actorId); if ( verified.payload?.purchaseRequestId !== purchaseRequestId || verified.payload?.attachmentId !== attachmentId || typeof verified.payload?.exp !== "number" || verified.payload.exp <= Date.now() || !actorId.success ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const attachment = await fastify.prisma.purchaseAttachment.findFirst({ where: { id: attachmentId, purchase_request_id: purchaseRequestId, }, select: { storage_path: true, mime_type: true, original_name: true, }, }); if (!attachment) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } let opened = null; try { opened = await openReadStream({ config: fastify.config, storagePath: attachment.storage_path, }); } catch (error) { if (error instanceof StorageNotFoundError) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } throw error; } try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: actorId.data, action: "PURCHASE_ATTACHMENT_DOWNLOADED", targetType: "purchase_attachment", targetId: attachmentId, extra: { purchaseRequestId, exp: verified.payload.exp, }, }), }); } catch (error) { fastify.log.error({ err: error, attachmentId }, "Audit log write failed"); } reply.type(attachment.mime_type || "application/octet-stream"); reply.header( "content-disposition", `inline; filename=\"${sanitizeFilename(attachment.original_name)}\"`, ); return reply.send(opened.stream); }, ); } module.exports = purchaseRequestRoutes; module.exports.__testables = { handlePurchaseArchiveErpSync, parseArchiveBody, };