负载下采购提交事务超过 Prisma 交互事务默认 5000ms(P2028)返回 500。
分段计时显示无单一瓶颈(总时长中位 843ms、最大 3506ms,编号锁等待
最多 535ms),是约 20 条顺序查询整体变慢贴近上限。报销新建/提交同结构。
三处改用项目既有的 { maxWait: 5000, timeout: 10000 },事务内容不变;
新增采购与报销提交的事务选项回归断言。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2709 行
84 KiB
JavaScript
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,
|
|
};
|