基于 Python + LangChain + React 实现智能发票识别与验真系统实战

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

一、项目背景
在企业财务管理中,发票的录入、校验和验真是一项高频且容易出错的工作。传统方式依赖人工逐字录入,不仅效率低下,而且容易出现金额错误、漏项等问题。本文将详细介绍如何利用 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;
九、关键设计
-
双引擎 OCR + 自动降级: 主力使用 Qwen 视觉模型端到端识别,置信度不足时自动切换到 PaddleOCR + 文本模型的组合方案,兼顾了准确率和鲁棒性。
-
置信度可视化: AI 对每个字段自评置信度,前端通过绿/橙/红三色边框直观展示,帮助人工快速定位需要重点复核的字段。
-
财务自动验算: 在 AI 识别之后、人工确认之前,自动校验价税合计和明细金额的数学一致性,防止 AI 产生数字幻觉。
-
人工修正闭环: 确认入库时自动对比 AI 原始结果与最终提交数据,记录修改了哪些字段,为后续分析 AI 准确率提供数据支撑。
-
官方渠道验真: 接入阿里云增值税发票核验 API,支持数电发票和纸质发票两种类型的自动判断,返回查验一致、已作废、已冲红等多种状态。
十、总结
本系统实现了从发票图片上传到 AI 识别、人工校验、官方验真的完整闭环。通过 LangChain + Qwen 视觉大模型的端到端识别能力,结合 PaddleOCR 的降级保障,以及阿里云官方核验接口的真伪验证,在保证准确率的同时大幅提升了发票处理效率。前端采用 React + Zustand 的三步向导式交互,让整个流程清晰直观。
技术选型上,LangChain 提供了统一的 LLM 调用接口(兼容 OpenAI 协议),使得在不同模型之间切换变得简单;Pydantic 的结构化输出确保了 AI 返回数据的类型安全;Zustand 的轻量级状态管理让前端的复杂交互逻辑清晰可维护。
注意:仅供参考!!!
更多推荐





所有评论(0)