const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { resolveBudgetScope, resolveBudgetScopeType, } = require("../services/budgetPool"); const UUID_SCHEMA = z.string().uuid(); const BUDGET_CREATE_SCHEMA = z .object({ code: z.string().trim().min(2).max(32), name: z.string().trim().min(1).max(120), departmentId: UUID_SCHEMA.nullable().optional(), legalEntityId: UUID_SCHEMA.nullable().optional(), costCenterId: UUID_SCHEMA.nullable().optional(), periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/), warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(), }) .strict(); const BUDGET_PATCH_SCHEMA = z .object({ name: z.string().trim().min(1).max(120).optional(), allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/).optional(), warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(), status: z.enum(["active", "archived"]).optional(), }) .strict(); const BUDGET_LIST_QUERY_SCHEMA = z.object({ status: z.enum(["active", "archived"]).optional(), departmentId: UUID_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), }); const BUDGET_RELATION_SELECT = { 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, }, }, }; function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function toDate(value) { return new Date(`${value}T00:00:00.000Z`); } function toMoneyString(value) { if (value == null) { return "0.00"; } const numeric = Number(value); return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00"; } function mapBudget(item) { const allocated = Number(item.allocated_amount ?? 0); const used = Number(item.used_amount ?? 0); const remaining = allocated - used; const scopeType = resolveBudgetScopeType(item); return { id: item.id, code: item.code, name: item.name, scopeType, departmentId: item.department_id ?? null, legalEntityId: item.legal_entity_id ?? null, costCenterId: item.cost_center_id ?? null, 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, status: item.status, periodStart: item.period_start?.toISOString?.()?.slice(0, 10) ?? null, periodEnd: item.period_end?.toISOString?.()?.slice(0, 10) ?? null, allocatedAmount: toMoneyString(item.allocated_amount), usedAmount: toMoneyString(item.used_amount), remainingAmount: toMoneyString(remaining), warningThresholdPercent: item.warning_threshold_percent, archivedAt: item.archived_at?.toISOString?.() ?? null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } function parseBudgetId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } async function ensureDepartmentAvailable(tx, departmentId) { if (!departmentId) { return null; } const department = await tx.department.findUnique({ where: { id: departmentId }, select: { id: true, name: true, archived_at: true }, }); if (!department) { throw buildValidationError({ departmentId: "not_found" }); } if (department.archived_at) { throw buildValidationError({ departmentId: "archived" }, 409); } return department; } async function ensureLegalEntityAvailable(tx, legalEntityId) { if (!legalEntityId) { return null; } const legalEntity = await tx.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true, archived_at: true, enabled: true }, }); if (!legalEntity) { throw buildValidationError({ legalEntityId: "not_found" }); } if (legalEntity.archived_at || !legalEntity.enabled) { throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409); } return legalEntity; } async function ensureCostCenterAvailable(tx, { costCenterId, legalEntityId }) { if (!costCenterId) { return null; } const costCenter = await tx.costCenter.findUnique({ where: { id: costCenterId }, select: { id: true, code: true, name: true, legal_entity_id: true, archived_at: true, enabled: true, }, }); if (!costCenter) { throw buildValidationError({ costCenterId: "not_found" }); } if (costCenter.archived_at || !costCenter.enabled) { throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409); } if (legalEntityId && costCenter.legal_entity_id !== legalEntityId) { throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409); } return costCenter; } async function fetchBudgetPoolOrThrow(tx, budgetId) { const budget = await tx.budgetPool.findUnique({ where: { id: budgetId }, include: BUDGET_RELATION_SELECT, }); if (!budget) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return budget; } async function budgetsRoutes(fastify) { fastify.get( "/budgets", { preHandler: fastify.requirePermission("budget.view"), }, async (request, reply) => { const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const query = parsed.data; const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const skip = (page - 1) * pageSize; const where = { ...(query.status ? { status: query.status } : {}), ...(query.departmentId ? { department_id: query.departmentId } : {}), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), ...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.budgetPool.findMany({ where, orderBy: [{ period_start: "desc" }, { created_at: "desc" }], skip, take: pageSize, include: BUDGET_RELATION_SELECT, }), fastify.prisma.budgetPool.count({ where }), ]); return sendData(reply, request, { page, pageSize, total, items: items.map(mapBudget), }); }, ); fastify.post( "/budgets", { preHandler: fastify.requirePermission("budget.manage"), }, async (request, reply) => { const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const body = parsed.data; const start = toDate(body.periodStart); const end = toDate(body.periodEnd); if (start > end) { throw buildValidationError({ periodEnd: "must_be_after_periodStart" }); } const scope = resolveBudgetScope({ departmentId: body.departmentId ?? null, legalEntityId: body.legalEntityId ?? null, costCenterId: body.costCenterId ?? null, }); await ensureDepartmentAvailable(fastify.prisma, scope.departmentId); await ensureLegalEntityAvailable(fastify.prisma, scope.legalEntityId); await ensureCostCenterAvailable(fastify.prisma, { costCenterId: scope.costCenterId, legalEntityId: scope.legalEntityId, }); const created = await fastify.prisma.budgetPool.create({ data: { code: body.code.trim().toUpperCase(), name: body.name.trim(), department_id: scope.departmentId, legal_entity_id: scope.legalEntityId, cost_center_id: scope.costCenterId, status: "active", period_start: start, period_end: end, allocated_amount: body.allocatedAmount, used_amount: "0.00", warning_threshold_percent: body.warningThresholdPercent ?? 80, created_by: request.employee.id, updated_by: request.employee.id, }, include: BUDGET_RELATION_SELECT, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "BUDGET_CREATED", targetType: "budget_pool", targetId: created.id, extra: { code: created.code, scopeType: scope.scopeType, departmentId: scope.departmentId, legalEntityId: scope.legalEntityId, costCenterId: scope.costCenterId, allocatedAmount: created.allocated_amount, }, }), }); return sendData(reply, request, mapBudget(created)); }, ); fastify.patch( "/budgets/:id", { preHandler: fastify.requirePermission("budget.manage"), }, async (request, reply) => { const budgetId = parseBudgetId(request.params?.id); const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { throw buildValidationError({ body: "empty_patch" }); } await fetchBudgetPoolOrThrow(fastify.prisma, budgetId); const patch = parsed.data; const updated = await fastify.prisma.budgetPool.update({ where: { id: budgetId }, data: { ...(patch.name != null ? { name: patch.name.trim() } : {}), ...(patch.allocatedAmount != null ? { allocated_amount: patch.allocatedAmount } : {}), ...(patch.warningThresholdPercent != null ? { warning_threshold_percent: patch.warningThresholdPercent } : {}), ...(patch.status ? { status: patch.status } : {}), ...(patch.status === "archived" ? { archived_at: new Date() } : {}), ...(patch.status === "active" ? { archived_at: null } : {}), updated_by: request.employee.id, }, include: BUDGET_RELATION_SELECT, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "BUDGET_UPDATED", targetType: "budget_pool", targetId: budgetId, extra: { patch, }, }), }); return sendData(reply, request, mapBudget(updated)); }, ); fastify.post( "/budgets/:id/archive", { preHandler: fastify.requirePermission("budget.manage"), }, async (request, reply) => { const budgetId = parseBudgetId(request.params?.id); const current = await fetchBudgetPoolOrThrow(fastify.prisma, budgetId); const updated = await fastify.prisma.budgetPool.update({ where: { id: budgetId }, data: { status: "archived", archived_at: new Date(), updated_by: request.employee.id, }, include: BUDGET_RELATION_SELECT, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "BUDGET_ARCHIVED", targetType: "budget_pool", targetId: budgetId, extra: { code: current.code, scopeType: resolveBudgetScopeType(current), }, }), }); return sendData(reply, request, mapBudget(updated)); }, ); } module.exports = budgetsRoutes;