消費税パターン帳票ビューアー実装記録 - SVG仕訳表からHTML/CSS証憑テンプレートへの方針転換

開発mdx-playgroundメモ

消費税パターン帳票ビューアー実装記録

消費税経理処理パターン(931件)の各仕訳に対応する証憑(請求書・領収書等)をプレビュー表示するビューアーを実装した。当初はSVGで仕訳表を描画する方向で進めたが、ユーザーからのフィードバックで方針転換し、最終的にHTML/CSSによる4種類の証憑テンプレートに落ち着いた。

背景と目的

消費税パターンページ /tax-patterns には931件の経理処理パターンが掲載されている。各パターンには取引内容(problem文)と仕訳データがあるが、「この仕訳はどんな帳票から起こされるのか」が視覚的にわからない。

目的は、仕訳データから対応する証憑(請求書・領収書等)を自動生成し、仕訳と並べて表示すること。これにより「取引 -> 証憑 -> 仕訳」の一連の流れが直感的に理解できるようになる。

最初のアプローチ: SVG仕訳表(失敗)

SVGで仕訳テーブルを描画する方向

最初はSVGで仕訳の借方・貸方テーブルを描画する実装を進めた。SVGは座標ベースでレイアウトを制御できるため、固定サイズの帳票表示に向いているという判断だった。

しかし、ユーザーから「イメージが違う」というフィードバックがあった。

ユーザーの期待: 仕訳テーブルの再表示ではなく、実際の請求書や領収書のような「証憑」のプレビューだった。

参考資料の提供

ユーザーからReact JSXファイル voucher_viewer.jsx が参考として提供された。このファイルでは、HTML/CSSで請求書風のレイアウトを構築し、propsでデータを流し込む方式が採用されていた。

この参考資料を基に、方針を「SVG仕訳表」から「HTML/CSS証憑テンプレート」に転換した。

SVG実装のリバート

誤ったSVG実装を git revert で取り消し、HTML/CSS証憑テンプレートで再実装を開始した。

証憑テンプレートの設計

4種類のテンプレート

931件のパターンを分析し、以下の4種類のテンプレートでカバーする設計にした。

#テンプレート対象取引件数カバー率
1Invoice(請求書)売上・仕入・経費・建設系595件63.9%
2Receipt(領収書)現金取引・小売145件15.6%
3Credit Note(返還請求書)返品・値引き24件2.6%
4Sales Statement(売上計算書)委託販売2件0.2%
-None(帳票なし)決算整理・自家消費・対象外165件17.7%

決算整理仕訳(165件/17.7%)はそもそも外部帳票が存在しないため、Noneとして何も表示しない。

帳票タイプ自動判定ロジック

仕訳データの taxCategoryBase(税区分)を第一優先で使い、取引の種類に応じて帳票タイプを自動判定する。

判定優先順位:
1. taxCategoryBase に「返還」を含む -> credit-note
2. 全エントリが「対象外」 -> none(決算整理仕訳)
3. problem に「委託販売」「売上計算書」 -> sales-statement
4. problem に「現金で」 -> receipt
5. その他 -> invoice

実装の詳細

ファイル構成

apps/web/app/
  utils/
    voucher-types.ts          # 共通型定義
    voucher-mapper.ts         # データ変換ロジック
  components/voucher/
    InvoiceTemplate.vue       # 請求書(適格請求書)
    ReceiptTemplate.vue       # 領収書(適格簡易請求書)
    CreditNoteTemplate.vue    # 返還請求書(適格返還請求書)
    SalesStatementTemplate.vue # 売上計算書(委託販売)
  pages/tax-patterns/
    voucher-demo.vue          # デモページ(タブ切替・左右分割)

1. 型定義(voucher-types.ts)

証憑データの共通インターフェースを定義する。

export type VoucherType = 'invoice' | 'receipt' | 'credit-note' | 'sales-statement' | 'none';

export interface VoucherLineItem {
  description: string;
  amount: number;
  taxRate: string;       // "10%" | "8%軽減" | "8%(旧)" etc.
  netAmount: number;     // 税抜金額
  taxAmount: number;     // 消費税額
}

export interface VoucherData {
  type: VoucherType;
  documentNo: string;
  counterparty: string;
  lineItems: VoucherLineItem[];
  totalAmount: number;
  remarks?: string;
}

