关联 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
2209 行
65 KiB
JavaScript
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,
|
|
};
|