• #tax-assistant
  • #OCR
  • #Gemini
  • #Claude Code
  • #Vue
  • #SQLite
  • #設計
開発メモ

概要

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

実装の特徴は以下の3点。

  1. クライアントごとに異なる帳票フォーマットに対応するため、プロンプトをJSON形式で外部管理
  2. ドキュメントタイプ(receipt/sales_slip)をDBレベルで管理し、UIでフィルタリング可能に
  3. スクエア明細CSVとの突合フローを設計し、借方勘定科目の自動判定を可能に

背景

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

既存システムは領収書・レシートの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売上伝票

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 2: クライアント別スキーマ管理

ディレクトリ構造

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

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

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

{
  "document_type": "sales_slip",
  "client_id": "client_001",
  "client_name": "サンプル美容室",
  "prompt": "この画像は美容室の手書き売上伝票です。以下の情報を抽出してください:\n- 日付(年/月/日)\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": "店販売上金額" },
      "total_amount": { "type": "integer", "description": "合計金額" }
    },
    "required": ["date", "technical_sales", "retail_sales", "total_amount"]
  }
}

この方式のメリットは以下の通り。

  • クライアントごとに帳票フォーマットが違っても、JSONを編集するだけで対応可能
  • 人間が確認しやすい(コードを読む必要がない)
  • バージョン管理が容易

OCRコードでの読み込み

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

def get_schema_for_client(client_id: str, document_type: str) -> dict:
    """クライアント固有スキーマを取得。なければデフォルトスキーマを返す"""
    client_schema_path = Path(f"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ファイルも同名で対応させることで、コードの追跡が容易

最終的なコマンド構成

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

共通処理のモジュール化

各スクリプトは document_importer.py の共通関数を呼び出す構成にした。

# import_sales_slip.py
from document_importer import process_documents

def main():
    process_documents(
        client_id=client_id,
        document_type='sales_slip',
        inbox_path=inbox_path
    )

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なしで各テーブルを単独で扱える

新テーブル構成

-- 領収書・レシート用
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 で自動計算される。これにより、技術売上と店販売上を入力するだけで合計が自動的に算出される。

統合ビュー

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

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;

Phase 6: UI実装

帳票種別タブ

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

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

年月フィルター

バッチ内のファイルを年・月でフィルタリングする機能を追加した。これにより、以下のチェックが効率化された。

  • 年フィルター: 明らかに違う年のOCR結果をすぐに発見できる
  • 月フィルター: 月ごとにまとめられた帳票のチェックが容易に
// 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;
});

編集ロック機能

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

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

得られた知見

対話ベースの設計の有効性

GUIベースのクライアント管理ではなく、Claude Codeとの対話でクライアント情報を登録する方式を採用した。

ユーザー: 新しいクライアントを登録して
Claude: クライアント名を教えてください
ユーザー: (スクリーンショットを送信)
Claude: 画像から情報を読み取りました。以下で登録します...

この方式のメリットは、入力形式を問わない(JSON、CSV、スクリーンショット何でもOK)点にある。

Codexレビューの活用

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

コマンド実行時の注意点

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

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

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

今後の課題

  • /import-csv コマンドの実装(スクエア明細CSV取込)
  • /match-sales コマンドの実装(売上伝票とスクエア明細のマッチング)
  • 請求書(invoice)対応の検討
  • インボイス制度対応(税区分カラムの追加)