概要
税務アシスタントプロジェクトで、既存の領収書・レシートOCR機能に加えて、手書き売上伝票のOCR対応を実装した。対話ベースでClaude Codeに指示を出しながら、計画策定から実装完了まで1日で完了した。
実装の特徴は以下の3点。
- クライアントごとに異なる帳票フォーマットに対応するため、プロンプトをJSON形式で外部管理
- ドキュメントタイプ(receipt/sales_slip)をDBレベルで管理し、UIでフィルタリング可能に
- スクエア明細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_id | document_type | display_name |
|---|---|---|
| client_001 | receipt | 領収書・レシート |
| client_001 | sales_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_receipts と gemini_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 のようにオプションで分岐する設計を検討した。しかし、以下の理由からコマンド自体を分離する方針に変更した。
- スラッシュコマンドの名前から用途が明確になる
- 将来の帳票タイプ追加(invoice等)に対応しやすい
- Pythonファイルも同名で対応させることで、コードの追跡が容易
最終的なコマンド構成
| コマンド | 用途 | Pythonファイル |
|---|---|---|
/import-receipts | 領収書・レシート | import_receipts.py |
/import-sales-slip | 売上伝票 | import_sales_slip.py |
/import-csv | CSV取込(スクエア明細等) | 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)にレビューを依頼したところ、「分離すべき」という結論が返ってきた。理由は以下の通り。
- NULL地獄回避: 領収書には不要な
technical_sales/retail_salesがNULLで埋まる - 制約の明確化: 売上伝票には必須、領収書には不要といったカラム制約が設定できる
- クエリの単純化: 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
);
amount は GENERATED 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)対応の検討
- インボイス制度対応(税区分カラムの追加)