应用场景与解决方案

OCR 识别发票订单:用 OCR 和本地大模型从 PDF、扫描件和照片中提取数据到 Excel

6 个步骤 11 分钟阅读 19 次阅读 0
本文目录

运营和财务团队每天要花大量时间,把发票、采购订单、送货单和供应商报价单手工录入表格。借助 OCR 识别扫描件和照片,再由本地大模型严格按照你定义的字段返回结果,大部分录入工作都可以自动化。本文把 Tesseract、OCRmyPDF 与 Ollama 的结构化输出结合起来:每份单据都会生成一个包含提取字段的 JSON 文件,汇总表格会标记出明细金额加起来与合计对不上的单据,人工只需核对这些例外情况。整个流程都在你自己的服务器上运行。

提取出来的数字只能当作草稿:用于付款、记账或报关之前,请核对合计金额、银行信息和单据编号。含有个人数据的单据必须按照你所承担的隐私保护义务进行处理;在自己的服务器上处理有帮助,但不能替代这些义务。

步骤 1:安装工具

# Debian / Ubuntu: PDF text, OCR engine with English and Simplified Chinese, OCR for PDFs
apt install -y python3-venv poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim ocrmypdf
python3 -m venv /opt/extract
/opt/extract/bin/pip install ollama
ollama pull qwen2.5:7b

Ollama 的部署方法请参阅使用 Ollama 部署本地大模型。如需识别其他语言,请安装对应的 Tesseract 语言包,例如 tesseract-ocr-jpn、tesseract-ocr-tha 或 tesseract-ocr-rus。

步骤 2:检查能从单据中提取出怎样的文字

# Digital PDF (created by software): the text can be read directly
pdftotext -layout invoice.pdf - | head -40

# Scanned PDF: add a text layer with OCR first, then read it
ocrmypdf -l eng+chi_sim --skip-text scan.pdf scan-ocr.pdf
pdftotext -layout scan-ocr.pdf - | head -40

# Photo or screenshot
tesseract photo.jpg - -l eng+chi_sim | head -40

如果这里输出的文字清晰可读,模型就能从中提取字段;如果是一堆乱码,请先改善输入质量:以 300 dpi 扫描,拍照时把单据放平并保证光线充足。

步骤 3:创建提取脚本

SCHEMA 定义了你需要提取哪些字段。Ollama 会强制模型严格按照这个结构作答,因此输出始终是有效的 JSON。

cat > /opt/extract/extract.py <<'EOF2'
import csv, json, os, subprocess, sys, tempfile
import ollama

MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANG = os.environ.get("OCR_LANG", "eng+chi_sim")
SCHEMA = {
    "type": "object",
    "properties": {
        "supplier": {"type": "string"},
        "document_number": {"type": "string"},
        "date": {"type": "string", "description": "YYYY-MM-DD"},
        "currency": {"type": "string"},
        "total": {"type": "number"},
        "tax": {"type": "number"},
        "items": {"type": "array", "items": {"type": "object", "properties": {
            "description": {"type": "string"}, "quantity": {"type": "number"},
            "unit_price": {"type": "number"}, "amount": {"type": "number"}},
            "required": ["description", "quantity", "amount"]}},
    },
    "required": ["supplier", "document_number", "date", "currency", "total", "tax", "items"],
}

def text_of(path):
    run = lambda cmd: subprocess.run(cmd, capture_output=True, text=True).stdout
    if not path.lower().endswith(".pdf"):
        return run(["tesseract", path, "-", "-l", LANG])
    text = run(["pdftotext", "-layout", path, "-"])
    if len(text.strip()) > 50:
        return text
    with tempfile.TemporaryDirectory() as tmp:           # scanned PDF: OCR it first
        ocr = os.path.join(tmp, "ocr.pdf")
        subprocess.run(["ocrmypdf", "-q", "-l", LANG, "--skip-text", path, ocr], check=True)
        return run(["pdftotext", "-layout", ocr, "-"])

