4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Dify+OCRで紙の請求書をPostgreSQLへ取り込む

4
Posted at

はじめに

紙の請求書やFAXが残っている業務では、システム化した後にも「紙を見ながら入力する」という作業が残りがちです。

業務システムやAIを使った自動化に関わっていると、こういう作業を見たとき、いきなり全自動にしたくなります。ただ、私は先に「どこまで機械に任せて、どこから人が確認するか」を決めるようにしています。

今回はその考え方で、次の流れを作ります。

紙・FAX
  ↓
PDF / 画像
  ↓
OCR
  ↓
Difyで請求書項目へ整理
  ↓
入力チェック
  ↓
PostgreSQLへ保存
  ↓
人が確認

ポイントは、OCRやLLMの結果をそのまま確定データにしないことです。

この記事では、DifyのワークフローとTesseract OCR、Pythonの小さなAPI、PostgreSQLを組み合わせます。最後まで進めると、請求書ファイルを渡してデータベースの確認待ちレコードを作るところまで試せます。

結論

OCRは文字を読むところ、Difyは項目を整理するところ、PostgreSQLへの書き込みは小さなAPIに分離しました。
最初から自動確定にはせず、pending_review として保存します。
AIには転記の下ごしらえを任せ、人が確定する構成にしています。

🔧 環境

この記事では、2026年9月26日時点で確認できる次の構成を基準にします。

項目 バージョン
OS Ubuntu 24.04 LTS
Dify 1.17.1
PostgreSQL 18.6
Tesseract OCR 5.5.3
Python 3.12
FastAPI 0.116系
psycopg 3系

Dify 1.17.1は2026年9月10日に公開されています。PostgreSQLについては18が現行のメジャーバージョンで、18.6が2026年8月13日に公開されています。Tesseract 5.5.3は2026年7月24日のリリースです。

今回は再現しやすさを優先し、OCRを外部サービスに依存させずTesseractで動かします。実運用では、帳票の種類や読み取り精度、データの取り扱い条件に応じてOCRサービスを差し替えられるようにしておくのがよいと思います。

なお、Difyは更新が速いため、画面上の細かなラベルは利用しているバージョンによって変わる可能性があります。

実装

1. まず処理の責任を分ける

今回の構成は次のようにしました。

Difyの中ですべてを処理するのではなく、OCRとDB更新をAPIとして外に出しています。

理由は単純です。

Difyにはワークフローの制御を担当させたいからです。

OCRエンジンを変更したくなったときにDifyのフロー全体を書き直したくありません。また、DBへのINSERTをLLMが自由に生成するSQLに任せる構成も避けたいところです。

そこで責任を次の3つに分けます。

処理 担当
OCR Python + Tesseract
項目抽出、フロー制御 Dify
DB検証、保存 Python API

この境界を決めてから作り始めます。

2. PostgreSQLのテーブルを作る

今回は請求書を次の形で保存します。

CREATE TABLE invoices (
    id BIGSERIAL PRIMARY KEY,
    source_id VARCHAR(100) NOT NULL UNIQUE,
    supplier_name TEXT,
    invoice_number TEXT,
    invoice_date DATE,
    due_date DATE,
    total_amount NUMERIC(14, 2),
    currency VARCHAR(3) NOT NULL DEFAULT 'JPY',
    raw_ocr_text TEXT NOT NULL,
    status VARCHAR(30) NOT NULL DEFAULT 'pending_review',
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    reviewed_at TIMESTAMPTZ
);

CREATE INDEX idx_invoices_status
    ON invoices(status);

CREATE INDEX idx_invoices_invoice_number
    ON invoices(invoice_number);

ここで重要なのは raw_ocr_text と status です。

構造化した結果だけ保存すると、後から「なぜこの値になったのか」を確認しにくくなります。そのためOCRの元テキストも残します。

また、登録直後の状態を pending_review にします。

pending_review
    ↓ 人が確認
confirmed

という状態遷移を想定しています。

