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 行删除
+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);