feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)
这个提交包含在:
@@ -0,0 +1,278 @@
|
||||
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;
|
||||
在新工单中引用
屏蔽一个用户