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),
})),
});
},
);
文件差异内容过多而无法显示 加载差异
@@ -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();
});
});