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 { 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(), }) .strict(), 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; } return new Date(`${trimmed}T00:00:00.000Z`); } 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 }) { if (linkedItemId) { const linkedItem = await tx.reimbursementItem.findFirst({ where: { id: linkedItemId, reimbursement_id: reimbursementId, }, select: { id: true }, }); if (!linkedItem) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { linkedItemId: "item_not_in_reimbursement" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "missing_total_amount_for_auto_item" }, }); } const expenseType = await resolveDefaultExpenseType(tx); if (!expenseType) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { expenseTypeId: "missing_enabled_expense_type" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { file: "only_single_file_supported" }, }); } fileName = part.filename ?? "upload"; fileMimeType = part.mimetype; if (!isPdfInvoiceUpload(fileName, fileMimeType)) { throw new AppError({ code: "VALID_001", message: "请上传 PDF 发票文件", statusCode: 400, details: { file: "pdf_invoice_required" }, }); } const chunks = []; for await (const chunk of part.file) { fileSize += chunk.length; if (fileSize > invoiceUploadMaxBytes) { throw new AppError({ code: "VALID_001", message: invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES ? "PDF 发票不能超过 7 MiB" : "参数校验失败", statusCode: 400, details: { file: invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES ? "ocr_pdf_too_large" : "file_too_large", maxBytes: invoiceUploadMaxBytes, }, }); } chunks.push(chunk); } if (fileSize === 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { file: "empty_file" }, }); } fileBuffer = Buffer.concat(chunks); continue; } fields[part.fieldname] = part.value; } } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { file: "file_too_large" }, }); } throw error; } if (!fileBuffer) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { file: "missing" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { owner_employee_id: "invalid_owner" }, }); } } const ocrType = fields.ocr_type ?? "general"; if (!OCR_TYPE_ALLOWLIST.has(ocrType)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { ocr_type: "invalid" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsedBody.error.flatten(), }); } if (Object.keys(parsedBody.data).length === 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty_patch" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_edit" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { invoiceDate: "invalid_date" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { amount: "invalid_money" }, }); } updateData.amount = parsedAmount; } if ("taxAmount" in input) { const parsedAmount = normalizeMoneyInput(input.taxAmount); if (parsedAmount === undefined) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { taxAmount: "invalid_money" }, }); } updateData.tax_amount = parsedAmount; } if ("totalAmount" in input) { const parsedAmount = normalizeMoneyInput(input.totalAmount); if (parsedAmount === undefined) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { totalAmount: "invalid_money" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { ocr: "missing_required_fields", missingFields, }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_transition" }, }); } } if (Object.keys(updateData).length === 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty_patch" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "cannot_archive_bound_invoice" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_archive" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const invoiceId = parsedInvoiceId.data; let responseInvoice = null; await fastify.prisma.$transaction(async (tx) => { 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.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "not_bound" }, }); } 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; const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!isOwn && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { reimbursement: "invalid_status_for_unbind" }, }); } } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_retry" }, }); } if (!isOcrEnabledForType(fastify.config, current.ocr_type)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { ocr: "disabled" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "already_bound" }, }); } if (!["available", "ocr_failed"].includes(current.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_override" }, }); } if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "missing_dedup_key" }, }); } 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const parsedBody = bindBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsedBody.error.flatten(), }); } const invoiceId = parsedInvoiceId.data; try { await fastify.prisma.$transaction(async (tx) => { 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, }, }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id) { throw new AppError({ code: "VALID_005", message: "发票已被其他业务单据占用/绑定", statusCode: 409, }); } if (invoice.status !== "available") { throw new AppError({ code: "VALID_005", message: "发票已被其他业务单据占用/绑定", statusCode: 409, details: { status: invoice.status }, }); } if (!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 } = parsedBody.data; const reimbursementRows = await tx.$queryRaw` SELECT id, owner_id, submitter_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; const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!isOwn && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { reimbursement: "invalid_status_for_bind" }, }); } if (invoice.owner_employee_id !== reimbursement.owner_id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "owner_mismatch" }, }); } const resolvedLink = await resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate: reimbursement.reimbursement_date, invoice, linkedItemId, actorId: request.employee.id, }); 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 new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { travelOrder: "invalid_status_for_bind" }, }); } 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;