R55 A1-A6:报销统计仪表盘与批量审批
这个提交包含在:
@@ -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),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+691
-476
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,226 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
roleCodes: ["manager"],
|
||||
permissionCodes: ["expense.view_all"],
|
||||
};
|
||||
const queryRaw =
|
||||
options.queryRaw ??
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ status: "draft", count: 2, total_amount: "100.00" },
|
||||
{ status: "paid", count: 1, total_amount: "80.00" },
|
||||
])
|
||||
.mockResolvedValueOnce([{ avg_processing_days: 3.5 }]);
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const permissionCodes = employee.permissionCodes ?? [];
|
||||
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
if (!app.hasPermission(actor, permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("config", { appTimezone: "Asia/Shanghai" });
|
||||
app.decorate("prisma", {
|
||||
$queryRaw: queryRaw,
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||
return { app, queryRaw };
|
||||
}
|
||||
|
||||
describe("reimbursements stats routes", () => {
|
||||
it("returns summary with filters and zero-filled status buckets", async () => {
|
||||
const { app, queryRaw } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/reimbursements/stats/summary?dateFrom=2026-01-01&dateTo=2026-03-31&departmentId=${DEPARTMENT_ID}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(queryRaw.mock.calls[0][0].values).toEqual(
|
||||
expect.arrayContaining([
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
new Date("2026-03-31T00:00:00.000Z"),
|
||||
DEPARTMENT_ID,
|
||||
ACTOR_ID,
|
||||
]),
|
||||
);
|
||||
expect(response.json().data).toEqual({
|
||||
totalCount: 3,
|
||||
totalAmount: "180.00",
|
||||
byStatus: {
|
||||
draft: { count: 2, totalAmount: "100.00" },
|
||||
reviewing: { count: 0, totalAmount: "0.00" },
|
||||
approved: { count: 0, totalAmount: "0.00" },
|
||||
paid: { count: 1, totalAmount: "80.00" },
|
||||
rejected: { count: 0, totalAmount: "0.00" },
|
||||
cancelled: { count: 0, totalAmount: "0.00" },
|
||||
},
|
||||
avgProcessingDays: 3.5,
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns empty summary when there is no matching data", async () => {
|
||||
const { app } = await buildApp({
|
||||
queryRaw: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([{ avg_processing_days: null }]),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/reimbursements/stats/summary",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toMatchObject({
|
||||
totalCount: 0,
|
||||
totalAmount: "0.00",
|
||||
avgProcessingDays: null,
|
||||
byStatus: {
|
||||
draft: { count: 0, totalAmount: "0.00" },
|
||||
reviewing: { count: 0, totalAmount: "0.00" },
|
||||
approved: { count: 0, totalAmount: "0.00" },
|
||||
paid: { count: 0, totalAmount: "0.00" },
|
||||
rejected: { count: 0, totalAmount: "0.00" },
|
||||
cancelled: { count: 0, totalAmount: "0.00" },
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns monthly trends with count and paid metrics", async () => {
|
||||
const { app } = await buildApp({
|
||||
queryRaw: vi.fn().mockResolvedValue([
|
||||
{
|
||||
period_start: new Date("2026-02-01T00:00:00.000Z"),
|
||||
count: 4,
|
||||
amount: "220.00",
|
||||
paid_count: 2,
|
||||
paid_amount: "120.00",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/reimbursements/stats/trend?granularity=month",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items).toEqual([
|
||||
{
|
||||
period: "2026-02",
|
||||
count: 4,
|
||||
amount: "220.00",
|
||||
paidCount: 2,
|
||||
paidAmount: "120.00",
|
||||
},
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns top expense types and approval bottlenecks", async () => {
|
||||
const queryRaw = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
expense_type_id: "type-1",
|
||||
expense_type_name: "差旅交通",
|
||||
count: 5,
|
||||
total_amount: "520.00",
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
assignee_id: null,
|
||||
assignee_role: "manager",
|
||||
assignee_name: null,
|
||||
pending_count: 3,
|
||||
oldest_pending_days: 5,
|
||||
},
|
||||
]);
|
||||
const { app } = await buildApp({ queryRaw });
|
||||
|
||||
const topResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/reimbursements/stats/top-expense-types?limit=5",
|
||||
});
|
||||
const bottleneckResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/reimbursements/stats/approval-bottleneck",
|
||||
});
|
||||
|
||||
expect(topResponse.statusCode).toBe(200);
|
||||
expect(topResponse.json().data.items).toEqual([
|
||||
{
|
||||
expenseTypeId: "type-1",
|
||||
expenseTypeName: "差旅交通",
|
||||
count: 5,
|
||||
totalAmount: "520.00",
|
||||
},
|
||||
]);
|
||||
expect(bottleneckResponse.statusCode).toBe(200);
|
||||
expect(bottleneckResponse.json().data.items).toEqual([
|
||||
{
|
||||
assigneeId: null,
|
||||
assigneeName: "主管",
|
||||
pendingCount: 3,
|
||||
oldestPendingDays: 5,
|
||||
},
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects users without expense.view_all permission", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
roleCodes: ["employee"],
|
||||
permissionCodes: ["expense.view_own"],
|
||||
},
|
||||
queryRaw: vi.fn(),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/reimbursements/stats/summary",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const workflowRoutes = require("../../../src/routes/workflow");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
function buildTask(taskId, overrides = {}) {
|
||||
return {
|
||||
id: taskId,
|
||||
instance_id: taskId,
|
||||
step_id: "approval_1_manager",
|
||||
step_name: "经理审批",
|
||||
assignee_id: ACTOR_ID,
|
||||
assignee_role: null,
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comment: null,
|
||||
created_at: new Date("2026-04-08T08:00:00.000Z"),
|
||||
completed_at: null,
|
||||
completed_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkflowInstance(taskId, entityType = "reimbursement") {
|
||||
return {
|
||||
id: taskId,
|
||||
entity_type: entityType,
|
||||
entity_id: taskId,
|
||||
current_step_id: "approval_1_manager",
|
||||
context_data: {},
|
||||
workflow_definition: {
|
||||
definition_json: {
|
||||
steps: [
|
||||
{
|
||||
id: "approval_1_manager",
|
||||
name: "经理审批",
|
||||
assignee_rule: { type: "role", value: "manager" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
roleCodes: ["manager"],
|
||||
permissionCodes: ["expense.approve"],
|
||||
};
|
||||
const state = {
|
||||
tasks: new Map(
|
||||
(options.tasks ?? [
|
||||
buildTask("11111111-1111-4111-8111-111111111111"),
|
||||
buildTask("22222222-2222-4222-8222-222222222222", { status: "completed", outcome: "approved" }),
|
||||
buildTask("33333333-3333-4333-8333-333333333333"),
|
||||
buildTask("44444444-4444-4444-8444-444444444444"),
|
||||
]).map((item) => [item.id, { ...item }]),
|
||||
),
|
||||
instances: new Map(
|
||||
(options.instances ?? [
|
||||
buildWorkflowInstance("11111111-1111-4111-8111-111111111111", "reimbursement"),
|
||||
buildWorkflowInstance("22222222-2222-4222-8222-222222222222", "reimbursement"),
|
||||
buildWorkflowInstance("33333333-3333-4333-8333-333333333333", "leave"),
|
||||
buildWorkflowInstance("44444444-4444-4444-8444-444444444444", "reimbursement"),
|
||||
]).map((item) => [item.id, { ...item }]),
|
||||
),
|
||||
history: [],
|
||||
};
|
||||
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async (_strings, ...values) => {
|
||||
const taskId = String(values[0] ?? "");
|
||||
const task = state.tasks.get(taskId);
|
||||
return task ? [{ ...task }] : [];
|
||||
}),
|
||||
workflowInstance: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
return state.instances.get(String(where.id)) ?? null;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const current = state.instances.get(String(where.id));
|
||||
if (!current) return null;
|
||||
state.instances.set(String(where.id), { ...current, ...data });
|
||||
return state.instances.get(String(where.id));
|
||||
}),
|
||||
},
|
||||
workflowTask: {
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
if (options.updateErrorTaskId && where.id === options.updateErrorTaskId) {
|
||||
throw new Error("update_failed");
|
||||
}
|
||||
const current = state.tasks.get(where.id);
|
||||
if (!current) return null;
|
||||
state.tasks.set(where.id, { ...current, ...data });
|
||||
return state.tasks.get(where.id);
|
||||
}),
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
create: vi.fn(async () => {
|
||||
throw new Error("unexpected_create");
|
||||
}),
|
||||
},
|
||||
workflowHistory: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
state.history.push(data);
|
||||
return { id: `history-${state.history.length}` };
|
||||
}),
|
||||
count: vi.fn(async () => 0),
|
||||
},
|
||||
reimbursementForm: {
|
||||
update: vi.fn(async () => ({})),
|
||||
findUnique: vi.fn(async () => null),
|
||||
},
|
||||
leaveRequest: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
leaveLedger: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
purchaseRequest: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
travelRequest: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
notificationChannel: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
employee: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: "target",
|
||||
status: "active",
|
||||
roles: [{ role_code: "manager" }],
|
||||
})),
|
||||
},
|
||||
notificationDelivery: {
|
||||
create: vi.fn(async () => ({ id: "delivery-1" })),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
};
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
imNotifierEnabled: false,
|
||||
emailEnabled: false,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("assertEntityPermission", () => {});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(workflowRoutes, { prefix: "/api" });
|
||||
return { app, prisma, state, tx };
|
||||
}
|
||||
|
||||
describe("workflow batch routes", () => {
|
||||
it("approves reimbursement tasks and skips already processed ones", async () => {
|
||||
const { app, prisma, state } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/workflow/batch-approve",
|
||||
payload: {
|
||||
taskIds: [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
|
||||
expect(response.json().data).toEqual({
|
||||
results: [
|
||||
{
|
||||
taskId: "11111111-1111-4111-8111-111111111111",
|
||||
outcome: "approved",
|
||||
},
|
||||
{
|
||||
taskId: "22222222-2222-4222-8222-222222222222",
|
||||
outcome: "skipped",
|
||||
reason: "already_processed",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
approved: 1,
|
||||
rejected: 0,
|
||||
skipped: 1,
|
||||
errored: 0,
|
||||
},
|
||||
});
|
||||
expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("completed");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects reimbursement tasks with required shared comment", async () => {
|
||||
const { app, state } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/workflow/batch-reject",
|
||||
payload: {
|
||||
taskIds: ["44444444-4444-4444-8444-444444444444"],
|
||||
comment: "材料不完整,请补充说明后重新提交。",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
results: [
|
||||
{
|
||||
taskId: "44444444-4444-4444-8444-444444444444",
|
||||
outcome: "rejected",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
approved: 0,
|
||||
rejected: 1,
|
||||
skipped: 0,
|
||||
errored: 0,
|
||||
},
|
||||
});
|
||||
expect(state.tasks.get("44444444-4444-4444-8444-444444444444").status).toBe("rejected");
|
||||
|
||||
const invalidResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/workflow/batch-reject",
|
||||
payload: {
|
||||
taskIds: ["44444444-4444-4444-8444-444444444444"],
|
||||
comment: " ",
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalidResponse.statusCode).toBe(400);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not roll back successful tasks when one task fails or has unsupported entity type", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
updateErrorTaskId: "44444444-4444-4444-8444-444444444444",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/workflow/batch-approve",
|
||||
payload: {
|
||||
taskIds: [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
results: [
|
||||
{
|
||||
taskId: "11111111-1111-4111-8111-111111111111",
|
||||
outcome: "approved",
|
||||
},
|
||||
{
|
||||
taskId: "33333333-3333-4333-8333-333333333333",
|
||||
outcome: "error",
|
||||
reason: "VALID_001: 参数校验失败",
|
||||
},
|
||||
{
|
||||
taskId: "44444444-4444-4444-8444-444444444444",
|
||||
outcome: "error",
|
||||
reason: "update_failed",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
approved: 1,
|
||||
rejected: 0,
|
||||
skipped: 0,
|
||||
errored: 2,
|
||||
},
|
||||
});
|
||||
expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("completed");
|
||||
expect(state.tasks.get("44444444-4444-4444-8444-444444444444").status).toBe("pending");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects oversized batch payloads and missing permissions", async () => {
|
||||
const { app } = await buildApp();
|
||||
const oversizedPayload = {
|
||||
taskIds: Array.from({ length: 51 }, (_, index) =>
|
||||
`00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||
),
|
||||
};
|
||||
|
||||
const invalidResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/workflow/batch-approve",
|
||||
payload: oversizedPayload,
|
||||
});
|
||||
expect(invalidResponse.statusCode).toBe(400);
|
||||
|
||||
await app.close();
|
||||
|
||||
const noPermission = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
roleCodes: ["employee"],
|
||||
permissionCodes: ["expense.view_own"],
|
||||
},
|
||||
});
|
||||
|
||||
const forbiddenResponse = await noPermission.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/workflow/batch-approve",
|
||||
payload: {
|
||||
taskIds: ["11111111-1111-4111-8111-111111111111"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(forbiddenResponse.statusCode).toBe(403);
|
||||
|
||||
await noPermission.app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户