手动填入的不和规范的信息,解析后见如下:    

一、项目背景

在企业财务管理中,发票的录入、校验和验真是一项高频且容易出错的工作。传统方式依赖人工逐字录入,不仅效率低下,而且容易出现金额错误、漏项等问题。本文将详细介绍如何利用 AI 视觉大模型、OCR 技术和 React 前端,构建一个从「发票图片上传」到「AI 智能识别」再到「第三方验真」的全流程自动化系统。

二、技术栈总览

层次 技术 说明
前端 React 19 + Ant Design 5 + Zustand 三步向导式交互,置信度可视化
后端 Python Flask RESTful API,文件上传与数据持久化
AI 识别 LangChain + 通义千问 Qwen 3.5 Omni Plus(视觉模型) 直接从发票图片提取结构化 JSON
降级 OCR PaddleOCR + Qwen 3.7 Plus(文本模型) 低置信度时自动降级
发票验真 阿里云 OCR 增值税发票核验 API 官方渠道验证发票真伪
数据库 MySQL(PyMySQL) 存储发票数据、明细、OCR 日志

三、系统架构

┌──────────────────────────────────────────────────────────┐
│                     React 前端                            │
│  ┌──────────┐   ┌──────────────┐   ┌──────────────────┐  │
│  │ 上传组件  │──▶│ 校验工作台    │──▶│   完成页(统计)    │  │
│  │ (拖拽/粘贴)│   │ (表单+明细+验真)│  │                  │  │
│  └──────────┘   └──────────────┘   └──────────────────┘  │
│        │                │                    ▲            │
│        ▼                ▼                    │            │
│  ┌─────────────────────────────────────────────────┐     │
│  │          Zustand Store (状态管理)                 │     │
│  └─────────────────────────────────────────────────┘     │
│        │                ▲                                 │
└────────┼────────────────┼─────────────────────────────────┘
         │  HTTP API      │
         ▼                │
┌──────────────────────────────────────────────────────────┐
│                   Flask 后端                               │
│  ┌───────────┐  ┌──────────────┐  ┌───────────────────┐  │
│  │ /upload   │  │ /confirm     │  │ /<id>/verify      │  │
│  │ 图片+OCR  │  │ 确认入库      │  │ 阿里云验真         │  │
│  └─────┬─────┘  └──────────────┘  └────────┬──────────┘  │
│        │                                    │             │
│  ┌─────▼─────────────────────────┐   ┌─────▼──────────┐  │
│  │  Qwen-VL 视觉模型 (主)         │   │ 阿里云          │  │
│  │  PaddleOCR + Qwen (降级)      │   │ VerifyVATInvoice│  │
│  └───────────────────────────────┘   └────────────────┘  │
│        │                                                    │
│  ┌─────▼──────────────────────────────┐                   │
│  │  MySQL (invoices / items / logs)    │                   │
│  └────────────────────────────────────┘                   │
└──────────────────────────────────────────────────────────┘

四、数据库设计

系统设计了三张表,分别存储发票主信息、商品明细和 OCR 日志:

-- 发票主表
CREATE TABLE IF NOT EXISTS invoices (
    id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
    invoice_type VARCHAR(50) COMMENT '发票类型',
    invoice_code VARCHAR(20) COMMENT '发票代码',
    invoice_number VARCHAR(20) COMMENT '发票号码',
    invoice_date DATE COMMENT '开票日期',
    check_code VARCHAR(50) COMMENT '校验码',
    buyer_name VARCHAR(200) COMMENT '购方名称',
    buyer_tax_number VARCHAR(50) COMMENT '购方纳税人识别号',
    seller_name VARCHAR(200) COMMENT '销方名称',
    seller_tax_number VARCHAR(50) COMMENT '销方纳税人识别号',
    total_amount_ex_tax DECIMAL(15,2) COMMENT '不含税金额',
    total_tax DECIMAL(15,2) COMMENT '合计税额',
    total_amount DECIMAL(15,2) COMMENT '价税合计',
    original_file_path VARCHAR(500) COMMENT '原始文件路径',
    preview_file_path VARCHAR(500) COMMENT '预览图路径',
    ocr_result_json TEXT COMMENT 'AI 原始识别结果',
    status ENUM('pending', 'confirmed', 'rejected') DEFAULT 'pending',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 商品明细表
CREATE TABLE IF NOT EXISTS invoice_items (
    id INT AUTO_INCREMENT PRIMARY KEY,
    invoice_id VARCHAR(36) NOT NULL,
    item_name VARCHAR(200) COMMENT '商品/服务名称',
    specification VARCHAR(100) COMMENT '规格型号',
    unit VARCHAR(20) COMMENT '单位',
    quantity DECIMAL(15,4) COMMENT '数量',
    unit_price DECIMAL(15,4) COMMENT '单价',
    amount DECIMAL(15,2) COMMENT '金额',
    tax_rate VARCHAR(20) COMMENT '税率',
    tax_amount DECIMAL(15,2) COMMENT '税额',
    FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
);

-- OCR 日志表(记录识别方法、置信度、验真结果等)
CREATE TABLE IF NOT EXISTS invoice_ocr_logs (
    id INT AUTO_INCREMENT PRIMARY KEY,
    invoice_id VARCHAR(36),
    ocr_method VARCHAR(50) COMMENT 'qwen-vl / paddleocr / paddleocr+qwen',
    confidence_overall FLOAT COMMENT '整体置信度',
    api_latency_ms INT COMMENT 'API 耗时(ms)',
    token_usage INT COMMENT 'Token 消耗',
    human_modified_fields TEXT COMMENT '人工修改的字段 JSON',
    verify_result TEXT COMMENT '验真结果 JSON',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL
);

status 字段设计为 pending → confirmed/rejected 的状态机,记录每张发票从 AI 识别到人工确认的全生命周期。

五、后端核心实现

5.1 Pydantic 结构化输出模型

为了让 AI 模型返回结构化的发票数据,使用 Pydantic 定义了严格的数据模型:

from pydantic import BaseModel, Field
from typing import Optional

class InvoiceItem(BaseModel):
    name: str = Field(description="商品/服务名称")
    specification: Optional[str] = Field(default=None, description="规格型号")
    unit: Optional[str] = Field(default=None, description="单位")
    quantity: Optional[float] = Field(default=None, description="数量")
    unit_price: Optional[float] = Field(default=None, description="单价")
    amount: Optional[float] = Field(default=None, description="金额")
    tax_rate: Optional[str] = Field(default=None, description="税率")
    tax_amount: Optional[float] = Field(default=None, description="税额")

class InvoiceResult(BaseModel):
    invoice_type: str = Field(description="发票类型")
    invoice_code: Optional[str] = Field(default=None, description="发票代码")
    invoice_number: Optional[str] = Field(default=None, description="发票号码")
    invoice_date: Optional[str] = Field(default=None, description="开票日期")
    check_code: Optional[str] = Field(default=None, description="校验码")
    buyer_name: Optional[str] = Field(default=None, description="购方名称")
    buyer_tax_number: Optional[str] = Field(default=None, description="购方纳税人识别号")
    seller_name: Optional[str] = Field(default=None, description="销方名称")
    seller_tax_number: Optional[str] = Field(default=None, description="销方纳税人识别号")
    total_amount_ex_tax: Optional[float] = Field(default=None, description="不含税金额")
    total_tax: Optional[float] = Field(default=None, description="合计税额")
    total_amount: Optional[float] = Field(default=None, description="价税合计")
    total_amount_cn: Optional[str] = Field(default=None, description="价税合计中文大写")
    items: list[InvoiceItem] = Field(default_factory=list, description="商品明细行")
    confidence: dict[str, float] = Field(default_factory=dict, description="各字段置信度 0-1")

5.2 图片预处理

上传的图片/PDF 需要经过预处理才能发送给 AI 模型。对于图片,进行等比缩放和压缩;对于 PDF,使用 PyMuPDF 转换为图片:

from PIL import Image
import fitz  # PyMuPDF
import base64, io

def preprocess_image(file_bytes, max_long_side=2048, quality=85):
    """压缩预处理图片,返回 base64 data URL"""
    img = Image.open(io.BytesIO(file_bytes))

    # 等比缩放(超过 2048px 的长边)
    w, h = img.size
    if max(w, h) > max_long_side:
        ratio = max_long_side / max(w, h)
        img = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS)

    # RGBA/P 模式转 RGB
    if img.mode in ('RGBA', 'P'):
        img = img.convert('RGB')

    # 压缩为 JPEG
    buffer = io.BytesIO()
    img.save(buffer, format='JPEG', quality=quality)
    b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
    return f"data:image/jpeg;base64,{b64}", buffer.getvalue()

