542 行
17 KiB
JavaScript
542 行
17 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),
|
|
},
|
|
reimbursementItem: {
|
|
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
|
findMany: vi.fn(async () => options.reimbursementItems ?? [
|
|
{ id: "item-1", amount: reimbursement.total_amount },
|
|
]),
|
|
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" })),
|
|
},
|
|
};
|
|
|
|
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("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("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("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();
|
|
});
|
|
});
|