实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
@@ -0,0 +1,124 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const expenseTypesRoutes = require("../../../src/routes/expenseTypes");
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
function buildRecord(overrides = {}) {
return {
id: EXPENSE_TYPE_ID,
code: "travel_meal",
name: "伙食补助费",
description: "按公司标准填写补助算式",
category: "travel",
sort_order: 10,
account_code_default: null,
requires_invoice: false,
is_system_default: true,
enabled: true,
created_at: new Date("2026-08-29T00:00:00.000Z"),
updated_at: new Date("2026-08-29T00:00:00.000Z"),
...overrides,
};
}
async function buildApp() {
const app = Fastify();
let record = buildRecord();
const auditRows = [];
const tx = {
expenseType: {
create: vi.fn(async ({ data }) => {
record = buildRecord({ ...data, id: EXPENSE_TYPE_ID, is_system_default: false });
return record;
}),
findUnique: vi.fn(async () => record),
update: vi.fn(async ({ data }) => {
record = { ...record, ...data };
return record;
}),
},
auditLog: {
create: vi.fn(async ({ data }) => {
auditRows.push(data);
return { id: `audit-${auditRows.length}` };
}),
},
};
const prisma = {
expenseType: {
findMany: vi.fn(async () => [record]),
},
$transaction: vi.fn(async (callback) => callback(tx)),
};
app.decorate("prisma", prisma);
app.decorate("authenticate", async (request) => {
request.employee = { id: ACTOR_ID, permissionCodes: ["system.settings"] };
});
app.decorate("requirePermission", () => async (request) => {
request.employee = { id: ACTOR_ID, permissionCodes: ["system.settings"] };
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details ?? null },
});
}
return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001" } });
});
await app.register(expenseTypesRoutes, { prefix: "/api" });
return { app, prisma, tx, auditRows, getRecord: () => record };
}
describe("expense type invoice requirement contract", () => {
it("maps requiresInvoice in list responses", async () => {
const { app, prisma } = await buildApp();
const response = await app.inject({ method: "GET", url: "/api/expense-types?enabled=true" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items).toEqual([
expect.objectContaining({ code: "travel_meal", requiresInvoice: false }),
]);
expect(prisma.expenseType.findMany).toHaveBeenCalledWith(expect.objectContaining({
select: expect.objectContaining({ requires_invoice: true }),
}));
await app.close();
});
it("persists and audits requiresInvoice on create and update", async () => {
const { app, tx, auditRows, getRecord } = await buildApp();
const createResponse = await app.inject({
method: "POST",
url: "/api/expense-types",
payload: {
code: "travel_meal",
name: "伙食补助费",
category: "travel",
sortOrder: 10,
requiresInvoice: false,
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json().data.requiresInvoice).toBe(false);
expect(tx.expenseType.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ requires_invoice: false }),
}));
expect(auditRows[0].extra).toEqual(expect.objectContaining({ requiresInvoice: false }));
const updateResponse = await app.inject({
method: "PATCH",
url: `/api/expense-types/${EXPENSE_TYPE_ID}`,
payload: { requiresInvoice: true },
});
expect(updateResponse.statusCode).toBe(200);
expect(updateResponse.json().data.requiresInvoice).toBe(true);
expect(getRecord().requires_invoice).toBe(true);
expect(auditRows[1].extra.changes.requiresInvoice).toEqual({ before: false, after: true });
await app.close();
});
});
@@ -80,6 +80,19 @@ function buildPrisma({
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
},
featureFlag: {
findUnique: vi.fn().mockResolvedValue({
id: "99999999-9999-4999-8999-999999999999",
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(),
}),
},
$queryRaw: buildQueryRawHandler(state),
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
};
@@ -87,7 +100,7 @@ function buildPrisma({
return { prisma, state };
}
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1" }) {
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1", invoiceGuardEnabled = false }) {
const app = Fastify();
app.decorate("prisma", prisma);
@@ -96,6 +109,7 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
appOrigin: "http://localhost:5173",
maxUploadSizeBytes: 10 * 1024 * 1024,
invoiceArchiveUnusedDays: 30,
reimbursementInvoiceGuardEnabled: invoiceGuardEnabled,
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
@@ -133,6 +147,47 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
}
describe("invoicePool travel-order bind routes", () => {
it("requires an explicit reimbursement item while the invoice guard is enabled", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
invoice: {
id: "11111111-1111-4111-8111-111111111111",
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: null,
total_amount: "1683.00",
},
reimbursement: {
id: reimbursementId,
owner_id: "actor-1",
submitter_id: "actor-1",
status: "draft",
reimbursement_date: new Date("2026-05-08T00:00:00.000Z"),
},
});
const app = await buildApp({
prisma,
permissionCodes: ["invoice.upload"],
invoiceGuardEnabled: true,
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind",
headers: { authorization: "Bearer test" },
payload: { reimbursementId },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({
linkedItemId: "required_when_invoice_guard_enabled",
});
expect(state.invoice.status).toBe("available");
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
await app.close();
});
it("rejects a view-all nonparticipant before binding a reimbursement invoice", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
@@ -30,6 +30,7 @@ const TRAIN_TYPE_ID = "22222222-2222-4222-8222-222222222222";
const BUS_TYPE_ID = "33333333-3333-4333-8333-333333333333";
const TAXI_TYPE_ID = "44444444-4444-4444-8444-444444444444";
const TRANSPORT_TYPE_ID = "55555555-5555-4555-8555-555555555555";
const MEAL_ALLOWANCE_TYPE_ID = "66666666-6666-4666-8666-666666666666";
function cloneDate(value) {
return value ? new Date(value) : value;
@@ -130,6 +131,7 @@ async function buildApp(options = {}) {
{ id: BUS_TYPE_ID, code: "travel_bus", name: "大巴车票", category: "travel", enabled: true },
{ id: TAXI_TYPE_ID, code: "travel_taxi", name: "打车费", category: "travel", enabled: true },
{ id: TRANSPORT_TYPE_ID, code: "transport", name: "legacy-交通费", category: "travel", enabled: true },
{ id: MEAL_ALLOWANCE_TYPE_ID, code: "travel_meal", name: "伙食补助费", category: "travel", enabled: true, requires_invoice: false },
],
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
auditLogs: [],
@@ -195,6 +197,9 @@ async function buildApp(options = {}) {
if (where?.enabled === true && !item.enabled) {
return false;
}
if (where?.requires_invoice === true && item.requires_invoice !== true) {
return false;
}
if (ids && !ids.includes(item.id)) {
return false;
}
@@ -464,6 +469,34 @@ describe("reimbursement create enhancements", () => {
await app.close();
});
it("keeps invoice-free allowance types available to reimbursement templates", async () => {
const { app, state } = await buildApp({
templates: [buildTemplate({
items: [{
expenseTypeId: MEAL_ALLOWANCE_TYPE_ID,
description: "伙食补助",
defaultAmount: "300.00",
}],
})],
});
const response = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: { templateId: TEMPLATE_ID, reimbursementDate: "2026-04-09" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items).toEqual([
expect.objectContaining({
expenseTypeId: MEAL_ALLOWANCE_TYPE_ID,
amount: "300.00",
}),
]);
expect(state.reimbursementItems).toHaveLength(1);
await app.close();
});
it("rejects templateId and linkedTravelRequestId when both are provided", async () => {
const { app } = await buildApp();
@@ -97,13 +97,22 @@ async function buildApp(options = {}) {
}),
},
invoicePool: {
count: vi.fn(async () => 1),
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 ?? [
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 },
})),
@@ -169,6 +178,19 @@ async function buildApp(options = {}) {
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 = {
@@ -194,6 +216,7 @@ async function buildApp(options = {}) {
budgetControlMode: "warn",
imNotifierEnabled: false,
emailEnabled: false,
reimbursementInvoiceGuardEnabled: options.invoiceGuardEnabled ?? false,
});
app.decorate("hasPermission", (employee, permissionCode) => {
const permissionCodes = employee?.permissionCodes ?? [];
@@ -306,6 +329,142 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("requires prior approval basis for assets items", async () => {
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).toBe(400);
expect(response.json().error.details).toEqual({
items: "asset_prior_approval_basis_required",
itemIds: ["asset-item"],
});
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
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" });