0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

金額を持たない行を3件、帳簿の配列に入れたら、残高は ¥NaN・純損益は ¥0・取引台帳は例外になった(同じ行が集計ごとに別の顔をする)

0
Posted at

自分の会社の数字を出すダッシュボード(素の HTML と JavaScript)で、いちばん上の「現預金残高」が6日あまり ¥NaN のまま出ていました。原因は、帳簿(取引の配列)に、取引ではない行を3件まちがえて入れたことです。その行は日付と題名と本文と状態だけを持ち、種別(type)も金額(amount)も持っていませんでした。

直したあとで、残高・純損益・取引台帳の3つの集計と描く順番だけを小さく作り直して測ると、同じ1行が、集計の書き方ごとに別の顔をしていました。

出し方 取引0件のとき 取引ではない行が1件あるとき
残高(日ごとに足し込む) ¥0 ¥NaN
純損益(種別で絞ってから合計) ¥0 ¥0(行は黙って抜ける)
取引台帳(1行ずつ金額を表示) 描ける TypeError で止まる
取引台帳より後ろの節 描ける 呼ばれない

この3つの中で NaN と出るのは残高だけで、純損益は正しそうな ¥0 を出し、取引台帳は例外で止まって後ろの節を道連れにします。

(当時の画面には、この再現に入れていない部品もありました。日次の棒グラフと残高推移の折れ線グラフは、縦軸の上限を niceMax(Math.max(...)) で決めており、niceMax(NaN) は NaN を返すので、縦軸の目盛りも NaN になる作りでした。折れ線の終端ラベルも yen() で残高を出すので ¥NaN です。事業別の損益の表には、事業を持たない行があるだけで「未分類」の行が1つ足され、取引0件なのに ¥0 の行が出ていました。いずれも実ファイルのコードを読んで確かめたもので、画面を目で見て確かめてはいません)

1. 何が起きていたか

当時の画面の集計は次の形でした(実ファイルから該当行だけを抜き出しています)。

const yen = n => "¥" + n.toLocaleString("ja-JP");
d[e.type === "income" ? "income" : "expense"] += e.amount;
const totalIncome = D.ledger.filter(e => e.type === "income").reduce((s, e) => s + e.amount, 0);
const totalExpense = D.ledger.filter(e => e.type === "expense").reduce((s, e) => s + e.amount, 0);

取引台帳の各行は ${yen(e.amount)} で金額を出していました。描く順番は renderTiles → … → renderOps → renderTasks → renderLedger → renderApprovals で、render() の中のどの呼び出しも try で包んでいません(途中の非同期の節には中に try を持つものもありますが、取引台帳と承認待ちは同期で呼ばれます)。

種別も金額も無い行がここへ入ると、こうなります。

  • 日ごとの集計は、type が "income" でないものを全部支出側へ振るので、expense += undefined で NaN になります。残高は NaN を足し込んで NaN になり、NaN.toLocaleString() は例外を出さずに "NaN" を返します。だから画面には ¥NaN と出ます
  • 合計は先に type で絞るので、type を持たない行はどちらにも入りません。収入0・支出0で、純損益は ¥0 と出ます。正しそうに見える数字です
  • 取引台帳は yen(undefined) を呼びます。undefined.toLocaleString は TypeError です。この例外で取引台帳の描画が止まり、その後ろの節(承認待ち)は一度も呼ばれません

undefined は足し算では黙って NaN になり、メソッド呼び出しでは例外になる。同じ欠けた値が、触り方によって「静かな壊れ」と「うるさい壊れ」の両方を作っていました。

2. 再現スクリプト

依存なし・ファイルを読み書きしない・通信しない形で、当時の書き方を縮めました。入れたのは残高・純損益・取引台帳の集計と、節を順番に呼ぶ描き方だけです(グラフと事業別の損益は入れていません)。出力の「事業に紐づかない行」は、事業別の損益で「未分類」の行が出る条件(business を持たない行)を数えたものです。

