1076 行
37 KiB
JavaScript
1076 行
37 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)
|
|
)),
|
|
findMany: vi.fn(async ({ where } = {}) => (
|
|
where?.linked_item_id === null
|
|
? Array.from(
|
|
{ length: options.unlinkedBoundInvoiceCount ?? 0 },
|
|
(_unused, index) => ({ id: `unlinked-invoice-${index + 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,
|
|
description: "合成支出",
|
|
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,
|
|
status: reimbursement.status,
|
|
form_number: reimbursement.form_number,
|
|
total_amount: reimbursement.total_amount,
|
|
items: (options.reimbursementItems ?? []).map((item) => ({
|
|
expense_type: { enabled: true },
|
|
description: "合成支出",
|
|
...item,
|
|
})),
|
|
owner: {
|
|
id: EMPLOYEE_ID,
|
|
name: "申请人",
|
|
},
|
|
})),
|
|
},
|
|
workflowInstance: {
|
|
findFirst: vi.fn(async () => options.existingWorkflowInstance ?? null),
|
|
},
|
|
};
|
|
|
|
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", reasonCode: "reimbursement.items_at_least_one_required" });
|
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
|
await app.close();
|
|
});
|
|
|
|
it.each([null, { code: "office_supplies", enabled: false }])("blocks unclassified or disabled types before financial and approval side effects: %j", async (expenseType) => {
|
|
const { app, tx } = await buildApp({
|
|
reimbursementItems: [
|
|
{ id: "unclassified-item", amount: "20.00", expense_type: expenseType },
|
|
{ id: "classified-item", amount: "30.00" },
|
|
],
|
|
});
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
|
expect(response.statusCode, response.body).toBe(400);
|
|
expect(response.json().error.details).toEqual({
|
|
itemIds: ["unclassified-item"],
|
|
reasonCode: "reimbursement.items_expense_type_required",
|
|
});
|
|
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
|
|
expect(tx.approvalRule.findFirst).not.toHaveBeenCalled();
|
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
|
expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled();
|
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
|
await app.close();
|
|
});
|
|
|
|
it("blocks an unclassified precheck before financial evaluation", async () => {
|
|
const { app } = await buildApp({ reimbursementItems: [{ id: "unclassified-item", expense_type: null }] });
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck` });
|
|
expect(response.statusCode, response.body).toBe(400);
|
|
expect(response.json().error.details).toEqual({
|
|
itemIds: ["unclassified-item"], reasonCode: "reimbursement.items_expense_type_required",
|
|
});
|
|
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
|
|
await app.close();
|
|
});
|
|
|
|
it.each(["submit", "precheck"])("requires a real description for an auto-created item before %s", async (action) => {
|
|
const { app, tx } = await buildApp({ reimbursementItems: [
|
|
{ id: "no-description", amount: "100.00", description: " " },
|
|
] });
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/${action}` });
|
|
expect(response.statusCode, response.body).toBe(400);
|
|
expect(response.json().error.details).toEqual({ itemIds: ["no-description"], reasonCode: "reimbursement.items_description_required" });
|
|
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
|
|
expect(tx.workflowInstance.create).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"],
|
|
reasonCode: "reimbursement.items_positive_amount_required",
|
|
});
|
|
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", reasonCode: "reimbursement.invoices_bound_invoice_required" });
|
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
|
await app.close();
|
|
});
|
|
|
|
it("keeps the finance manual-review success marker when the B2 guard is off", async () => {
|
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
|
const { app } = await buildApp({
|
|
invoiceGuardEnabled: false,
|
|
boundInvoiceCount: 1,
|
|
reimbursementItems: [{
|
|
id: "manual-review-guard-off",
|
|
amount: "180.00",
|
|
invoice_pool_items: [{
|
|
id: "off-invoice-guard-off",
|
|
total_amount: null,
|
|
ocr_attempts: 0,
|
|
ocr_last_requested_at: null,
|
|
ocr_last_error: null,
|
|
ocr_completed_at: null,
|
|
}],
|
|
}],
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
|
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
expect(response.json().data).toEqual(expect.objectContaining({
|
|
invoiceAmountManualReviewRequired: true,
|
|
manualReviewItemCount: 1,
|
|
}));
|
|
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("identifies unlinked invoices without exposing only a count", async () => {
|
|
const { app } = await buildApp({
|
|
invoiceGuardEnabled: true,
|
|
unlinkedBoundInvoiceCount: 2,
|
|
});
|
|
|
|
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({
|
|
unlinkedInvoiceCount: 2,
|
|
unlinkedInvoiceIds: ["unlinked-invoice-1", "unlinked-invoice-2"],
|
|
}));
|
|
await app.close();
|
|
});
|
|
|
|
it("blocks a linked invoice whose OCR ran without producing an amount", async () => {
|
|
const { app, tx } = await buildApp({
|
|
invoiceGuardEnabled: true,
|
|
reimbursementItems: [{
|
|
id: "ocr-failed-item",
|
|
amount: "180.00",
|
|
invoice_pool_items: [{
|
|
id: "ocr-failed-invoice",
|
|
total_amount: null,
|
|
ocr_attempts: 1,
|
|
ocr_last_requested_at: new Date("2026-08-30T10:00:00.000Z"),
|
|
ocr_last_error: "amount_missing",
|
|
ocr_completed_at: new Date("2026-08-30T10:00:01.000Z"),
|
|
}],
|
|
}],
|
|
});
|
|
|
|
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({
|
|
missingInvoiceItemIds: [],
|
|
amountUnavailableItems: [{
|
|
itemId: "ocr-failed-item",
|
|
invoiceIds: ["ocr-failed-invoice"],
|
|
}],
|
|
}));
|
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
|
await app.close();
|
|
});
|
|
|
|
it("allows a linked PDF with no OCR execution evidence and marks finance manual review", async () => {
|
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
|
const { app } = await buildApp({
|
|
invoiceGuardEnabled: true,
|
|
reimbursementItems: [{
|
|
id: "manual-review-item",
|
|
amount: "180.00",
|
|
invoice_pool_items: [{
|
|
id: "off-invoice",
|
|
total_amount: null,
|
|
ocr_attempts: 0,
|
|
ocr_last_requested_at: null,
|
|
ocr_last_error: null,
|
|
ocr_completed_at: null,
|
|
}],
|
|
}],
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
|
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
expect(response.json().data).toEqual(expect.objectContaining({
|
|
invoiceAmountManualReviewRequired: true,
|
|
manualReviewItemCount: 1,
|
|
}));
|
|
await app.close();
|
|
});
|
|
|
|
it("does not compare a declared amount against only the known part of mixed evidence", async () => {
|
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
|
const { app } = await buildApp({
|
|
invoiceGuardEnabled: true,
|
|
reimbursementItems: [{
|
|
id: "mixed-evidence-item",
|
|
amount: "1000.00",
|
|
invoice_pool_items: [
|
|
{ id: "known-invoice", total_amount: "600.00" },
|
|
{
|
|
id: "off-invoice",
|
|
total_amount: null,
|
|
ocr_attempts: 0,
|
|
ocr_last_requested_at: null,
|
|
ocr_last_error: null,
|
|
ocr_completed_at: null,
|
|
},
|
|
],
|
|
}],
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
|
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
expect(response.json().data).toEqual(expect.objectContaining({
|
|
invoiceAmountManualReviewRequired: true,
|
|
manualReviewItemCount: 1,
|
|
}));
|
|
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("allows five required items when each has one verified bound invoice", async () => {
|
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
|
const reimbursementItems = Array.from({ length: 5 }, (_unused, index) => ({
|
|
id: `verified-item-${index + 1}`,
|
|
amount: "100.00",
|
|
invoice_pool_items: [{
|
|
id: `verified-invoice-${index + 1}`,
|
|
total_amount: "100.00",
|
|
}],
|
|
}));
|
|
const { app, tx } = await buildApp({
|
|
invoiceGuardEnabled: true,
|
|
reimbursementItems,
|
|
authoritativeTotal: "500.00",
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
|
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
expect(response.json().data).toEqual(expect.objectContaining({
|
|
invoiceAmountManualReviewRequired: false,
|
|
manualReviewItemCount: 0,
|
|
}));
|
|
expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1);
|
|
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 a submitted 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"],
|
|
},
|
|
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: "approved",
|
|
form_number: "BX-20260408-001",
|
|
total_amount: "180.00",
|
|
},
|
|
});
|
|
|
|
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("rejects a view-all nonparticipant precheck for a never-submitted draft", async () => {
|
|
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(403);
|
|
expect(response.json().error).toMatchObject({ code: "PERM_001", message: "无权限" });
|
|
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
|
|
await app.close();
|
|
});
|
|
|
|
it("allows a view-all nonparticipant precheck for a returned draft", async () => {
|
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
|
const { app } = await buildApp({
|
|
actor: {
|
|
id: "finance-viewer",
|
|
department_id: DEPARTMENT_ID,
|
|
roleCodes: ["finance"],
|
|
permissionCodes: ["expense.submit", "expense.view_all"],
|
|
},
|
|
existingWorkflowInstance: { id: "returned-workflow" },
|
|
});
|
|
|
|
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",
|
|
}),
|
|
]);
|
|
expect(state.workflowInstanceCreatePayload?.context_data).toEqual(expect.objectContaining({
|
|
budgetCheck: null,
|
|
budgetCheckRecorded: true,
|
|
}));
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("persists the calculated budget check in the workflow submission snapshot", async () => {
|
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
|
resolveApplicableBudgetPoolByContextMock.mockResolvedValue({
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
code: "BUDGET-2026",
|
|
allocated_amount: "1000.00",
|
|
used_amount: "700.00",
|
|
warning_threshold_percent: 80,
|
|
});
|
|
|
|
const { app, state } = await buildApp();
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(state.workflowInstanceCreatePayload?.context_data?.budgetCheck).toEqual({
|
|
budgetId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
budgetCode: "BUDGET-2026",
|
|
controlMode: "warn",
|
|
usagePercent: 88,
|
|
exceededThreshold: true,
|
|
exceededBudget: false,
|
|
});
|
|
expect(state.workflowInstanceCreatePayload?.context_data?.budgetCheckRecorded).toBe(true);
|
|
|
|
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();
|
|
});
|
|
});
|