文件
hyb-oa/server/tests/unit/routes/reimbursements.expense-standards.test.js
T

764 行
25 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", "expense.view_own"],
};
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 ({ where } = {}) => (
where?.linked_item_id === null
? (options.unlinkedBoundInvoiceCount ?? 0)
: (options.boundInvoiceCount ?? 1)
)),
},
reimbursementItem: {
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
findMany: vi.fn(async () => (options.reimbursementItems ?? [
{ id: "item-1", amount: reimbursement.total_amount },
]).map((item) => ({
asset_prior_approval_basis: null,
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
invoice_pool_items: [{ id: "invoice-1", total_amount: item.amount }],
...item,
}))),
aggregate: vi.fn(async () => ({
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
})),
},
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" })),
},
featureFlag: {
findUnique: vi.fn(async () => options.invoiceGuardEnabled ? {
id: "88888888-8888-4888-8888-888888888888",
flag_key: "reimbursement_invoice_guard_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "test",
version: 1,
created_at: new Date(),
updated_at: new Date(),
} : null),
},
};
const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: {
findUnique: vi.fn(async () => ({
id: reimbursement.id,
owner_id: reimbursement.owner_id,
submitter_id: reimbursement.submitter_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,
reimbursementInvoiceGuardEnabled: options.invoiceGuardEnabled ?? 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("rejects an admin view-all nonparticipant before creating submission side effects", async () => {
const { app, prisma, tx } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["admin"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(403);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
expect(tx.workflowTask.create).not.toHaveBeenCalled();
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.findUnique).not.toHaveBeenCalled();
await app.close();
});
it("blocks travel reimbursement submit until the structured summary is complete", async () => {
const { app, tx } = await buildApp({ travelItem: true });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
travelSummary: "incomplete",
missingFields: ["travelPurpose", "travelLocation", "travelStartDate", "travelEndDate"],
}));
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("rejects submit when the reimbursement has no items", async () => {
const { app, tx } = await buildApp({ reimbursementItems: [] });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ items: "at_least_one_required" });
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
await app.close();
});
it("rejects submit and identifies items with non-positive amounts", async () => {
const { app, tx } = await buildApp({
reimbursementItems: [
{ id: "item-zero", amount: "0.00" },
{ id: "item-valid", amount: "20.00" },
],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({
items: "positive_amount_required",
itemIds: ["item-zero"],
});
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("allows assets items without an approval basis and continues submission", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({
reimbursementItems: [{
id: "asset-item",
amount: "2200.00",
expense_type: { code: "assets_office_electronics", category: "assets", requires_invoice: true },
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1);
await app.close();
});
it("skips invoice amount checks for invoice-free allowance items when the guard is on", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: true,
boundInvoiceCount: 0,
reimbursementItems: [{
id: "allowance-item",
amount: "180.00",
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
invoice_pool_items: [],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(200);
await app.close();
});
it("allows an invoice-free allowance-only reimbursement when the guard is off", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: false,
boundInvoiceCount: 0,
reimbursementItems: [{
id: "allowance-item",
amount: "180.00",
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
invoice_pool_items: [],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(200);
await app.close();
});
it("keeps the legacy whole-form invoice requirement for mixed or required items when the guard is off", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: false,
boundInvoiceCount: 0,
reimbursementItems: [
{
id: "allowance-item",
amount: "180.00",
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
invoice_pool_items: [],
},
{
id: "required-item",
amount: "100.00",
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
invoice_pool_items: [],
},
],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ invoices: "bound_invoice_required" });
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("blocks required-invoice items without linked invoices when the guard is on", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{ id: "missing-invoice", amount: "180.00", invoice_pool_items: [] }],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
items: "invoice_item_guard_failed",
missingInvoiceItemIds: ["missing-invoice"],
}));
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("blocks bound invoices that have no explicit item link when the guard is on", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
unlinkedBoundInvoiceCount: 1,
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
items: "invoice_item_guard_failed",
unlinkedInvoiceCount: 1,
}));
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("blocks declared amounts above the linked invoice total when the guard is on", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{
id: "amount-over",
amount: "180.00",
invoice_pool_items: [{ id: "invoice-low", total_amount: "120.00" }],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.amountExceededItems).toEqual([{
itemId: "amount-over",
declaredAmount: "180.00",
invoiceTotal: "120.00",
}]);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("recomputes a stale total before selecting the approval rule", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(200);
expect(tx.reimbursementForm.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ total_amount: "320.00" }),
}));
expect(tx.approvalRule.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ min_amount: { lte: "320.00" } }),
}));
await app.close();
});
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("rejects an ordinary nonparticipant before resolving reimbursement precheck", async () => {
const { app } = await buildApp({
actor: {
id: "ordinary-nonparticipant",
department_id: DEPARTMENT_ID,
roleCodes: ["employee"],
permissionCodes: ["expense.submit"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(403);
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
await app.close();
});
it("allows a view-all nonparticipant to read reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["finance"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(200);
expect(resolveEntityPrecheckMock).toHaveBeenCalledTimes(1);
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();
});
});