def pdf_to_images(file_bytes, dpi=150):
    """PDF 转图片列表"""
    doc = fitz.open(stream=file_bytes, filetype="pdf")
    results = []
    for page_num in range(min(len(doc), 10)):
        page = doc[page_num]
        mat = fitz.Matrix(dpi / 72, dpi / 72)
        pix = page.get_pixmap(matrix=mat)
        img_bytes = pix.tobytes("png")
        data_url, _ = preprocess_image(img_bytes)
        results.append((data_url, img_bytes))
    doc.close()
    return results

5.3 主 OCR 引擎:Qwen-VL 视觉模型

核心识别采用阿里云通义千问 Qwen 3.5 Omni Plus 视觉语言模型。通过 LangChain 的 ChatOpenAI 接口(兼容 OpenAI 协议),将发票图片和严格的 JSON Schema 提示词一起发送给模型:

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
VISION_MODEL = "qwen3.5-omni-plus"

def extract_with_qwen_vl(image_data_urls):
    """使用 Qwen-VL 视觉模型直接识别发票"""
    llm = ChatOpenAI(
        model=VISION_MODEL,
        openai_api_key=DASHSCOPE_API_KEY,
        openai_api_base=DASHSCOPE_BASE_URL,
    )

    content = [
        {
            "type": "text",
            "text": (
                "请仔细识别这张发票图片,提取所有字段信息。\n"
                "严格按以下 JSON 格式返回,不要添加任何其他文字:\n"
                f"{INVOICE_JSON_SCHEMA}\n\n"
                "注意:\n"
                "- 金额字段必须是数字,不要带¥符号\n"
                "- confidence 是每个字段的置信度评分(0-1),模糊或看不清的字段给低分\n"
                "- 如果某个字段无法识别,设为 null"
            ),
        }
    ]
    for url in image_data_urls:
        content.append({"type": "image_url", "image_url": {"url": url}})

    message = HumanMessage(content=content)
    response = llm.invoke([message])
    return _parse_invoice_json(response.content)

关键设计点:

  • 利用 DashScope 兼容 OpenAI 的接口协议,无需额外 SDK
  • Prompt 中同时给出 JSON Schema 和注意事项,确保模型输出可解析的结构化数据
  • 每个字段要求模型自评置信度(0-1),为后续的降级策略提供依据

5.4 降级 OCR:PaddleOCR + Qwen 文本模型

当 Qwen-VL 的置信度低于 0.7 时,系统自动降级到 PaddleOCR 进行文字提取,再用 Qwen 文本模型进行结构化:

from paddleocr import PaddleOCR

def extract_with_paddleocr(image_bytes):
    """使用 PaddleOCR 提取文本和坐标"""
    ocr = PaddleOCR(use_angle_cls=True, lang='ch', show_log=False)
    result = ocr.ocr(image_bytes, cls=True)
    lines = []
    for page in result:
        if page:
            for line in page:
                box = line[0]       # 坐标框
                text = line[1][0]   # 文字
                conf = line[1][1]   # 置信度
                box_str = str([[int(p[0]), int(p[1])] for p in box])
                lines.append(f"[位置:{box_str}] {text} (置信度:{conf:.2f})")
    return "\n".join(lines)

TEXT_MODEL = "qwen3.7-plus"

