• #tax-assistant
  • #OCR
  • #Gemini
  • #Claude Code
  • #Vue
  • #SQLite
  • #設計
  • #AskUserQuestion
  • #対話ベースCRUD
開発tax-assistantメモ

概要

税務アシスタントプロジェクトで、既存の領収書・レシートOCR機能に加えて、手書き売上伝票のOCR対応を実装した。Claude Codeとの対話ベースで計画策定から実装完了まで、1日で以下を完了した。

  • データベースにdocument_typeカラムを追加するマイグレーション
  • クライアント別ドキュメントタイプ管理(client_document_types テーブル)
  • クライアント別スキーマ設計(data/clients/{client_id}/schemas/*.json
  • スラッシュコマンドの分離(/import-receipts/import-csv/import-sales-slip
  • Pythonスクリプトのモジュール化(common.pyimport_receipts.pyimport_sales_slip.py
  • Gemini APIを使ったOCR処理のdocument_type対応
  • テーブル分離(gemini_receiptsgemini_sales_slips
  • UIの帳票種別切替・年月フィルター・編集ロック機能

背景

このクライアント(美容室)では、手書きの売上伝票を使用している。伝票には「技術売上」(施術料金)と「店販売上」(商品販売)が記載されており、これをデジタル化して会計ソフト(マネーフォワード)にインポートする必要がある。

既存システムは領収書・レシートのOCRに対応していたが、売上伝票は仕訳の方向が逆(貸方が売上になる)であり、読み取るべき項目も異なる。単なる拡張ではなく設計の見直しが必要だった。

Phase 1: ドキュメントタイプのDB管理

client_document_types テーブルの設計

クライアントごとに対応するドキュメントタイプが異なるため、DBで管理する方式を採用した。

CREATE TABLE client_document_types (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    client_id TEXT NOT NULL,
    document_type TEXT NOT NULL CHECK (document_type IN ('receipt', 'sales_slip', 'invoice')),
    display_name TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(client_id, document_type)
);

サンプル美容室(client_001)には以下を登録。

client_iddocument_typedisplay_name
client_001receipt領収書・レシート
client_001sales_slip売上伝票

クライアント追加時に自動でドキュメントタイプを登録する方式(方法1)を採用した。

gemini_reads テーブルの拡張

既存の gemini_reads テーブルに document_type カラムを追加。

ALTER TABLE gemini_reads ADD COLUMN document_type TEXT NOT NULL DEFAULT 'receipt'
    CHECK (document_type IN ('receipt', 'sales_slip', 'invoice'));

CREATE INDEX idx_gemini_reads_document_type ON gemini_reads(document_type);

後にテーブル分離の設計判断により、gemini_receiptsgemini_sales_slips に分離することになった(Phase 5で詳述)。

Phase 2: クライアント別スキーマ管理

ディレクトリ構造

プロンプトをPythonコードにハードコードするのではなく、クライアントごとにJSONファイルで管理する方式を採用した。

data/
└── clients/
    └── client_001/
        └── schemas/
            └── sales_slip.json

この方式を採用した理由は以下の通り。

  • DBに追加すると確認が面倒
  • JSONファイルなら人間が直接確認できる
  • 生成AIとのやり取りで修正が容易

売上伝票用スキーマ(美容室向け)

{
  "document_type": "sales_slip",
  "client_id": "client_001",
  "client_name": "サンプル美容室",
  "prompt": "この画像は美容室の手書き売上伝票です。以下の情報を抽出してください:\n- 日付(年/月/日)\n- 技術売上金額(施術料金の合計)\n- 店販売上金額(商品販売の合計)\n手書き文字が読みにくい場合は、可能性の高い候補を提示してください。",
  "output_schema": {
    "type": "object",
    "properties": {
      "date": { "type": "string", "description": "日付(YYYY-MM-DD形式)" },
      "technical_sales": { "type": "integer", "description": "技術売上金額" },
      "retail_sales": { "type": "integer", "description": "店販売上金額" }
    },
    "required": ["date", "technical_sales", "retail_sales"]
  }
}

勘定科目はスキーマから除外した。売上伝票では貸方が売上であることは自明であり、借方(現金 or 売掛金)はCSV明細との突合で判断するため。

OCRコードでの読み込み

ocr.py でクライアント固有スキーマを優先的に読み込む。

def get_schema_for_client(client_id: str, document_type: str) -> dict:
    """クライアント固有スキーマを取得。なければデフォルトスキーマを返す"""
    client_schema_path = Path(f"data/clients/{client_id}/schemas/{document_type}.json")
    if client_schema_path.exists():
        with open(client_schema_path, 'r', encoding='utf-8') as f:
            return json.load(f)

    # デフォルトスキーマにフォールバック
    default_schema_path = Path(f"schemas/{document_type}.json")
    with open(default_schema_path, 'r', encoding='utf-8') as f:
        return json.load(f)

Phase 3: スラッシュコマンドの粒度分離

当初の設計と問題点

最初は /import-receipts --type=sales_slip のようにオプションで分岐する設計を検討した。しかし、以下の理由からコマンド自体を分離する方針に変更した。

  1. スラッシュコマンドの名前から用途が明確になる
  2. 将来の帳票タイプ追加(invoice等)に対応しやすい
  3. Pythonファイルも同名で対応させることで、コードの追跡が容易
  4. 帳票ごとにGeminiに渡すプロンプトが異なる

最終的なコマンド構成

コマンド用途Pythonファイル
/import-receipts領収書・レシートimport_receipts.py
/import-sales-slip売上伝票import_sales_slip.py
/import-csvCSV取込(スクエア明細等)import_csv.py

Pythonスクリプトのモジュール化

ファイル構成を以下のように整理した。

src/
├── common.py              # 共通処理(DB接続、ユーティリティ)
├── import_receipts.py     # 領収書・レシート専用
├── import_sales_slip.py   # 売上伝票専用
└── import_csv.py          # CSV取込専用

共通処理を common.py に切り出し、各スクリプトがインポートして使用する構成にした。

# import_sales_slip.py
from common import get_db_connection, process_batch
from ocr import analyze_receipt_with_gemini

def main(client_id: str, inbox_path: str):
    """売上伝票のOCR処理を実行"""
    batch_id = generate_batch_id()
    images = get_images_from_inbox(inbox_path)

    for image_path in images:
        result = analyze_receipt_with_gemini(
            image_path,
            client_id=client_id,
            document_type='sales_slip'
        )
        save_to_db(result, batch_id, document_type='sales_slip')

この構成のメリットは以下の通り。

  • ファイル名を見れば用途がわかる
  • 帳票タイプごとに固有の処理を追加しやすい
  • 共通処理の変更が全スクリプトに反映される

Phase 4: スクエア明細CSVとのマッチングフロー

問題設定

売上伝票だけでは、代金の受取方法(現金 or クレジットカード)が分からない。クレジットカード決済の場合、借方は「売掛金」になり、現金の場合は「現金」になる。

この判断を自動化するため、スクエア(クレジットカード決済端末)の明細CSVと売上伝票を突合する設計を行った。

マッチングロジック

売上伝票の日付 + 金額 = スクエア明細の日付 + 金額

このキーでマッチした場合はクレジットカード決済と判断する。

// キー生成例
const key = `${isoDate}_${amount}`;  // "2024-01-06_22880"

マッチング結果と仕訳

結果借方勘定科目理由
マッチ売掛金クレジットカード決済
不マッチ現金現金での受け取り

注意点

  • 同一日付・同一金額の取引が複数ある場合は、出現回数でマッチング
  • スクエアの「入金」レコードは別仕訳(売掛金 → 普通預金)なので除外

Phase 5: テーブル分離

判断の経緯

当初は gemini_reads テーブルに document_type カラムを追加する方式で実装した。しかし、領収書と売上伝票でカラム構成が異なることから、テーブルを分離する方針に変更した。

Codex(GPT-5.2)にレビューを依頼したところ、「分離すべき」という結論が返ってきた。理由は以下の通り。

  1. NULL地獄回避: 領収書には不要な technical_sales / retail_sales がNULLで埋まる
  2. 制約の明確化: 売上伝票には必須、領収書には不要といったカラム制約が設定できる
  3. クエリの単純化: JOINなしで各テーブルを単独で扱える
  4. 拡張性: 将来の帳票タイプ追加時にも同じパターンで対応可能

新テーブル構成

テーブル名は gemini_ プレフィックスを付けて、Gemini APIからの出力であることを明示した。同じ場所に並ぶことで区別しやすくなる。

-- 領収書・レシート用
CREATE TABLE gemini_receipts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    file_name TEXT NOT NULL,
    batch_id TEXT NOT NULL,
    date TEXT,
    amount INTEGER DEFAULT 0,
    account_category TEXT DEFAULT '仮払金',
    summary TEXT,
    payee TEXT,
    concat_file_name TEXT,
    confirmed INTEGER DEFAULT 0,
    ocr_text TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

-- 売上伝票用
CREATE TABLE gemini_sales_slips (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    file_name TEXT NOT NULL,
    batch_id TEXT NOT NULL,
    date TEXT,
    technical_sales INTEGER DEFAULT 0,
    retail_sales INTEGER DEFAULT 0,
    amount INTEGER GENERATED ALWAYS AS (technical_sales + retail_sales) STORED,
    payee TEXT,
    summary TEXT,
    concat_file_name TEXT,
    confirmed INTEGER DEFAULT 0,
    ocr_text TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

amountGENERATED ALWAYS AS ... STORED で自動計算される。これにより、技術売上と店販売上を入力するだけで合計が算出される。SQLite 3.45.1でサポートされている機能。

UNIQUE制約について

ファイル名にUNIQUE制約は付けない。スキャナーの設定によっては 001.jpg のような連番ファイル名になり、日付・時刻がファイル名に含まれない場合がある。中身が違うファイルが同名になる可能性があるため。

バッチIDは秒単位のタイムスタンプで生成するため、実質ユニークになるが、複数行で共有されるカラムなのでUNIQUE制約は不要。

統合ビュー

月次推移表など、両方のデータを統合して扱う場面のためにビューを作成した。

CREATE VIEW monthly_transactions AS
SELECT
    batch_id, file_name, date, amount, payee, 'receipt' as document_type
FROM gemini_receipts
UNION ALL
SELECT
    batch_id, file_name, date, amount, payee, 'sales_slip' as document_type
FROM gemini_sales_slips;

マイグレーション実行結果

データ移行は正常に完了した。

  • gemini_receipts: 134件
  • gemini_sales_slips: 70件

旧テーブル gemini_reads は後方互換性のため一時的に残し、移行完了を確認後に削除した。

Phase 6: UI実装

帳票種別タブ

左サイドバーに「レシート」「売上伝票」の切替タブを追加した。

<BatchDocumentTypeList
  :items="documentTypes"
  :selected="currentDocumentType"
  @select="handleSelectDocumentType"
/>

種別を切り替えると、前回見ていた位置(バッチ、年、月、ファイル)をlocalStorageから復元する。これにより、種別間を行き来しても作業位置を失わない。

年月フィルター

バッチ内のファイルを年・月でフィルタリングする機能を追加した。

カラム構成:

帳票種別 | バッチ一覧 | 年 | 月 | ファイル

運用フロー:

  1. バッチ単位で帳票をスキャン(月ごとにまとめられている前提)
  2. 年フィルター: 明らかに違う年のOCR結果をすぐに発見
  3. 月フィルター: 月ごとのまとまりを確認
  4. ファイル一覧: 日付・金額の確認

この順序は矢印キーでのナビゲーション(日付昇順)と一致させた。年リストも昇順(古い順: 2023年 → 2024年)で表示。

// useReceipts.ts
const yearStats = computed(() => {
  const stats = new Map<number, { total: number; confirmed: number }>();
  batchItems.value.forEach(item => {
    const year = new Date(item.date).getFullYear();
    const existing = stats.get(year) || { total: 0, confirmed: 0 };
    existing.total++;
    if (item.confirmed) existing.confirmed++;
    stats.set(year, existing);
  });
  return stats;
});

年・月のカラムにも進捗表示(3/10)を追加した。

編集ロック機能

確定済みの帳票が誤って編集されないよう、編集ロック機能を実装した。

状態ボタン表示操作可能
未確定「確定」(Ctrl+Enter)全フィールド編集可
確定済み「編集」編集不可(読み取り専用)
編集中「保存」全フィールド編集可

「編集」ボタンはアウトライン青、「保存」ボタンはソリッド青で視覚的に区別した。

確定済みのアイテムでCtrl+Enterを押しても、カウントが増えないようガード処理を追加。

ファイルリストの自動スクロール

選択アイテムがリストの中央付近に表示されるよう、自動スクロール機能を実装した。

// 自動スクロール(中央寄せ)
const selectedEl = itemRefs.value[newIndex];
if (selectedEl) {
  selectedEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
}

これにより、先のアイテムが常に見える状態でナビゲーションできる。

帳票画像の件数表示

帳票画像の件数表示をバッチ単位に変更した。「1/70件」のようにバッチ内の位置がわかる。

バグ修正: document_typeの同期

売上伝票を選択した状態でファイルをクリックすると、帳票種別がレシートに切り替わるバグがあった。原因は、アイテムにdocument_typeが設定されていないとデフォルトで'receipt'になる処理だった。

loadAllReceiptsでバッチのdocument_typeをアイテムに設定するよう修正した。

// useReceipts.ts
function loadAllReceipts() {
  // ...
  batches.value.forEach(batch => {
    batch.items.forEach(item => {
      // バッチのdocument_typeをアイテムにも設定
      item.document_type = batch.document_type;
    });
  });
}

対話ベースCRUD設計思想

GUIベースのクライアント管理ではなく、Claude Codeとの対話でクライアント情報を登録する方式を採用した。AskUserQuestionを使った対話ベースのCRUD設計の思想は以下の通り。

従来のGUIベースの問題点

  • フォームの項目を一つずつ埋める必要がある
  • 入力形式が固定(テキストボックス、ドロップダウン等)
  • エラーメッセージが機械的

対話ベースのメリット

ユーザー: 新しいクライアントを登録して
Claude: クライアント名を教えてください
ユーザー: (スクリーンショットを送信)
Claude: 画像から情報を読み取りました。以下で登録します...
  • 入力形式を問わない(JSON、CSV、スクリーンショット何でもOK)
  • 不足している情報だけを質問される
  • 柔軟に情報を補完できる

この設計思想は requirements.md に明記し、今後のコマンド設計でも踏襲する。

得られた知見

Codexレビューの活用

設計判断に迷った際、Codex(GPT-5.2)にレビューを依頼した。テーブル分離の判断など、客観的な視点を得るのに役立った。

Codex CLIとの連携の注意点

| tee を使うとパイプが詰まる問題が発生した。変数経由で出力を受け取る方式が安定する。

# NG: パイプが詰まる
codex -p "レビューして" | tee output.md

# OK: 変数経由
result=$(codex -p "レビューして")
echo "$result" > output.md

# OK: リダイレクト(teeが不要な場合)
codex -p "レビューして" > output.md

OCR精度の検証

70件の売上伝票をOCRした結果、手動で作成した正解データと比較したところ、31件に差異があった(正解率56%)。主な差異は金額の読み取りミス。手書き文字の認識精度向上が今後の課題。

今後の課題

  • /import-csv コマンドの実装(スクエア明細CSV取込)
  • /match-sales コマンドの実装(売上伝票とスクエア明細のマッチング)
  • 請求書(invoice)対応の検討
  • インボイス制度対応(税区分カラムの追加)
  • OCR精度向上(プロンプト改善、モデル選定)