仕訳練習画面の BS/PL を開くと、当座預金が初期値ゼロから始まって最初の支払で ▲500 のマイナスを点灯させていた。当座預金の期首残高が無いまま「当座預金 / 現金」を解かせていたのが原因で、ユーザーは「これ実務とどう違うの?」と画面を二度見する。今日はこの違和感を、Codex に計画書を 5 回レビューさせて潰しきり、22 ケースのユニットテストまで通した記録をまとめる。
何を作ったか
/quiz/practice?chapter=N[§ion=M] のパスパラメータを読み、フィルタされた問題群(最大 51 問程度)に登場する全勘定科目を解く前から BS/PL に枠表示し、さらに累積で全問解いてもどの BS 科目もマイナスにならない初期残高を純粋関数で動的算出する仕組みを入れた。
対象プロジェクトは別リポジトリの eurekapu-nuxt4。今回触ったのは以下 3 ファイルのみ。
| ファイル | 種別 | 概要 |
|---|---|---|
app/utils/accountMapping.ts | 変更(+141 行) | 純粋関数群を追加 |
app/pages/quiz/practice.vue | 変更(38 行) | baseState に統合 |
tests/unit/quizInitialState.test.ts | 新規 | 全 11 章ループ検証 |
旧実装の問題点
旧実装(memo/2026-03-02/bs-pl-interactive-display.md で組んだ方式)は次のようになっていた。
- 全章共通の固定
INITIAL_STATE: 現金 1,000 / 普通預金 1,000 / 資本金 1,000 / 繰越利益剰余金 1,000 - BS の科目内訳は「セッション中に登場した科目」だけ動的に追加
そのため次の症状が出る。
chapter=1には「当座預金から支払」「売掛金を受け取った」等の仕訳が含まれるが、当座預金の期首残高がゼロのため最初の支払でマイナスに突入する- 売掛金が一旦プラスになって後で 0 に戻る不自然な動きが出る
- 問題を解くまで該当科目が BS に出てこないので、ユーザーは「次に何の科目が出るのか」を画面から読み取れない
計画書を memo に切る → Codex に投げる
要件をひとまとめにして memo/2026-05-09/quiz-bs-pl-preset-balance-plan.md を書き、codex exec -m gpt-5.5 に「瑣末な点へのクソリプはしないで。致命的な点だけ指摘して」と投げる。これを 5 回まわした。
1 回目: 「事前テーブル方式は不要」と発想がひっくり返る
最初の計画は scripts/calc-quiz-initial-balances.mjs を回して quizInitialBalances.ts を事前生成する方式だった。Codex のレビュー直後に自分で気づいた論点として「実行時に動的算出できるなら、事前テーブルは要らないのでは?」が湧いて、即採用。
これでファイルが 2 本(スクリプト + 生成データ)丸ごと不要になり、データ追加・改訂時の自動追従も得られた。section 指定(?chapter=1§ion=2)にも特別なロジックなしで自然に対応できる。
Codex 1 回目の致命的指摘は別件で 5 件:
- 繰越利益剰余金が借方流出する章(配当・減資など)でマイナスに振れる可能性 → 不足分を現金期首にバックフィルする方式で吸収
- contra asset(減価償却累計額・貸倒引当金)はマイナス側に積む控除科目なので、テストの非負チェックから除外
chapter=8 section=1判定(会社設立特例)がpractice.vue内にインラインで散らばっていた → 共通純粋関数shouldUseFoundationEmptyState(qs)に集約- 貸借一致式の二重加算(
assets === liabilities + equity + netIncomeと書きそうになっていた) → 既存実装のequityには利益剰余金 = netIncomeが含まれるため、assets === liabilities + equityで完了 calculateCumulativeImpactが initial を mutate しないか →deepCopyStateで純粋関数化済みと検証
2 回目以降: 純資産マイナス防止と判定式の統一
codex exec resume --last で文脈を引き継いだまま再レビュー。
- 3 回目の指摘 1: 配当仕訳「繰越利益剰余金 / 未払配当金」や減資仕訳「資本金 / 資本剰余金」で純資産科目が借方発生する。資産・負債と同じく
bs_equity全科目にroundUp100(dr[X])の下限を当てる必要がある - 3 回目の指摘 2: 会社設立特例の判定式が文書・実装・テストで微妙に揺れていた →
qs.length > 0 && qs.every(isFoundationQuestion)で全箇所を統一 - 4 回目の指摘:
chapter=8 section=1の判定が「qs.length === 1 && isFoundationQuestion(qs[0])」のままだと、将来 section=1 が複数問になったとき壊れる →every形式に書き換え - 5 回目: OK が出て計画書確定
5 回目で「致命的な点なし」と返ってきたタイミングで実装に着手。
残高決定アルゴリズム
computeInitialState(questions) の決定式は次のとおり。フィルタ範囲を 1 周して dr[X] / cr[X] を集計し、科目分類ごとに次の式を当てる。
| 科目分類 | 期首残高 |
|---|---|
| 資産(contra 以外) | roundUp100(cr) |
| 資産(contra: 減価償却累計額・貸倒引当金) | 0 |
| 負債 | roundUp100(dr) |
| 資本金 | max(1000, roundUp100(dr)) |
| その他純資産(利益準備金 等) | roundUp100(dr) |
| 繰越利益剰余金 | 差額バランス調整 + roundUp100(dr) 下限。不足分は現金にバックフィル |
| PL 系 | 0(枠だけ表示) |
roundUp100 は次の純粋関数。
const roundUp100 = (n: number): number => {
if (n <= 0) return 0
return Math.max(1000, Math.ceil(n / 100) * 100)
}
意図は「資産は累積貸方合計(最大流出量)が下限、負債は累積借方合計(最大返済額)が下限」「ゼロでない以上は最低 1,000 円から」「100 円単位で丸める」の 3 点。
繰越利益剰余金は「貸借バランス用の差額」と「借方流出額の下限」の両方を同時に満たす最大値で決定する。両方を満たせない場合は不足分を現金期首に上乗せして両条件を成立させる。これが Codex 1 回目指摘 1 と 3 回目指摘 1 の統合対処になる。
practice.vue 側の差分
ロジックを純粋関数に閉じ込めた結果、practice.vue の差分は実質 1 ブロックに収束した。
- const cumulativeBase = computed(() =>
- questions.value[0] && isFoundationQuestion(questions.value[0])
- ? emptyState() : INITIAL_STATE)
- const singleBase = computed(() =>
- current.value && isFoundationQuestion(current.value)
- ? emptyState() : INITIAL_STATE)
+ const baseState = computed((): FinancialState => {
+ const qs = questions.value
+ if (qs.length === 0) return emptyState()
+ if (shouldUseFoundationEmptyState(qs)) return emptyState()
+ return computeInitialState(qs)
+ })
cumulativeImpact / singleImpact の参照先を統合 baseState に置換し、インラインの isFoundationQuestion を削除して共通関数に寄せた。INITIAL_STATE と calculateImpact の import も落とせる。
テスト 22 ケース pass
tests/unit/quizInitialState.test.ts を新規作成し、全 11 章を 1 ループずつ回して全問累積で全 BS 科目(contra asset 除く)の非負と貸借一致を検証する。
| テスト群 | 件数 | 内容 |
|---|---|---|
roundUp100 | 3 | 0 以下 / 1〜1000 / 1001 以上の境界 |
shouldUseFoundationEmptyState | 3 | 空 / 全 Foundation / chapter=8 混在 |
aggregateAccountTotals | 1 | chapter=1 の現金 cr 集計 |
| 全 11 章ループ | 11 | 各章 51 問×全中間累積で「全 BS 科目(contra 除く)非負」+「貸借一致」 |
| section 指定 | 1 | chapter=1 section=2 でも貸借一致+非負 |
| 資本金常時 1,000 | 1 | フィルタ範囲不在でも資本金=1,000 以上で表示 |
| 全 BS/PL 科目枠 | 2 | 当座預金・売上・仕入の枠表示確認 |
pnpm vitest run tests/unit/quizInitialState.test.ts # 22 pass
pnpm vitest run tests/unit/accountMapping.test.ts # 32 pass(既存回帰なし)
chapter=11 の問 24(q_11_9_0)には「損益勘定」を含む仕訳があり、累積計算で console.warn が 1 件出る。これは既存の挙動(仕様)で、テストは正常 pass。決算振替仕訳をクイズの試算表に直接反映させない設計は触らない。
目視確認
dev サーバを pnpm dev で起動(ポート 3200 が他プロセスで埋まっており 3000 にフォールバック)、http://localhost:3000/quiz/practice?chapter=1 を開く。
DOM スナップショットで 24 科目(資産 11 / 負債 4 / 純資産 3 / PL 6)の枠が並び、貸借一致は 資産 11,200 = 負債 3,100 + 純資産 8,100 で揃った。現金は本来の貸方合計から算出される値より大きく出ているが、これは繰越利益剰余金の roundUp100(dr) 下限を満たすために自動でバックフィルされた結果で、計画通りの挙動。
chapter=8 section=1(会社設立)は旧挙動どおり emptyState(科目ゼロ表示)に落ちた。chapter=8 の混在パターンは管理者ログインなしの環境ではアクセス制限で目視できなかったが、ユニットテストの全 11 章ループで貸借一致と非負性を検証済みなので、実装側の自信としては十分。
学びメモ
事前生成より動的算出を疑う。 当初は「全章ぶんの初期残高をスクリプトで事前計算→TS データ化」というファイル 2 本コースを設計していた。Codex 1 回目のレビュー直後に「実行時に純粋関数で算出できるなら事前テーブル要らないのでは」と気づき、新規ファイル 2 本がそのまま消えた。データ更新時の追従コストもゼロに落ちた。設計を書き上げる前に「これ事前計算する必要ある?」と一度疑うのは効く。
判定式は 1 箇所に集約してから書き始める。 chapter=8 section=1 判定が practice.vue 内のインライン三項演算子 + テスト内の独自実装 + 計画書本文の説明文で 3 箇所に散らばっていた。Codex に「式が揃っていない」と指摘され、共通純粋関数 shouldUseFoundationEmptyState(qs) を切り出してから全箇所がそれを参照する形に揃えた。判定ロジックは最初から関数で書き、文書も実装も同じ式を引用する習慣が要る。
Codex 5 回は重くない。 1 回あたり数分。仕様の穴が出るたび codex exec resume --last で文脈を引き継ぎ、計画書を更新して再投入。5 回目で「致命的な点なし」を引いたタイミングで実装に入ると、書きながら気づく後戻りがほぼ消える。実装フェーズ自体は 1 セッションで accountMapping.ts 追加 → practice.vue 統合 → 22 テスト pass → 目視まで一気通貫で抜けた。
ファイル参照
- 実装:
app/utils/accountMapping.ts:330-471 - 統合:
app/pages/quiz/practice.vue:281-308 - テスト:
tests/unit/quizInitialState.test.ts:1-181 - 計画書:
memo/2026-05-09/quiz-bs-pl-preset-balance-plan.md - 作業ログ:
memo/2026-05-09/quiz-bs-pl-preset-balance-worklog.md