feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-09 23:19:02 +08:00
父节点 b87cc55aca
当前提交 e5685f18b2
共修改 28 个文件,包含 1616 行新增和 121 行删除
@@ -0,0 +1 @@
ALTER TABLE "reimbursement_items" ALTER COLUMN "expense_type_id" DROP NOT NULL;
+2 -2
查看文件
@@ -1497,7 +1497,7 @@ model ReimbursementForm {
model ReimbursementItem {
id String @id @default(uuid()) @db.Uuid
reimbursement_id String @db.Uuid
expense_type_id String @db.Uuid
expense_type_id String? @db.Uuid
expense_date DateTime @db.Date
amount Decimal @db.Decimal(14, 2)
description String
@@ -1509,7 +1509,7 @@ model ReimbursementItem {
updated_by String? @db.Uuid
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
expense_type ExpenseType @relation(fields: [expense_type_id], references: [id], onDelete: Restrict)
expense_type ExpenseType? @relation(fields: [expense_type_id], references: [id], onDelete: Restrict)
invoice_pool_items InvoicePool[]
+3
查看文件
@@ -1,8 +1,11 @@
const ERROR_REASONS = [
{"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"},
{"code":"reimbursement.items_description_required","domain":"reimbursement","audience":"user","defaultMessage":"请补充明细的支出说明后再提交。","recovery":"follow_guidance"},
{"code":"reimbursement.material_preview_limit","domain":"reimbursement","audience":"user","defaultMessage":"报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。","recovery":"view_individually"},
{"code":"purchase_attachment.unsupported_type","domain":"purchase_attachment","audience":"user","defaultMessage":"仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。","recovery":"choose_file"},
{"code":"reimbursement.invoice_amount_required","domain":"reimbursement","audience":"user","defaultMessage":"票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。","recovery":"follow_guidance"},
{"code":"reimbursement.expense_type_required","domain":"reimbursement","audience":"user","defaultMessage":"当前没有可用费用类型,请联系管理员维护后再添加票据。","recovery":"follow_guidance"},
{"code":"reimbursement.items_expense_type_required","domain":"reimbursement","audience":"user","defaultMessage":"请补充明细的费用类型;同类明细可批量设置,已停用的类型请重新选择。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_not_bound","domain":"reimbursement","audience":"user","defaultMessage":"该票据已不再关联业务单据,请刷新票据列表后确认。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_unbind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能移出票据,请先核实审批和付款状态。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_bind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能添加票据,请刷新并确认单据状态。","recovery":"follow_guidance"},
+76 -7
查看文件
@@ -1,4 +1,5 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { suggestInvoiceExpenseType } = require("../services/invoiceExpenseClassification");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -37,8 +38,10 @@ const bindBodySchema = z.union([
.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
createItem: z.literal(true).optional(),
})
.strict(),
.strict()
.refine((body) => !(body.createItem && body.linkedItemId)),
z
.object({
travelOrderId: z.string().uuid(),
@@ -264,7 +267,31 @@ async function recomputeReimbursementTotal(tx, reimbursementId, actorId) {
return nextTotal;
}
async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate, invoice, linkedItemId, actorId }) {
async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate, invoice, linkedItemId, actorId, createItem }) {
if (createItem) {
const expenseDate = toDateOnlyFromDate(invoice.invoice_date);
if (!expenseDate || !Number.isFinite(expenseDate.getTime()) || invoice.total_amount == null || Number(invoice.total_amount) <= 0 || !Number.isFinite(Number(invoice.total_amount))) {
throw buildBusinessValidationError("reimbursement.invoice_prefill_incomplete", { invoice: "missing_date_or_positive_amount" }, 409);
}
const expenseTypes = await tx.expenseType.findMany({
where: { enabled: true, code: "travel_train" },
select: { id: true, code: true, enabled: true },
});
const suggestion = suggestInvoiceExpenseType(invoice, expenseTypes);
const created = await tx.reimbursementItem.create({
data: {
reimbursement_id: reimbursementId,
expense_type_id: suggestion?.expenseTypeId ?? null,
expense_date: expenseDate,
amount: toMoneyString(invoice.total_amount),
description: suggestion?.source === "document_category" ? "铁路交通费用" : "",
created_by: actorId,
updated_by: actorId,
},
select: { id: true },
});
return { linkedItemId: created.id, createdItem: true };
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
@@ -1016,6 +1043,16 @@ async function invoicePoolRoutes(fastify) {
let responseInvoice = null;
await fastify.prisma.$transaction(async (tx) => {
const bindingSnapshot = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: { linked_reimbursement_id: true },
});
if (bindingSnapshot?.linked_reimbursement_id) {
await tx.$queryRaw`
SELECT id FROM "reimbursement_forms"
WHERE id = ${bindingSnapshot.linked_reimbursement_id}::uuid FOR UPDATE
`;
}
const invoiceRows = await tx.$queryRaw`
SELECT id, status, linked_reimbursement_id, travel_order_id
FROM "invoice_pool"
@@ -1032,6 +1069,9 @@ async function invoicePoolRoutes(fastify) {
}
const invoice = invoiceRows[0];
if ((invoice.linked_reimbursement_id ?? null) !== (bindingSnapshot?.linked_reimbursement_id ?? null)) {
throw buildBusinessValidationError("reimbursement.invoice_unbind_status_invalid", { invoice: "binding_changed" }, 409);
}
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
throw buildBusinessValidationError("reimbursement.invoice_not_bound", { invoice: "not_bound" }, 409);
}
@@ -1440,6 +1480,23 @@ async function invoicePoolRoutes(fastify) {
try {
await fastify.prisma.$transaction(async (tx) => {
if ("reimbursementId" in parsedBody.data) {
if (!fastify.hasPermission(request.employee, "invoice.upload")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const forms = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
WHERE id = ${parsedBody.data.reimbursementId}::uuid FOR UPDATE
`;
const form = forms[0];
if (!form) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (form.owner_id !== request.employee.id && form.submitter_id !== request.employee.id) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
if (!["draft", "rejected"].includes(form.status)) {
throw buildBusinessValidationError("reimbursement.invoice_bind_status_invalid", { reimbursement: "invalid_status_for_bind" }, 409);
}
}
const invoiceRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
@@ -1472,6 +1529,7 @@ async function invoicePoolRoutes(fastify) {
travel_order_id: true,
archived_reason: true,
is_duplicate_override: true,
ocr_result: { select: { result_json: true } },
},
});
@@ -1483,7 +1541,10 @@ async function invoicePoolRoutes(fastify) {
});
}
if (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id) {
const sameFormRetry = parsedBody.data.createItem === true &&
invoice.status === "bound" && !invoice.travel_order_id &&
invoice.linked_reimbursement_id === parsedBody.data.reimbursementId && Boolean(invoice.linked_item_id);
if (!sameFormRetry && (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id)) {
throw new AppError({
code: "VALID_005",
message: "发票已被其他业务单据占用/绑定",
@@ -1491,7 +1552,7 @@ async function invoicePoolRoutes(fastify) {
});
}
if (invoice.status !== "available") {
if (!sameFormRetry && invoice.status !== "available") {
throw new AppError({
code: "VALID_005",
message: "发票已被其他业务单据占用/绑定",
@@ -1500,7 +1561,7 @@ async function invoicePoolRoutes(fastify) {
});
}
if (!invoice.is_duplicate_override) {
if (!sameFormRetry && !invoice.is_duplicate_override) {
const duplicateBlocker = await resolveInvoiceDedupBlocker(tx, {
excludeInvoiceId: invoiceId,
fileSha256: invoice.file_sha256,
@@ -1533,7 +1594,7 @@ async function invoicePoolRoutes(fastify) {
});
}
const { reimbursementId, linkedItemId } = parsedBody.data;
const { reimbursementId, linkedItemId, createItem } = parsedBody.data;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, status, reimbursement_date
FROM "reimbursement_forms"
@@ -1568,6 +1629,13 @@ async function invoicePoolRoutes(fastify) {
throw buildBusinessValidationError("reimbursement.invoice_owner_mismatch", { invoice: "owner_mismatch" }, 409);
}
if (sameFormRetry) {
await resolveInvoiceLinkedItem(tx, {
reimbursementId, invoice, linkedItemId: invoice.linked_item_id, actorId: request.employee.id,
});
return;
}
const invoiceGuardGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
@@ -1575,7 +1643,7 @@ async function invoicePoolRoutes(fastify) {
departmentId: reimbursement.department_id ?? null,
strictDepartment: true,
});
if (invoiceGuardGate.enabled && !linkedItemId) {
if (invoiceGuardGate.enabled && !linkedItemId && !createItem) {
throw buildBusinessValidationError("reimbursement.invoice_item_required", { linkedItemId: "required_when_invoice_guard_enabled" }, 400);
}
@@ -1585,6 +1653,7 @@ async function invoicePoolRoutes(fastify) {
invoice,
linkedItemId,
actorId: request.employee.id,
createItem,
});
await tx.invoicePool.update({
+69 -12
查看文件
@@ -1,4 +1,5 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ensureClassifiedReimbursementItems, ensureReimbursementItemDescriptions } = require("../services/invoiceExpenseClassification");
const { Prisma } = require("@prisma/client");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -178,10 +179,10 @@ const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
});
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
expenseTypeId: z.string().uuid(),
expenseTypeId: z.string().uuid().nullable(),
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
description: z.string().trim().min(1).max(500),
description: z.string().trim().max(500),
assetPriorApprovalBasis: z.string().trim().max(500).nullable().optional(),
});
@@ -3511,11 +3512,11 @@ async function reimbursementRoutes(fastify) {
throw buildBusinessValidationError("reimbursement.status_invalid_item_edit_status", { status: "invalid_item_edit_status" }, 409);
}
const expenseType = await tx.expenseType.findUnique({
const expenseType = body.expenseTypeId === null ? null : await tx.expenseType.findUnique({
where: { id: body.expenseTypeId },
select: { id: true, enabled: true, category: true },
});
if (!expenseType || !expenseType.enabled) {
if (body.expenseTypeId !== null && (!expenseType || !expenseType.enabled)) {
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
}
const created = await tx.reimbursementItem.create({
@@ -3526,7 +3527,7 @@ async function reimbursementRoutes(fastify) {
amount: body.amount,
description: body.description,
asset_prior_approval_basis:
expenseType.category === "assets" ? body.assetPriorApprovalBasis?.trim() || null : null,
expenseType?.category === "assets" ? body.assetPriorApprovalBasis?.trim() || null : null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -3562,6 +3563,52 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.post(
"/reimbursements/:id/items/classify",
{ preHandler: fastify.requirePermission("expense.edit_own") },
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsed = z.object({
itemIds: z.array(UUID_SCHEMA).min(1).max(100),
expenseTypeId: UUID_SCHEMA,
}).strict().safeParse(request.body ?? {});
if (!parsed.success) {
throw buildBusinessValidationError("reimbursement.invalid_input", parsed.error.flatten(), 400);
}
const itemIds = [...new Set(parsed.data.itemIds)];
await fastify.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid FOR UPDATE
`;
const reimbursement = rows[0];
if (!reimbursement) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw buildBusinessValidationError("reimbursement.status_invalid_item_edit_status", { status: "invalid_item_edit_status" }, 409);
}
const items = await tx.reimbursementItem.findMany({
where: { reimbursement_id: reimbursementId, id: { in: itemIds } },
select: { id: true },
});
if (items.length !== itemIds.length) {
throw buildBusinessValidationError("reimbursement.linked_item_id_item_not_in_reimbursement", { itemIds: "item_not_in_reimbursement" }, 409);
}
const expenseType = await tx.expenseType.findUnique({
where: { id: parsed.data.expenseTypeId }, select: { id: true, enabled: true },
});
if (!expenseType?.enabled) {
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
}
await tx.reimbursementItem.updateMany({
where: { reimbursement_id: reimbursementId, id: { in: itemIds } },
data: { expense_type_id: expenseType.id, updated_by: request.employee.id },
});
});
return sendData(reply, request, { itemIds, expenseTypeId: parsed.data.expenseTypeId });
},
);
fastify.patch(
"/reimbursements/:id/items/:itemId",
{
@@ -3627,12 +3674,14 @@ async function reimbursementRoutes(fastify) {
});
}
const targetExpenseTypeId = parsedBody.data.expenseTypeId ?? item.expense_type_id;
const expenseType = await tx.expenseType.findUnique({
const targetExpenseTypeId = parsedBody.data.expenseTypeId === undefined
? item.expense_type_id
: parsedBody.data.expenseTypeId;
const expenseType = targetExpenseTypeId === null ? null : await tx.expenseType.findUnique({
where: { id: targetExpenseTypeId },
select: { id: true, enabled: true, category: true },
});
if (!expenseType || !expenseType.enabled) {
if (targetExpenseTypeId !== null && (!expenseType || !expenseType.enabled)) {
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
}
const targetApprovalBasis =
@@ -3642,7 +3691,7 @@ async function reimbursementRoutes(fastify) {
const updated = await tx.reimbursementItem.update({
where: { id: parsedItemId.data },
data: {
...(typeof parsedBody.data.expenseTypeId === "string"
...(parsedBody.data.expenseTypeId !== undefined
? { expense_type_id: parsedBody.data.expenseTypeId }
: {}),
...(typeof parsedBody.data.expenseDate === "string"
@@ -3655,7 +3704,7 @@ async function reimbursementRoutes(fastify) {
? { description: parsedBody.data.description.trim() }
: {}),
asset_prior_approval_basis:
expenseType.category === "assets"
expenseType?.category === "assets"
? targetApprovalBasis?.trim() || null
: null,
updated_by: request.employee.id,
@@ -3847,12 +3896,17 @@ async function reimbursementRoutes(fastify) {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true, status: true },
select: {
id: true, owner_id: true, submitter_id: true, status: true,
items: { select: { id: true, description: true, expense_type: { select: { enabled: true } } } },
},
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
await ensureOwnOrCanViewAll(fastify, request, reimbursement);
ensureClassifiedReimbursementItems(reimbursement.items ?? []);
ensureReimbursementItemDescriptions(reimbursement.items ?? []);
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
@@ -3903,8 +3957,9 @@ async function reimbursementRoutes(fastify) {
select: {
id: true,
amount: true,
description: true,
expense_type: {
select: { code: true, category: true, requires_invoice: true },
select: { code: true, category: true, requires_invoice: true, enabled: true },
},
invoice_pool_items: {
where: { status: "bound" },
@@ -3922,6 +3977,8 @@ async function reimbursementRoutes(fastify) {
if (!reimbursementItems.length) {
throw buildBusinessValidationError("reimbursement.items_at_least_one_required", { items: "at_least_one_required" }, 400);
}
ensureClassifiedReimbursementItems(reimbursementItems);
ensureReimbursementItemDescriptions(reimbursementItems);
const invalidAmountItems = reimbursementItems
.filter((item) => !Number.isFinite(Number(item.amount)) || Number(item.amount) <= 0)
.map((item) => item.id);
@@ -0,0 +1,30 @@
const { mapInvoiceDocumentMeta } = require("./invoiceDocumentMeta");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
function suggestInvoiceExpenseType(invoice, expenseTypes) {
const { documentCategory } = mapInvoiceDocumentMeta(invoice);
if (documentCategory !== "electronic_train_ticket") return null;
const expenseType = expenseTypes.find((type) => type.enabled === true && type.code === "travel_train");
if (!expenseType) return null;
return { expenseTypeId: expenseType.id, source: "document_category" };
}
function ensureClassifiedReimbursementItems(items) {
const itemIds = items
.filter((item) => !item.expense_type || item.expense_type.enabled === false)
.map((item) => item.id);
if (itemIds.length) {
throw buildBusinessValidationError("reimbursement.items_expense_type_required", { itemIds }, 400);
}
}
function ensureReimbursementItemDescriptions(items) {
const itemIds = items.filter((item) => !item.description?.trim()).map((item) => item.id);
if (itemIds.length) {
throw buildBusinessValidationError("reimbursement.items_description_required", { itemIds }, 400);
}
}
module.exports = { suggestInvoiceExpenseType, ensureClassifiedReimbursementItems, ensureReimbursementItemDescriptions };
@@ -1,8 +1,11 @@
{
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
"reimbursement.items_description_required": "请补充明细的支出说明后再提交。",
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
"reimbursement.items_expense_type_required": "请补充明细的费用类型;同类明细可批量设置,已停用的类型请重新选择。",
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
@@ -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: [
@@ -0,0 +1,54 @@
const { suggestInvoiceExpenseType } = require("../../../src/services/invoiceExpenseClassification");
const enabledTypes = [
{ id: "train-type", code: "travel_train", enabled: true },
{ id: "hotel-type", code: "travel_hotel", enabled: true },
{ id: "meal-type", code: "business_client_meal", enabled: true },
];
function invoiceWithCategory(documentCategory, extra = {}) {
return { ocr_result: { result_json: { documentCategory } }, ...extra };
}
describe("invoice expense classification", () => {
it("suggests the enabled train type from a recognized electronic train ticket", () => {
expect(suggestInvoiceExpenseType(invoiceWithCategory("electronic_train_ticket"), enabledTypes))
.toEqual({ expenseTypeId: "train-type", source: "document_category" });
});
it.each(["vat_invoice", "full_vat_invoice", "unknown", null])(
"does not guess an expense type from category %s, seller, filename or amount",
(category) => {
expect(suggestInvoiceExpenseType(invoiceWithCategory(category, {
seller_name: "合成酒店餐饮公司",
original_name: "办公用品.pdf",
total_amount: "100.00",
}), enabledTypes)).toBeNull();
},
);
it("does not substitute another enabled type when the matching type is disabled", () => {
const expenseTypes = enabledTypes.map((type) => ({ ...type, enabled: type.code !== "travel_train" }));
expect(suggestInvoiceExpenseType(invoiceWithCategory("electronic_train_ticket"), expenseTypes)).toBeNull();
});
it("does not substitute the first enabled type when the matching type is absent", () => {
expect(suggestInvoiceExpenseType(invoiceWithCategory("electronic_train_ticket"), enabledTypes.slice(1)))
.toBeNull();
});
it("does not infer from the historical provider or generic upload type", () => {
expect(suggestInvoiceExpenseType({ ocr_provider: "tencent", ocr_type: "vat" }, enabledTypes)).toBeNull();
});
it("does not infer from client-facing category fields instead of persisted OCR metadata", () => {
expect(suggestInvoiceExpenseType({ documentCategory: "electronic_train_ticket" }, enabledTypes)).toBeNull();
});
it("does not mutate invoice data or expense types", () => {
const invoice = invoiceWithCategory("electronic_train_ticket");
const before = JSON.stringify({ invoice, enabledTypes });
suggestInvoiceExpenseType(invoice, enabledTypes);
expect(JSON.stringify({ invoice, enabledTypes })).toBe(before);
});
});