def structure_with_text_model(ocr_text):
    """使用 Qwen 文本模型对 PaddleOCR 输出进行结构化"""
    llm = ChatOpenAI(
        model=TEXT_MODEL,
        openai_api_key=DASHSCOPE_API_KEY,
        openai_api_base=DASHSCOPE_BASE_URL,
    )
    prompt = (
        f"以下是 OCR 从发票图片中提取的文本和坐标信息。\n"
        f"请根据这些信息提取发票的结构化数据。\n"
        f"严格按以下 JSON 格式返回:\n"
        f"{INVOICE_JSON_SCHEMA}\n\n"
        f"OCR 识别结果:\n{ocr_text}\n\n"
    )
    response = llm.invoke([HumanMessage(content=prompt)])
    return _parse_invoice_json(response.content)

降级策略: PaddleOCR 提供位置感知的文字提取,Qwen 文本模型根据坐标关系理解字段布局,两者配合弥补了视觉模型在模糊图片上的不足。

5.5 财务验算

AI 识别结果需要通过财务逻辑校验,确保数学一致性:

def validate_invoice(result: InvoiceResult) -> list[dict]:
    """财务逻辑校验"""
    errors = []

    # 规则1:价税合计 = 不含税金额 + 合计税额
    if all([result.total_amount_ex_tax, result.total_tax, result.total_amount]):
        expected = round(result.total_amount_ex_tax + result.total_tax, 2)
        actual = round(result.total_amount, 2)
        if abs(expected - actual) > 0.01:
            errors.append({
                "field": "total_amount",
                "message": f"金额验算失败: {result.total_amount_ex_tax} + {result.total_tax} = {expected} ≠ {actual}",
            })

    # 规则2:明细金额之和 = 合计金额
    if result.items:
        items_amount_sum = round(sum(item.amount or 0 for item in result.items), 2)
        items_tax_sum = round(sum(item.tax_amount or 0 for item in result.items), 2)

        if result.total_amount_ex_tax and abs(items_amount_sum - round(result.total_amount_ex_tax, 2)) > 0.01:
            errors.append({
                "field": "items_amount",
                "message": f"明细金额之和 {items_amount_sum} ≠ 合计金额 {result.total_amount_ex_tax}",
            })
        if result.total_tax and abs(items_tax_sum - round(result.total_tax, 2)) > 0.01:
            errors.append({
                "field": "items_tax",
                "message": f"明细税额之和 {items_tax_sum} ≠ 合计税额 {result.total_tax}",
            })

    return errors

5.6 主处理流程

将以上各环节串联成完整的处理流水线:

def process_upload(file_bytes, filename):
    """完整流程:预处理 → OCR → 结构化 → 验算 → 入库"""
    start_time = time.time()
    invoice_id = str(uuid.uuid4())
    ocr_method = "qwen3.5-omni-plus"
    confidence_overall = 0.0

    # Step 1: 预处理(图片压缩 or PDF 转图片)
    ext = os.path.splitext(filename)[1].lower()
    if ext == '.pdf':
        pages = pdf_to_images(file_bytes)
        image_data_urls = [p[0] for p in pages]
    elif ext in ('.jpg', '.jpeg', '.png'):
        preview_data_url, _ = preprocess_image(file_bytes)
        image_data_urls = [preview_data_url]

    # Step 2: Qwen-VL 视觉模型识别
    result = extract_with_qwen_vl(image_data_urls)
    if result and result.confidence:
        confidence_overall = round(
            sum(result.confidence.values()) / len(result.confidence), 4
        )

    # Step 3: 低置信度时降级到 PaddleOCR + Qwen 文本模型
    if result is None or confidence_overall < 0.7:
        ocr_text = extract_with_paddleocr(file_bytes)
        result = structure_with_text_model(ocr_text)
        ocr_method = "paddleocr+qwen"

    # Step 4: 财务验算
    validation_errors = validate_invoice(result)

    # Step 5: 存入数据库(status=pending)
    latency_ms = int((time.time() - start_time) * 1000)
    # ... 数据库插入逻辑 ...

    return {
        "invoice_id": invoice_id,
        "ocr_method": ocr_method,
        "confidence_overall": confidence_overall,
        "latency_ms": latency_ms,
        "fields": result.model_dump(),
        "validation_errors": validation_errors,
        "preview_data_url": preview_data_url,
    }

5.7 发票验真:阿里云增值税发票核验 API

确认入库后,调用阿里云官方发票核验接口进行真伪验证:

from alibabacloud_ocr_api20210707.client import Client
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_ocr_api20210707 import models as ocr_models

def verify_invoice_with_aliyun(invoice_id):
    """调用阿里云发票核验 API 验证发票真伪"""
    invoice = get_invoice(invoice_id)
    ocr_result = json.loads(invoice.get('ocr_result_json', '{}'))

    # 从发票数据中提取关键字段
    invoice_number = invoice.get('invoice_number', '')
    invoice_date = invoice.get('invoice_date', '')
    date_str = str(invoice_date).replace('-', '')[:8]  # YYYYMMDD

    # 初始化阿里云 OCR 客户端
    config = open_api_models.Config(
        access_key_id=os.getenv('ALIBABA_CLOUD_ACCESS_KEY_ID', ''),
        access_key_secret=os.getenv('ALIBABA_CLOUD_ACCESS_KEY_SECRET', ''),
    )
    config.endpoint = 'ocr-api.cn-hangzhou.aliyuncs.com'
    client = Client(config)

    # 区分数电发票和纸质发票
    is_digital = len(str(invoice_number)) >= 20  # 数电发票号码 >= 20位

    request = ocr_models.VerifyVATInvoiceRequest(
        invoice_code=None if is_digital else invoice.get('invoice_code'),
        invoice_no=str(invoice_number),
        invoice_date=date_str,
    )

    # 数电发票传含税金额,纸质发票传不含税金额
    if is_digital:
        request.invoice_sum = str(invoice.get('total_amount'))
    else:
        request.invoice_sum = str(invoice.get('total_amount_ex_tax'))

    # 校验码后6位
    check_code = invoice.get('check_code', '')
    if check_code and len(str(check_code)) >= 6:
        request.verify_code = str(check_code)[-6:]

    # 调用核验 API
    response = client.verify_vatinvoice(request)
    # ... 解析响应、判断验真结果 ...

验真结果处理:

