const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { ROLE_PERMISSIONS } = require("../constants/permissions"); const ROLE_CODES = Object.keys(ROLE_PERMISSIONS); const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/); const CREATE_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100), minAmount: MONEY_STRING_SCHEMA, maxAmount: MONEY_STRING_SCHEMA.nullable().optional(), level1Role: z.enum(ROLE_CODES), level2Role: z.enum(ROLE_CODES).nullable().optional(), level3Role: z.enum(ROLE_CODES).nullable().optional(), enabled: z.boolean().optional(), priority: z.number().int().min(0).max(10000).optional(), }); const UPDATE_BODY_SCHEMA = CREATE_BODY_SCHEMA.partial(); function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseApprovalRuleId(params) { const parsed = z.string().uuid().safeParse(params?.id); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } return parsed.data; } function ensureAmountRangeValid(minAmount, maxAmount) { if (maxAmount == null) { return; } const minValue = Number(minAmount); const maxValue = Number(maxAmount); if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { amount: "invalid" }, }); } if (maxValue <= minValue) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { maxAmount: "must_be_greater_than_minAmount" }, }); } } function mapApprovalRule(item) { return { id: item.id, name: item.name, minAmount: item.min_amount?.toString?.() ?? String(item.min_amount ?? "0"), maxAmount: item.max_amount == null ? null : item.max_amount?.toString?.() ?? String(item.max_amount), level1Role: item.level1_role, level2Role: item.level2_role ?? null, level3Role: item.level3_role ?? null, enabled: item.enabled, priority: item.priority, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } async function approvalRulesRoutes(fastify) { fastify.post( "/approval-rules", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const body = parseBody(CREATE_BODY_SCHEMA, request.body); const maxAmount = Object.prototype.hasOwnProperty.call(body, "maxAmount") ? body.maxAmount : null; ensureAmountRangeValid(body.minAmount, maxAmount); const result = await fastify.prisma.$transaction(async (tx) => { let priority = body.priority; if (priority == null) { const aggregate = await tx.approvalRule.aggregate({ _max: { priority: true } }); priority = (aggregate._max.priority ?? 0) + 1; } const created = await tx.approvalRule.create({ data: { name: body.name, min_amount: body.minAmount, max_amount: maxAmount ?? null, level1_role: body.level1Role, level2_role: body.level2Role ?? null, level3_role: body.level3Role ?? null, enabled: body.enabled ?? true, priority, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, name: true, min_amount: true, max_amount: true, level1_role: true, level2_role: true, level3_role: true, enabled: true, priority: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "APPROVAL_RULE_CREATED", targetType: "approval_rule", targetId: created.id, extra: { name: created.name, minAmount: created.min_amount?.toString?.() ?? String(created.min_amount), maxAmount: created.max_amount == null ? null : created.max_amount?.toString?.() ?? String(created.max_amount), level1Role: created.level1_role, level2Role: created.level2_role ?? null, level3Role: created.level3_role ?? null, enabled: created.enabled, priority: created.priority, }, }), }); return created; }); return sendData(reply, request, mapApprovalRule(result)); }, ); fastify.patch( "/approval-rules/:id", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const approvalRuleId = parseApprovalRuleId(request.params); const body = parseBody(UPDATE_BODY_SCHEMA, request.body); if (Object.keys(body).length === 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty_patch" }, }); } const result = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "approval_rules" WHERE id = ${approvalRuleId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.approvalRule.findUnique({ where: { id: approvalRuleId }, select: { id: true, name: true, min_amount: true, max_amount: true, level1_role: true, level2_role: true, level3_role: true, enabled: true, priority: true, created_at: true, updated_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const nextMin = body.minAmount ?? (current.min_amount?.toString?.() ?? String(current.min_amount)); const nextMax = Object.prototype.hasOwnProperty.call(body, "maxAmount") ? body.maxAmount : current.max_amount == null ? null : current.max_amount?.toString?.() ?? String(current.max_amount); ensureAmountRangeValid(nextMin, nextMax); const updated = await tx.approvalRule.update({ where: { id: approvalRuleId }, data: { ...(body.name != null ? { name: body.name } : {}), ...(body.minAmount != null ? { min_amount: body.minAmount } : {}), ...(Object.prototype.hasOwnProperty.call(body, "maxAmount") ? { max_amount: body.maxAmount ?? null } : {}), ...(body.level1Role != null ? { level1_role: body.level1Role } : {}), ...(Object.prototype.hasOwnProperty.call(body, "level2Role") ? { level2_role: body.level2Role ?? null } : {}), ...(Object.prototype.hasOwnProperty.call(body, "level3Role") ? { level3_role: body.level3Role ?? null } : {}), ...(body.enabled != null ? { enabled: body.enabled } : {}), ...(body.priority != null ? { priority: body.priority } : {}), updated_by: request.employee.id, }, select: { id: true, name: true, min_amount: true, max_amount: true, level1_role: true, level2_role: true, level3_role: true, enabled: true, priority: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "APPROVAL_RULE_UPDATED", targetType: "approval_rule", targetId: approvalRuleId, extra: { patch: body, }, }), }); return updated; }); return sendData(reply, request, mapApprovalRule(result)); }, ); } module.exports = approvalRulesRoutes;