文件
hyb-oa/server/src/routes/purchaseRequests.js
T
wangsli e39e41287e feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力
关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
2026-03-21 18:03:20 +08:00

2209 行
65 KiB
JavaScript

const crypto = require("node:crypto");
const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { compactDateString, parseDateOnlyString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { issueDocumentNumber } = require("../services/documentSequence");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildPurchaseRequestPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const {
PROCUREMENT_ERP_TARGETS,
enqueuePurchaseAccrualJournalExport,
enqueuePurchaseArchiveSummaryExport,
loadPurchaseRequestErpContext,
} = require("../services/integrations/erpProcurement");
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
const { sendNotificationEmailSafe } = require("../services/email");
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const UUID_SCHEMA = z.string().uuid();
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/);
const PURCHASE_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected", "cancelled"]);
const PURCHASE_LIST_QUERY_SCHEMA = z.object({
status: PURCHASE_STATUS_SCHEMA.optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
mine: z.enum(["0", "1", "true", "false"]).optional(),
supplierId: UUID_SCHEMA.optional(),
legalEntityId: UUID_SCHEMA.optional(),
costCenterId: UUID_SCHEMA.optional(),
});
const PURCHASE_PATCH_SCHEMA = z
.object({
title: z.string().trim().max(200).optional(),
reason: z.string().trim().max(2000).nullable().optional(),
currencyCode: z.string().trim().min(1).max(8).optional(),
legalEntityId: UUID_SCHEMA.nullable().optional(),
costCenterId: UUID_SCHEMA.nullable().optional(),
supplierId: UUID_SCHEMA.nullable().optional(),
documentCategoryId: UUID_SCHEMA.nullable().optional(),
budgetPoolId: UUID_SCHEMA.nullable().optional(),
expectedDeliveryDate: DATE_ONLY_SCHEMA.nullable().optional(),
items: z
.array(
z
.object({
description: z.string().trim().max(500),
specification: z.string().trim().max(500).nullable().optional(),
quantity: z.union([MONEY_STRING_SCHEMA, z.number().positive()]),
unitPrice: z.union([MONEY_STRING_SCHEMA, z.number().min(0)]),
isAssetCandidate: z.boolean().optional(),
})
.strict(),
)
.max(200)
.optional(),
})
.strict();
const PURCHASE_ARCHIVE_FILE_SCHEMA = z
.object({
storagePath: z.string().trim().min(1).max(500),
originalName: z.string().trim().min(1).max(255),
mimeType: z.string().trim().min(1).max(100),
fileSize: z.coerce.number().int().min(0),
})
.strict();
const PURCHASE_ARCHIVE_BODY_SCHEMA = z
.object({
poNo: z.string().trim().min(1).max(64),
contractNo: z.string().trim().max(64).nullable().optional(),
archivedFiles: z.array(PURCHASE_ARCHIVE_FILE_SCHEMA).max(20).optional(),
})
.strict();
const PURCHASE_SUMMARY_SELECT = {
id: true,
request_no: true,
applicant_id: true,
submitter_id: true,
department_id: true,
legal_entity_id: true,
cost_center_id: true,
supplier_id: true,
document_category_id: true,
budget_pool_id: true,
title: true,
reason: true,
currency_code: true,
total_amount: true,
expected_delivery_date: true,
status: true,
created_at: true,
updated_at: true,
applicant: {
select: {
id: true,
employee_no: true,
name: true,
},
},
submitter: {
select: {
id: true,
employee_no: true,
name: true,
},
},
department: {
select: {
id: true,
name: true,
},
},
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
legal_entity_id: true,
},
},
supplier: {
select: {
id: true,
code: true,
name: true,
},
},
document_category: {
select: {
id: true,
code: true,
name: true,
entity_type: true,
},
},
budget_pool: {
select: {
id: true,
code: true,
name: true,
},
},
};
const PURCHASE_DETAIL_SELECT = {
...PURCHASE_SUMMARY_SELECT,
items: {
orderBy: { created_at: "asc" },
select: {
id: true,
description: true,
specification: true,
quantity: true,
unit_price: true,
line_amount: true,
is_asset_candidate: true,
created_at: true,
updated_at: true,
},
},
attachments: {
orderBy: { created_at: "asc" },
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
uploaded_by: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
order_archive: {
select: {
id: true,
po_no: true,
contract_no: true,
archived_files: true,
archived_at: true,
archived_by: true,
archived_by_employee: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
workflow_instances: {
where: {
entity_type: "purchase_request",
current_status: "pending",
},
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
current_status: true,
workflow_tasks: {
where: { status: "pending" },
orderBy: { created_at: "asc" },
take: 1,
select: {
id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
},
},
},
},
};
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parsePurchaseRequestId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
function parseAttachmentId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ attachmentId: "invalid_uuid" });
}
return parsed.data;
}
function parseListQuery(query) {
const parsed = PURCHASE_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
status: parsed.data.status,
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
mine:
parsed.data.mine == null
? true
: ["1", "true"].includes(parsed.data.mine),
supplierId: parsed.data.supplierId,
legalEntityId: parsed.data.legalEntityId,
costCenterId: parsed.data.costCenterId,
};
}
function parsePatchBody(body) {
const parsed = PURCHASE_PATCH_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseArchiveBody(body) {
const parsed = PURCHASE_ARCHIVE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeBudgetControlMode(config) {
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
precheck: precheck.blockingReasons,
},
});
}
function ensurePurchaseReadPermission(fastify, request) {
const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own");
const canViewAll =
fastify.hasPermission(request.employee, "procurement.view_all") ||
fastify.hasPermission(request.employee, "procurement.manage");
if (!canViewOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
return { canViewOwn, canViewAll };
}
function canReadPurchaseRequest(request, purchaseRequest) {
if (!purchaseRequest) {
return false;
}
if (
request.employee.permissionCodes.includes("*") ||
request.employee.permissionCodes.includes("procurement.view_all") ||
request.employee.permissionCodes.includes("procurement.manage")
) {
return true;
}
return (
purchaseRequest.applicant_id === request.employee.id ||
purchaseRequest.submitter_id === request.employee.id
);
}
function ensureDraftOwner(request, purchaseRequest, detailsKey = "purchaseRequest") {
if (purchaseRequest.applicant_id !== request.employee.id) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { [detailsKey]: "only_applicant" },
});
}
}
function toIsoDate(value) {
return value?.toISOString?.()?.slice(0, 10) ?? null;
}
function toIsoDateTime(value) {
return value?.toISOString?.() ?? null;
}
function moneyToFixed(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00";
}
function decimalInputToString(value, { min, field }) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) {
throw buildValidationError({ [field]: "invalid_decimal" });
}
if (numeric < min) {
throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" });
}
return numeric.toFixed(2);
}
function computeLineAmount(quantity, unitPrice) {
const quantityNumber = Number(quantity);
const unitPriceNumber = Number(unitPrice);
return (quantityNumber * unitPriceNumber).toFixed(2);
}
function mapEmployeeBasic(employee) {
if (!employee) {
return null;
}
return {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
};
}
function mapPurchaseRequestSummary(item) {
return {
id: item.id,
requestNo: item.request_no ?? null,
applicantId: item.applicant_id,
submitterId: item.submitter_id ?? null,
departmentId: item.department_id,
legalEntityId: item.legal_entity_id ?? null,
costCenterId: item.cost_center_id ?? null,
supplierId: item.supplier_id ?? null,
documentCategoryId: item.document_category_id ?? null,
budgetPoolId: item.budget_pool_id ?? null,
title: item.title,
reason: item.reason ?? null,
currencyCode: item.currency_code,
totalAmount: moneyToFixed(item.total_amount),
expectedDeliveryDate: toIsoDate(item.expected_delivery_date),
status: item.status,
createdAt: toIsoDateTime(item.created_at),
updatedAt: toIsoDateTime(item.updated_at),
applicant: mapEmployeeBasic(item.applicant),
submitter: mapEmployeeBasic(item.submitter),
department: item.department
? {
id: item.department.id,
name: item.department.name,
}
: null,
legalEntity: item.legal_entity
? {
id: item.legal_entity.id,
code: item.legal_entity.code,
name: item.legal_entity.name,
}
: null,
costCenter: item.cost_center
? {
id: item.cost_center.id,
code: item.cost_center.code,
name: item.cost_center.name,
legalEntityId: item.cost_center.legal_entity_id,
}
: null,
supplier: item.supplier
? {
id: item.supplier.id,
code: item.supplier.code,
name: item.supplier.name,
}
: null,
documentCategory: item.document_category
? {
id: item.document_category.id,
code: item.document_category.code,
name: item.document_category.name,
entityType: item.document_category.entity_type,
}
: null,
budgetPool: item.budget_pool
? {
id: item.budget_pool.id,
code: item.budget_pool.code,
name: item.budget_pool.name,
}
: null,
};
}
function mapPurchaseOrderArchive(item) {
if (!item) {
return null;
}
return {
id: item.id,
poNo: item.po_no,
contractNo: item.contract_no ?? null,
archivedAt: toIsoDateTime(item.archived_at),
archivedBy: item.archived_by,
archivedByEmployee: mapEmployeeBasic(item.archived_by_employee),
archivedFiles: Array.isArray(item.archived_files)
? item.archived_files.map((entry) => ({
storagePath: entry?.storagePath ?? null,
originalName: entry?.originalName ?? null,
mimeType: entry?.mimeType ?? null,
fileSize: Number(entry?.fileSize ?? 0),
}))
: [],
};
}
function mapPurchaseRequestDetail(item, request) {
const summary = mapPurchaseRequestSummary(item);
const pendingWorkflow = item.workflow_instances?.[0]
? {
instanceId: item.workflow_instances[0].id,
currentStatus: item.workflow_instances[0].current_status,
currentStepId: item.workflow_instances[0].current_step_id,
task: item.workflow_instances[0].workflow_tasks?.[0]
? {
id: item.workflow_instances[0].workflow_tasks[0].id,
stepId: item.workflow_instances[0].workflow_tasks[0].step_id,
stepName: item.workflow_instances[0].workflow_tasks[0].step_name,
assigneeId: item.workflow_instances[0].workflow_tasks[0].assignee_id,
assigneeRole: item.workflow_instances[0].workflow_tasks[0].assignee_role,
}
: null,
}
: null;
return {
...summary,
items: (item.items ?? []).map((entry) => ({
id: entry.id,
description: entry.description,
specification: entry.specification ?? null,
quantity: moneyToFixed(entry.quantity),
unitPrice: moneyToFixed(entry.unit_price),
lineAmount: moneyToFixed(entry.line_amount),
isAssetCandidate: Boolean(entry.is_asset_candidate),
createdAt: toIsoDateTime(entry.created_at),
updatedAt: toIsoDateTime(entry.updated_at),
})),
attachments: (item.attachments ?? []).map((attachment) => ({
id: attachment.id,
originalName: attachment.original_name,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
uploadedBy: attachment.uploaded_by,
createdAt: toIsoDateTime(attachment.created_at),
uploader: mapEmployeeBasic(attachment.uploader),
})),
archiveOrder: mapPurchaseOrderArchive(item.order_archive),
pendingWorkflow,
permissions: {
canEditDraft: item.status === "draft" && item.applicant_id === request.employee.id,
canSubmit: item.status === "draft" && item.applicant_id === request.employee.id && request.employee.permissionCodes.includes("procurement.submit"),
canWithdraw: item.status === "reviewing" && item.applicant_id === request.employee.id && request.employee.permissionCodes.includes("procurement.submit"),
canArchiveOrder:
item.status === "approved" &&
(request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("procurement.manage")),
},
};
}
async function ensureLegalEntityAvailable(tx, legalEntityId) {
if (!legalEntityId) {
return null;
}
const item = await tx.legalEntity.findUnique({
where: { id: legalEntityId },
select: { id: true, code: true, name: true, enabled: true, archived_at: true },
});
if (!item) {
throw buildValidationError({ legalEntityId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409);
}
return item;
}
async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) {
if (!costCenterId) {
return null;
}
const item = await tx.costCenter.findUnique({
where: { id: costCenterId },
select: {
id: true,
code: true,
name: true,
legal_entity_id: true,
enabled: true,
archived_at: true,
},
});
if (!item) {
throw buildValidationError({ costCenterId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409);
}
if (legalEntityId && item.legal_entity_id !== legalEntityId) {
throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409);
}
return item;
}
async function ensureSupplierAvailable(tx, supplierId, legalEntityId) {
if (!supplierId) {
return null;
}
const item = await tx.supplier.findUnique({
where: { id: supplierId },
select: {
id: true,
code: true,
name: true,
enabled: true,
archived_at: true,
legal_entity_id: true,
},
});
if (!item) {
throw buildValidationError({ supplierId: "not_found" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ supplierId: "disabled_or_archived" }, 409);
}
if (legalEntityId && item.legal_entity_id && item.legal_entity_id !== legalEntityId) {
throw buildValidationError({ supplierId: "legalEntityId_mismatch" }, 409);
}
return item;
}
async function ensureDocumentCategoryAvailable(tx, documentCategoryId) {
if (!documentCategoryId) {
return null;
}
const item = await tx.documentCategory.findUnique({
where: { id: documentCategoryId },
select: {
id: true,
code: true,
name: true,
entity_type: true,
enabled: true,
archived_at: true,
},
});
if (!item) {
throw buildValidationError({ documentCategoryId: "not_found" }, 409);
}
if (item.entity_type !== "purchase_request") {
throw buildValidationError({ documentCategoryId: "entity_type_mismatch" }, 409);
}
if (!item.enabled || item.archived_at) {
throw buildValidationError({ documentCategoryId: "disabled_or_archived" }, 409);
}
return item;
}
async function ensureBudgetPoolAvailable(tx, budgetPoolId) {
if (!budgetPoolId) {
return null;
}
const item = await tx.budgetPool.findUnique({
where: { id: budgetPoolId },
select: {
id: true,
code: true,
name: true,
department_id: true,
legal_entity_id: true,
cost_center_id: true,
status: true,
archived_at: true,
period_start: true,
period_end: true,
},
});
if (!item) {
throw buildValidationError({ budgetPoolId: "not_found" }, 409);
}
if (item.status !== "active" || item.archived_at) {
throw buildValidationError({ budgetPoolId: "disabled_or_archived" }, 409);
}
return item;
}
async function loadPurchaseRequestForUpdate(tx, purchaseRequestId) {
const rows = await tx.$queryRaw`
SELECT id, applicant_id, submitter_id, department_id, legal_entity_id, cost_center_id, supplier_id,
document_category_id, budget_pool_id, request_no, currency_code, status
FROM "purchase_requests"
WHERE id = ${purchaseRequestId}::uuid
FOR UPDATE
`;
if (!Array.isArray(rows) || rows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return rows[0];
}
async function replacePurchaseRequestItems(tx, purchaseRequestId, items, actorId) {
await tx.purchaseRequestItem.deleteMany({
where: { purchase_request_id: purchaseRequestId },
});
if (!items.length) {
return "0.00";
}
const createData = items.map((item) => {
const quantity = decimalInputToString(item.quantity, { min: 0.01, field: "quantity" });
const unitPrice = decimalInputToString(item.unitPrice, { min: 0, field: "unitPrice" });
const lineAmount = computeLineAmount(quantity, unitPrice);
return {
purchase_request_id: purchaseRequestId,
description: item.description.trim(),
specification: item.specification?.trim() || null,
quantity,
unit_price: unitPrice,
line_amount: lineAmount,
is_asset_candidate: Boolean(item.isAssetCandidate),
created_by: actorId,
updated_by: actorId,
};
});
await tx.purchaseRequestItem.createMany({ data: createData });
const totalAmount = createData
.reduce((sum, item) => sum + Number(item.line_amount), 0)
.toFixed(2);
return totalAmount;
}
async function recomputePurchaseRequestTotal(tx, purchaseRequestId, actorId) {
const aggregate = await tx.purchaseRequestItem.aggregate({
where: { purchase_request_id: purchaseRequestId },
_sum: { line_amount: true },
});
const nextTotal = moneyToFixed(aggregate._sum.line_amount ?? 0);
await tx.purchaseRequest.update({
where: { id: purchaseRequestId },
data: {
total_amount: nextTotal,
updated_by: actorId,
},
});
return nextTotal;
}
async function persistAttachment({ config, purchaseRequestId, filePart }) {
const chunks = [];
let fileSize = 0;
for await (const chunk of filePart.file) {
fileSize += chunk.length;
if (fileSize > config.maxUploadSizeBytes) {
throw buildValidationError({ file: "file_too_large" });
}
chunks.push(chunk);
}
if (fileSize <= 0) {
throw buildValidationError({ file: "empty" });
}
const buffer = Buffer.concat(chunks);
const safeName = (filePart.filename || "attachment")
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "") || "attachment";
const extension = path.extname(safeName);
const basename = extension ? safeName.slice(0, -extension.length) : safeName;
const key = path.posix.join(
"purchase-requests",
purchaseRequestId,
`${compactDateString(config.appTimezone)}-${basename}-${crypto.randomUUID()}${extension}`,
);
const storagePath = await writeBuffer({
config,
key,
buffer,
contentType: filePart.mimetype,
});
return {
storagePath,
originalName: filePart.filename || safeName,
mimeType: filePart.mimetype || "application/octet-stream",
fileSize,
};
}
function sanitizeFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
}
async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) {
const purchaseRequest = await tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
id: true,
applicant_id: true,
submitter_id: true,
status: true,
},
});
if (!purchaseRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (!canReadPurchaseRequest(request, purchaseRequest)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
return purchaseRequest;
}
async function handlePurchaseArchiveErpSync({
tx,
config,
request,
actorId,
purchaseRequestId,
archiveId,
isFirstArchive,
loadContext = loadPurchaseRequestErpContext,
evaluateGate = evaluateFeatureFlag,
enqueueAccrual = enqueuePurchaseAccrualJournalExport,
enqueueSummary = enqueuePurchaseArchiveSummaryExport,
}) {
const context = await loadContext(tx, purchaseRequestId);
if (!context?.purchaseRequest?.order_archive) {
return { skipped: true, reason: 'purchase_order_archive_missing' };
}
const purchaseRequest = context.purchaseRequest;
const archive = purchaseRequest.order_archive;
const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null;
const gate = await evaluateGate({
prisma: tx,
config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
if (!gate.enabled) {
const skipTargets = isFirstArchive
? [PROCUREMENT_ERP_TARGETS.accrualJournal, PROCUREMENT_ERP_TARGETS.archiveSummary]
: [PROCUREMENT_ERP_TARGETS.archiveSummary];
for (const erpTarget of skipTargets) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
targetType: 'purchase_order_archive',
targetId: archiveId,
extra: {
erpTarget,
eventType: erpTarget === PROCUREMENT_ERP_TARGETS.archiveSummary ? 'erp.procurement.export' : 'erp.journal.export',
purchaseRequestId,
purchaseOrderArchiveId: archiveId,
gateReason: gate.reason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
},
}),
});
}
return { skipped: true, gate, purchaseRequest, archive };
}
const enqueuedItems = [];
if (isFirstArchive) {
const accrual = await enqueueAccrual(tx, config, {
purchaseRequest,
archive,
occurredAt: archive.archived_at ?? new Date(),
actorId,
requeueOnFailure: false,
});
enqueuedItems.push(accrual);
}
const summary = await enqueueSummary(tx, config, {
purchaseRequest,
archive,
archivedAt: archive.archived_at ?? new Date(),
actorId,
requeueOnFailure: false,
});
enqueuedItems.push(summary);
for (const item of enqueuedItems) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: 'ERP_SYNC_ENQUEUED',
targetType: 'purchase_order_archive',
targetId: archiveId,
extra: {
erpTarget: item.erpTarget,
eventType: item.eventType,
purchaseRequestId,
purchaseOrderArchiveId: archiveId,
outboxId: item.id,
duplicated: item.duplicated,
requeued: item.requeued,
gateReason: gate.reason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
},
}),
});
}
return {
skipped: false,
gate,
purchaseRequest,
archive,
items: enqueuedItems,
};
}
async function purchaseRequestRoutes(fastify) {
fastify.get(
"/purchase-requests",
{
schema: { tags: ["Purchase Requests"], summary: "List Purchase Requests" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
const permissionState = ensurePurchaseReadPermission(fastify, request);
const query = parseListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const mineOnly = permissionState.canViewAll ? query.mine : true;
const where = {
...(query.status ? { status: query.status } : {}),
...(query.supplierId ? { supplier_id: query.supplierId } : {}),
...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}),
...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}),
...(mineOnly
? {
OR: [
{ applicant_id: request.employee.id },
{ submitter_id: request.employee.id },
],
}
: {}),
};
const [items, total] = await Promise.all([
fastify.prisma.purchaseRequest.findMany({
where,
orderBy: [{ updated_at: "desc" }, { created_at: "desc" }],
skip,
take: query.pageSize,
select: PURCHASE_SUMMARY_SELECT,
}),
fastify.prisma.purchaseRequest.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
mine: mineOnly,
items: items.map(mapPurchaseRequestSummary),
});
},
);
fastify.post(
"/purchase-requests",
{
schema: { tags: ["Purchase Requests"], summary: "Create Purchase Request Draft" },
preHandler: fastify.requirePermission("procurement.create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "procurement",
departmentId: request.employee.department_id ?? null,
});
const purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.purchaseRequest.create({
data: {
applicant_id: request.employee.id,
department_id: request.employee.department_id,
title: "",
reason: null,
currency_code: "CNY",
total_amount: "0.00",
status: "draft",
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: PURCHASE_DETAIL_SELECT,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_REQUEST_CREATED",
targetType: "purchase_request",
targetId: created.id,
extra: {
applicantId: created.applicant_id,
departmentId: created.department_id,
},
}),
});
return created;
});
return sendData(reply, request, {
purchaseRequest: mapPurchaseRequestDetail(purchaseRequest, request),
});
},
);
fastify.get(
"/purchase-requests/:id",
{
schema: { tags: ["Purchase Requests"], summary: "Get Purchase Request Detail" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensurePurchaseReadPermission(fastify, request);
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: PURCHASE_DETAIL_SELECT,
});
if (!purchaseRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (!canReadPurchaseRequest(request, purchaseRequest)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "purchase_request",
entity_id: purchaseRequestId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
...mapPurchaseRequestDetail(purchaseRequest, request),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
fastify.patch(
"/purchase-requests/:id",
{
schema: { tags: ["Purchase Requests"], summary: "Update Purchase Request Draft" },
preHandler: fastify.requirePermission("procurement.create"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const payload = parsePatchBody(request.body);
const purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
}
if (payload.legalEntityId) {
await ensureLegalEntityAvailable(tx, payload.legalEntityId);
}
if (payload.costCenterId) {
await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId ?? locked.legal_entity_id ?? null);
}
if (payload.supplierId) {
await ensureSupplierAvailable(tx, payload.supplierId, payload.legalEntityId ?? locked.legal_entity_id ?? null);
}
if (payload.documentCategoryId) {
await ensureDocumentCategoryAvailable(tx, payload.documentCategoryId);
}
if (payload.budgetPoolId) {
await ensureBudgetPoolAvailable(tx, payload.budgetPoolId);
}
const updateData = {
...(payload.title !== undefined ? { title: payload.title.trim() } : {}),
...(payload.reason !== undefined ? { reason: payload.reason?.trim() || null } : {}),
...(payload.currencyCode !== undefined ? { currency_code: payload.currencyCode.trim().toUpperCase() } : {}),
...(payload.legalEntityId !== undefined ? { legal_entity_id: payload.legalEntityId || null } : {}),
...(payload.costCenterId !== undefined ? { cost_center_id: payload.costCenterId || null } : {}),
...(payload.supplierId !== undefined ? { supplier_id: payload.supplierId || null } : {}),
...(payload.documentCategoryId !== undefined ? { document_category_id: payload.documentCategoryId || null } : {}),
...(payload.budgetPoolId !== undefined ? { budget_pool_id: payload.budgetPoolId || null } : {}),
...(payload.expectedDeliveryDate !== undefined
? {
expected_delivery_date: payload.expectedDeliveryDate
? parseDateOnlyString(payload.expectedDeliveryDate)
: null,
}
: {}),
updated_by: request.employee.id,
};
await tx.purchaseRequest.update({
where: { id: purchaseRequestId },
data: updateData,
});
let totalAmount = null;
if (payload.items) {
totalAmount = await replacePurchaseRequestItems(tx, purchaseRequestId, payload.items, request.employee.id);
}
await tx.purchaseRequest.update({
where: { id: purchaseRequestId },
data: {
total_amount: totalAmount ?? undefined,
updated_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_REQUEST_UPDATED",
targetType: "purchase_request",
targetId: purchaseRequestId,
extra: {
updatedFields: Object.keys(payload),
replacedItems: Boolean(payload.items),
},
}),
});
return tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: PURCHASE_DETAIL_SELECT,
});
});
return sendData(reply, request, {
purchaseRequest: mapPurchaseRequestDetail(purchaseRequest, request),
});
},
);
fastify.post(
"/purchase-requests/:id/precheck",
{
preHandler: fastify.requirePermission("procurement.submit"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: { id: true, department_id: true },
});
if (!purchaseRequest) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId));
}
const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/purchase-requests/:id/submit",
{
schema: { tags: ["Purchase Requests"], summary: "Submit Purchase Request" },
preHandler: fastify.requirePermission("procurement.submit"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
let response = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "draft") {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
}
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "procurement",
departmentId: locked.department_id ?? request.employee.department_id ?? null,
legalEntityId: locked.legal_entity_id ?? null,
});
const purchaseRequest = await tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
...PURCHASE_DETAIL_SELECT,
document_category: {
select: {
id: true,
code: true,
name: true,
entity_type: true,
enabled: true,
archived_at: true,
},
},
},
});
if (!purchaseRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (!purchaseRequest.legal_entity_id) {
throw buildValidationError({ legalEntityId: "required" });
}
if (!purchaseRequest.cost_center_id) {
throw buildValidationError({ costCenterId: "required" });
}
if (!purchaseRequest.supplier_id) {
throw buildValidationError({ supplierId: "required" });
}
if (!purchaseRequest.document_category_id) {
throw buildValidationError({ documentCategoryId: "required" });
}
if (!purchaseRequest.items?.length) {
throw buildValidationError({ items: "at_least_one_required" });
}
const legalEntity = await ensureLegalEntityAvailable(tx, purchaseRequest.legal_entity_id);
const costCenter = await ensureCostCenterAvailable(tx, purchaseRequest.cost_center_id, purchaseRequest.legal_entity_id);
const supplier = await ensureSupplierAvailable(tx, purchaseRequest.supplier_id, purchaseRequest.legal_entity_id);
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id);
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(tx, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const effectiveDate = new Date();
let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
legalEntityId: purchaseRequest.legal_entity_id,
costCenterId: purchaseRequest.cost_center_id,
effectiveDate,
});
let selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? null;
if (selectedBudgetPoolId) {
await ensureBudgetPoolAvailable(tx, selectedBudgetPoolId);
if (!resolvedBudgetPool || resolvedBudgetPool.id !== selectedBudgetPoolId) {
throw buildValidationError({ budgetPoolId: "context_mismatch" }, 409);
}
} else if (resolvedBudgetPool) {
selectedBudgetPoolId = resolvedBudgetPool.id;
}
const warnings = [];
const budgetControlMode = authoritativePrecheck?.budgetHint?.controlMode ?? normalizeBudgetControlMode(fastify.config);
if (!selectedBudgetPoolId) {
if (!budgetPrecheckGate.enabled && budgetControlMode === "block") {
throw buildValidationError({ budget: "missing_budget_pool" }, 409);
}
if (!budgetPrecheckGate.enabled) {
warnings.push("missing_budget_pool");
}
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "purchase_request",
is_active: true,
},
orderBy: { version: "desc" },
select: {
id: true,
definition_json: true,
},
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" }, 409);
}
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: purchaseRequest.applicant_id,
logger: fastify.log,
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
}
let requestNo = purchaseRequest.request_no;
if (!requestNo) {
const issued = await issueDocumentNumber(tx, {
documentType: "purchase_request",
documentCategoryCode: documentCategory.code,
actorId: request.employee.id,
timeZone: fastify.config.appTimezone,
});
requestNo = issued.documentNumber;
}
await tx.purchaseRequest.update({
where: { id: purchaseRequestId },
data: {
request_no: requestNo,
submitter_id: request.employee.id,
budget_pool_id: selectedBudgetPoolId,
total_amount: totalAmount,
status: "reviewing",
updated_by: request.employee.id,
},
});
const budgetReservation = await reserveBudgetForEntity(tx, {
budgetPoolId: selectedBudgetPoolId,
entityType: "purchase_request",
entityId: purchaseRequestId,
amount: totalAmount,
actorId: request.employee.id,
note: `submit_reserve:${requestNo ?? purchaseRequestId}`,
});
const workflowInstance = await tx.workflowInstance.create({
data: {
workflow_id: workflowDefinition.id,
entity_type: "purchase_request",
entity_id: purchaseRequestId,
purchase_request_id: purchaseRequestId,
submitter_id: request.employee.id,
current_status: "pending",
current_step_id: actionable.step.id,
context_data: {
owner_id: purchaseRequest.applicant_id,
department_id: purchaseRequest.department_id,
workflow_steps_snapshot: workflowSteps,
document_category_id: purchaseRequest.document_category_id,
legal_entity_id: legalEntity.id,
cost_center_id: costCenter.id,
supplier_id: supplier.id,
},
context_department_id: purchaseRequest.department_id,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
},
});
for (const skippedStep of skipped) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "skip",
payload: {
step_id: skippedStep.step.id,
reason: skippedStep.reason,
owner_id: purchaseRequest.applicant_id,
depth_checked: skippedStep.depthChecked,
},
},
});
}
const workflowTask = await tx.workflowTask.create({
data: {
instance_id: workflowInstance.id,
step_id: actionable.step.id,
step_name: actionable.step.name ?? actionable.step.id,
assignee_id: actionable.assigneeId,
assignee_role: actionable.assigneeRole,
},
select: {
id: true,
assignee_id: true,
assignee_role: true,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: workflowTask.id,
actor_id: request.employee.id,
action: "start",
payload: {
step_id: actionable.step.id,
skipped_step_count: skipped.length,
},
},
});
const recipients = await resolveRecipientsForAssignee(tx, {
assigneeId: workflowTask.assignee_id,
assigneeRole: workflowTask.assignee_role,
});
const title = `【待办】采购申请 ${requestNo} 待审批`;
const body = `采购申请 ${requestNo} 已提交,请及时处理。`;
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTask.id,
});
await enqueueImNotifications(tx, {
config: fastify.config,
logger: fastify.log,
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTask.id,
targetPath: `/workflow/tasks/${workflowTask.id}`,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.workflowTaskAssigned,
recipientEmployeeIds: recipients,
subject: title,
body,
targetPath: `/workflow/tasks/${workflowTask.id}`,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_REQUEST_SUBMITTED",
targetType: "purchase_request",
targetId: purchaseRequestId,
extra: {
requestNo,
totalAmount,
workflowInstanceId: workflowInstance.id,
workflowTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
warnings,
budgetReservation,
},
}),
});
response = {
purchaseRequestId,
requestNo,
status: "reviewing",
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
warnings,
budgetReservation,
};
});
for (const emailTask of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...emailTask,
});
}
return sendData(reply, request, response);
},
);
fastify.post(
"/purchase-requests/:id/withdraw",
{
schema: { tags: ["Purchase Requests"], summary: "Withdraw Purchase Request" },
preHandler: fastify.requirePermission("procurement.submit"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "reviewing") {
throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409);
}
const workflowInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "purchase_request",
entity_id: purchaseRequestId,
current_status: "pending",
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (workflowInstance) {
await tx.workflowTask.updateMany({
where: {
instance_id: workflowInstance.id,
status: "pending",
},
data: {
status: "cancelled",
completed_at: new Date(),
completed_by: request.employee.id,
},
});
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "terminated",
completed_at: new Date(),
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "withdraw",
payload: {
source: "purchase_request_withdraw",
},
},
});
}
const budgetRelease = await releaseReservedBudgetIfAny(tx, {
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
note: "withdraw_release_purchase_budget",
});
await tx.purchaseRequest.update({
where: { id: purchaseRequestId },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_REQUEST_WITHDRAWN",
targetType: "purchase_request",
targetId: purchaseRequestId,
extra: {
workflowInstanceId: workflowInstance?.id ?? null,
budgetRelease,
},
}),
});
response = {
purchaseRequestId,
status: "draft",
workflowInstanceId: workflowInstance?.id ?? null,
budgetRelease,
};
});
return sendData(reply, request, response);
},
);
fastify.get(
"/purchase-requests/:id/archive-order",
{
schema: { tags: ["Purchase Requests"], summary: "Get Purchase Order Archive" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensurePurchaseReadPermission(fastify, request);
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request);
const archive = await fastify.prisma.purchaseOrderArchive.findUnique({
where: { purchase_request_id: purchaseRequestId },
select: {
id: true,
po_no: true,
contract_no: true,
archived_files: true,
archived_at: true,
archived_by: true,
archived_by_employee: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
return sendData(reply, request, {
archiveOrder: mapPurchaseOrderArchive(archive),
});
},
);
fastify.post(
"/purchase-requests/:id/archive-order",
{
schema: { tags: ["Purchase Requests"], summary: "Archive Purchase Order" },
preHandler: fastify.requirePermission("procurement.manage"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const payload = parseArchiveBody(request.body);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
await loadReadablePurchaseRequest(tx, purchaseRequestId, request);
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
if (locked.status !== "approved") {
throw buildValidationError({ status: "only_approved_archivable" }, 409);
}
const existingArchive = await tx.purchaseOrderArchive.findUnique({
where: { purchase_request_id: purchaseRequestId },
select: { id: true },
});
const archive = await tx.purchaseOrderArchive.upsert({
where: { purchase_request_id: purchaseRequestId },
update: {
po_no: payload.poNo,
contract_no: payload.contractNo?.trim() || null,
archived_files: payload.archivedFiles ?? [],
archived_at: new Date(),
archived_by: request.employee.id,
},
create: {
purchase_request_id: purchaseRequestId,
po_no: payload.poNo,
contract_no: payload.contractNo?.trim() || null,
archived_files: payload.archivedFiles ?? [],
archived_by: request.employee.id,
},
select: {
id: true,
po_no: true,
contract_no: true,
archived_files: true,
archived_at: true,
archived_by: true,
archived_by_employee: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_ORDER_ARCHIVED",
targetType: "purchase_order_archive",
targetId: archive.id,
extra: {
purchaseRequestId,
poNo: archive.po_no,
contractNo: archive.contract_no ?? null,
archivedFileCount: Array.isArray(archive.archived_files) ? archive.archived_files.length : 0,
},
}),
});
try {
await handlePurchaseArchiveErpSync({
tx,
config: fastify.config,
request,
actorId: request.employee.id,
purchaseRequestId,
archiveId: archive.id,
isFirstArchive: !existingArchive,
});
} catch (error) {
fastify.log.error(
{ err: error, purchaseRequestId, purchaseOrderArchiveId: archive.id },
"Failed to enqueue purchase archive ERP events",
);
}
response = {
archiveOrder: mapPurchaseOrderArchive(archive),
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/purchase-requests/:id/attachments",
{
schema: { tags: ["Purchase Requests"], summary: "Upload Purchase Attachment" },
preHandler: fastify.requirePermission("procurement.create"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
id: true,
applicant_id: true,
status: true,
},
});
if (!purchaseRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
ensureDraftOwner(request, purchaseRequest);
if (purchaseRequest.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
}
let filePart = null;
try {
for await (const part of request.parts()) {
if (part.type === "file") {
if (filePart) {
throw buildValidationError({ file: "only_single_file_supported" });
}
filePart = part;
}
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
}
throw error;
}
if (!filePart) {
throw buildValidationError({ file: "required" });
}
const stored = await persistAttachment({
config: fastify.config,
purchaseRequestId,
filePart,
});
const attachment = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.purchaseAttachment.create({
data: {
purchase_request_id: purchaseRequestId,
storage_path: stored.storagePath,
original_name: stored.originalName,
mime_type: stored.mimeType,
file_size: stored.fileSize,
uploaded_by: request.employee.id,
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
uploaded_by: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_ATTACHMENT_UPLOADED",
targetType: "purchase_attachment",
targetId: created.id,
extra: {
purchaseRequestId,
originalName: stored.originalName,
fileSize: stored.fileSize,
},
}),
});
return created;
});
return sendData(reply, request, {
attachment: {
id: attachment.id,
originalName: attachment.original_name,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
uploadedBy: attachment.uploaded_by,
createdAt: toIsoDateTime(attachment.created_at),
uploader: mapEmployeeBasic(attachment.uploader),
},
});
},
);
fastify.delete(
"/purchase-requests/:id/attachments/:attachmentId",
{
schema: { tags: ["Purchase Requests"], summary: "Delete Purchase Attachment" },
preHandler: fastify.requirePermission("procurement.create"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const attachmentId = parseAttachmentId(request.params?.attachmentId);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
id: true,
applicant_id: true,
status: true,
},
});
if (!purchaseRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
ensureDraftOwner(request, purchaseRequest);
if (purchaseRequest.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
}
const attachment = await fastify.prisma.purchaseAttachment.findFirst({
where: {
id: attachmentId,
purchase_request_id: purchaseRequestId,
},
select: { id: true },
});
if (!attachment) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
await fastify.prisma.$transaction(async (tx) => {
await tx.purchaseAttachment.delete({ where: { id: attachmentId } });
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_ATTACHMENT_DELETED",
targetType: "purchase_attachment",
targetId: attachmentId,
extra: {
purchaseRequestId,
},
}),
});
});
return sendData(reply, request, {
outcome: "deleted",
attachmentId,
});
},
);
fastify.post(
"/purchase-requests/:id/attachments/:attachmentId/signed-url",
{
schema: { tags: ["Purchase Requests"], summary: "Issue Purchase Attachment Signed URL" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensurePurchaseReadPermission(fastify, request);
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const attachmentId = parseAttachmentId(request.params?.attachmentId);
await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request);
const attachment = await fastify.prisma.purchaseAttachment.findFirst({
where: {
id: attachmentId,
purchase_request_id: purchaseRequestId,
},
select: {
id: true,
},
});
if (!attachment) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
{
purchaseRequestId,
attachmentId,
actorId: request.employee.id,
exp: expiresAt,
},
fastify.config.jwtSecret,
);
const origin =
fastify.config.appOrigin ||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
const signedUrl = `${origin}/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/file?token=${encodeURIComponent(token)}`;
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_ATTACHMENT_SIGNED_URL_ISSUED",
targetType: "purchase_attachment",
targetId: attachmentId,
extra: {
purchaseRequestId,
expiresAt,
},
}),
});
return sendData(reply, request, {
signedUrl,
expiresAt: new Date(expiresAt).toISOString(),
});
},
);
fastify.get(
"/purchase-requests/:id/attachments/:attachmentId/file",
{
schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment By Token" },
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const attachmentId = parseAttachmentId(request.params?.attachmentId);
const token = request.query?.token;
if (typeof token !== "string") {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const actorId = UUID_SCHEMA.safeParse(verified.payload?.actorId);
if (
verified.payload?.purchaseRequestId !== purchaseRequestId ||
verified.payload?.attachmentId !== attachmentId ||
typeof verified.payload?.exp !== "number" ||
verified.payload.exp <= Date.now() ||
!actorId.success
) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const attachment = await fastify.prisma.purchaseAttachment.findFirst({
where: {
id: attachmentId,
purchase_request_id: purchaseRequestId,
},
select: {
storage_path: true,
mime_type: true,
original_name: true,
},
});
if (!attachment) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let opened = null;
try {
opened = await openReadStream({
config: fastify.config,
storagePath: attachment.storage_path,
});
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
throw error;
}
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: actorId.data,
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
targetType: "purchase_attachment",
targetId: attachmentId,
extra: {
purchaseRequestId,
exp: verified.payload.exp,
},
}),
});
} catch (error) {
fastify.log.error({ err: error, attachmentId }, "Audit log write failed");
}
reply.type(attachment.mime_type || "application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeFilename(attachment.original_name)}\"`,
);
return reply.send(opened.stream);
},
);
}
module.exports = purchaseRequestRoutes;
module.exports.__testables = {
handlePurchaseArchiveErpSync,
parseArchiveBody,
};