响应码 含义 系统状态
001 查验一致 verified(如 invalidMark 为 Y 则为已作废,H 为已冲红)
006 信息不一致 inconsistent
009 发票不存在 not_found

六、Flask API 层

后端通过 Flask 暴露 7 个 RESTful 接口:

@app.route("/api/invoice/upload", methods=["POST"])
def invoice_upload():
    """上传发票图片/PDF → OCR → 返回结构化数据"""
    file = request.files['file']
    file_bytes = file.read()
    result = process_upload(file_bytes, file.filename)
    return jsonify({"code": 200, "data": result})

@app.route("/api/invoice/confirm", methods=["POST"])
def invoice_confirm():
    """人工确认后提交入库"""
    data = request.json
    result = confirm_invoice(data["invoice_id"], data["fields"], data["items"])
    return jsonify({"code": 200, "data": result})

@app.route("/api/invoice/<invoice_id>/verify", methods=["POST"])
def invoice_verify(invoice_id):
    """发票验真(阿里云发票核验 API)"""
    result = verify_invoice_with_aliyun(invoice_id)
    return jsonify({"code": 200, "data": result})

# 还有 /list、/<id>、/stats、/<id>/image 四个查询接口

所有接口统一返回 { code: 200, data: {...} } 格式,前端通过 code 判断成功与否。

七、前端实现

7.1 API 服务层

封装了一个独立的 InvoiceService 类,统一管理与后端的通信:

const BASE_URL = process.env.REACT_APP_CHAT_API_URL || 'http://localhost:5000';

class InvoiceService {
  async uploadAndRecognize(file) {
    const formData = new FormData();
    formData.append('file', file);
    const res = await fetch(`${BASE_URL}/api/invoice/upload`, {
      method: 'POST', body: formData,
    });
    const data = await res.json();
    if (data.code === 200) return data.data;
    throw new Error(data.msg || '发票识别失败');
  }

  async verifyInvoice(invoiceId) {
    const res = await fetch(`${BASE_URL}/api/invoice/${invoiceId}/verify`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
    });
    const data = await res.json();
    if (data.code === 200) return data.data;
    throw new Error(data.msg || '验真失败');
  }
  // confirmInvoice、getInvoices、getInvoiceDetail、getStats ...
}

7.2 状态管理(Zustand Store)

使用 Zustand 管理全局状态,涵盖上传、识别、确认、验真、列表等所有阶段:

export const useInvoiceStore = create((set, get) => ({
  currentStep: 0,        // 0=上传, 1=校验, 2=完成
  uploading: false,
  invoiceData: null,
  confidence: {},
  confidenceOverall: 0,
  validationErrors: [],
  verifyResult: null,

  // 上传并识别
  uploadAndRecognize: async (file) => {
    set({ uploading: true });
    const result = await invoiceService.uploadAndRecognize(file);
    set({
      currentStep: 1,
      invoiceData: result.fields,
      confidenceOverall: result.confidence_overall,
      ocrMethod: result.ocr_method,
      latencyMs: result.latency_ms,
      validationErrors: result.validation_errors || [],
    });
  },

  // 发票验真
  verifyInvoice: async (invoiceId) => {
    set({ verifying: true, verifyResult: null });
    const result = await invoiceService.verifyInvoice(invoiceId);
    set({ verifying: false, verifyResult: result });
  },

  // 确认入库
  confirmInvoice: async () => {
    const { invoiceId, invoiceData } = get();
    const fields = { ...invoiceData };
    delete fields.items; delete fields.confidence;
    await invoiceService.confirmInvoice(invoiceId, fields, invoiceData.items);
    set({ currentStep: 2 });
  },
}));

7.3 三步向导式交互

主页面采用 Steps 组件实现三步向导:

Step 0 — 上传发票: 支持拖拽上传、点击选择、Ctrl+V 粘贴截图三种方式,文件类型限制为 JPG/PNG/PDF,大小限制 10MB。

Step 1 — 校验工作台: 左右分栏布局——左侧是带缩放/平移的发票原图预览,右侧是 AI 提取的可编辑表单。表单字段根据置信度显示不同颜色的边框:

const getBorderColor = (key) => {
  const score = confidence[key];
  if (score >= 0.7) return '#52c41a';   // 绿色 - 可信
  if (score >= 0.5) return '#fa8c16';   // 橙色 - 需复核
  return '#ff4d4f';                      // 红色 - 低置信
};

工作台底部提供三个操作按钮:

  • 重新识别 — 回到上传步骤
  • 发票验真 — 调用阿里云核验 API
  • 确认入库 — 将修正后的数据提交入库

Step 2 — 完成: 显示成功状态和 AI 效能统计(识别总数、平均置信度、平均耗时、人工修改率)。

7.4 商品明细编辑

商品明细使用可编辑的 Ant Design Table,支持内联编辑每行的名称、规格、单位、数量、单价、金额、税率、税额,以及动态增删行:

const InvoiceItemsTable = ({ items, onUpdateItem, onAddItem, onRemoveItem }) => {
  const columns = [
    { title: '名称', dataIndex: 'name',
      render: (val, _, idx) => (
        <Input value={val || ''} onChange={(e) => onUpdateItem(idx, 'name', e.target.value)} />
      ),
    },
    // ... 其他列 ...
    {
      title: '操作',
      render: (_, __, idx) => (
        <Popconfirm title="删除该行?" onConfirm={() => onRemoveItem(idx)}>
          <Button type="text" danger icon={<DeleteOutlined />} />
        </Popconfirm>
      ),
    },
  ];
  return <Table dataSource={items} columns={columns} pagination={false} />;
};

7.5 发票原图拖拽区

import React, { useState, useRef, useCallback, useEffect } from 'react';

