R55 A1-A6:报销统计仪表盘与批量审批
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户