feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -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"},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
在新工单中引用
屏蔽一个用户