const InvoiceImagePreview = ({ dataUrl, defaultScale = 1 }) => {
  const [scale, setScale] = useState(defaultScale);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
  const containerRef = useRef(null);

  // 滚轮缩放
  const handleWheel = useCallback((e) => {
    e.preventDefault();
    const delta = e.deltaY > 0 ? -0.1 : 0.1;
    setScale((prev) => Math.max(0.3, Math.min(5, prev + delta)));
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (container) {
      container.addEventListener('wheel', handleWheel, { passive: false });
      return () => container.removeEventListener('wheel', handleWheel);
    }
  }, [handleWheel]);

  // 拖拽
  const handleMouseDown = (e) => {
    setIsDragging(true);
    setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
  };

  const handleMouseMove = (e) => {
    if (!isDragging) return;
    setPosition({
      x: e.clientX - dragStart.x,
      y: e.clientY - dragStart.y,
    });
  };

  const handleMouseUp = () => {
    setIsDragging(false);
  };

  const resetView = () => {
    setScale(defaultScale);
    setPosition({ x: 0, y: 0 });
  };

  if (!dataUrl) {
    return (
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#bbb' }}>
        暂无图片预览
      </div>
    );
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      {/* 工具栏 */}
      <div style={{
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        padding: '6px 12px',
        borderBottom: '1px solid #f0f0f0',
        background: '#fafafa',
        fontSize: 12,
      }}>
        <span style={{ fontWeight: 600 }}>发票原图</span>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <span style={{ color: '#999' }}>{Math.round(scale * 100)}%</span>
          <a onClick={() => setScale((s) => Math.min(5, s + 0.2))} style={{ fontSize: 14, cursor: 'pointer' }}>+</a>
          <a onClick={() => setScale((s) => Math.max(0.3, s - 0.2))} style={{ fontSize: 14, cursor: 'pointer' }}>-</a>
          <a onClick={resetView} style={{ fontSize: 11, cursor: 'pointer' }}>重置</a>
        </div>
      </div>

      {/* 图片区域 */}
      <div
        ref={containerRef}
        style={{
          flex: 1,
          overflow: 'hidden',
          background: '#f5f5f5',
          cursor: isDragging ? 'grabbing' : 'grab',
          position: 'relative',
        }}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseUp}
      >
        <div style={{
          position: 'absolute',
          top: '50%',
          left: '50%',
          transform: `translate(-50%, -50%) translate(${position.x}px, ${position.y}px) scale(${scale})`,
          transition: isDragging ? 'none' : 'transform 0.1s ease',
        }}>
          <img
            src={dataUrl}
            alt="发票原图"
            style={{
              maxWidth: '90%',
              maxHeight: '90%',
              boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
              userSelect: 'none',
              pointerEvents: 'none',
            }}
            draggable={false}
          />
        </div>
      </div>
    </div>
  );
};

export default InvoiceImagePreview;

7.6 明细区

