feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -76,6 +76,7 @@ function buildPrisma({
|
||||
},
|
||||
expenseType: {
|
||||
findFirst: vi.fn().mockResolvedValue(expenseType),
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "train-type", code: "travel_train", enabled: true }]),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
@@ -147,6 +148,110 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
|
||||
}
|
||||
|
||||
describe("invoicePool travel-order bind routes", () => {
|
||||
const explicitFormId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const explicitInvoiceId = "11111111-1111-4111-8111-111111111111";
|
||||
function explicitInput(overrides = {}) {
|
||||
return {
|
||||
invoice: {
|
||||
id: explicitInvoiceId, status: "available", linked_reimbursement_id: null,
|
||||
linked_item_id: null, travel_order_id: null, total_amount: "168.00",
|
||||
...overrides.invoice,
|
||||
},
|
||||
reimbursement: {
|
||||
id: explicitFormId, owner_id: "actor-1", submitter_id: "actor-1", status: "draft",
|
||||
...overrides.reimbursement,
|
||||
},
|
||||
reimbursementItems: [{ id: "existing-same-amount", amount: "168.00" }],
|
||||
};
|
||||
}
|
||||
function explicitRequest(payload = {}) {
|
||||
return {
|
||||
method: "POST", url: `/api/invoice-pool/${explicitInvoiceId}/bind`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reimbursementId: explicitFormId, createItem: true, ...payload },
|
||||
};
|
||||
}
|
||||
|
||||
it.each([false, true])("explicitly creates an unclassified item without guessing or matching (guard=%s)", async (invoiceGuardEnabled) => {
|
||||
const { prisma } = buildPrisma(explicitInput());
|
||||
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"], invoiceGuardEnabled });
|
||||
try {
|
||||
const response = await app.inject(explicitRequest());
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(prisma.reimbursementItem.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
reimbursement_id: explicitFormId, expense_type_id: null,
|
||||
expense_date: new Date("2026-05-08T00:00:00Z"), amount: "168.00", description: "",
|
||||
created_by: "actor-1", updated_by: "actor-1",
|
||||
}, select: { id: true },
|
||||
});
|
||||
expect(prisma.reimbursementItem.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.expenseType.findFirst).not.toHaveBeenCalled();
|
||||
expect(response.json().data.linkedItemId).toBe("item-auto-1");
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("uses persisted train metadata and keeps same-form retries write-free", async () => {
|
||||
const { prisma, state } = buildPrisma(explicitInput({ invoice: {
|
||||
ocr_result: { result_json: { documentCategory: "electronic_train_ticket" } },
|
||||
} }));
|
||||
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"], invoiceGuardEnabled: true });
|
||||
try {
|
||||
const first = await app.inject(explicitRequest());
|
||||
expect(first.statusCode, first.body).toBe(200);
|
||||
expect(prisma.reimbursementItem.create.mock.calls[0][0].data).toMatchObject({
|
||||
expense_type_id: "train-type", description: "铁路交通费用",
|
||||
});
|
||||
prisma.reimbursementItem.findFirst.mockResolvedValue({ id: state.invoice.linked_item_id });
|
||||
const retry = await app.inject(explicitRequest());
|
||||
expect(retry.statusCode, retry.body).toBe(200);
|
||||
expect(prisma.reimbursementItem.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.invoicePool.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1);
|
||||
state.reimbursement.status = "reviewing";
|
||||
expect((await app.inject(explicitRequest())).statusCode).toBe(409);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ invoice_date: null }, 409],
|
||||
[{ total_amount: null }, 409],
|
||||
[{ total_amount: "0.00" }, 409],
|
||||
[{ status: "pending_ocr" }, 409],
|
||||
[{ owner_employee_id: "other-owner" }, 409],
|
||||
[{ status: "bound", linked_reimbursement_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }, 409],
|
||||
])("rejects invalid or unavailable evidence without creating an item: %j", async (invoice, expectedStatus) => {
|
||||
const { prisma } = buildPrisma(explicitInput({ invoice }));
|
||||
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||
try {
|
||||
expect((await app.inject(explicitRequest())).statusCode).toBe(expectedStatus);
|
||||
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
|
||||
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("rejects conflicting create and existing-item intents before querying", async () => {
|
||||
const { prisma } = buildPrisma(explicitInput());
|
||||
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||
try {
|
||||
expect((await app.inject(explicitRequest({ linkedItemId: explicitInvoiceId }))).statusCode).toBe(400);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("rejects a nonparticipant retry even when the invoice already points to this form", async () => {
|
||||
const { prisma } = buildPrisma(explicitInput({
|
||||
invoice: { status: "bound", linked_reimbursement_id: explicitFormId, linked_item_id: explicitInvoiceId },
|
||||
reimbursement: { owner_id: "other", submitter_id: "other" },
|
||||
}));
|
||||
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||
try {
|
||||
expect((await app.inject(explicitRequest())).statusCode).toBe(403);
|
||||
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
|
||||
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("requires an explicit reimbursement item while the invoice guard is enabled", async () => {
|
||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const { prisma, state } = buildPrisma({
|
||||
@@ -229,6 +334,22 @@ describe("invoicePool travel-order bind routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("refuses to unbind when the form changed after the binding snapshot", async () => {
|
||||
const { prisma } = buildPrisma(explicitInput({ invoice: {
|
||||
status: "bound", linked_reimbursement_id: explicitFormId, linked_item_id: "item-1",
|
||||
} }));
|
||||
prisma.invoicePool.findUnique.mockResolvedValueOnce({ linked_reimbursement_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" });
|
||||
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${explicitInvoiceId}/unbind`, headers: { authorization: "Bearer test" }, payload: {} });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reasonCode).toBe("reimbursement.invoice_unbind_status_invalid");
|
||||
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
|
||||
const firstLock = prisma.$queryRaw.mock.calls[0][0].join("?");
|
||||
expect(firstLock).toContain('FROM "reimbursement_forms"');
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("rejects a view-all nonparticipant before unbinding a reimbursement invoice", async () => {
|
||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const { prisma, state } = buildPrisma({
|
||||
|
||||
@@ -337,6 +337,18 @@ async function buildApp({
|
||||
}
|
||||
|
||||
describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
it("reads a saved unclassified draft without inventing an expense type", async () => {
|
||||
const record = buildReimbursementRecord({ status: "draft", workflow_instances: [] });
|
||||
record.items[0].expense_type_id = null;
|
||||
record.items[0].expense_type = null;
|
||||
const { app } = await buildApp({ reimbursement: record, latestWorkflowInstance: null });
|
||||
try {
|
||||
const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" } });
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data.items[0]).toMatchObject({ expenseTypeId: null, expenseType: null, amount: "188.80" });
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("allows an assigned workflow reviewer to read evidence without expense view permissions", async () => {
|
||||
const { app, prisma } = await buildApp({
|
||||
employeeId: "99999999-9999-4999-8999-999999999999",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
const Fastify = require("fastify");
|
||||
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||
|
||||
const FORM_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const ACTOR_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const ITEM_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const TYPE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
async function buildApp({ status = "draft", participant = true, expenseTypeId = null, missingItem = false, typeEnabled = true } = {}) {
|
||||
const item = {
|
||||
id: ITEM_ID,
|
||||
reimbursement_id: FORM_ID,
|
||||
expense_type_id: expenseTypeId,
|
||||
expense_date: new Date("2026-09-09T00:00:00Z"),
|
||||
amount: "100.00",
|
||||
description: "合成测试支出",
|
||||
};
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async () => [{
|
||||
id: FORM_ID, owner_id: participant ? ACTOR_ID : TYPE_ID, submitter_id: TYPE_ID, status,
|
||||
}]),
|
||||
expenseType: { findUnique: vi.fn(async () => ({ id: TYPE_ID, enabled: typeEnabled, category: "office" })) },
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => item),
|
||||
findMany: vi.fn(async () => missingItem ? [] : [item]),
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
create: vi.fn(async ({ data }) => ({ id: ITEM_ID, ...data })),
|
||||
update: vi.fn(async ({ data }) => Object.assign(item, data)),
|
||||
aggregate: vi.fn(async () => ({ _sum: { amount: "100.00" } })),
|
||||
},
|
||||
reimbursementForm: { update: vi.fn(async () => ({})) },
|
||||
};
|
||||
const app = Fastify();
|
||||
app.decorate("config", {});
|
||||
app.decorate("prisma", { $transaction: async (callback) => callback(tx) });
|
||||
app.decorate("requirePermission", () => async (request) => { request.employee = { id: ACTOR_ID }; });
|
||||
app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR_ID }; });
|
||||
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({
|
||||
error: { code: error.code, details: error.details },
|
||||
}));
|
||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||
return { app, tx };
|
||||
}
|
||||
|
||||
describe("draft reimbursement classification", () => {
|
||||
it("classifies only explicitly selected items without changing amount, date, description or approval basis", async () => {
|
||||
const { app, tx } = await buildApp();
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items/classify`,
|
||||
payload: { itemIds: [ITEM_ID, ITEM_ID], expenseTypeId: TYPE_ID } });
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(tx.reimbursementItem.updateMany).toHaveBeenCalledWith({
|
||||
where: { reimbursement_id: FORM_ID, id: { in: [ITEM_ID] } },
|
||||
data: { expense_type_id: TYPE_ID, updated_by: ACTOR_ID },
|
||||
});
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ missingItem: true }, 409], [{ typeEnabled: false }, 409],
|
||||
[{ participant: false }, 403], [{ status: "reviewing" }, 409],
|
||||
])("rejects a whole classification batch without partial writes: %j", async (options, expectedStatus) => {
|
||||
const { app, tx } = await buildApp(options);
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items/classify`,
|
||||
payload: { itemIds: [ITEM_ID], expenseTypeId: TYPE_ID } });
|
||||
expect(response.statusCode, response.body).toBe(expectedStatus);
|
||||
expect(tx.reimbursementItem.updateMany).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each(["draft", "rejected"])("saves an explicitly unclassified item in %s without guessing a type", async (status) => {
|
||||
const { app, tx } = await buildApp({ status });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items`, payload: {
|
||||
expenseTypeId: null, expenseDate: "2026-09-09", amount: "100.00", description: "",
|
||||
} });
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data.item.expenseTypeId).toBeNull();
|
||||
expect(tx.expenseType.findUnique).not.toHaveBeenCalled();
|
||||
expect(tx.reimbursementItem.create.mock.calls[0][0].data.expense_type_id).toBeNull();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, { description: "修正合成说明" }, null],
|
||||
[TYPE_ID, { expenseTypeId: null }, null],
|
||||
[null, { expenseTypeId: TYPE_ID }, TYPE_ID],
|
||||
])("edits classification without nullish fallback or fabricated defaults", async (expenseTypeId, payload, expected) => {
|
||||
const { app, tx } = await buildApp({ expenseTypeId });
|
||||
try {
|
||||
const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${FORM_ID}/items/${ITEM_ID}`, payload });
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data.item.expenseTypeId).toBe(expected);
|
||||
if (expected === null) expect(tx.expenseType.findUnique).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ participant: false }, 403],
|
||||
[{ status: "reviewing" }, 409],
|
||||
[{ status: "approved" }, 409],
|
||||
[{ status: "paid" }, 409],
|
||||
])("does not widen maintenance permissions or states: %j", async (options, statusCode) => {
|
||||
const { app, tx } = await buildApp(options);
|
||||
try {
|
||||
const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${FORM_ID}/items/${ITEM_ID}`, payload: { expenseTypeId: null } });
|
||||
expect(response.statusCode, response.body).toBe(statusCode);
|
||||
expect(tx.reimbursementItem.update).not.toHaveBeenCalled();
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
@@ -117,6 +117,7 @@ async function buildApp(options = {}) {
|
||||
{ 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,
|
||||
@@ -211,6 +212,11 @@ async function buildApp(options = {}) {
|
||||
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: "申请人",
|
||||
@@ -322,6 +328,50 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
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: [
|
||||
|
||||
在新工单中引用
屏蔽一个用户