// 金額を持たない行が帳簿の配列に混ざったとき、同じ行が集計ごとに別の顔をすることを測る。
// 依存0・ファイルを読み書きしない・通信しない。
//   node scripts/repro-missing-amount.mjs          人間向け
//   node scripts/repro-missing-amount.mjs --json   保存用
//
// 集計の書き方は、実際に壊れたダッシュボードの書き方をそのまま縮めたもの:
//   日ごとの集計   d[e.type === "income" ? "income" : "expense"] += e.amount
//   合計           filter(e => e.type === "income").reduce((s, e) => s + e.amount, 0)
//   表示           const yen = n => "¥" + n.toLocaleString("ja-JP")
//   描画           節を順番に呼ぶだけ(try で包まない)

const yen = n => "¥" + n.toLocaleString("ja-JP");

// 取引ではない行(業務日誌の行)。type も amount も持たない。
const stray = { date: "2026-08-28", title: "業務日誌の行", detail: "取引ではない" };

function aggregate(ledger, capital) {
  const byDay = new Map();
  for (const e of ledger) {
    const d = byDay.get(e.date) || { income: 0, expense: 0 };
    d[e.type === "income" ? "income" : "expense"] += e.amount;
    byDay.set(e.date, d);
  }
  let cash = capital;
  for (const d of byDay.values()) cash += d.income - d.expense;
  const totalIncome = ledger.filter(e => e.type === "income").reduce((s, e) => s + e.amount, 0);
  const totalExpense = ledger.filter(e => e.type === "expense").reduce((s, e) => s + e.amount, 0);
  const unassigned = ledger.filter(e => !e.business).length;
  return { cashNow: cash, totalIncome, totalExpense, net: totalIncome - totalExpense, unassigned };
}

function show(v) {
  try { return { text: yen(v), error: null }; }
  catch (err) { return { text: null, error: err.constructor.name + ": " + err.message }; }
}

// 節を順番に描く。どこかで例外が出たら、そのあとの節は呼ばれない。
function renderAll(ledger, sections, wrap) {
  const drawn = [];
  let error = null;
  for (const [name, fn] of sections) {
    if (wrap) {
      try { fn(ledger); drawn.push(name); } catch (err) { /* 包むと黙って次へ進む */ }
    } else {
      try { fn(ledger); drawn.push(name); }
      catch (err) { error = { at: name, message: err.constructor.name + ": " + err.message }; break; }
    }
  }
  return { drawn, notDrawn: sections.map(s => s[0]).filter(n => !drawn.includes(n)), error };
}

const sections = [
  ["タイル", l => yen(aggregate(l, 0).cashNow)],
  ["業務日誌", () => "ok"],
  ["取引台帳", l => l.map(e => yen(e.amount)).join("")],
  ["承認待ち", () => "ok"],
];

function run(label, ledger) {
  const agg = aggregate(ledger, 0);
  return {
    label,
    rows: ledger.length,
    cashNow: Number.isNaN(agg.cashNow) ? "NaN" : agg.cashNow,
    cashShown: show(agg.cashNow),
    totalIncome: agg.totalIncome,
    totalExpense: agg.totalExpense,
    netShown: show(agg.net),
    unassignedRows: agg.unassigned,
    render: renderAll(ledger, sections, false),
    renderWrapped: renderAll(ledger, sections, true),
  };
}

const isAmountRow = e => typeof e.amount === "number" && Number.isFinite(e.amount) && (e.type === "income" || e.type === "expense");

const cases = [
  run("取引0件(正しい状態)", []),
  run("取引ではない行が1件", [stray]),
  run("取引ではない行が3件", [stray, stray, stray]),
  run("同じ3件を、金額を持つ行だけに絞ってから渡す", [stray, stray, stray].filter(isAmountRow)),
];

const out = { node: process.version, cases };

if (process.argv.includes("--json")) {
  console.log(JSON.stringify(out, null, 2));
} else {
  for (const c of cases) {
    console.log(`■ ${c.label}(${c.rows}行)`);
    console.log(`  残高: ${c.cashShown.text ?? c.cashShown.error}`);
    console.log(`  純損益: ${c.netShown.text ?? c.netShown.error}(収入 ${c.totalIncome} / 支出 ${c.totalExpense})`);
    console.log(`  事業に紐づかない行: ${c.unassignedRows}`);
    console.log(`  描けた節: ${c.render.drawn.join(" / ") || "(なし)"}`);
    if (c.render.error) console.log(`  止まった節: ${c.render.error.at} — ${c.render.error.message}`);
    console.log(`  各節を try で包んだ場合に描けた節: ${c.renderWrapped.drawn.join(" / ")}`);
  }
  console.log(`Node ${process.version}`);
}