import React from 'react';
import { Button, Space, Tag, Spin, Alert } from 'antd';
import { CheckCircleOutlined, ReloadOutlined, ThunderboltOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import InvoiceImagePreview from './InvoiceImagePreview';
import InvoiceForm from './InvoiceForm';
import InvoiceItemsTable from './InvoiceItemsTable';
import { useInvoiceStore } from '../../../store/invoiceStore';

const VerifyResultBanner = ({ result }) => {
  if (!result) return null;

  const statusConfig = {
    verified: { type: 'success', icon: <CheckCircleOutlined />, text: result.status_text },
    invalid: { type: 'error', icon: <CheckCircleOutlined />, text: result.status_text },
    red_flushed: { type: 'warning', icon: <CheckCircleOutlined />, text: result.status_text },
    inconsistent: { type: 'error', icon: <SafetyCertificateOutlined />, text: result.status_text },
    not_found: { type: 'warning', icon: <SafetyCertificateOutlined />, text: result.status_text },
    failed: { type: 'error', icon: <SafetyCertificateOutlined />, text: result.status_text },
    error: { type: 'error', icon: <SafetyCertificateOutlined />, text: result.status_text },
  };

  const config = statusConfig[result.status] || statusConfig.error;
  return (
    <Alert
      type={config.type}
      showIcon
      icon={config.icon}
      message={config.text}
      description={result.verify_detail ? `销方: ${result.verify_detail.saler_name || '-'} | 购方: ${result.verify_detail.purchaser_name || '-'} | 价税合计: ¥${result.verify_detail.all_valorem_tax || '-'}` : null}
      style={{ margin: '0 12px 8px' }}
    />
  );
};

const InvoiceWorkbench = () => {
  const {
    invoiceId, invoiceData, confidence, validationErrors,
    previewDataUrl, ocrMethod, latencyMs, confidenceOverall,
    confirming, verifying, verifyResult,
    updateField, addItem, removeItem, updateItem,
    confirmInvoice, verifyInvoice, reset,
  } = useInvoiceStore();

  return (
    <div style={{ display: 'flex', height: '100%', background: '#fff' }}>
      {/* 左侧:图片预览 */}
      <div style={{ flex: 1, borderRight: '1px solid #f0f0f0', minHeight: 0 }}>
        <InvoiceImagePreview dataUrl={previewDataUrl} defaultScale={1.5} />
      </div>

      {/* 右侧:表单 + 明细 + 操作 */}
      <div style={{ width: 520, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
        {/* 头部信息 */}
        <div style={{
          padding: '8px 12px',
          borderBottom: '1px solid #f0f0f0',
          background: '#fafafa',
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
        }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>AI 提取结果</span>
          <Space size={8}>
            <Tag color="success" icon={<CheckCircleOutlined />}>
              识别成功
            </Tag>
            <Tag color="blue">
              <ThunderboltOutlined /> {ocrMethod}
            </Tag>
            <Tag color="default">
              {latencyMs}ms
            </Tag>
            <Tag color={confidenceOverall >= 0.7 ? 'success' : confidenceOverall >= 0.5 ? 'warning' : 'error'}>
              置信度: {(confidenceOverall * 100).toFixed(1)}%
            </Tag>
          </Space>
        </div>

        {/* 验真结果 */}
        <VerifyResultBanner result={verifyResult} />

        {/* 表单区域(可滚动) */}
        <div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
          <InvoiceForm
            data={invoiceData}
            confidence={confidence}
            validationErrors={validationErrors}
            onChange={updateField}
          />
          <InvoiceItemsTable
            items={invoiceData?.items || []}
            onUpdateItem={updateItem}
            onAddItem={addItem}
            onRemoveItem={removeItem}
          />
        </div>

        {/* 底部操作栏 */}
        <div style={{
          borderTop: '1px solid #f0f0f0',
          padding: '10px 12px',
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          background: '#fafafa',
        }}>
          <Space>
            <Button icon={<ReloadOutlined />} onClick={reset}>
              重新识别
            </Button>
            <Button
              icon={verifying ? <Spin size="small" /> : <SafetyCertificateOutlined />}
              onClick={() => verifyInvoice(invoiceId)}
              loading={verifying}
            >
              发票验真
            </Button>
          </Space>
          <Button
            type="primary"
            icon={confirming ? <Spin size="small" /> : <CheckCircleOutlined />}
            onClick={confirmInvoice}
            loading={confirming}
            size="large"
          >
            确认入库
          </Button>
        </div>
      </div>
    </div>
  );
};

export default InvoiceWorkbench;

7.7 历史记录区

import React, { useEffect, useState } from 'react';
import { Table, Tag, Button, Modal, Descriptions, Card, Row, Col, Statistic, Empty, Spin, Space } from 'antd';
import {
  ReloadOutlined, EyeOutlined, FileTextOutlined,
  ClockCircleOutlined, ThunderboltOutlined, PercentageOutlined,
  CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined,
  SafetyCertificateOutlined,
} from '@ant-design/icons';
import { useInvoiceStore } from '../../../store/invoiceStore';
import { invoiceService } from '../../../services/invoiceService';
import InvoiceImagePreview from './InvoiceImagePreview';

const BASE_URL = process.env.REACT_APP_CHAT_API_URL || 'http://localhost:5000';

const InvoiceHistory = () => {
  const { invoiceList, invoiceListTotal, invoiceListPage, fetchInvoiceList, stats, fetchStats, verifying, verifyResult, verifyInvoice } = useInvoiceStore();
  const [loading, setLoading] = useState(false);
  const [detailVisible, setDetailVisible] = useState(false);
  const [detail, setDetail] = useState(null);
  const [detailLoading, setDetailLoading] = useState(false);

  useEffect(() => {
    loadList(1);
    fetchStats(30);
  }, []);

  const loadList = async (page) => {
    setLoading(true);
    await fetchInvoiceList(page);
    setLoading(false);
  };

  const showDetail = async (invoiceId) => {
    setDetailVisible(true);
    setDetailLoading(true);
    try {
      const data = await invoiceService.getInvoiceDetail(invoiceId);
      setDetail(data);
    } catch (err) {
      console.error(err);
    }
    setDetailLoading(false);
  };

  const statusMap = {
    pending: { color: 'processing', text: '待确认' },
    confirmed: { color: 'success', text: '已确认' },
    rejected: { color: 'error', text: '已拒绝' },
  };

  const columns = [
    {
      title: '发票类型',
      dataIndex: 'invoice_type',
      width: 130,
      ellipsis: true,
    },
    {
      title: '发票号码',
      dataIndex: 'invoice_number',
      width: 120,
    },
    {
      title: '开票日期',
      dataIndex: 'invoice_date',
      width: 110,
    },
    {
      title: '购方名称',
      dataIndex: 'buyer_name',
      width: 160,
      ellipsis: true,
    },
    {
      title: '销方名称',
      dataIndex: 'seller_name',
      width: 160,
      ellipsis: true,
    },
    {
      title: '价税合计',
      dataIndex: 'total_amount',
      width: 110,
      render: (v) => v != null ? `¥${Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}` : '-',
    },
    {
      title: '状态',
      dataIndex: 'status',
      width: 80,
      render: (v) => {
        const s = statusMap[v] || { color: 'default', text: v };
        return <Tag color={s.color}>{s.text}</Tag>;
      },
    },
    {
      title: '识别时间',
      dataIndex: 'created_at',
      width: 160,
    },
    {
      title: '操作',
      width: 70,
      render: (_, record) => (
        <Button type="link" size="small" icon={<EyeOutlined />} onClick={() => showDetail(record.id)}>
          详情
        </Button>
      ),
    },
  ];

  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
      {/* 顶部统计 */}
      {stats && (
        <div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa' }}>
          <Row gutter={24}>
            <Col span={6}>
              <Statistic title="识别总数" value={stats.total} prefix={<FileTextOutlined />} valueStyle={{ fontSize: 18 }} />
            </Col>
            <Col span={6}>
              <Statistic title="平均置信度" value={(stats.avg_confidence * 100).toFixed(1)} suffix="%" prefix={<PercentageOutlined />} valueStyle={{ fontSize: 18 }} />
            </Col>
            <Col span={6}>
              <Statistic title="平均耗时" value={stats.avg_latency_ms} suffix="ms" prefix={<ClockCircleOutlined />} valueStyle={{ fontSize: 18 }} />
            </Col>
            <Col span={6}>
              <Statistic title="人工修改率" value={(stats.human_modify_rate * 100).toFixed(1)} suffix="%" valueStyle={{ fontSize: 18, color: stats.human_modify_rate < 0.2 ? '#52c41a' : '#fa8c16' }} />
            </Col>
          </Row>
        </div>
      )}

      {/* 表格 */}
      <div style={{ flex: 1, padding: '12px 16px', overflow: 'auto' }}>
        <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
          <Button icon={<ReloadOutlined />} size="small" onClick={() => loadList(invoiceListPage)}>刷新</Button>
        </div>
        <Table
          dataSource={invoiceList}
          columns={columns}
          loading={loading}
          rowKey="id"
          size="small"
          pagination={{
            current: invoiceListPage,
            total: invoiceListTotal,
            pageSize: 20,
            showTotal: (t) => `共 ${t} 条`,
            onChange: (page) => loadList(page),
          }}
          locale={{ emptyText: <Empty description="暂无识别记录" /> }}
        />
      </div>

      {/* 详情弹窗 - 左图右表 */}
      <Modal
        title={<><InfoCircleOutlined /> 发票详情</>}
        open={detailVisible}
        onCancel={() => setDetailVisible(false)}
        footer={
          <Space>
            {verifyResult && (
              <Tag color={
                verifyResult.status === 'verified' ? 'success' :
                verifyResult.status === 'invalid' || verifyResult.status === 'inconsistent' ? 'error' : 'warning'
              } style={{ fontSize: 12 }}>
                {verifyResult.status_text}
              </Tag>
            )}
            <Button
              icon={<SafetyCertificateOutlined />}
              loading={verifying}
              onClick={() => detail && verifyInvoice(detail.id)}
              type="primary"
              ghost
            >
              发票验真
            </Button>
            <Button onClick={() => setDetailVisible(false)}>关闭</Button>
          </Space>
        }
        width={1100}
        style={{ top: 20 }}
      >
        {detailLoading ? (
          <div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
        ) : detail ? (
          <div style={{ display: 'flex', gap: 16, maxHeight: 'calc(100vh - 200px)' }}>
            {/* 左侧:发票原图(复用校验工作台的图片预览组件,支持拖拽+滚轮缩放) */}
            <div style={{ flex: 2, minWidth: 0 }}>
              <InvoiceImagePreview dataUrl={`${BASE_URL}/api/invoice/${detail.id}/image`} defaultScale={2} />
            </div>

            {/* 右侧:发票信息 */}
            <div style={{ width: 420, overflowY: 'auto' }}>
              <Descriptions bordered size="small" column={1} style={{ marginBottom: 12 }}>
                <Descriptions.Item label="发票类型">{detail.invoice_type || '-'}</Descriptions.Item>
                <Descriptions.Item label="状态">
                  <Tag color={statusMap[detail.status]?.color}>{statusMap[detail.status]?.text || detail.status}</Tag>
                </Descriptions.Item>
                <Descriptions.Item label="发票代码">{detail.invoice_code || '-'}</Descriptions.Item>
                <Descriptions.Item label="发票号码">{detail.invoice_number || '-'}</Descriptions.Item>
                <Descriptions.Item label="开票日期">{detail.invoice_date || '-'}</Descriptions.Item>
                <Descriptions.Item label="校验码">{detail.check_code || '-'}</Descriptions.Item>
                <Descriptions.Item label="购方名称">{detail.buyer_name || '-'}</Descriptions.Item>
                <Descriptions.Item label="购方纳税人识别号">{detail.buyer_tax_number || '-'}</Descriptions.Item>
                <Descriptions.Item label="销方名称">{detail.seller_name || '-'}</Descriptions.Item>
                <Descriptions.Item label="销方纳税人识别号">{detail.seller_tax_number || '-'}</Descriptions.Item>
                <Descriptions.Item label="不含税金额">{detail.total_amount_ex_tax != null ? `¥${Number(detail.total_amount_ex_tax).toFixed(2)}` : '-'}</Descriptions.Item>
                <Descriptions.Item label="合计税额">{detail.total_tax != null ? `¥${Number(detail.total_tax).toFixed(2)}` : '-'}</Descriptions.Item>
                <Descriptions.Item label="价税合计"><strong>{detail.total_amount != null ? `¥${Number(detail.total_amount).toFixed(2)}` : '-'}</strong></Descriptions.Item>
              </Descriptions>

              {/* 商品明细 */}
              {detail.items && detail.items.length > 0 && (
                <>
                  <div style={{ fontWeight: 600, marginBottom: 6, fontSize: 12 }}>商品明细</div>
                  <Table
                    dataSource={detail.items}
                    columns={[
                      { title: '名称', dataIndex: 'item_name', ellipsis: true, width: 100 },
                      { title: '数量', dataIndex: 'quantity', width: 50 },
                      { title: '金额', dataIndex: 'amount', width: 70 },
                      { title: '税率', dataIndex: 'tax_rate', width: 50 },
                      { title: '税额', dataIndex: 'tax_amount', width: 70 },
                    ]}
                    rowKey="id"
                    size="small"
                    pagination={false}
                    scroll={{ y: 200 }}
                  />
                </>
              )}

              {/* OCR 日志 */}
              {detail.ocr_logs && detail.ocr_logs.length > 0 && (
                <>
                  <div style={{ fontWeight: 600, margin: '10px 0 6px', fontSize: 12 }}>识别日志</div>
                  {detail.ocr_logs.map((log) => (
                    <div key={log.id} style={{ display: 'flex', gap: 10, fontSize: 11, color: '#888', marginBottom: 4 }}>
                      <span><ThunderboltOutlined /> {log.ocr_method}</span>
                      <span><PercentageOutlined /> {log.confidence_overall}</span>
                      <span><ClockCircleOutlined /> {log.api_latency_ms}ms</span>
                      {log.human_modified_fields && <span>修改: {log.human_modified_fields}</span>}
                    </div>
                  ))}
                </>
              )}
            </div>
          </div>
        ) : null}
      </Modal>
    </div>
  );
};

export default InvoiceHistory;

八、完整数据流转

用户上传发票图片
       │
       ▼
┌──────────────────────────────────┐
│  Flask /api/invoice/upload       │
│  ① 图片预处理(压缩/PDF转图片)    │
│  ② Qwen-VL 视觉模型识别          │
│  ③ 置信度 < 0.7?                │
│     是 → PaddleOCR + Qwen 文本模型 │
│  ④ 财务验算(金额一致性校验)      │
│  ⑤ 存入 DB (status=pending)      │
└──────────────────────────────────┘
       │ 返回结构化 JSON + 置信度
       ▼
┌──────────────────────────────────┐
│  React 校验工作台                 │
│  ① 左侧:发票原图预览             │
│  ② 右侧:AI 提取结果(可编辑)     │
│  ③ 置信度颜色标识(绿/橙/红)      │
│  ④ 财务验算错误提示               │
└──────────────────────────────────┘
       │
       ├──▶ 发票验真 ──▶ 阿里云 VerifyVATInvoice API
       │                  返回:一致/不一致/已作废/已冲红
       │
       ▼
┌──────────────────────────────────┐
│  确认入库                         │
│  ① 人工修正后的数据写入 DB         │
│  ② 记录修改了哪些字段             │
│  ③ status: pending → confirmed    │
│  ④ 显示 AI 效能统计               │
└──────────────────────────────────┘
import React from 'react';
import { Button, Space, Tag, Spin, Alert } from 'antd';
import { CheckCircleOutlined, ReloadOutlined, ThunderboltOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import InvoiceImagePreview from './InvoiceImagePreview';
import InvoiceForm from './InvoiceForm';
import InvoiceItemsTable from './InvoiceItemsTable';
import { useInvoiceStore } from '../../../store/invoiceStore';

const VerifyResultBanner = ({ result }) => {
  if (!result) return null;

  const statusConfig = {
    verified: { type: 'success', icon: <CheckCircleOutlined />, text: result.status_text },
    invalid: { type: 'error', icon: <CheckCircleOutlined />, text: result.status_text },
    red_flushed: { type: 'warning', icon: <CheckCircleOutlined />, text: result.status_text },
    inconsistent: { type: 'error', icon: <SafetyCertificateOutlined />, text: result.status_text },
    not_found: { type: 'warning', icon: <SafetyCertificateOutlined />, text: result.status_text },
    failed: { type: 'error', icon: <SafetyCertificateOutlined />, text: result.status_text },
    error: { type: 'error', icon: <SafetyCertificateOutlined />, text: result.status_text },
  };

  const config = statusConfig[result.status] || statusConfig.error;
  return (
    <Alert
      type={config.type}
      showIcon
      icon={config.icon}
      message={config.text}
      description={result.verify_detail ? `销方: ${result.verify_detail.saler_name || '-'} | 购方: ${result.verify_detail.purchaser_name || '-'} | 价税合计: ¥${result.verify_detail.all_valorem_tax || '-'}` : null}
      style={{ margin: '0 12px 8px' }}
    />
  );
};

const InvoiceWorkbench = () => {
  const {
    invoiceId, invoiceData, confidence, validationErrors,
    previewDataUrl, ocrMethod, latencyMs, confidenceOverall,
    confirming, verifying, verifyResult,
    updateField, addItem, removeItem, updateItem,
    confirmInvoice, verifyInvoice, reset,
  } = useInvoiceStore();

  return (
    <div style={{ display: 'flex', height: '100%', background: '#fff' }}>
      {/* 左侧:图片预览 */}
      <div style={{ flex: 1, borderRight: '1px solid #f0f0f0', minHeight: 0 }}>
        <InvoiceImagePreview dataUrl={previewDataUrl} defaultScale={1.5} />
      </div>

      {/* 右侧:表单 + 明细 + 操作 */}
      <div style={{ width: 520, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
        {/* 头部信息 */}
        <div style={{
          padding: '8px 12px',
          borderBottom: '1px solid #f0f0f0',
          background: '#fafafa',
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
        }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>AI 提取结果</span>
          <Space size={8}>
            <Tag color="success" icon={<CheckCircleOutlined />}>
              识别成功
            </Tag>
            <Tag color="blue">
              <ThunderboltOutlined /> {ocrMethod}
            </Tag>
            <Tag color="default">
              {latencyMs}ms
            </Tag>
            <Tag color={confidenceOverall >= 0.7 ? 'success' : confidenceOverall >= 0.5 ? 'warning' : 'error'}>
              置信度: {(confidenceOverall * 100).toFixed(1)}%
            </Tag>
          </Space>
        </div>

        {/* 验真结果 */}
        <VerifyResultBanner result={verifyResult} />

        {/* 表单区域(可滚动) */}
        <div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
          <InvoiceForm
            data={invoiceData}
            confidence={confidence}
            validationErrors={validationErrors}
            onChange={updateField}
          />
          <InvoiceItemsTable
            items={invoiceData?.items || []}
            onUpdateItem={updateItem}
            onAddItem={addItem}
            onRemoveItem={removeItem}
          />
        </div>

        {/* 底部操作栏 */}
        <div style={{
          borderTop: '1px solid #f0f0f0',
          padding: '10px 12px',
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          background: '#fafafa',
        }}>
          <Space>
            <Button icon={<ReloadOutlined />} onClick={reset}>
              重新识别
            </Button>
            <Button
              icon={verifying ? <Spin size="small" /> : <SafetyCertificateOutlined />}
              onClick={() => verifyInvoice(invoiceId)}
              loading={verifying}
            >
              发票验真
            </Button>
          </Space>
          <Button
            type="primary"
            icon={confirming ? <Spin size="small" /> : <CheckCircleOutlined />}
            onClick={confirmInvoice}
            loading={confirming}
            size="large"
          >
            确认入库
          </Button>
        </div>
      </div>
    </div>
  );
};

export default InvoiceWorkbench;

九、关键设计

  1. 双引擎 OCR + 自动降级: 主力使用 Qwen 视觉模型端到端识别,置信度不足时自动切换到 PaddleOCR + 文本模型的组合方案,兼顾了准确率和鲁棒性。

  2. 置信度可视化: AI 对每个字段自评置信度,前端通过绿/橙/红三色边框直观展示,帮助人工快速定位需要重点复核的字段。

  3. 财务自动验算: 在 AI 识别之后、人工确认之前,自动校验价税合计和明细金额的数学一致性,防止 AI 产生数字幻觉。

  4. 人工修正闭环: 确认入库时自动对比 AI 原始结果与最终提交数据,记录修改了哪些字段,为后续分析 AI 准确率提供数据支撑。

  5. 官方渠道验真: 接入阿里云增值税发票核验 API,支持数电发票和纸质发票两种类型的自动判断,返回查验一致、已作废、已冲红等多种状态。

十、总结

本系统实现了从发票图片上传到 AI 识别、人工校验、官方验真的完整闭环。通过 LangChain + Qwen 视觉大模型的端到端识别能力,结合 PaddleOCR 的降级保障,以及阿里云官方核验接口的真伪验证,在保证准确率的同时大幅提升了发票处理效率。前端采用 React + Zustand 的三步向导式交互,让整个流程清晰直观。

技术选型上,LangChain 提供了统一的 LLM 调用接口(兼容 OpenAI 协议),使得在不同模型之间切换变得简单;Pydantic 的结构化输出确保了 AI 返回数据的类型安全;Zustand 的轻量级状态管理让前端的复杂交互逻辑清晰可维护。

注意:仅供参考!!!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