今回は確認画面までは作りません。まずは読み取り専用に近いところから始め、AIが生成した値を業務上の確定値として直接扱わない設計にしています。

3. OCR APIを作る

DifyからTesseractを直接操作するのではなく、HTTPで呼べる小さなAPIにします。

ディレクトリは次の程度で十分です。

invoice-import/
├── app.py
├── requirements.txt
└── docker-compose.yml

requirements.txt は次の内容です。

fastapi>=0.116,<0.117
uvicorn[standard]>=0.35,<0.36
python-multipart>=0.0.20,<0.1
psycopg[binary]>=3.2,<4

続いて app.py を作ります。

import hashlib
import os
import subprocess
import tempfile
from datetime import date
from decimal import Decimal
from pathlib import Path

import psycopg
from fastapi import FastAPI, File, HTTPException, UploadFile
from pydantic import BaseModel, Field

app = FastAPI()

DATABASE_URL = os.environ["DATABASE_URL"]

ALLOWED_CONTENT_TYPES = {
    "image/png",
    "image/jpeg",
    "image/tiff",
}


class InvoiceInput(BaseModel):
    source_id: str = Field(min_length=1, max_length=100)
    supplier_name: str | None = None
    invoice_number: str | None = None
    invoice_date: date | None = None
    due_date: date | None = None
    total_amount: Decimal | None = None
    currency: str = Field(default="JPY", min_length=3, max_length=3)
    raw_ocr_text: str = Field(min_length=1)


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/ocr")
async def ocr(file: UploadFile = File(...)):
    if file.content_type not in ALLOWED_CONTENT_TYPES:
        raise HTTPException(
            status_code=400,
            detail="unsupported file type",
        )

    data = await file.read()

    if not data:
        raise HTTPException(
            status_code=400,
            detail="empty file",
        )

    source_id = hashlib.sha256(data).hexdigest()

    suffix = Path(file.filename or "document.png").suffix

    with tempfile.TemporaryDirectory() as tmp:
        input_path = Path(tmp) / f"input{suffix}"
        input_path.write_bytes(data)

        result = subprocess.run(
            [
                "tesseract",
                str(input_path),
                "stdout",
                "-l",
                "jpn+eng",
                "--psm",
                "6",
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )

        if result.returncode != 0:
            raise HTTPException(
                status_code=500,
                detail="OCR failed",
            )

        text = result.stdout.strip()

    if not text:
        raise HTTPException(
            status_code=422,
            detail="no text detected",
        )

    return {
        "source_id": source_id,
        "text": text,
    }


@app.post("/invoices")
def create_invoice(invoice: InvoiceInput):
    if invoice.total_amount is not None and invoice.total_amount < 0:
        raise HTTPException(
            status_code=400,
            detail="total_amount must not be negative",
        )

    if invoice.currency != "JPY":
        raise HTTPException(
            status_code=400,
            detail="unsupported currency",
        )

    sql = """
        INSERT INTO invoices (
            source_id,
            supplier_name,
            invoice_number,
            invoice_date,
            due_date,
            total_amount,
            currency,
            raw_ocr_text,
            status
        )
        VALUES (
            %(source_id)s,
            %(supplier_name)s,
            %(invoice_number)s,
            %(invoice_date)s,
            %(due_date)s,
            %(total_amount)s,
            %(currency)s,
            %(raw_ocr_text)s,
            'pending_review'
        )
        ON CONFLICT (source_id)
        DO UPDATE SET
            supplier_name = EXCLUDED.supplier_name,
            invoice_number = EXCLUDED.invoice_number,
            invoice_date = EXCLUDED.invoice_date,
            due_date = EXCLUDED.due_date,
            total_amount = EXCLUDED.total_amount,
            currency = EXCLUDED.currency,
            raw_ocr_text = EXCLUDED.raw_ocr_text
        RETURNING id, status;
    """

    with psycopg.connect(DATABASE_URL) as conn:
        with conn.cursor() as cur:
            cur.execute(
                sql,
                invoice.model_dump(),
            )
            row = cur.fetchone()

    return {
        "id": row[0],
        "status": row[1],
    }

ここではファイル内容のSHA-256を source_id にしています。

同じファイルをもう一度流したとき、請求書レコードが際限なく増えないようにするためです。

業務では「Difyを再実行した」「HTTPがタイムアウトしたので再送した」といったことが普通に起きます。自動化では正常系だけでなく、同じ処理が2回来ても壊れないことを先に考えておくと扱いやすくなります。

4. コンテナでOCR APIとPostgreSQLを起動する

検証用の docker-compose.yml は次のようにします。

services:
  postgres:
    image: postgres:18.6
    environment:
      POSTGRES_DB: invoice_db
      POSTGRES_USER: invoice_app
      POSTGRES_PASSWORD: local_development_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  invoice_api:
    image: python:3.12-slim
    working_dir: /app
    volumes:
      - .:/app
    environment:
      DATABASE_URL: postgresql://invoice_app:local_development_password@postgres:5432/invoice_db
    command: >
      sh -c "
      apt-get update &&
      apt-get install -y --no-install-recommends
      tesseract-ocr
      tesseract-ocr-jpn &&
      pip install --no-cache-dir -r requirements.txt &&
      uvicorn app:app --host 0.0.0.0 --port 8000
      "
    ports:
      - "8000:8000"
    depends_on:
      - postgres

volumes:
  postgres_data:

これはローカル検証用です。

本番環境でパスワードをComposeファイルへ直接書く構成にはしません。シークレット管理の仕組みに分離します。また、APIをインターネットへそのまま公開する構成も避けます。

起動します。

docker compose up

テーブルはPostgreSQLへ接続して、先ほどのCREATE TABLEを実行しておきます。

OCR API単体を確認するときは、ホスト側へ公開した8000番ポートに対して、/ocr エンドポイントへ画像をmultipart/form-dataでPOSTします。

成功すると、おおむね次のようなJSONが返ります。

{
  "source_id": "ファイル内容から生成された識別子",
  "text": "請求書\n株式会社...\n請求金額..."
}

ここではまだ「請求金額」がどこなのかを判断していません。

OCRの仕事は文字列にするところまでです。

5. DifyのWorkflowを作る

DifyではWorkflowアプリを作成します。

入力としてファイルを1つ受け取れるようにし、次の順番でノードを配置します。

User Input
   ↓
HTTP Request: OCR
   ↓
Parameter Extractor
   ↓
IF/ELSE
   ↓
HTTP Request: PostgreSQL登録API
   ↓
Output

Difyの公式ドキュメントでも、Workflowでは入力、条件分岐、各種処理、Outputを組み合わせて処理を構成できます。

ファイル入力には、まず画像だけを許可します。

今回のPythonコードが受け付けるのはPNG、JPEG、TIFFです。FAXをPDFとして受け取る環境では、PDFをページ画像へ変換する処理をOCR API側へ追加するか、前段で画像化する必要があります。

ここは曖昧に対応ファイルを増やさないほうが安全です。

6. DifyからOCR APIを呼ぶ

HTTP Requestノードから、先ほどPythonで定義した /ocr エンドポイントを呼びます。

概念的な設定は次の形です。

Method:
POST

Destination:
invoice_api サービスの8000番ポート

Path:
/ocr

Body:
multipart/form-data

file:
User Inputで受け取ったファイル

ここで invoice_api は公開Webサイトではなく、Docker Compose内で定義したサービス名です。DifyとOCR APIを同じDockerネットワークで動かす場合は、このサービス名を内部通信の宛先として利用できます。

Difyを別の環境で動かす場合は、DifyからOCR APIへ到達できるネットワーク構成を用意します。インターネット上に無認証で公開するのではなく、内部ネットワークや適切な認証を使う前提で設計します。

OCR APIの戻り値から使いたいのは、

{
  "source_id": "...",
  "text": "..."
}

の2項目です。

7. OCR結果を請求書データへ変換する

次にParameter Extractorを置きます。

ここでLLMに自由な文章を書かせるのではなく、必要な項目だけを抽出させます。

抽出対象は次のようにしました。

supplier_name: string
invoice_number: string
invoice_date: string
due_date: string
total_amount: number
currency: string

指示文は次の内容にします。

入力は請求書をOCRしたテキストです。

次の項目だけを抽出してください。

- supplier_name: 請求元の名称
- invoice_number: 請求書番号
- invoice_date: 請求日。YYYY-MM-DD形式
- due_date: 支払期限。YYYY-MM-DD形式
- total_amount: 税込みの請求総額。数値のみ
- currency: 通貨コード。日本円ならJPY

ルール:
- 入力に書かれていない値を推測しない
- 読み取れない項目は空にする
- 小計と請求総額を混同しない
- 日付を推測で補完しない
- 金額に通貨記号やカンマを含めない
- 説明文を追加しない

ここで特に避けたいのが「それらしい値を補完する」ことです。

OCR結果に請求書番号がなければ、空で構いません。

自動化では空欄があることより、存在しない値がもっともらしく入るほうが扱いにくいと考えています。

8. IF/ELSEで最低限のチェックを入れる

LLMの出力後に、すぐ登録APIを呼びません。

IF/ELSEノードを置きます。

たとえば最初は、

supplier_name が空ではない
AND
total_amount が空ではない

という程度から始めます。

条件を満たさなければDB登録へ進ませず、Outputで

OCR結果を確認してください

という状態を返します。

ここでチェック項目を大量に増やす必要はありません。

むしろ、業務側で「何が欠けていたら処理を止めるのか」を先に決めます。

請求書番号が必須なのか、支払期限がなくても受け付けるのか。この判断はLLMではなく業務ルールです。

9. PostgreSQL登録APIを呼ぶ

条件を通過したら、2つ目のHTTP RequestノードからPythonで定義した /invoices エンドポイントを呼びます。

設定の考え方は次の形です。

Method:
POST

Destination:
invoice_api サービスの8000番ポート

Path:
/invoices

Content-Type:
application/json

Bodyは次の形です。

{
  "source_id": "OCR APIから取得",
  "supplier_name": "Parameter Extractorから取得",
  "invoice_number": "Parameter Extractorから取得",
  "invoice_date": "Parameter Extractorから取得",
  "due_date": "Parameter Extractorから取得",
  "total_amount": "Parameter Extractorから取得",
  "currency": "Parameter Extractorから取得",
  "raw_ocr_text": "OCR APIのtext"
}

Difyの画面では固定文字列を書くのではなく、それぞれ対応するノードの変数を選択します。

成功すると、

{
  "id": 1,
  "status": "pending_review"
}

のようなレスポンスになります。

PostgreSQLで確認します。

SELECT
    id,
    supplier_name,
    invoice_number,
    invoice_date,
    total_amount,
    status
FROM invoices
ORDER BY id DESC;

ここまでで、

ファイル
↓
OCR
↓
項目抽出
↓
チェック
↓
DB

がつながりました。

10. FAXの自動取り込みは後から足す

ここまでの構成では、人がDifyへファイルを渡しています。

FAXやメール添付から完全に自動で流したい場合でも、私は最初からそこまでつなげません。

まず、

手動アップロード
↓
OCR
↓
構造化
↓
pending_review

を安定させます。

その後で、

FAX受信
↓
ファイル保存
↓
Dify Workflow API

という前段を追加します。

こうしておくと問題が起きたとき、

「FAX取得がおかしいのか」
「OCRがおかしいのか」
「項目抽出がおかしいのか」
「DB登録がおかしいのか」

を切り分けやすくなります。

紙をなくすことより、処理の境界を明確にすることのほうが先です。

⚠️ ハマりどころ

OCRできたことと、正しく読めたことは同じではない

Tesseractから文字列が返れば成功、と判定したくなります。

しかし、

¥18,800

が、

¥18,300

になっていても、OCR処理そのものは正常終了します。

特に金額、日付、請求書番号は業務への影響が大きいため、HTTPステータスだけで正常判定するのは足りません。

最初の運用では pending_review を外さないほうが扱いやすいです。

OCRとLLMの責任を混ぜない

OCRに請求書の意味まで判断させたり、LLMに画像処理からDB更新まで任せたりすると、どこで値が変わったのか追いにくくなります。

今回は、

OCR
「何と書いてあるか」

LLM
「どの項目に当たるか」

Python API
「保存してよい形式か」

人
「業務上、確定してよいか」

と分けました。

私はAIを使った業務自動化でも、この分け方を大切にしています。

SQLをLLMに生成させない

今回、LLMからPostgreSQLへ直接SQLを送らせていません。

登録できるカラムとSQLはPython側で固定しています。

LLMの役割は、

{
  "supplier_name": "...",
  "total_amount": 18800
}

のような構造化までです。

この境界があるだけで、プロンプト変更によって突然UPDATEやDELETEが発生するような設計を避けられます。

金額は文字列の後処理だけでは足りない

請求書には、

小計
消費税
合計
今回請求額
前回残高

など、複数の金額が載ることがあります。

「一番大きな数字を取る」といったルールでは危険です。

LLMに意味を判断させる場合でも、元OCRテキストを残し、人が比較できる状態にします。

帳票形式が固定されているなら、LLMより座標や正規表現による抽出のほうが安定するケースもあります。

AIを使えるからといって、すべてをAIに渡す必要はありません。

PDFをそのままTesseractへ渡さない

今回のサンプルAPIは画像を対象にしています。

複数ページPDFへ対応するときは、

PDF
↓
ページごとの画像
↓
OCR
↓
テキスト結合

という処理をOCR API側に追加します。

Dify側にPDF変換処理まで持たせるより、文書処理の責任をOCRサービス側へ寄せたほうが後から交換しやすくなります。

同じファイルが再送される前提で作る

ワークフローでは再実行が起きます。

そのため今回のテーブルでは、

source_id VARCHAR(100) NOT NULL UNIQUE

とし、INSERTには ON CONFLICT を使いました。

単に、

INSERT INTO invoices ...

だけにすると、再送のたびに同じ請求書が増えます。

ネットワーク越しの処理では「一度しか呼ばれない」と考えないほうが安全です。

OCR原文を無期限に保存するかは別途決める

この記事では検証しやすくするため raw_ocr_text を保存しています。

一方、実際の請求書には取引先情報などが含まれます。

そのため実運用では、

誰が閲覧できるか
どの期間保存するか
バックアップにも残すか
ログへ出してよいか
削除時にどこまで消すか

を業務ルールと合わせて決める必要があります。

「デバッグに便利だから全部保存する」は、長期運用の理由にはなりません。

📝 まとめ

紙の請求書やFAXの転記を減らす仕組みは、OCRを入れただけでは完成しません。

今回作ったのは、

Dify
  ↓
OCR API
  ↓
Tesseract
  ↓
Difyで構造化
  ↓
固定された登録API
  ↓
PostgreSQL
  ↓
pending_review

という小さなパイプラインです。

Difyにすべてを詰め込まず、OCRとDB更新を分離したことで、あとからOCRエンジンを変更したり、入力チェックを増やしたりしやすくなります。

もう一つ大事なのは、最初から「無人化」を完成形にしなかったことです。

請求書では、1文字の読み違いがそのまま金額や日付の違いになります。そこでまずは、転記を自動化し、確定は人に残します。

私がAIを業務へ入れるときに考えている「人が決め、AIが下ごしらえする」という形にも合います。

紙を見ながらすべて入力する状態から、

機械が読む
↓
機械が整理する
↓
人は確認する

へ変える。

小さな会社の業務自動化では、このくらいのところから始めるのが扱いやすいと考えています。

参考文献

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?