tax-assistant 帳票種別・データ連携の改善
帳票種別(document_type)まわりの機能を一日かけて整備した。クレカ明細テーブルへのdocument_type_id追加、領収書の貸方表示バグ修正、売上伝票の2行表示対応、Squareの重複レコード修正、document_typeへのslugカラム追加と、関連する作業を連続で進めた記録。
全体の流れ
- クレカ明細にdocument_type_idを紐付け
- 領収書の貸方表示バグ修正
- 売上伝票の貸方2行表示
- Squareの重複レコード修正
- document_typeにslugカラム追加
- 書籍データベース設計の議論
以下、それぞれの内容を書く。
1. クレカ明細にdocument_type_idを紐付け
背景
クレカ明細(creditcard_transactions)テーブルには、どの帳票種別に属するかの情報がなかった。仕訳ビューアで貸方の勘定科目を表示するとき、値がハードコードされていて、帳票種別ごとの設定を参照していなかった。
テーブル変更
creditcard_transactionsテーブルにdocument_type_idカラムを追加した。
ALTER TABLE creditcard_transactions
ADD COLUMN document_type_id INTEGER REFERENCES document_types(id);
外部キー制約でdocument_typesテーブルと紐付けている。NULLを許容する設計にして、既存データへの影響を最小限にした。
CSV取り込み時の自動解決
CSV取り込み処理にresolve_document_type_id関数を追加した。クレカ明細のCSVを取り込むとき、ファイル名やカード種別から対応するdocument_type_idを自動で割り当てる。
def resolve_document_type_id(card_type: str, db: Session) -> int | None:
"""カード種別からdocument_type_idを解決する"""
mapping = {
"visa": "creditcard_visa",
"mastercard": "creditcard_master",
"amex": "creditcard_amex",
}
slug = mapping.get(card_type.lower())
if slug is None:
return None
doc_type = db.query(DocumentType).filter(
DocumentType.slug == slug
).first()
return doc_type.id if doc_type else None
DB insert関数も修正して、document_type_idを受け取れるようにした。
フロントエンド型定義の更新
TypeScript側の型定義にもdocument_type_idを追加した。
interface CreditcardTransaction {
id: number;
date: string;
description: string;
amount: number;
document_type_id: number | null; // 追加
// ...
}
仕訳ビューアの修正
仕訳ビューアで貸方の勘定科目を表示する部分を修正した。以前は"未払金"のようにハードコードされていたが、帳票設定(document_type)のデフォルト貸方値を参照するように変更した。
// Before: ハードコード
const creditAccount = "未払金";
// After: document_typeの設定を参照
const creditAccount = documentType?.default_credit_account ?? "未払金";
既存データのマイグレーション
既存265件のクレカ明細データに対して、document_type_id=5(クレカ明細の帳票種別ID)を一括設定した。
UPDATE creditcard_transactions
SET document_type_id = 5
WHERE document_type_id IS NULL;
マイグレーション実行後、265件全てに正しいdocument_type_idが設定されていることを確認した。
2. 領収書の貸方表示バグ修正
症状
領収書(receipt)の仕訳データを表示するとき、貸方が空文字で表示されていた。本来は帳票種別の設定に従って「現金」や「普通預金」が入るべきところ。
原因
convertReceipt()関数の中で、貸方が空文字にハードコードされていた。
# バグのあったコード
def convert_receipt(receipt: Receipt) -> JournalEntry:
return JournalEntry(
debit_account=receipt.account_title,
credit_account="", # ここが空文字固定
amount=receipt.amount,
# ...
)
修正
document_type情報を引数として受け取り、デフォルト貸方値を参照するように修正した。
def convert_receipt(
receipt: Receipt,
document_type: DocumentType | None = None,
) -> JournalEntry:
credit = ""
if document_type and document_type.default_credit_account:
credit = document_type.default_credit_account
return JournalEntry(
debit_account=receipt.account_title,
credit_account=credit,
amount=receipt.amount,
# ...
)
テスト追加
この修正に合わせて13件のテストを追加した。
- document_typeがNoneの場合のフォールバック
- document_typeにdefault_credit_accountが設定されている場合
- default_credit_accountが空文字の場合
- 複数の帳票種別での動作確認
全テストがパスすることを確認。
3. 売上伝票の貸方2行表示
売上伝票では、1つの売上に対して貸方が2行になるケースがある。たとえば美容室の売上なら「技術売上高」と「店販売上高」の2行に分かれる。これに対応した。
テーブル設計
document_type_journal_linesテーブルを新規作成した。
CREATE TABLE document_type_journal_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_type_id INTEGER NOT NULL REFERENCES document_types(id),
line_order INTEGER NOT NULL DEFAULT 1,
side TEXT NOT NULL CHECK (side IN ('debit', 'credit')),
account_title TEXT NOT NULL,
source_field TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(document_type_id, line_order, side)
);
source_fieldは、その行の金額をどのフィールドから取得するかを指定する。たとえば"technical_sales"なら技術売上の金額、"retail_sales"なら店販売上の金額を参照する。
バックエンドAPI
CRUD用のエンドポイントを作成した。
GET /api/document-types/{id}/journal-lines
POST /api/document-types/{id}/journal-lines
PUT /api/document-types/{id}/journal-lines/{line_id}
DELETE /api/document-types/{id}/journal-lines/{line_id}
フロントエンドの接続
DetailPanelコンポーネントからAPIを呼び出し、帳票種別ごとの仕訳行設定を表示・編集できるようにした。
テーブルのrowspanレンダリング
仕訳ビューアのテーブルで、貸方が2行ある場合にrowspanを使って表示する。
+----------+--------+----------+--------+
| 日付 | 借方 | 貸方 | 金額 |
+----------+--------+----------+--------+
| 2/6 | 現金 | 技術売上 | 80,000 |
| | +----------+--------+
| | | 店販売上 | 15,000 |
+----------+--------+----------+--------+
借方側(現金)は1行でrowspan=2、貸方側は2行に分かれる。
source_fieldのNULL問題
source_fieldがNULLのとき、金額の取得に失敗して0円表示になるバグがあった。source_fieldがNULLなら合計金額をそのまま使うフォールバックを入れて対応した。
const getAmount = (line: JournalLine, record: SalesRecord): number => {
if (line.source_field && record[line.source_field] !== undefined) {
return record[line.source_field] as number;
}
// source_fieldがない場合は合計金額を使う
return record.total_amount;
};
4. Squareの重複レコード修正
発覚
Squareからの売上データを確認していたら、同じ日・同じ金額のエントリが重複して存在していることに気付いた。調査すると、340件の重複レコードが見つかった(10,780円のエントリなど)。
原因
extract_time_and_tx_no()関数にバグがあった。Squareの明細データからトランザクション番号(tx_no)と時刻を抽出する処理で、同じトランザクションに対して複数のレコードが生成されていた。
修正
extract_time_and_tx_no()関数を修正して、1トランザクション1レコードになるようにした。
def extract_time_and_tx_no(description: str) -> tuple[str | None, str | None]:
"""Square明細からtx_noと時刻を抽出する
入力例: "Square 12:30 #TX-12345"
出力: ("12:30", "TX-12345")
"""
time_match = re.search(r"(\d{2}:\d{2})", description)
tx_match = re.search(r"#(TX-\d+)", description)
time_str = time_match.group(1) if time_match else None
tx_no = tx_match.group(1) if tx_match else None
return time_str, tx_no
クリーンアップ
重複データを削除して、340件を170件に削減した。削除対象はtx_noとdateが同一のレコードのうち、idが大きいほう。
DELETE FROM deposits
WHERE id IN (
SELECT d1.id
FROM deposits d1
INNER JOIN deposits d2
ON d1.tx_no = d2.tx_no
AND d1.date = d2.date
AND d1.id > d2.id
);
さらに、全depositレコードに正しいtx_no値が設定されていることを確認し、NULLのものは明細データから再抽出して埋めた。
5. document_typeにslugカラム追加
目的
document_typeをIDではなく人間が読める識別子で参照したかった。APIやフロントエンドのコードでdocument_type_id: 5と書くよりslug: "creditcard_visa"と書くほうがわかりやすい。
マイグレーション
ALTER TABLE document_types
ADD COLUMN slug TEXT UNIQUE;
UNIQUE制約を付けて、同じslugの帳票種別が登録されないようにした。NULLは許容する(SQLiteのUNIQUE制約はNULLを重複とみなさない)。
マイグレーションスクリプト
既存データにslugを設定するスクリプトを作成した。
SLUG_MAPPING = {
1: "receipt_cash",
2: "receipt_bank",
3: "deposit_square",
4: "deposit_cash",
5: "creditcard_visa",
# ...
}
def migrate_slugs(db: Session):
for type_id, slug in SLUG_MAPPING.items():
db.execute(
text("UPDATE document_types SET slug = :slug WHERE id = :id"),
{"slug": slug, "id": type_id},
)
db.commit()
Pythonバリデーション
PydanticモデルにslugフィールドのバリデーターIを追加した。英数字とアンダースコアのみ許可。
from pydantic import BaseModel, field_validator
class DocumentTypeCreate(BaseModel):
name: str
slug: str | None = None
@field_validator("slug")
@classmethod
def validate_slug(cls, v: str | None) -> str | None:
if v is None:
return v
if not re.match(r"^[a-z0-9_]+$", v):
raise ValueError(
"slugは英小文字・数字・アンダースコアのみ使用可能です"
)
return v
CRUD関数の対応
既存のCRUD関数にslugでの検索を追加した。
def get_document_type_by_slug(db: Session, slug: str) -> DocumentType | None:
return db.query(DocumentType).filter(DocumentType.slug == slug).first()
フロントエンド
TypeScript側の型にslugを追加し、useIncomeViewerコンポーザブルでIDの代わりにslugを使うように変更した。
interface DocumentType {
id: number;
name: string;
slug: string | null; // 追加
default_credit_account: string | null;
// ...
}
// useIncomeViewer.ts
const depositType = documentTypes.value.find(
(dt) => dt.slug === "deposit_square"
);
IDのマジックナンバーがコードから消えて、可読性が上がった。
6. 書籍データベース設計の議論
開発作業の合間に、個人蔵書900冊のデータベース設計について議論した。直接の実装はしていないが、設計方針を固めた。
要件
- 900冊の蔵書メタデータ(タイトル、著者、ISBN、出版年など)を管理
- PDF化した書籍の全文検索
- OCRテキストの格納
SQLite vs TypeScriptデータストレージ
TypeScriptファイルにデータを直接書く方式(tax-assistantの消費税パターンと同じ手法)と、SQLiteに入れる方式を比較した。
- 900冊のメタデータ程度ならTypeScriptファイルでも管理できる
- ただしOCRテキストや全文検索が入ると、SQLiteのFTS5が有利
- 更新頻度が高いデータ(読書メモ、評価など)はDBのほうが扱いやすい
2層DB構成
最終的に2層構成を検討した。
- 構造化メタデータ層: SQLiteテーブルに書籍情報(タイトル、著者、ISBN、分類、棚位置など)
- 全文検索/OCRコンテンツ層: SQLite FTS5テーブルにOCRテキスト、目次、索引など
メタデータ層は手動入力やISBN APIからの取得、コンテンツ層はPDF OCRパイプラインからの自動投入を想定。
PDF OCRワークフロー
PDF → ページ分割 → OCR (Tesseract/Vision API) → テキスト → FTS5
OCR精度の問題があるので、元PDFへのページ番号リンクを保持しておき、検索結果から原本に飛べるようにする設計。
今日の作業を振り返って
document_type関連の作業は、テーブル設計 → マイグレーション → バックエンドAPI → フロントエンド型定義 → UI表示という一連の流れを何度も繰り返した。特にクレカ明細のdocument_type_id紐付けと売上伝票の2行表示は、DBスキーマからUI描画まで全レイヤーに変更が必要で、それぞれ数時間かかっている。
Squareの重複レコード修正は、データ品質の問題として地味だが放置できない類のもの。340件が170件に半減したので、売上集計の数字もこれで正しくなった。
slugカラムの追加は、IDのマジックナンバー問題を解消する小さな改善だが、コードの可読性に直結するので早めにやってよかった。