應用場景與解決方案

OCR 識別發票訂單:用 OCR 和本地大模型從 PDF、掃描件和照片中提取資料到 Excel

6 個步驟 11 分鐘閱讀 47 次閱讀 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、系統版本、所用模型和報錯輸出,如有可能再附一份不含敏感資訊的示例單據,方便工程師快速定位。

這篇文章有幫助嗎?

相關教程