• #データベース
  • #マイグレーション
  • #Python
  • #Vue.js
  • #会計
開発tax-assistantメモ

tax-assistant 帳票種別・データ連携の改善

帳票種別(document_type)まわりの機能を一日かけて整備した。クレカ明細テーブルへのdocument_type_id追加、領収書の貸方表示バグ修正、売上伝票の2行表示対応、Squareの重複レコード修正、document_typeへのslugカラム追加と、関連する作業を連続で進めた記録。


全体の流れ

  1. クレカ明細にdocument_type_idを紐付け
  2. 領収書の貸方表示バグ修正
  3. 売上伝票の貸方2行表示
  4. Squareの重複レコード修正
  5. document_typeにslugカラム追加
  6. 書籍データベース設計の議論

以下、それぞれの内容を書く。


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層構成を検討した。

  1. 構造化メタデータ層: SQLiteテーブルに書籍情報(タイトル、著者、ISBN、分類、棚位置など)
  2. 全文検索/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のマジックナンバー問題を解消する小さな改善だが、コードの可読性に直結するので早めにやってよかった。