ポイント: taxRatestring 型にしている。実データに 8%(旧) が2件存在するため、列挙型ではなく文字列で受け取る設計にした(Codexレビュー指摘#2への対応)。

2. データ変換ロジック(voucher-mapper.ts)

仕訳データ(AccountingExample)から証憑データ(VoucherData)への変換を担う。主要な関数は3つ。

detectVoucherType: 帳票タイプ自動判定

export function detectVoucherType(example: AccountingExample): VoucherType {
  // 1. 返品・値引き系: カテゴリ名 + 税区分「返還」で判定
  const hasReturn = example.categoryName.includes('値引')
    || example.categoryName.includes('戻し')
    || example.categoryName.includes('戻り')
    || example.patterns.some(p => p.entries.some(e =>
        e.debit.taxCategoryBase.includes('返還')
        || e.credit.taxCategoryBase.includes('返還')
      ));
  if (hasReturn) return 'credit-note';

  // 2. 委託販売
  if (example.problem.includes('委託販売') || example.problem.includes('売上計算書'))
    return 'sales-statement';

  // 3. 現金取引
  if (example.problem.includes('現金で'))
    return 'receipt';

  // 4. 全エントリが「対象外」-> 内部処理(帳票なし)
  const allOutside = example.patterns.every(p =>
    p.entries.every(e =>
      e.debit.taxCategoryBase === '対象外' && e.credit.taxCategoryBase === '対象外'
    )
  );
  if (allOutside) return 'none';

  // 5. デフォルト: 請求書
  return 'invoice';
}

初期の実装ではカテゴリ名やproblem文の文言に依存していたが、Codexレビュー(指摘#7)で「文言依存の判定が脆い」と指摘され、taxCategoryBase(税区分)をベースにした判定ロジックに改善した。

calcTaxBreakdown: 税込から税抜・税額を算出

export function calcTaxBreakdown(amount: number, taxRate: string): { net: number; tax: number } {
  // 免税・非課税・不課税は消費税なし
  if (taxRate === '免税' || taxRate === '非課税' || taxRate === '不課税') {
    return { net: amount, tax: 0 };
  }
  const rateNum = taxRate === '8%軽減' || taxRate === '8%(旧)' ? 8 : 10;
  const tax = Math.floor(amount * rateNum / (100 + rateNum));
  return { net: amount - tax, tax };
}

この関数には2つの重要な修正が入っている。

resolveTaxRate: taxCategoryBaseから表示用税率を決定

function resolveTaxRate(taxCategoryBase: string, taxRate?: string): string {
  if (taxCategoryBase === '輸出免税売上') return '免税';
  if (taxCategoryBase === '非課税売上') return '非課税';
  if (taxCategoryBase === '不課税売上') return '不課税';
  return taxRate || '10%';
}

輸出免税売上の場合に消費税率を「免税」として扱い、税額を0にする対応を追加した。

3. 請求書テンプレート(InvoiceTemplate.vue)

最も汎用的なテンプレート。931件中595件(63.9%)をカバーする。インボイス制度の適格請求書フォーマットに準拠したレイアウトにした。

主な構成要素:

  • タイトル(「請求書」+「適格請求書」サブタイトル)
  • 宛名(取引先名 御中)
  • 請求金額ボックス(税込合計を大きく表示)
  • 発行者情報(社名・住所・電話・登録番号)
  • 商品明細テーブル(No./品名/数量/単価/税率/金額)
  • 税率別内訳テーブル(税抜金額/消費税額/税込金額)
  • 軽減税率注記(8%対象品目がある場合のみ表示)
  • 支払い条件セクション

税率別内訳テーブルでは、computed プロパティで明細行を税率ごとに集計している。

const taxSummary = computed(() => {
  const map = new Map<string, { net: number; tax: number; total: number }>();
  props.voucher.lineItems.forEach(item => {
    const existing = map.get(item.taxRate) || { net: 0, tax: 0, total: 0 };
    existing.net += item.netAmount;
    existing.tax += item.taxAmount;
    existing.total += item.amount;
    map.set(item.taxRate, existing);
  });
  return Array.from(map.entries()).sort(([a], [b]) => b.localeCompare(a));
});

4. 領収書テンプレート(ReceiptTemplate.vue)

現金取引・小売購入に使用。請求書より幅が狭く(max-width: 480px)、シンプルなレイアウトにしている。

特徴的なのは「但し書き」の自動生成。明細行の品名を結合して「但し ○○・△△として」の文言を作る。

const purposeText = computed(() => {
  const names = props.voucher.lineItems.map(i => i.description);
  return names.join('') + 'として';
});

5. 返還請求書テンプレート(CreditNoteTemplate.vue)

返品・値引きに使用。合計行のラベルが「返還合計」になり、返還理由セクションが追加されている。

6. 売上計算書テンプレート(SalesStatementTemplate.vue)

委託販売の2件のみが対象。明細テーブルの2行目以降は手数料として負の値(赤字)で表示し、フッターに「差引精算額」を表示する。

7. デモページ(voucher-demo.vue)

全テンプレートを統合するデモページ。URL: /tax-patterns/voucher-demo

レイアウト構成:

  • ヘッダー: 「証憑テンプレート デモ」+全件数表示
  • タブバー: Invoice / Receipt / Credit Note / Sales Statement の4タブ切替
  • サンプルカード: 各タブで先頭10件を表示。左右分割レイアウトで「問題文+仕訳」と「証憑プレビュー」を並べて表示

各タブのバッジに該当件数を表示し、分類の全体像がわかるようにしている。

const typeCounts = computed(() => {
  const counts = new Map<VoucherType, number>();
  allExamples.value.forEach(ex => {
    const type = detectVoucherType(ex);
    counts.set(type, (counts.get(type) || 0) + 1);
  });
  return counts;
});

途中で発生した問題と修正

浮動小数点誤差の修正(1650000/1.1問題)

税込金額から税抜・税額を算出する際、JavaScriptの浮動小数点演算で誤差が発生した。

// 修正前: 浮動小数点除算
const net = Math.floor(amount / (1 + rate));
// 1,650,000 / 1.1 = 1499999.9999... -> Math.floor -> 1,499,999(1円ずれる)

修正後は整数演算で端数を回避する方式に変更した。

// 修正後: 整数演算で端数回避
const rateNum = taxRate === '8%軽減' || taxRate === '8%(旧)' ? 8 : 10;
const tax = Math.floor(amount * rateNum / (100 + rateNum));
return { net: amount - tax, tax };
// 1,650,000 * 10 / 110 = 150,000(正確)
// net = 1,650,000 - 150,000 = 1,500,000(正確)

amount / (1 + rate)amount * rateNum / (100 + rateNum) に置き換えることで、中間値が大きくなりIEEE 754の精度で表現できる範囲に収まるようにした。

輸出免税売上の消費税0%対応

元の実装では全ての取引に対して10%または8%で税額を計算していた。しかし輸出免税売上(taxCategoryBase === '輸出免税売上')は消費税が0%のため、税額=0、金額=税抜金額として扱う必要がある。

resolveTaxRate 関数を追加し、taxCategoryBase から表示用の税率文字列を解決する層を設けた。calcTaxBreakdown は税率文字列が「免税」「非課税」「不課税」の場合に { net: amount, tax: 0 } を返す。

Codexレビューと計画修正

実装計画に対してCodexによるレビューを受け、以下の指摘と対応を行った。

#重要度指摘内容対応
1重大件数が886ではなく931件修正済み。931件前提で再計算
2重大taxRateに8%(旧)が2件存在string型に変更。列挙型ではなくバリデーションで対応
3重大擬似コードの未定義関数完全な仕様を記載して解消
43表示経路の考慮不足Phase 2(本体統合時)で対応予定
5JournalPatternCardにclick未実装Phase 2で対応予定
6モバイル未設計Phase 1はデスクトップ限定。1024px以下で1カラムに切替
7文言依存の判定が脆い税区分/カテゴリIDベースの判定に変更

特に指摘#2(8%(旧) の存在)と指摘#7(文言依存の判定ロジック)は実装に直接影響する重要な修正だった。

Phase 2(本体統合)の見送り・凍結

Phase 1のプロトタイプ検証の結果、以下の理由でPhase 2(本体の /tax-patterns ページへの統合)は見送りとした。

見送りの理由

  1. 支払い条件の推定が困難: 分割払い・前受金・手形等をproblem文からNLP的に抽出する必要がある
  2. 帳票の方向が逆転する: 売上系は「当社が発行」、費用系は「取引先から受領」でテンプレートの発行者/宛名が逆になる
  3. 非課税・免税の帳票フォーマット分岐: 土地売買は契約書、輸出は英語INVOICE等、帳票種類がさらに分岐する
  4. 決算整理仕訳(165件/17.7%): そもそも外部帳票が存在しない取引

これらを正確に実装する複雑さと対費用効果を考慮し、デモページはそのまま残すがこれ以上の開発は行わない判断とした。

帳票表示内容マトリックス

Phase 1の検証で明らかになった、taxCategoryBase別の帳票表示パターンを整理しておく。

taxCategoryBase件数帳票タイプ税率表示税額計算
課税売上91Invoice10% / 8%軽減税込から算出
課税仕入593Invoice10% / 8%軽減税込から算出
輸出免税売上1Invoice免税(0%)税額=0
非課税売上20Invoice非課税税額=0
課税売上返還11Credit Note元取引の税率返還額から算出
課税仕入返還13Credit Note元取引の税率返還額から算出
有価証券譲渡2None--
課税貸倒2None--
対象外1,523含めない--

まとめ

実装したもの

  • 4種類のHTML/CSS証憑テンプレート(Vue.jsコンポーネント)
  • 仕訳データから証憑データへの自動変換ロジック
  • デモページ(/tax-patterns/voucher-demo)で動作確認可能
  • 浮動小数点誤差を回避した整数ベースの税額計算
  • 輸出免税・非課税・不課税への対応

学んだこと

  • ユーザーの期待を早期に確認する重要性: SVGで仕訳テーブルを表示する方向で実装を進めてしまい、リバートが必要になった。プロトタイプを見せてフィードバックをもらうサイクルは短い方がよい
  • JavaScriptの浮動小数点演算には注意: 金額計算では amount / (1 + rate) ではなく amount * rateNum / (100 + rateNum) のように整数演算に寄せる
  • スコープの見極め: 931件全てを完璧にカバーしようとすると複雑さが指数関数的に増大する。Phase 1(デモ)で検証してからPhase 2(統合)を判断するアプローチは正しかった
  • Codexレビューの有効性: データの件数ミスや型の問題を実装前に発見できた
#Vue.js #消費税#インボイス制度#証憑テンプレート#HTML/CSS #TypeScript #Nuxt3 #Codexレビュー