rows = []
for path in sys.argv[1:]:
    if not os.path.isfile(path):
        continue
    r = ollama.chat(model=MODEL, format=SCHEMA, options={"temperature": 0, "num_ctx": 8192}, messages=[
        {"role": "system", "content": "Extract the requested fields from the document text. "
         "Copy names, numbers and IDs exactly as written. Use an empty string or 0 "
         "for anything that is not in the text. Do not guess."},
        {"role": "user", "content": text_of(path)[:15000]}])
    d = json.loads(r.message.content)
    items = round(sum(i.get("amount", 0) for i in d["items"]), 2)
    ok = d["total"] > 0 and min(abs(items - d["total"]), abs(items + d["tax"] - d["total"])) < 0.02
    with open(os.path.splitext(path)[0] + ".json", "w", encoding="utf-8") as f:
        json.dump(d, f, ensure_ascii=False, indent=2)
    rows.append([os.path.basename(path), d["supplier"], d["document_number"], d["date"],
                 d["currency"], d["total"], d["tax"], len(d["items"]), "OK" if ok else "CHECK"])
    print(path, "OK" if ok else "CHECK", flush=True)

with open("summary.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerow(["file", "supplier", "document_number", "date", "currency", "total", "tax", "lines", "check"])
    w.writerows(rows)
EOF2

步骤 4:批量处理一个文件夹的单据

cd /data/invoices/2026-10
/opt/extract/bin/python /opt/extract/extract.py *.pdf *.jpg *.png
# For a large folder, run it in the background:
nohup /opt/extract/bin/python /opt/extract/extract.py *.pdf > extract.log 2>&1 &

每份单据旁边都会生成一个 .json 文件,包含全部字段和明细行;summary.csv 中每份单据对应一行。用 Excel 打开后筛选 check 列:OK 表示明细金额之和与合计金额一致(含税或不含税均可),CHECK 表示该单据需要人工检查。

步骤 5:根据单据类型调整字段

  • 采购订单:添加 buyer、delivery_date,并在明细行中添加 SKU 字段。
  • 物流单据:使用 tracking_number、consignee、weight_kg 和 packages 等字段。
  • 供应商报价单:提取 valid_until、payment_terms 和单价,在同一张表中对比各家供应商。

像上面的 date 一样,为字段加上简短的 description,就能告诉模型你期望的格式。如果要处理多种单据,请为每种类型单独准备一个 schema,分别放在不同的脚本或文件夹中。

步骤 6:提高识别准确率

  • 由软件直接生成的 PDF 效果最好;扫描件和照片的效果取决于图像质量。
  • 温度保持为 0,这样同一份单据每次都会得到相同的结果。
  • qwen2.5:14b 等更大的模型能更好地处理复杂版式和长表格,代价是速度变慢。
  • 把原始文件和对应的 JSON 放在一起,审计时每个数字都能追溯到原件。

常见问题

处理一份单据需要多长时间?

在 CPU 服务器上,单页发票通常需要几十秒,较长的多页单据可能需要几分钟,具体取决于模型。大批量任务建议放在夜间运行。

能否把单据发送给云端 AI 处理?

许多 AI API 同样支持结构化输出,但单据会因此离开你的服务器。请先确认你的隐私保护义务、服务商的条款及其支持地区。对于发票、合同和客户数据,本地部署通常是更稳妥的选择。

结果能否直接导入我们的财务或 ERP 系统?

大多数系统都支持导入 CSV 文件:可以映射 summary.csv 的各列,或把 JSON 文件转换成系统要求的导入格式。只自动导入标记为 OK 的行,CHECK 行留给人工审核。

如果按以上步骤操作后问题仍未解决,请提交工单联系 IMIDC 7×24 技术支持,并附上服务器 IP、系统版本、所用模型和报错输出,如有可能再附一份不含敏感信息的示例单据,方便工程师快速定位。

这篇文章有帮助吗?

相关教程