消費税パターン帳票ビューアー実装記録 - SVG仕訳表からHTML/CSS証憑テンプレートへの方針転換
消費税パターン帳票ビューアー実装記録
消費税経理処理パターン(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種類のテンプレートでカバーする設計にした。
| # | テンプレート | 対象取引 | 件数 | カバー率 |
|---|---|---|---|---|
| 1 | Invoice(請求書) | 売上・仕入・経費・建設系 | 595件 | 63.9% |
| 2 | Receipt(領収書) | 現金取引・小売 | 145件 | 15.6% |
| 3 | Credit Note(返還請求書) | 返品・値引き | 24件 | 2.6% |
| 4 | Sales 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;
}
ポイント: taxRate は string 型にしている。実データに 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 | 重大 | 擬似コードの未定義関数 | 完全な仕様を記載して解消 |
| 4 | 高 | 3表示経路の考慮不足 | Phase 2(本体統合時)で対応予定 |
| 5 | 高 | JournalPatternCardにclick未実装 | Phase 2で対応予定 |
| 6 | 中 | モバイル未設計 | Phase 1はデスクトップ限定。1024px以下で1カラムに切替 |
| 7 | 中 | 文言依存の判定が脆い | 税区分/カテゴリIDベースの判定に変更 |
特に指摘#2(8%(旧) の存在)と指摘#7(文言依存の判定ロジック)は実装に直接影響する重要な修正だった。
Phase 2(本体統合)の見送り・凍結
Phase 1のプロトタイプ検証の結果、以下の理由でPhase 2(本体の /tax-patterns ページへの統合)は見送りとした。
見送りの理由
- 支払い条件の推定が困難: 分割払い・前受金・手形等をproblem文からNLP的に抽出する必要がある
- 帳票の方向が逆転する: 売上系は「当社が発行」、費用系は「取引先から受領」でテンプレートの発行者/宛名が逆になる
- 非課税・免税の帳票フォーマット分岐: 土地売買は契約書、輸出は英語INVOICE等、帳票種類がさらに分岐する
- 決算整理仕訳(165件/17.7%): そもそも外部帳票が存在しない取引
これらを正確に実装する複雑さと対費用効果を考慮し、デモページはそのまま残すがこれ以上の開発は行わない判断とした。
帳票表示内容マトリックス
Phase 1の検証で明らかになった、taxCategoryBase別の帳票表示パターンを整理しておく。
| taxCategoryBase | 件数 | 帳票タイプ | 税率表示 | 税額計算 |
|---|---|---|---|---|
| 課税売上 | 91 | Invoice | 10% / 8%軽減 | 税込から算出 |
| 課税仕入 | 593 | Invoice | 10% / 8%軽減 | 税込から算出 |
| 輸出免税売上 | 1 | Invoice | 免税(0%) | 税額=0 |
| 非課税売上 | 20 | Invoice | 非課税 | 税額=0 |
| 課税売上返還 | 11 | Credit Note | 元取引の税率 | 返還額から算出 |
| 課税仕入返還 | 13 | Credit Note | 元取引の税率 | 返還額から算出 |
| 有価証券譲渡 | 2 | None | - | - |
| 課税貸倒 | 2 | None | - | - |
| 対象外 | 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レビューの有効性: データの件数ミスや型の問題を実装前に発見できた