このPC(Windows 11・Node v24.18.1)で流した結果です。

■ 取引0件(正しい状態)(0行)
  残高: ¥0
  純損益: ¥0(収入 0 / 支出 0)
  事業に紐づかない行: 0
  描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
  各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
■ 取引ではない行が1件(1行)
  残高: ¥NaN
  純損益: ¥0(収入 0 / 支出 0)
  事業に紐づかない行: 1
  描けた節: タイル / 業務日誌
  止まった節: 取引台帳 — TypeError: Cannot read properties of undefined (reading 'toLocaleString')
  各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 承認待ち
■ 取引ではない行が3件(3行)
  残高: ¥NaN
  純損益: ¥0(収入 0 / 支出 0)
  事業に紐づかない行: 3
  描けた節: タイル / 業務日誌
  止まった節: 取引台帳 — TypeError: Cannot read properties of undefined (reading 'toLocaleString')
  各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 承認待ち
■ 同じ3件を、金額を持つ行だけに絞ってから渡す(0行)
  残高: ¥0
  純損益: ¥0(収入 0 / 支出 0)
  事業に紐づかない行: 0
  描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
  各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
Node v24.18.1

最後の行は、同じ3件を「種別が収入か支出で、金額が有限の数」の行だけに絞ってから渡したものです。画面は元に戻ります。**ただしこれは、間違って入った行を黙って捨てているだけです。**3件が帳簿に入っているという事実は、どこにも出ません。

もう1つ、各節を try で包んだ場合も測りました。取引台帳が止まっても、後ろの承認待ちは描かれます。**ただし取引台帳は空のまま、例外も表に出ません。**包むと「後ろの節が道連れになる」は直りますが、「壊れたことに気づかない」は悪くなります。

3. 6日あまり気づかなかった理由

行を入れたコミットは 2026-08-28 07:15:05、直したコミットは 2026-09-03 17:48:28 で、間は6日10時間33分です。その間、自分の作業記録には何度も、画面は嘘になっていないと書いていました。

当時のテストを数え直すと、行を入れたコミットの時点ではテストファイルは17本で、app/index.html を読むものは0本でした。直す直前のコミットでテストファイルは26本あり、app/index.html を読むものは2本ありました。2本とも、ファイルを文字列として読んで、ある数値や要素が書かれているかを見るものです。**画面の script を実際に走らせるテストは0本でした。**集計の入力(帳簿)の側も、テストは一度も読んでいません。

つまり、検査は在ったのに、¥NaN が出る道を1度も通っていませんでした。

4. 測っていないこと

  • 再現は当時の書き方を縮めたもので、当時のファイルそのものを走らせてはいません(当時の画面を DOM の代わりの部品で走らせた実測は、直した日に別に取っていますが、その出力はこの記事に載せていません)
  • 再現に入れたのは残高・純損益・取引台帳・描く順番だけです。グラフの縦軸と終端ラベル、事業別の損益の「未分類」の行は、実ファイルのコードを読んだだけで、再現でも画面でも測っていません
  • 試したのは「種別も金額も無い行」の1形だけです。金額が文字列の "1980" だった場合(足し算が文字列の連結になる)などは測っていません
  • try で包む・行を絞る以外の直し方(入れる側で形を検査して拒む・型を付ける)は試していません
  • 1台・Node v24.18.1 だけです

5. やめたこと

  • 取引の配列に、取引ではない行を入れられる形のままにしておくこと。入れる側で「種別と金額を持たない行は入れない」を確かめる
  • 画面を「入力のデータが正しいか」だけで確かめること。描いた結果に NaN や undefined が出ていないか、描かれた節が空でないかまで見る(私はこのあと、画面の script を1回走らせてそれを見る検査を毎朝の確認に入れました)
  • 間違った行を、集計の手前で黙って捨てて画面を元に戻すこと。戻った画面は、壊れていたことを何も言いません

私の無料ページに、Windows で AI を無人で走らせている人向けに、タスクの設定を読み取りだけで確かめる道具を置いています(ページの説明は道具より古く、道具のほうが多くを見ます)。 https://h26005.tailf680df.ts.net:10000/free.html

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?