文件
hyb-oa/server/tests/unit/routes/reimbursements.expense-standards.test.js
T
wangsli 35948729cf
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
2026-07-28 13:42:48 +08:00

475 行
14 KiB
JavaScript

const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const budgetPool = require("../../../src/services/budgetPool");
const feeControl = require("../../../src/services/feeControl");
const notifications = require("../../../src/services/notifications");
const resolveApplicableBudgetPoolByContextMock = vi.fn();
const resolveEntityPrecheckMock = vi.fn();
const createRiskFlagsFromPrecheckMock = vi.fn();
const resolveRecipientsForAssigneeMock = vi.fn();
const createNotificationsWithRecordsMock = vi.fn(async () => []);
const originalResolveApplicableBudgetPoolByContext = budgetPool.resolveApplicableBudgetPoolByContext;
const originalResolveEntityPrecheck = feeControl.resolveEntityPrecheck;
const originalCreateRiskFlagsFromPrecheck = feeControl.createRiskFlagsFromPrecheck;
const originalResolveRecipientsForAssignee = notifications.resolveRecipientsForAssignee;
const originalCreateNotificationsWithRecords = notifications.createNotificationsWithRecords;
budgetPool.resolveApplicableBudgetPoolByContext = resolveApplicableBudgetPoolByContextMock;
feeControl.resolveEntityPrecheck = resolveEntityPrecheckMock;
feeControl.createRiskFlagsFromPrecheck = createRiskFlagsFromPrecheckMock;
notifications.resolveRecipientsForAssignee = resolveRecipientsForAssigneeMock;
notifications.createNotificationsWithRecords = createNotificationsWithRecordsMock;
delete require.cache[require.resolve("../../../src/routes/reimbursements")];
const reimbursementRoutes = require("../../../src/routes/reimbursements");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333";
const FINANCE_MGR_ID = "44444444-4444-4444-8444-444444444444";
function buildPrecheck(overrides = {}) {
return {
entityType: "reimbursement",
entityId: REIMBURSEMENT_ID,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["可直接提交"],
...overrides,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
roleCodes: [],
permissionCodes: ["expense.submit"],
};
const reimbursement = options.reimbursement ?? {
id: REIMBURSEMENT_ID,
owner_id: EMPLOYEE_ID,
submitter_id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
reimbursement_date: new Date("2026-04-08T00:00:00.000Z"),
status: "draft",
form_number: "BX-20260408-001",
total_amount: "180.00",
};
const approvalRule = options.approvalRule ?? {
id: "55555555-5555-4555-8555-555555555555",
level1_role: "finance",
level2_role: null,
level3_role: null,
owner_self_fallback_role: null,
priority: 1,
};
const ownerRoles = options.ownerRoles ?? ["employee"];
const ownerManagerId = Object.prototype.hasOwnProperty.call(options, "ownerManagerId")
? options.ownerManagerId
: null;
const fallbackHasHolder = options.fallbackHasHolder ?? true;
const state = {
workflowInstanceCreatePayload: null,
workflowTaskCreatePayload: null,
};
const tx = {
$queryRaw: vi.fn(async () => [reimbursement]),
workflowInstance: {
findFirst: vi.fn(async () => null),
create: vi.fn(async ({ data, select }) => {
state.workflowInstanceCreatePayload = data;
return {
id: "44444444-4444-4444-8444-444444444444",
current_step_id: data.current_step_id,
...(select?.current_step_id ? {} : {}),
};
}),
},
invoicePool: {
count: vi.fn(async () => 1),
},
budgetLedgerEntry: {
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
},
budgetPool: {
update: vi.fn(async () => ({ id: "budget-pool-1" })),
},
approvalRule: {
findFirst: vi.fn(async () => approvalRule),
},
employee: {
findUnique: vi.fn(async ({ where }) => {
if (where.id === reimbursement.owner_id) {
return {
id: reimbursement.owner_id,
direct_manager_id: ownerManagerId,
roles: ownerRoles.map((role_code) => ({ role_code })),
};
}
if (where.id === ownerManagerId) {
return {
id: ownerManagerId,
direct_manager_id: null,
roles: [{ role_code: "manager" }],
};
}
return null;
}),
},
employeeRole: {
findFirst: vi.fn(async () => (
fallbackHasHolder ? { employee_id: FINANCE_MGR_ID } : null
)),
},
workflowDefinition: {
findFirst: vi.fn(async () => ({
id: "66666666-6666-4666-8666-666666666666",
})),
},
reimbursementForm: {
update: vi.fn(async () => ({
id: reimbursement.id,
})),
},
workflowTask: {
create: vi.fn(async ({ data }) => {
state.workflowTaskCreatePayload = data;
return {
id: "77777777-7777-4777-8777-777777777777",
assignee_id: data.assignee_id,
assignee_role: data.assignee_role,
};
}),
},
workflowHistory: {
create: vi.fn(async () => ({ id: "history-1" })),
},
auditLog: {
create: vi.fn(async () => ({ id: "audit-1" })),
},
};
const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: {
findUnique: vi.fn(async () => ({
id: reimbursement.id,
form_number: reimbursement.form_number,
total_amount: reimbursement.total_amount,
owner: {
id: EMPLOYEE_ID,
name: "申请人",
},
})),
},
};
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
budgetControlMode: "warn",
imNotifierEnabled: false,
emailEnabled: false,
});
app.decorate("hasPermission", (employee, permissionCode) => {
const permissionCodes = employee?.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
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.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, prisma, tx, state };
}
describe("reimbursements routes expense standard integration", () => {
afterAll(() => {
budgetPool.resolveApplicableBudgetPoolByContext = originalResolveApplicableBudgetPoolByContext;
feeControl.resolveEntityPrecheck = originalResolveEntityPrecheck;
feeControl.createRiskFlagsFromPrecheck = originalCreateRiskFlagsFromPrecheck;
notifications.resolveRecipientsForAssignee = originalResolveRecipientsForAssignee;
notifications.createNotificationsWithRecords = originalCreateNotificationsWithRecords;
});
beforeEach(() => {
vi.clearAllMocks();
resolveApplicableBudgetPoolByContextMock.mockResolvedValue(null);
createRiskFlagsFromPrecheckMock.mockResolvedValue([]);
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
});
it("returns standardChecks from reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
decision: "warn",
standardChecks: [
{
itemId: "item-1",
expenseTypeId: "88888888-8888-4888-8888-888888888888",
expenseTypeName: "交通费",
amount: "180.00",
standardId: "99999999-9999-4999-8999-999999999999",
standardName: "交通费上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "warn",
scopeType: "global",
scopeKey: null,
exceeded: true,
exceededAmount: "60.00",
},
],
}),
);
const { app } = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data.standardChecks).toEqual([
expect.objectContaining({
itemId: "item-1",
controlMode: "warn",
exceededAmount: "60.00",
}),
]);
expect(resolveEntityPrecheckMock).toHaveBeenCalledWith(
expect.objectContaining({
entityType: "reimbursement",
entityId: REIMBURSEMENT_ID,
actorDepartmentId: DEPARTMENT_ID,
}),
);
await app.close();
});
it("blocks submit with 400 and standardViolations when block standard is exceeded", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
decision: "block",
blockingReasons: [
{
code: "expense_standard_blocked",
title: "费用标准超限",
detail: "交通费超出上限",
},
],
standardChecks: [
{
itemId: "item-block",
expenseTypeId: "88888888-8888-4888-8888-888888888888",
expenseTypeName: "交通费",
amount: "260.00",
standardId: "99999999-9999-4999-8999-999999999999",
standardName: "交通费阻断上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "block",
scopeType: "global",
scopeKey: null,
exceeded: true,
exceededAmount: "140.00",
},
],
}),
);
const { app, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toEqual(
expect.objectContaining({
code: "VALID_001",
details: expect.objectContaining({
standardViolations: [
expect.objectContaining({
itemId: "item-block",
controlMode: "block",
}),
],
}),
}),
);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("allows warn submit and persists standardViolations into workflow context", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
decision: "warn",
warnings: [
{
code: "expense_standard_warning",
title: "费用标准预警",
detail: "交通费超出上限",
},
],
standardChecks: [
{
itemId: "item-warn",
expenseTypeId: "88888888-8888-4888-8888-888888888888",
expenseTypeName: "交通费",
amount: "180.00",
standardId: "99999999-9999-4999-8999-999999999999",
standardName: "交通费预警上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "warn",
scopeType: "department",
scopeKey: DEPARTMENT_ID,
exceeded: true,
exceededAmount: "60.00",
},
],
}),
);
const { app, state } = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
reimbursementId: REIMBURSEMENT_ID,
status: "reviewing",
precheckDecision: "warn",
standardWarnings: [
expect.objectContaining({
itemId: "item-warn",
controlMode: "warn",
}),
],
}),
);
expect(state.workflowInstanceCreatePayload?.context_data?.standardViolations).toEqual([
expect.objectContaining({
itemId: "item-warn",
exceededAmount: "60.00",
}),
]);
await app.close();
});
it("uses one finance fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, state } = await buildApp({
ownerRoles: ["ceo"],
ownerManagerId: null,
approvalRule: {
id: "55555555-5555-4555-8555-555555555555",
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
priority: 1,
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(200);
expect(state.workflowTaskCreatePayload).toEqual(
expect.objectContaining({
assignee_id: null,
assignee_role: "finance",
}),
);
expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback");
expect(
state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map(
(step) => step.assignee_rule,
),
).toEqual([{ type: "role", value: "finance" }]);
await app.close();
});
it("blocks CEO owner submit when owner fallback has no active non-self holder", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({
ownerRoles: ["ceo"],
ownerManagerId: null,
fallbackHasHolder: false,
approvalRule: {
id: "55555555-5555-4555-8555-555555555555",
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
priority: 1,
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual(
expect.objectContaining({
workflow: "no_actionable_approver",
reason: "owner_self_fallback_role_unavailable",
fallbackRole: "finance",
}),
);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
});