文件
hyb-oa/server/src/routes/dashboard.js
T

279 行
7.5 KiB
JavaScript

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;