文件
hyb-oa/server/src/routes/purchaseRequests.js
T
wangsli和Claude Opus 5.5 b6f0c83608 采购提交与报销新建/提交事务显式使用10秒上限 [09-27-purchase-submit-tx-timeout]
负载下采购提交事务超过 Prisma 交互事务默认 5000ms(P2028)返回 500。
分段计时显示无单一瓶颈(总时长中位 843ms、最大 3506ms,编号锁等待
最多 535ms),是约 20 条顺序查询整体变慢贴近上限。报销新建/提交同结构。
三处改用项目既有的 { maxWait: 5000, timeout: 10000 },事务内容不变;
新增采购与报销提交的事务选项回归断言。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 12:27:04 +08:00

2709 行
84 KiB
JavaScript

const { resolveNumberingContext } = require("../services/businessNumbering");
const { getErrorReason } = require("../constants/errorReasons");
const { z } = require("zod");
const { Prisma } = require("@prisma/client");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString, isValidDateOnlyString } = 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 { 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 PURCHASE_ATTACHMENT_POLICY_REASONS = Object.freeze({
"dangerous_filename": "purchase_attachment.dangerous_filename",
"corrupt_content": "purchase_attachment.corrupt_content",
"multiple_pages": "purchase_attachment.multiple_pages",
"pixel_limit": "purchase_attachment.pixel_limit",
"type_mismatch": "purchase_attachment.type_mismatch",
"empty_file": "purchase_attachment.empty_file",
"size_limit": "purchase_attachment.too_large"
});
const UUID_SCHEMA = z.string().uuid();
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" });
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/);
const PURCHASE_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected", "cancelled"]);
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 = {
numbering_version: true,
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 parsePurchaseRequestId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildBusinessValidationError("purchase_request.id_invalid", { id: "invalid_uuid" });
}
return parsed.data;
}
function parseAttachmentId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildBusinessValidationError("purchase_attachment.id_invalid", { attachmentId: "invalid_uuid" });
}
return parsed.data;
}
function parseListQuery(query) {
const parsed = PURCHASE_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildBusinessValidationError("purchase_request.query_invalid", 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 buildBusinessValidationError("purchase_request.update_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
function parseArchiveBody(body) {
const parsed = PURCHASE_ARCHIVE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildBusinessValidationError("purchase_request.archive_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
function normalizeBudgetControlMode(config) {
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
function buildPrecheckBlockedError(precheck) {
return buildBusinessValidationError("purchase_request.precheck_blocked", {
precheck: precheck.blockingReasons,
}, 409);
}
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 }, invalidReason, rangeReason) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) {
throw buildBusinessValidationError(getErrorReason(invalidReason).code, { [field]: "invalid_decimal" });
}
if (numeric < min) {
throw buildBusinessValidationError(getErrorReason(rangeReason).code, { [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 buildBusinessValidationError("purchase_request.company_not_found", { legalEntityId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildBusinessValidationError("purchase_request.company_inactive", { 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 buildBusinessValidationError("purchase_request.cost_center_not_found", { costCenterId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildBusinessValidationError("purchase_request.cost_center_inactive", { costCenterId: "disabled_or_archived" }, 409);
}
if (legalEntityId && item.legal_entity_id !== legalEntityId) {
throw buildBusinessValidationError("purchase_request.cost_center_company_mismatch", { 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 buildBusinessValidationError("purchase_request.supplier_not_found", { supplierId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildBusinessValidationError("purchase_request.supplier_inactive", { supplierId: "disabled_or_archived" }, 409);
}
if (legalEntityId && item.legal_entity_id && item.legal_entity_id !== legalEntityId) {
throw buildBusinessValidationError("purchase_request.supplier_company_mismatch", { 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 buildBusinessValidationError("purchase_request.document_category_not_found", { documentCategoryId: "not_found" }, 409);
}
if (item.entity_type !== "purchase_request") {
throw buildBusinessValidationError("purchase_request.document_category_type_invalid", { documentCategoryId: "entity_type_mismatch" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildBusinessValidationError("purchase_request.document_category_inactive", { 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 buildBusinessValidationError("purchase_request.budget_pool_not_found", { budgetPoolId: "not_found" }, 409);
}
if (item.status !== "active" || item.archived_at) {
throw buildBusinessValidationError("purchase_request.budget_pool_inactive", { 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 buildBusinessValidationError("purchase_request.project_link_disabled", { 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 buildBusinessValidationError("purchase_request.project_archived", { 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" }, "purchase_request.quantity_invalid", "purchase_request.quantity_too_small");
const unitPrice = decimalInputToString(item.unitPrice, { min: 0, field: "unitPrice" }, "purchase_request.unit_price_invalid", "purchase_request.unit_price_negative");
const lineAmount = computeLineAmount(quantity, unitPrice);
return {
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 buildBusinessValidationError("purchase_attachment.download_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
function parsePurchaseAttachmentDeleteBody(body) {
const parsed = PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildBusinessValidationError("purchase_attachment.delete_input_invalid", parsed.error.flatten());
}
return parsed.data;
}
function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) {
const error = buildBusinessValidationError("purchase_attachment.download_blocked", {
attachment: "security_validation_failed",
reason: reasonCode,
}, statusCode);
error.message = message;
return error;
}
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 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 createdId = await fastify.prisma.$transaction(async (tx) => {
const numbering = await resolveNumberingContext(tx);
const created = await tx.purchaseRequest.create({
data: {
numbering_version: numbering.version,
applicant_id: request.employee.id,
department_id: request.employee.department_id,
title: "",
reason: null,
currency_code: "CNY",
total_amount: "0.00",
status: "draft",
created_by: request.employee.id,
updated_by: request.employee.id,
},
// Keep response-only relation hydration outside the write transaction.
select: { id: true, applicant_id: true, department_id: true },
});
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.id;
});
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: createdId },
select: PURCHASE_DETAIL_SELECT,
});
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);
await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "draft") {
throw buildBusinessValidationError("purchase_request.not_editable", { 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),
},
}),
});
});
// Only response hydration belongs outside the write transaction. All
// validation, item/amount changes and their audit still commit together.
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: PURCHASE_DETAIL_SELECT,
});
return sendData(reply, request, {
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 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 buildBusinessValidationError("purchase_request.workflow_missing", { 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 buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409);
}
// ~20 sequential queries plus document numbering; under load this exceeded
// Prisma's 5s interactive-transaction default (P2028 -> 500).
const submitResult = await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "draft") {
throw buildBusinessValidationError("purchase_request.not_submittable", { 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 buildBusinessValidationError("purchase_request.company_required", { legalEntityId: "required" });
}
if (!purchaseRequest.cost_center_id) {
throw buildBusinessValidationError("purchase_request.cost_center_required", { costCenterId: "required" });
}
if (!purchaseRequest.supplier_id) {
throw buildBusinessValidationError("purchase_request.supplier_required", { supplierId: "required" });
}
if (!purchaseRequest.document_category_id) {
throw buildBusinessValidationError("purchase_request.document_category_required", { documentCategoryId: "required" });
}
if (!purchaseRequest.items?.length) {
throw buildBusinessValidationError("purchase_request.items_required", { items: "at_least_one_required" });
}
if (purchaseRequest.applicant_id !== unlockedPurchaseRequest.applicant_id) {
throw buildBusinessValidationError("purchase_request.approver_unavailable", { 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 effectiveDate = new Date();
const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
legalEntityId: purchaseRequest.legal_entity_id,
costCenterId: purchaseRequest.cost_center_id,
effectiveDate,
}, { forUpdate: true, additionalPoolIds: [purchaseRequest.budget_pool_id] });
let selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? null;
if (selectedBudgetPoolId) {
await ensureBudgetPoolAvailable(tx, selectedBudgetPoolId);
if (!resolvedBudgetPool || resolvedBudgetPool.id !== selectedBudgetPoolId) {
throw buildBusinessValidationError("purchase_request.budget_context_mismatch", { budgetPoolId: "context_mismatch" }, 409);
}
} else if (resolvedBudgetPool) {
selectedBudgetPoolId = resolvedBudgetPool.id;
}
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
logger: fastify.log,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorDepartmentId: request.employee.department_id ?? null,
budgetPoolForWrite: resolvedBudgetPool,
});
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
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 buildBusinessValidationError("purchase_request.budget_required", { budget: "missing_budget_pool" }, 409);
}
if (!budgetPrecheckGate.enabled) {
warnings.push("missing_budget_pool");
}
}
if (!budgetPrecheckGate.enabled && budgetControlMode === "block" && resolvedBudgetPool
&& new Prisma.Decimal(resolvedBudgetPool.used_amount).plus(totalAmount).gt(resolvedBudgetPool.allocated_amount)) {
throw buildPrecheckBlockedError({ blockingReasons: [{ code: "budget_exceeded", title: "预算将被超用", detail: "当前采购金额会导致预算池超出已分配额度。" }] });
}
let requestNo = purchaseRequest.request_no;
if (!requestNo) {
const issued = await issueDocumentNumber(tx, {
documentType: "purchase_request",
numberingVersion: purchaseRequest.numbering_version,
documentCategoryCode: documentCategory.code,
actorId: request.employee.id,
timeZone: fastify.config.appTimezone,
});
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,
},
};
}, { maxWait: 5000, timeout: 10000 });
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 buildBusinessValidationError("purchase_request.not_withdrawable", { 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 buildBusinessValidationError("purchase_request.not_archivable", { 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,
},
}),
});
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 buildBusinessValidationError("purchase_attachment.not_editable", { status: "only_draft_editable" }, 409);
}
if (typeof request.isMultipart !== "function" || !request.isMultipart()) {
throw buildBusinessValidationError("purchase_attachment.multipart_required", { 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 buildBusinessValidationError("purchase_attachment.single_file_required", { file: "only_single_file_supported" });
}
const fileBuffer = await readBoundedStream(part.file, maxBytes);
if (part.fieldname !== "file") {
throw buildBusinessValidationError("purchase_attachment.field_invalid", { 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 buildBusinessValidationError("purchase_attachment.too_large", { file: "file_too_large" });
}
if (error?.code === "FST_FILES_LIMIT") {
throw buildBusinessValidationError("purchase_attachment.single_file_required", { file: "only_single_file_supported" });
}
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
throw buildBusinessValidationError("purchase_attachment.multipart_limit", { file: "multipart_limit_exceeded" });
}
if (error instanceof PurchaseAttachmentPolicyError) {
if (error.reasonCode === "unsupported_type") throw buildBusinessValidationError("purchase_attachment.unsupported_type", error.details ?? { file: error.reasonCode });
throw buildBusinessValidationError(getErrorReason(PURCHASE_ATTACHMENT_POLICY_REASONS[error.reasonCode] ?? "purchase_attachment.validation_failed").code, error.details ?? { file: error.reasonCode });
}
throw error;
}
if (!normalizedFile) {
throw buildBusinessValidationError("purchase_attachment.file_required", { 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 buildBusinessValidationError("purchase_attachment.not_editable", { 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 buildBusinessValidationError("purchase_attachment.download_reason_required", { 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,
parseArchiveBody,
};