const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const QUERY_SCHEMA = z.object({ dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), departmentId: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(30).optional(), }); function parseQuery(query) { const parsed = QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null; const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T23:59:59.999Z`) : null; if (dateFrom && dateTo && dateFrom > dateTo) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { dateTo: "must_be_after_dateFrom" }, }); } return { dateFrom, dateTo, departmentId: parsed.data.departmentId ?? null, limit: parsed.data.limit ?? 8, }; } function canViewAll(request) { return request.employee.roleCodes.some((code) => ["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code), ); } function buildReimbursementWhere(request, query) { const where = { ...(query.dateFrom || query.dateTo ? { reimbursement_date: { ...(query.dateFrom ? { gte: query.dateFrom } : {}), ...(query.dateTo ? { lte: query.dateTo } : {}), }, } : {}), ...(query.departmentId ? { department_id: query.departmentId } : {}), }; if (canViewAll(request)) { return where; } return { ...where, OR: [ { owner_id: request.employee.id }, { submitter_id: request.employee.id }, { owner: { direct_manager_id: request.employee.id } }, ], }; } function moneyString(value) { if (value == null) { return "0.00"; } const numeric = Number(value); if (!Number.isFinite(numeric)) { return "0.00"; } return numeric.toFixed(2); } async function dashboardRoutes(fastify) { fastify.get( "/dashboard/expense-overview", { preHandler: fastify.requirePermission("report.dashboard.view"), }, async (request, reply) => { const query = parseQuery(request.query); const where = buildReimbursementWhere(request, query); const [totalCount, totalAmountAgg, paidCount, paidAmountAgg, reviewingCount] = await Promise.all([ fastify.prisma.reimbursementForm.count({ where }), fastify.prisma.reimbursementForm.aggregate({ where, _sum: { total_amount: true }, }), fastify.prisma.reimbursementForm.count({ where: { ...where, status: "paid" }, }), fastify.prisma.reimbursementForm.aggregate({ where: { ...where, status: "paid" }, _sum: { total_amount: true }, }), fastify.prisma.reimbursementForm.count({ where: { ...where, status: "reviewing" }, }), ]); return sendData(reply, request, { totalCount, totalAmount: moneyString(totalAmountAgg._sum.total_amount), paidCount, paidAmount: moneyString(paidAmountAgg._sum.total_amount), reviewingCount, }); }, ); fastify.get( "/dashboard/trends", { preHandler: fastify.requirePermission("report.dashboard.view"), }, async (request, reply) => { const query = parseQuery(request.query); const where = buildReimbursementWhere(request, query); const groups = await fastify.prisma.reimbursementForm.groupBy({ where, by: ["reimbursement_date"], _sum: { total_amount: true, }, _count: { _all: true, }, orderBy: { reimbursement_date: "asc", }, }); const points = groups.map((item) => ({ date: item.reimbursement_date?.toISOString?.().slice(0, 10) ?? "unknown", amount: moneyString(item._sum.total_amount), count: item._count._all, })); return sendData(reply, request, { points, }); }, ); fastify.get( "/dashboard/top-categories", { preHandler: fastify.requirePermission("report.dashboard.view"), }, async (request, reply) => { const query = parseQuery(request.query); const where = buildReimbursementWhere(request, query); const grouped = await fastify.prisma.reimbursementItem.groupBy({ where: { reimbursement_form: where, }, by: ["expense_type_id"], _sum: { amount: true, }, _count: { _all: true, }, orderBy: { _sum: { amount: "desc", }, }, take: query.limit, }); const typeIds = grouped.map((item) => item.expense_type_id); const expenseTypes = typeIds.length ? await fastify.prisma.expenseType.findMany({ where: { id: { in: typeIds } }, select: { id: true, code: true, name: true }, }) : []; const typeMap = new Map(expenseTypes.map((item) => [item.id, item])); const items = grouped.map((item) => { const expenseType = typeMap.get(item.expense_type_id); return { code: expenseType?.code ?? "unknown", name: expenseType?.name ?? "未知", amount: moneyString(item._sum.amount), count: item._count._all, }; }); return sendData(reply, request, { items, }); }, ); fastify.get( "/dashboard/approval-efficiency", { preHandler: fastify.requirePermission("report.dashboard.view"), }, async (request, reply) => { const query = parseQuery(request.query); const where = buildReimbursementWhere(request, query); const approvedForms = await fastify.prisma.reimbursementForm.findMany({ where: { ...where, status: { in: ["approved", "paid"], }, }, select: { id: true, created_at: true, }, }); const formIdSet = approvedForms.map((item) => item.id); const instances = formIdSet.length ? await fastify.prisma.workflowInstance.findMany({ where: { entity_type: "reimbursement", reimbursement_id: { in: formIdSet }, completed_at: { not: null }, current_status: "approved", }, select: { reimbursement_id: true, completed_at: true, }, }) : []; const completedMap = new Map( instances.map((item) => [item.reimbursement_id, item.completed_at]), ); const durations = approvedForms .map((form) => { const completedAt = completedMap.get(form.id); if (!completedAt) { return null; } return Math.max(0, completedAt.getTime() - form.created_at.getTime()); }) .filter((item) => item != null); const totalMs = durations.reduce((sum, value) => sum + value, 0); const avgMs = durations.length ? Math.round(totalMs / durations.length) : 0; return sendData(reply, request, { approvedCount: durations.length, averageHours: Number((avgMs / 3_600_000).toFixed(2)), }); }, ); } module.exports = dashboardRoutes;