const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const UUID_SCHEMA = z.string().uuid(); const ENTITY_TYPE_SCHEMA = z.enum(["all", "reimbursement", "purchase_request", "travel_request"]); const CONTROL_MODE_SCHEMA = z.enum(["warn", "block", "manual_review"]); const LIST_QUERY_SCHEMA = z.object({ entityType: ENTITY_TYPE_SCHEMA.optional(), enabled: z.enum(["true", "false"]).optional(), }); const WRITE_SCHEMA = z.object({ name: z.string().trim().min(1).max(120), entityType: ENTITY_TYPE_SCHEMA, departmentId: UUID_SCHEMA.nullable().optional(), legalEntityId: UUID_SCHEMA.nullable().optional(), costCenterId: UUID_SCHEMA.nullable().optional(), expenseTypeId: UUID_SCHEMA.nullable().optional(), controlMode: CONTROL_MODE_SCHEMA, priority: z.coerce.number().int().min(1).max(9999).optional(), enabled: z.boolean().optional(), notes: z.string().trim().max(500).nullable().optional(), }).strict(); const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch", }); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseRuleId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } function parseQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { entityType: parsed.data.entityType ?? null, enabled: parsed.data.enabled == null ? null : parsed.data.enabled === "true", }; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } if (parsed.data.costCenterId && !parsed.data.legalEntityId) { throw buildValidationError({ costCenterId: "legalEntityId_required" }, 409); } if (parsed.data.entityType !== "reimbursement" && parsed.data.expenseTypeId) { throw buildValidationError({ expenseTypeId: "only_reimbursement_supported" }, 409); } return parsed.data; } function mapRule(item) { return { id: item.id, name: item.name, entityType: item.entity_type, departmentId: item.department_id ?? null, legalEntityId: item.legal_entity_id ?? null, costCenterId: item.cost_center_id ?? null, expenseTypeId: item.expense_type_id ?? null, controlMode: item.control_mode, priority: item.priority, enabled: item.enabled, notes: item.notes ?? null, archivedAt: item.archived_at?.toISOString?.() ?? null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } async function budgetControlRulesRoutes(fastify) { fastify.get( "/budget-control-rules", { preHandler: fastify.requirePermission("budget.manage") }, async (request, reply) => { const query = parseQuery(request.query); const items = await fastify.prisma.budgetControlRule.findMany({ where: { ...(query.entityType ? { entity_type: query.entityType } : {}), ...(query.enabled == null ? {} : { enabled: query.enabled }), archived_at: null, }, orderBy: [{ priority: "asc" }, { created_at: "asc" }], }); return sendData(reply, request, { items: items.map(mapRule) }); }, ); fastify.post( "/budget-control-rules", { preHandler: fastify.requirePermission("budget.manage") }, async (request, reply) => { const body = parseBody(WRITE_SCHEMA, request.body); const created = await fastify.prisma.budgetControlRule.create({ data: { name: body.name, entity_type: body.entityType, department_id: body.departmentId ?? null, legal_entity_id: body.legalEntityId ?? null, cost_center_id: body.costCenterId ?? null, expense_type_id: body.expenseTypeId ?? null, control_mode: body.controlMode, priority: body.priority ?? 100, enabled: body.enabled ?? true, notes: body.notes ?? null, created_by: request.employee.id, updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "BUDGET_CONTROL_RULE_CREATED", targetType: "budget_control_rule", targetId: created.id, extra: mapRule(created), }), }); return reply.code(201).send({ requestId: request.id, data: { item: mapRule(created) } }); }, ); fastify.patch( "/budget-control-rules/:id", { preHandler: fastify.requirePermission("budget.manage") }, async (request, reply) => { const ruleId = parseRuleId(request.params?.id); const body = parseBody(PATCH_SCHEMA, request.body); const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } }); if (!current || current.archived_at) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const updated = await fastify.prisma.budgetControlRule.update({ where: { id: ruleId }, data: { ...(body.name !== undefined ? { name: body.name } : {}), ...(body.entityType !== undefined ? { entity_type: body.entityType } : {}), ...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}), ...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}), ...(body.costCenterId !== undefined ? { cost_center_id: body.costCenterId } : {}), ...(body.expenseTypeId !== undefined ? { expense_type_id: body.expenseTypeId } : {}), ...(body.controlMode !== undefined ? { control_mode: body.controlMode } : {}), ...(body.priority !== undefined ? { priority: body.priority } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), ...(body.notes !== undefined ? { notes: body.notes } : {}), updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "BUDGET_CONTROL_RULE_UPDATED", targetType: "budget_control_rule", targetId: updated.id, extra: { fields: Object.keys(body) }, }), }); return sendData(reply, request, { item: mapRule(updated) }); }, ); fastify.post( "/budget-control-rules/:id/archive", { preHandler: fastify.requirePermission("budget.manage") }, async (request, reply) => { const ruleId = parseRuleId(request.params?.id); const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } }); if (!current || current.archived_at) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const archived = await fastify.prisma.budgetControlRule.update({ where: { id: ruleId }, data: { enabled: false, archived_at: new Date(), updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "BUDGET_CONTROL_RULE_ARCHIVED", targetType: "budget_control_rule", targetId: archived.id, }), }); return sendData(reply, request, { item: mapRule(archived) }); }, ); } module.exports = budgetControlRulesRoutes;