R55 A1-A6:报销统计仪表盘与批量审批

这个提交包含在:
2026-04-08 09:53:47 +08:00
父节点 3e694fa60c
当前提交 40c3b7632a
共修改 12 个文件,包含 2668 行新增和 544 行删除
+489 -2
查看文件
@@ -1,9 +1,11 @@
const { Prisma } = require("@prisma/client");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
const {
createNotificationsWithRecords,
resolveRecipientsForAssignee,
@@ -60,6 +62,30 @@ const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
description: z.string().trim().min(1).max(500),
});
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const REIMBURSEMENT_STATS_QUERY_SCHEMA = z.object({
dateFrom: DATE_ONLY_QUERY_SCHEMA.optional(),
dateTo: DATE_ONLY_QUERY_SCHEMA.optional(),
departmentId: z.string().uuid().optional(),
});
const REIMBURSEMENT_TREND_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
granularity: z.enum(["month", "week"]).optional(),
});
const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
limit: z.coerce.number().int().min(1).max(50).optional(),
});
const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
const ROLE_LABELS = {
manager: "主管",
ceo: "CEO",
finance: "财务",
finance_mgr: "财务经理",
administration: "行政",
admin: "管理员",
hr: "HR",
};
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
@@ -191,6 +217,206 @@ function parseReimbursementUpdateBody(body) {
return parsed.data;
}
function parseQuery(schema, query) {
const parsed = schema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseStatsQuery(query) {
const parsed = parseQuery(REIMBURSEMENT_STATS_QUERY_SCHEMA, query);
const dateFrom = parsed.dateFrom ? parseDateOnlyString(parsed.dateFrom) : null;
const dateTo = parsed.dateTo ? parseDateOnlyString(parsed.dateTo) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return {
dateFrom,
dateTo,
dateFromRaw: parsed.dateFrom ?? null,
dateToRaw: parsed.dateTo ?? null,
departmentId: parsed.departmentId ?? null,
};
}
function parseTrendQuery(query) {
const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query);
const base = parseStatsQuery(parsed);
return {
...base,
granularity: parsed.granularity ?? "month",
};
}
function parseTopExpenseTypesQuery(query) {
const parsed = parseQuery(REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA, query);
const base = parseStatsQuery(parsed);
return {
...base,
limit: parsed.limit ?? 10,
};
}
function sqlIdentifier(alias, column) {
return Prisma.raw(`"${alias}"."${column}"`);
}
function buildReimbursementStatsConditions({
request,
dateFrom,
dateTo,
departmentId,
formAlias = "rf",
ownerAlias = "owner_employee",
}) {
const conditions = [Prisma.sql`1 = 1`];
if (dateFrom) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`);
}
if (dateTo) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} <= ${dateTo}`);
}
if (departmentId) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "department_id")} = ${departmentId}`);
}
if (!isGlobalViewRole(request.employee.roleCodes ?? [])) {
conditions.push(
Prisma.sql`(
${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
OR ${sqlIdentifier(formAlias, "submitter_id")} = ${request.employee.id}
OR ${sqlIdentifier(ownerAlias, "direct_manager_id")} = ${request.employee.id}
)`,
);
}
return conditions;
}
function toNumericValue(value) {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric) ? numeric : 0;
}
function toNullableNumber(value) {
if (value == null) {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function toFixedMoneyString(value) {
return toNumericValue(value).toFixed(2);
}
function formatTrendPeriod(value, granularity) {
if (!value) {
return null;
}
const dateValue = value instanceof Date ? value : new Date(value);
if (Number.isNaN(dateValue.getTime())) {
return String(value);
}
return granularity === "month"
? dateValue.toISOString().slice(0, 7)
: dateValue.toISOString().slice(0, 10);
}
function formatRoleLabel(roleCode) {
if (!roleCode) {
return "未分配";
}
return ROLE_LABELS[roleCode] ?? roleCode;
}
function buildAllowedActionsForPendingWorkflow(request, entityType) {
const approvePermission = resolveEntityPermission(entityType, "approve");
const canApprove =
approvePermission && hasPermission(request.employee.permissionCodes ?? [], approvePermission);
if (!canApprove) {
return [];
}
const actions = ["approve", "reject", "return"];
if (hasPermission(request.employee.permissionCodes ?? [], "expense.transfer")) {
actions.push("transfer");
}
return actions;
}
function isPendingWorkflowTaskActionable(request, task, delegatedPrincipalIds) {
if (!task || task.status !== "pending") {
return false;
}
const roleCodes = request.employee.roleCodes ?? [];
if (!task.assignee_id && !task.assignee_role) {
return true;
}
if (task.assignee_id && task.assignee_id === request.employee.id) {
return true;
}
if (task.assignee_role && roleCodes.includes(task.assignee_role)) {
return true;
}
return Boolean(task.assignee_id && delegatedPrincipalIds.includes(task.assignee_id));
}
function buildPendingWorkflowSummary(item, request, delegatedPrincipalIds) {
const pendingInstance = item.workflow_instances?.[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
if (!pendingInstance || !pendingTask) {
return null;
}
if (!isPendingWorkflowTaskActionable(request, pendingTask, delegatedPrincipalIds)) {
return null;
}
const allowedActions = buildAllowedActionsForPendingWorkflow(request, "reimbursement");
if (!allowedActions.length) {
return null;
}
return {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
allowedActions,
task: {
id: pendingTask.id,
stepName: pendingTask.step_name,
assigneeId: pendingTask.assignee_id,
assigneeRole: pendingTask.assignee_role,
},
};
}
function parseItemBody(body) {
const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
@@ -312,6 +538,7 @@ function mapReimbursementSummary(item) {
name: item.project.name,
}
: null,
pendingWorkflow: item.pending_workflow ?? null,
};
}
@@ -815,6 +1042,10 @@ async function reimbursementRoutes(fastify) {
}
const skip = (query.page - 1) * query.pageSize;
const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
const where = {
...(query.status ? { status: query.status } : {}),
...(scope === "all"
@@ -849,6 +1080,26 @@ async function reimbursementRoutes(fastify) {
submitter: {
select: { id: true, employee_no: true, name: true },
},
workflow_instances: {
where: { current_status: "pending" },
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
workflow_tasks: {
where: { status: "pending" },
take: 1,
select: {
id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
},
},
},
},
},
}),
fastify.prisma.reimbursementForm.count({ where }),
@@ -859,7 +1110,243 @@ async function reimbursementRoutes(fastify) {
pageSize: query.pageSize,
total,
scope,
items: items.map(mapReimbursementSummary),
items: items.map((item) =>
mapReimbursementSummary({
...item,
pending_workflow: buildPendingWorkflowSummary(item, request, delegatedPrincipalIds),
}),
),
});
},
);
fastify.get(
"/reimbursements/stats/summary",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats summary" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseStatsQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const [statusRows, avgRow] = await Promise.all([
fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
rf.status::text AS status,
COUNT(*)::int AS count,
COALESCE(SUM(rf.total_amount), 0)::text AS total_amount
FROM "reimbursement_forms" rf
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
WHERE ${Prisma.join(conditions, " AND ")}
GROUP BY rf.status
`,
),
fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
ROUND(AVG(EXTRACT(EPOCH FROM (rf.paid_at - submit_info.submitted_at)) / 86400.0)::numeric, 2)::float8
AS avg_processing_days
FROM "reimbursement_forms" rf
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
JOIN (
SELECT reimbursement_id, MAX(created_at) AS submitted_at
FROM "workflow_instances"
WHERE entity_type = 'reimbursement' AND reimbursement_id IS NOT NULL
GROUP BY reimbursement_id
) submit_info ON submit_info.reimbursement_id = rf.id
WHERE ${Prisma.join(
[
...conditions,
Prisma.sql`rf.status = 'paid'`,
Prisma.sql`rf.paid_at IS NOT NULL`,
],
" AND ",
)}
`,
),
]);
const byStatus = Object.fromEntries(
REIMBURSEMENT_STATUS_VALUES.map((status) => [
status,
{
count: 0,
totalAmount: toFixedMoneyString(0),
},
]),
);
for (const row of statusRows) {
byStatus[row.status] = {
count: Number(row.count ?? 0),
totalAmount: toFixedMoneyString(row.total_amount ?? 0),
};
}
const totalCount = REIMBURSEMENT_STATUS_VALUES.reduce(
(sum, status) => sum + Number(byStatus[status]?.count ?? 0),
0,
);
const totalAmountNumber = REIMBURSEMENT_STATUS_VALUES.reduce(
(sum, status) => sum + toNumericValue(byStatus[status]?.totalAmount),
0,
);
return sendData(reply, request, {
totalCount,
totalAmount: toFixedMoneyString(totalAmountNumber),
byStatus,
avgProcessingDays: toNullableNumber(avgRow?.[0]?.avg_processing_days),
});
},
);
fastify.get(
"/reimbursements/stats/trend",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats trend" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseTrendQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const periodExpression =
query.granularity === "week"
? Prisma.sql`date_trunc('week', rf.reimbursement_date)`
: Prisma.sql`date_trunc('month', rf.reimbursement_date)`;
const rows = await fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
${periodExpression} AS period_start,
COUNT(*)::int AS count,
COALESCE(SUM(rf.total_amount), 0)::text AS amount,
COUNT(*) FILTER (WHERE rf.status = 'paid')::int AS paid_count,
COALESCE(SUM(rf.total_amount) FILTER (WHERE rf.status = 'paid'), 0)::text AS paid_amount
FROM "reimbursement_forms" rf
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
WHERE ${Prisma.join(conditions, " AND ")}
GROUP BY period_start
ORDER BY period_start ASC
`,
);
return sendData(reply, request, {
items: rows.map((row) => ({
period: formatTrendPeriod(row.period_start, query.granularity),
count: Number(row.count ?? 0),
amount: toFixedMoneyString(row.amount ?? 0),
paidCount: Number(row.paid_count ?? 0),
paidAmount: toFixedMoneyString(row.paid_amount ?? 0),
})),
});
},
);
fastify.get(
"/reimbursements/stats/top-expense-types",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement top expense types" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseTopExpenseTypesQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const rows = await fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
et.id::text AS expense_type_id,
et.name::text AS expense_type_name,
COUNT(ri.id)::int AS count,
COALESCE(SUM(ri.amount), 0)::text AS total_amount
FROM "reimbursement_items" ri
INNER JOIN "reimbursement_forms" rf ON rf.id = ri.reimbursement_id
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
INNER JOIN "expense_types" et ON et.id = ri.expense_type_id
WHERE ${Prisma.join(conditions, " AND ")}
GROUP BY et.id, et.name
ORDER BY SUM(ri.amount) DESC, COUNT(ri.id) DESC, et.name ASC
LIMIT ${query.limit}
`,
);
return sendData(reply, request, {
items: rows.map((row) => ({
expenseTypeId: row.expense_type_id,
expenseTypeName: row.expense_type_name,
count: Number(row.count ?? 0),
totalAmount: toFixedMoneyString(row.total_amount ?? 0),
})),
});
},
);
fastify.get(
"/reimbursements/stats/approval-bottleneck",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement approval bottleneck" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseStatsQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const rows = await fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
wt.assignee_id::text AS assignee_id,
wt.assignee_role::text AS assignee_role,
approver.name::text AS assignee_name,
COUNT(*)::int AS pending_count,
FLOOR(MAX(EXTRACT(EPOCH FROM (NOW() - wt.created_at)) / 86400.0))::int AS oldest_pending_days
FROM "workflow_tasks" wt
INNER JOIN "workflow_instances" wi ON wi.id = wt.instance_id AND wi.entity_type = 'reimbursement'
INNER JOIN "reimbursement_forms" rf ON rf.id = wi.reimbursement_id
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
LEFT JOIN "employees" approver ON approver.id = wt.assignee_id
WHERE ${Prisma.join(
[
...conditions,
Prisma.sql`wt.status = 'pending'`,
Prisma.sql`rf.status = 'reviewing'`,
],
" AND ",
)}
GROUP BY wt.assignee_id, wt.assignee_role, approver.name
ORDER BY pending_count DESC, oldest_pending_days DESC, approver.name ASC NULLS LAST, wt.assignee_role ASC NULLS LAST
`,
);
return sendData(reply, request, {
items: rows.map((row) => ({
assigneeId: row.assignee_id,
assigneeName: row.assignee_name ?? formatRoleLabel(row.assignee_role),
pendingCount: Number(row.pending_count ?? 0),
oldestPendingDays: Number(row.oldest_pending_days ?? 0),
})),
});
},
);
文件差异内容过多而无法显示 加载差异