279 行
7.5 KiB
JavaScript
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;
|