const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { suggestInvoiceExpenseType } = require("../services/invoiceExpenseClassification"); const crypto = require("node:crypto"); const path = require("node:path"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { compactDateString } = require("../utils/date"); const { sha256Hex } = require("../utils/crypto"); const { toMoneyString } = require("../utils/workflow"); const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { OCR_PDF_MAX_BYTES, getMissingRequiredOcrFields, isOcrEnabledForType, } = require("../services/ocr"); const { enqueueOcrInvoice } = require("../jobs/pgBossJobs"); const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification"); const { registerInvoicePoolReadRoutes } = require("./invoicePool/read"); const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers"); const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects"); const { writeBuffer } = require("../services/storage"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { mapInvoiceUnavailableReason, resolveInvoiceAvailability, resolveInvoiceDedupBlocker, resolveUploadDedupDecision, } = require("../services/invoiceGovernance"); const INVOICE_UPLOAD_MIME_TYPE = "application/pdf"; const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]); const bindBodySchema = z.union([ z .object({ reimbursementId: z.string().uuid(), linkedItemId: z.string().uuid().optional(), createItem: z.literal(true).optional(), }) .strict() .refine((body) => !(body.createItem && body.linkedItemId)), z .object({ travelOrderId: z.string().uuid(), }) .strict(), ]); const invoiceEditBodySchema = z .object({ status: z.enum(["available"]).optional(), remarks: z.string().max(500).nullable().optional(), invoiceCode: z.string().trim().min(1).max(64).nullable().optional(), invoiceNumber: z.string().trim().min(1).max(64).nullable().optional(), invoiceDate: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .nullable() .optional(), sellerName: z.string().trim().min(1).max(256).nullable().optional(), buyerName: z.string().trim().min(1).max(256).nullable().optional(), amount: z.string().trim().min(1).max(32).nullable().optional(), taxAmount: z.string().trim().min(1).max(32).nullable().optional(), totalAmount: z.string().trim().min(1).max(32).nullable().optional(), }) .strict(); const overrideDuplicateBodySchema = z .object({ refFormNumber: z.string().trim().min(1).max(64), reason: z.string().trim().min(1).max(200), }) .strict(); const DEDUP_CONSTRAINT_NAMES = new Set([ "invoice_pool_file_sha256_dedup_ux", "invoice_pool_invoice_code_number_dedup_ux", ]); const DEDUP_FIELD_NAMES = new Set(["file_sha256", "invoice_code", "invoice_number"]); function tokenizeMetaString(value) { if (typeof value !== "string") { return []; } const normalized = value.trim().toLowerCase(); if (!normalized) { return []; } const splitTokens = normalized .split(/[^a-z0-9_]+/i) .map((item) => item.trim().toLowerCase()) .filter(Boolean); return [normalized, ...splitTokens]; } function normalizeConstraintMeta(value) { if (Array.isArray(value)) { return value .flatMap((item) => tokenizeMetaString(String(item))) .filter(Boolean); } if (typeof value === "string") { return tokenizeMetaString(value); } return []; } function collectFieldTokens(meta) { return new Set( [ ...normalizeConstraintMeta(meta.target), ...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.constraint?.fields), ].filter((token) => DEDUP_FIELD_NAMES.has(token)), ); } function containsDedupConstraintName(metaTokens) { const constraints = Array.from(DEDUP_CONSTRAINT_NAMES); return metaTokens.some((token) => constraints.some((constraint) => token.includes(constraint))); } function isFieldBasedDedup(fieldTokens) { if (fieldTokens.size === 1 && fieldTokens.has("file_sha256")) { return true; } if ( fieldTokens.size === 2 && fieldTokens.has("invoice_code") && fieldTokens.has("invoice_number") ) { return true; } return false; } function isInvoiceDedupConflict(error) { if (!error || error.code !== "P2002") { return false; } const meta = error.meta ?? {}; const metaTokens = [ ...normalizeConstraintMeta(meta.constraint), ...normalizeConstraintMeta(meta.target), ...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.originalMessage), ]; if (containsDedupConstraintName(metaTokens)) { return true; } const fieldTokens = collectFieldTokens(meta); return isFieldBasedDedup(fieldTokens); } function normalizeMoneyInput(value) { if (value == null) { return null; } const input = String(value).trim(); if (!input) { return null; } if (!/^\d+(?:\.\d{1,2})?$/.test(input)) { return undefined; } const [intPart, fracPartRaw = ""] = input.split("."); const fracPart = `${fracPartRaw}00`.slice(0, 2); return `${intPart}.${fracPart}`; } function parseDateOnlyString(value) { if (value == null) { return null; } if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); if (!trimmed) { return null; } if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { return undefined; } const parsed = new Date(`${trimmed}T00:00:00.000Z`); if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== trimmed) { return undefined; } return parsed; } function isPdfInvoiceUpload(fileName, mimeType) { return mimeType === INVOICE_UPLOAD_MIME_TYPE && path.extname(fileName ?? "").toLowerCase() === ".pdf"; } async function resolveDefaultExpenseType(tx) { const preferred = await tx.expenseType.findFirst({ where: { enabled: true, code: { in: ["other_misc", "office_supplies", "business_client_meal"] }, }, orderBy: { sort_order: "asc" }, select: { id: true }, }); if (preferred) { return preferred; } return tx.expenseType.findFirst({ where: { enabled: true }, orderBy: { sort_order: "asc" }, select: { id: true }, }); } function toDateOnlyFromDate(value) { if (value instanceof Date) { return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`); } return null; } function buildAutoItemDescription(invoice) { const sellerName = String(invoice.seller_name ?? "").trim(); if (sellerName) { return `发票自动生成:${sellerName}`; } const originalName = String(invoice.original_name ?? "").trim(); if (originalName) { return `发票自动生成:${originalName}`.slice(0, 500); } return "发票自动生成明细"; } async function recomputeReimbursementTotal(tx, reimbursementId, actorId) { const aggregate = await tx.reimbursementItem.aggregate({ where: { reimbursement_id: reimbursementId }, _sum: { amount: true }, }); const nextTotal = aggregate._sum.amount ?? "0"; await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { total_amount: nextTotal, updated_by: actorId, }, }); return nextTotal; } 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: { id: linkedItemId, reimbursement_id: reimbursementId, }, select: { id: true }, }); if (!linkedItem) { throw buildBusinessValidationError("reimbursement.linked_item_id_item_not_in_reimbursement", { linkedItemId: "item_not_in_reimbursement" }, 400); } return { linkedItemId: linkedItem.id, createdItem: false }; } const items = await tx.reimbursementItem.findMany({ where: { reimbursement_id: reimbursementId }, orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, amount: true, }, }); if (items.length === 1) { return { linkedItemId: items[0].id, createdItem: false }; } if (items.length > 1 && invoice.total_amount != null) { const invoiceAmount = toMoneyString(invoice.total_amount); const matched = items.filter((item) => toMoneyString(item.amount) === invoiceAmount); if (matched.length === 1) { return { linkedItemId: matched[0].id, createdItem: false }; } } if (invoice.total_amount == null) { throw buildBusinessValidationError("reimbursement.invoice_amount_required", { invoice: "missing_total_amount_for_auto_item" }, 409); } const expenseType = await resolveDefaultExpenseType(tx); if (!expenseType) { throw buildBusinessValidationError("reimbursement.expense_type_required", { expenseTypeId: "missing_enabled_expense_type" }, 409); } const expenseDate = toDateOnlyFromDate(invoice.invoice_date) ?? toDateOnlyFromDate(reimbursementDate) ?? new Date(); const created = await tx.reimbursementItem.create({ data: { reimbursement_id: reimbursementId, expense_type_id: expenseType.id, expense_date: expenseDate, amount: toMoneyString(invoice.total_amount), description: buildAutoItemDescription(invoice), created_by: actorId, updated_by: actorId, }, select: { id: true }, }); return { linkedItemId: created.id, createdItem: true }; } async function invoicePoolRoutes(fastify) { registerInvoicePoolReadRoutes(fastify); fastify.post( "/invoice-pool/upload", { preHandler: fastify.requirePermission("invoice.upload"), }, async (request, reply) => { let fileBuffer = null; let fileName = null; let fileMimeType = null; let fileSize = 0; const fields = {}; const configuredUploadMaxBytes = Number(fastify.config.maxUploadSizeBytes); const invoiceUploadMaxBytes = Number.isFinite(configuredUploadMaxBytes) && configuredUploadMaxBytes > 0 ? Math.min(configuredUploadMaxBytes, OCR_PDF_MAX_BYTES) : OCR_PDF_MAX_BYTES; try { for await (const part of request.parts()) { if (part.type === "file") { if (fileBuffer) { throw buildBusinessValidationError("invoice_pool.single_file_required", { file: "only_single_file_supported" }, 400); } fileName = part.filename ?? "upload"; fileMimeType = part.mimetype; if (!isPdfInvoiceUpload(fileName, fileMimeType)) { const error = buildBusinessValidationError("invoice_pool.pdf_required", { file: "pdf_invoice_required" }); error.message = "请上传 PDF 发票文件"; throw error; } const chunks = []; for await (const chunk of part.file) { fileSize += chunk.length; if (fileSize > invoiceUploadMaxBytes) { if (invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES) { const error = buildBusinessValidationError("invoice_pool.ocr_pdf_too_large", { file: "ocr_pdf_too_large", maxBytes: invoiceUploadMaxBytes, }); error.message = "PDF 发票不能超过 7 MiB"; throw error; } throw buildBusinessValidationError("invoice_pool.upload_too_large", { file: "file_too_large", maxBytes: invoiceUploadMaxBytes, }); } chunks.push(chunk); } if (fileSize === 0) { throw buildBusinessValidationError("invoice_pool.empty_file", { file: "empty_file" }, 400); } fileBuffer = Buffer.concat(chunks); continue; } fields[part.fieldname] = part.value; } } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { throw buildBusinessValidationError("invoice_pool.upload_too_large", { file: "file_too_large" }, 400); } throw error; } if (!fileBuffer) { throw buildBusinessValidationError("invoice_pool.file_required", { file: "missing" }, 400); } const ownerEmployeeId = typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim() ? fields.owner_employee_id.trim() : request.employee.id; const isCrossOwner = ownerEmployeeId !== request.employee.id; let delegationGrantId = null; if (isCrossOwner) { const canManageUsers = fastify.hasPermission(request.employee, "system.users"); if (!canManageUsers) { const isAdministration = request.employee.roleCodes?.includes("administration"); const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit"); if (!isAdministration || !canProxySubmit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { owner_employee_id: "cross_owner_upload_not_allowed" }, }); } const delegation = await findActiveDelegationForDelegate(fastify.prisma, { principalEmployeeId: ownerEmployeeId, delegateEmployeeId: request.employee.id, allowedScopes: ["reimbursement_submit", "all"], now: new Date(), }); if (!delegation) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { delegation: "missing_active_grant" }, }); } delegationGrantId = delegation.id; } const ownerEmployee = await fastify.prisma.employee.findUnique({ where: { id: ownerEmployeeId }, select: { id: true, status: true }, }); if (!ownerEmployee || ownerEmployee.status !== "active") { throw buildBusinessValidationError("invoice_pool.owner_unavailable", { owner_employee_id: "invalid_owner" }, 409); } } const ocrType = fields.ocr_type ?? "general"; if (!OCR_TYPE_ALLOWLIST.has(ocrType)) { throw buildBusinessValidationError("invoice_pool.ocr_type_invalid", { ocr_type: "invalid" }, 400); } const fileSha256 = sha256Hex(fileBuffer); const uploadDedupDecision = await fastify.prisma.$transaction((tx) => resolveUploadDedupDecision(tx, { fileSha256, ownerEmployeeId, }), ); if (uploadDedupDecision.action !== "create") { const message = mapInvoiceUnavailableReason(uploadDedupDecision.reason); return sendData(reply, request, { invoicePoolId: uploadDedupDecision.action === "reuse_existing" ? uploadDedupDecision.invoice.id : null, status: uploadDedupDecision.invoice?.status ?? "ocr_failed", fileSha256, uploadAction: uploadDedupDecision.action, unavailableReason: uploadDedupDecision.reason, unavailableMessage: message, availabilityReason: uploadDedupDecision.reason, availabilityMessage: message, }); } const datePrefix = compactDateString(fastify.config.appTimezone); const extension = path.extname(fileName ?? "").toLowerCase(); const objectKey = path.posix.join(datePrefix, `${crypto.randomUUID()}${extension}`); const storagePath = await writeBuffer({ config: fastify.config, key: objectKey, buffer: fileBuffer, contentType: fileMimeType, }); const ocrEnabled = isOcrEnabledForType(fastify.config, ocrType); const status = ocrEnabled ? "pending_ocr" : "available"; const invoice = await fastify.prisma.$transaction(async (tx) => { const now = new Date(); const created = await tx.invoicePool.create({ data: { owner_employee_id: ownerEmployeeId, uploaded_by_employee_id: request.employee.id, storage_path: storagePath, original_name: fileName ?? "upload", mime_type: fileMimeType, file_size: fileSize, file_sha256: fileSha256, ocr_provider: ocrEnabled ? fastify.config.ocrProvider : undefined, ocr_type: ocrType, status, ocr_last_requested_at: status === "pending_ocr" ? now : null, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, status: true, file_sha256: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_POOL_UPLOADED", targetType: "invoice_pool", targetId: created.id, extra: { ownerEmployeeId, uploadedByEmployeeId: request.employee.id, crossOwner: isCrossOwner, delegationGrantId, ocrType, fileSha256, }, }), }); return created; }); if (invoice.status === "pending_ocr") { enqueueOcrInvoice(fastify, invoice.id).catch((error) => { request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue OCR invoice job"); }); } const availability = resolveInvoiceAvailability(invoice); return sendData(reply, request, { invoicePoolId: invoice.id, status: invoice.status, fileSha256: invoice.file_sha256, uploadAction: "create", availabilityReason: availability.reason, availabilityMessage: availability.message, }); }, ); registerInvoiceVerificationRoutes(fastify); fastify.patch( "/invoice-pool/:id", { preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400); } const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw buildBusinessValidationError("invoice_pool.edit_input_invalid", parsedBody.error.flatten()); } if (Object.keys(parsedBody.data).length === 0) { throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400); } const invoiceId = parsedInvoiceId.data; const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: buildInvoiceDetailSelect(), }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canEdit = canManage || current.owner_employee_id === request.employee.id || current.uploaded_by_employee_id === request.employee.id; if (!canEdit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["available", "ocr_failed"].includes(current.status)) { throw buildBusinessValidationError("invoice_pool.not_editable", { status: "invalid_status_for_edit" }, 409); } const input = parsedBody.data; const updateData = {}; if ("remarks" in input) { updateData.remarks = input.remarks; } if ("invoiceCode" in input) { updateData.invoice_code = input.invoiceCode; } if ("invoiceNumber" in input) { updateData.invoice_number = input.invoiceNumber; } if ("invoiceDate" in input) { const parsedDate = parseDateOnlyString(input.invoiceDate); if (parsedDate === undefined) { throw buildBusinessValidationError("invoice_pool.date_invalid", { invoiceDate: "invalid_date" }, 400); } updateData.invoice_date = parsedDate; } if ("sellerName" in input) { updateData.seller_name = input.sellerName; } if ("buyerName" in input) { updateData.buyer_name = input.buyerName; } if ("amount" in input) { const parsedAmount = normalizeMoneyInput(input.amount); if (parsedAmount === undefined) { throw buildBusinessValidationError("invoice_pool.amount_invalid", { amount: "invalid_money" }, 400); } updateData.amount = parsedAmount; } if ("taxAmount" in input) { const parsedAmount = normalizeMoneyInput(input.taxAmount); if (parsedAmount === undefined) { throw buildBusinessValidationError("invoice_pool.tax_amount_invalid", { taxAmount: "invalid_money" }, 400); } updateData.tax_amount = parsedAmount; } if ("totalAmount" in input) { const parsedAmount = normalizeMoneyInput(input.totalAmount); if (parsedAmount === undefined) { throw buildBusinessValidationError("invoice_pool.total_amount_invalid", { totalAmount: "invalid_money" }, 400); } updateData.total_amount = parsedAmount; } if (input.status) { if (input.status === "available" && current.status === "ocr_failed") { const nextSnapshot = { invoiceCode: "invoiceCode" in input ? input.invoiceCode : current.invoice_code, invoiceNumber: "invoiceNumber" in input ? input.invoiceNumber : current.invoice_number, invoiceDate: "invoiceDate" in input ? input.invoiceDate : current.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, totalAmount: "totalAmount" in input ? normalizeMoneyInput(input.totalAmount) : current.total_amount?.toString?.() ?? null, sellerName: "sellerName" in input ? input.sellerName : current.seller_name, }; const missingFields = getMissingRequiredOcrFields(current.ocr_type, nextSnapshot); if (missingFields.length) { throw buildBusinessValidationError("invoice_pool.ocr_fields_missing", { ocr: "missing_required_fields", missingFields, }, 409); } updateData.status = "available"; updateData.ocr_last_error = null; updateData.ocr_completed_at = new Date(); updateData.ocr_locked_at = null; updateData.ocr_lock_owner = null; } else { throw buildBusinessValidationError("invoice_pool.status_transition_invalid", { status: "invalid_status_transition" }, 409); } } if (Object.keys(updateData).length === 0) { throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400); } updateData.updated_by = request.employee.id; const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: updateData, select: buildInvoiceDetailSelect(), }); const changes = {}; const trackedFields = [ "status", "remarks", "invoice_code", "invoice_number", "invoice_date", "seller_name", "buyer_name", "amount", "tax_amount", "total_amount", ]; for (const field of trackedFields) { const before = current[field]; const after = updated[field]; const beforeValue = before instanceof Date ? before.toISOString().slice(0, 10) : typeof before === "object" && before?.toString ? before.toString() : before ?? null; const afterValue = after instanceof Date ? after.toISOString().slice(0, 10) : typeof after === "object" && after?.toString ? after.toString() : after ?? null; if (beforeValue !== afterValue) { changes[field] = { before: beforeValue, after: afterValue, }; } } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_POOL_EDITED", targetType: "invoice_pool", targetId: invoiceId, extra: { changes, }, }), }); return updated; }); return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/archive", { preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400); } const invoiceId = parsedInvoiceId.data; const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, linked_reimbursement_id: true, travel_order_id: true, owner_employee_id: true, uploaded_by_employee_id: true, archived_reason: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canEdit = canManage || current.owner_employee_id === request.employee.id || current.uploaded_by_employee_id === request.employee.id; if (!canEdit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (current.linked_reimbursement_id || current.travel_order_id) { throw buildBusinessValidationError("invoice_pool.archive_bound", { invoice: "cannot_archive_bound_invoice" }, 409); } if (current.status === "archived" && current.archived_reason === "manual") { return tx.invoicePool.findUnique({ where: { id: invoiceId }, select: buildInvoiceDetailSelect(), }); } if (!["available", "ocr_failed"].includes(current.status)) { throw buildBusinessValidationError("invoice_pool.archive_status_invalid", { status: "invalid_status_for_archive" }, 409); } const now = new Date(); const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "archived", archived_reason: "manual", archived_at: now, updated_by: request.employee.id, }, select: buildInvoiceDetailSelect(), }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_POOL_ARCHIVED", targetType: "invoice_pool", targetId: invoiceId, extra: { reason: "manual", }, }), }); return updated; }); return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/unbind", { preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400); } const invoiceId = parsedInvoiceId.data; 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" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } 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); } if (invoice.linked_reimbursement_id) { const reimbursementRows = await tx.$queryRaw` SELECT id, status, owner_id, submitter_id FROM "reimbursement_forms" WHERE id = ${invoice.linked_reimbursement_id}::uuid FOR UPDATE `; if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const reimbursement = reimbursementRows[0]; const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; if (!isOwn) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["draft", "rejected"].includes(reimbursement.status)) { throw buildBusinessValidationError("reimbursement.invoice_unbind_status_invalid", { reimbursement: "invalid_status_for_unbind" }, 409); } } else { if (!fastify.hasPermission(request.employee, "travel.payment.manage")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } } responseInvoice = await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, travel_order_id: null, updated_by: request.employee.id, }, select: buildInvoiceDetailSelect(), }); }); return sendData(reply, request, normalizeInvoiceResponse(responseInvoice)); }, ); fastify.post( "/invoice-pool/:id/retry-ocr", { preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400); } const invoiceId = parsedInvoiceId.data; const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, ocr_type: true, ocr_attempts: true, ocr_last_requested_at: true, owner_employee_id: true, uploaded_by_employee_id: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canEdit = canManage || current.owner_employee_id === request.employee.id || current.uploaded_by_employee_id === request.employee.id; if (!canEdit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (current.status !== "ocr_failed") { throw buildBusinessValidationError("invoice_pool.ocr_retry_status_invalid", { status: "invalid_status_for_retry" }, 409); } if (!isOcrEnabledForType(fastify.config, current.ocr_type)) { throw buildBusinessValidationError("invoice_pool.ocr_unavailable", { ocr: "disabled" }, 409); } const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3); if ((current.ocr_attempts ?? 0) >= maxAttempts) { throw new AppError({ code: "VALID_006", message: "OCR 重试次数已达上限", statusCode: 409, details: { ocr: "attempts_exceeded" }, }); } const now = new Date(); if (current.ocr_last_requested_at) { const diffMs = now.getTime() - new Date(current.ocr_last_requested_at).getTime(); if (diffMs < 60_000) { throw new AppError({ code: "VALID_007", message: "OCR 重试过于频繁,请稍后再试", statusCode: 409, details: { ocr: "too_frequent" }, }); } } const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "pending_ocr", ocr_provider: fastify.config.ocrProvider, ocr_last_requested_at: now, ocr_last_error: null, ocr_completed_at: null, ocr_locked_at: null, ocr_lock_owner: null, updated_by: request.employee.id, }, select: buildInvoiceDetailSelect(), }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_OCR_RETRIED", targetType: "invoice_pool", targetId: invoiceId, extra: { attempts: current.ocr_attempts ?? 0, nextStatus: "pending_ocr", }, }), }); return updated; }); enqueueOcrInvoice(fastify, invoice.id).catch((error) => { request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue retry OCR invoice job"); }); return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/override-duplicate", { preHandler: fastify.requirePermission("expense.finance.override_duplicate"), }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400); } const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw buildBusinessValidationError("invoice_pool.override_input_invalid", parsedBody.error.flatten()); } const invoiceId = parsedInvoiceId.data; const { refFormNumber, reason } = parsedBody.data; const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, linked_reimbursement_id: true, travel_order_id: true, file_sha256: true, invoice_code: true, invoice_number: true, is_duplicate_override: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (current.linked_reimbursement_id || current.travel_order_id) { throw buildBusinessValidationError("invoice_pool.override_bound", { invoice: "already_bound" }, 409); } if (!["available", "ocr_failed"].includes(current.status)) { throw buildBusinessValidationError("invoice_pool.override_status_invalid", { status: "invalid_status_for_override" }, 409); } if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) { throw buildBusinessValidationError("invoice_pool.dedup_identity_missing", { invoice: "missing_dedup_key" }, 409); } const dedupFilters = []; if (current.file_sha256) { dedupFilters.push({ file_sha256: current.file_sha256 }); } if (current.invoice_code && current.invoice_number) { dedupFilters.push({ invoice_code: current.invoice_code, invoice_number: current.invoice_number, }); } const conflicts = await tx.invoicePool.findMany({ where: { id: { not: invoiceId }, is_duplicate_override: false, AND: [ { OR: dedupFilters }, { OR: [ { status: "bound" }, { status: "archived", archived_reason: "used_complete" }, ], }, ], }, select: { id: true, status: true, linked_reimbursement_id: true, linked_reimbursement: { select: { status: true, form_number: true, }, }, }, }); const blocked = conflicts.find((item) => ["approved", "paid"].includes(item.linked_reimbursement?.status), ); if (blocked) { throw new AppError({ code: "VALID_004", message: "原单已审批/已付款,禁止判重例外", statusCode: 409, details: { conflictInvoiceId: blocked.id, conflictFormNumber: blocked.linked_reimbursement?.form_number ?? null, }, }); } const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: { is_duplicate_override: true, override_ref_form_number: refFormNumber, override_reason: reason, overridden_by: request.employee.id, updated_by: request.employee.id, }, select: buildInvoiceDetailSelect(), }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_DUPLICATE_OVERRIDDEN", targetType: "invoice_pool", targetId: invoiceId, extra: { refFormNumber, reason, conflictInvoiceIds: conflicts.map((item) => item.id), }, }), }); return updated; }); return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/bind", { preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400); } const parsedBody = bindBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw buildBusinessValidationError("reimbursement.invalid_input", parsedBody.error.flatten(), 400); } const invoiceId = parsedInvoiceId.data; 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" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const invoice = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { status: true, owner_employee_id: true, uploaded_by_employee_id: true, file_sha256: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, original_name: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, travel_order_id: true, archived_reason: true, is_duplicate_override: true, ocr_result: { select: { result_json: true } }, }, }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } 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: "发票已被其他业务单据占用/绑定", statusCode: 409, }); } if (!sameFormRetry && invoice.status !== "available") { throw new AppError({ code: "VALID_005", message: "发票已被其他业务单据占用/绑定", statusCode: 409, details: { status: invoice.status }, }); } if (!sameFormRetry && !invoice.is_duplicate_override) { const duplicateBlocker = await resolveInvoiceDedupBlocker(tx, { excludeInvoiceId: invoiceId, fileSha256: invoice.file_sha256, invoiceCode: invoice.invoice_code, invoiceNumber: invoice.invoice_number, ownerEmployeeId: invoice.owner_employee_id, }); if (duplicateBlocker) { throw new AppError({ code: "VALID_003", message: "发票不能重复添加", statusCode: 409, details: { invoice: duplicateBlocker.reason, message: mapInvoiceUnavailableReason(duplicateBlocker.reason), conflictInvoiceId: duplicateBlocker.conflict.id, conflictFormNumber: duplicateBlocker.conflict.linked_reimbursement?.form_number ?? null, }, }); } } if ("reimbursementId" in parsedBody.data) { if (!fastify.hasPermission(request.employee, "invoice.upload")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } 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" WHERE id = ${reimbursementId}::uuid FOR UPDATE `; if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const reimbursement = reimbursementRows[0]; const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; if (!isOwn) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["draft", "rejected"].includes(reimbursement.status)) { throw buildBusinessValidationError("reimbursement.invoice_bind_status_invalid", { reimbursement: "invalid_status_for_bind" }, 409); } if (invoice.owner_employee_id !== reimbursement.owner_id) { 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, flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard, departmentId: reimbursement.department_id ?? null, strictDepartment: true, }); if (invoiceGuardGate.enabled && !linkedItemId && !createItem) { throw buildBusinessValidationError("reimbursement.invoice_item_required", { linkedItemId: "required_when_invoice_guard_enabled" }, 400); } const resolvedLink = await resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate: reimbursement.reimbursement_date, invoice, linkedItemId, actorId: request.employee.id, createItem, }); await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "bound", linked_reimbursement_id: reimbursementId, linked_item_id: resolvedLink.linkedItemId, travel_order_id: null, updated_by: request.employee.id, }, }); if (resolvedLink.createdItem) { await recomputeReimbursementTotal(tx, reimbursementId, request.employee.id); } return; } if (!fastify.hasPermission(request.employee, "travel.payment.manage")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const { travelOrderId } = parsedBody.data; const travelOrderRows = await tx.$queryRaw` SELECT id, status FROM "travel_orders" WHERE id = ${travelOrderId}::uuid FOR UPDATE `; if (!Array.isArray(travelOrderRows) || travelOrderRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const travelOrder = travelOrderRows[0]; if (!["confirmed", "paid"].includes(travelOrder.status)) { throw buildBusinessValidationError("reimbursement.travel_invoice_bind_status_invalid", { travelOrder: "invalid_status_for_bind" }, 409); } await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "bound", linked_reimbursement_id: null, linked_item_id: null, travel_order_id: travelOrderId, updated_by: request.employee.id, }, }); }); } catch (error) { if (isInvoiceDedupConflict(error)) { throw new AppError({ code: "VALID_003", message: "发票判重命中", statusCode: 409, }); } if (error?.code === "P2002") { fastify.log.warn( { meta: error?.meta ?? {} }, "Unexpected P2002 not from invoice dedup constraints", ); } throw error; } const invoice = await fastify.prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, linked_reimbursement_id: true, linked_item_id: true, travel_order_id: true, total_amount: true, }, }); return sendData(reply, request, { id: invoice.id, status: invoice.status, linkedReimbursementId: invoice.linked_reimbursement_id, linkedItemId: invoice.linked_item_id, linkedTravelOrderId: invoice.travel_order_id, totalAmount: toMoneyString(invoice.total_amount), }); }, ); } module.exports = invoicePoolRoutes;