合計が1ウォンずつ合わないのに、誰も再現できない
一言でいうと
金額は通貨ごとに小数桁数が異なり、その桁を浮動小数点数に任せると、合計が元帳と静かにずれます。保存は整数の最小単位で、丸める桁はポリシーで、余りは按分ルールで固めること。それがこの問題のすべてです。
なぜ必要なのか
手数料清算バッチが、ある日から「合計が3ウォン合わない」と言われ始めます。担当者がテーブルを開いて1行ずつ確認しても、どの行も間違っていません。ところが、足すと違います。再現してみるよう言われて同じ入力を入れ直すと、そのときは合います。こうしたことが2か月ほど続き、結局「バッチを回し直せば合う」という運用手順ができます。
原因は、たいてい2か所です。金額をdoubleで持っていたか、丸める桁をコードごとに違えて決めていたか。どちらも1行あたり1しかずれないので、単体テストでは捕まりません。テストデータは12.34のような素直な数字を使い、素直な数字では間違わないからです。
ここに通貨が混ざると、問題がもう1つ加わります。「小数点以下2桁」はドルの事情であって、世の中のルールではありません。ウォンと円は小数桁数が0で、クウェート・ディナールとバーレーン・ディナールは3です。金(XAU)のように、小数桁数という概念自体がないコードも、同じ表に入っています。
どう動くのか
通貨コードの維持機関はSIXで、一覧の原本はList One XMLです。各項目のCcyMnrUntsが、その通貨の小数桁数です。発行日が2026-01-01のバージョンから直接読み取った値は、次のとおりです。
KRW 0 JPY 0 CLP 0 (최소단위가 통화 단위 그 자체)
USD 2 (센트)
KWD 3 BHD 3 TND 3 (필스)
XAU N.A. (금. 소수 자리가 숫자가 아니다)
4つの韓国語コメントは、順に、最小単位が通貨単位そのものであること、セント、フィルス、金は小数桁数が数字ではないことを述べています。
だから、金額を扱うコードの最初の決定は、整数の最小単位で保存することです。1,234.56ドルは123456とUSDの2つの値で保存し、画面に出すときにだけ小数点を差し込みます。こうすれば、足し算と引き算で誤差が生じる余地がなくなります。
誤差が生じうるのは、掛け算と割り算だけです。料率を掛けると最小単位より小さい桁が出て、その桁をどう丸めるかを決めなければなりません。Pythonのdecimalモジュールは、quantize()で桁を丸め、ROUND_HALF_UPやROUND_HALF_EVENのようなモードを選べるようにします。デフォルトは銀行丸め(ROUND_HALF_EVEN)で、これはPython組み込みのround()も同じです。そのため、「0.5は切り上げ」と信じて組んだコードが、ちょうど半分の場合に別の答えを出します。2つのうちどちらが正しいかは、技術が決めることではありません。どちらなのかを文書に書き、コードがその1つだけを使うようにすることが答えです。
保存側にも同じ落とし穴があります。SQLiteのデータ型は、列ではなく値に付き、INTEGERで宣言した欄に実数を入れるとREALとして入ります。PostgreSQLのnumericのドキュメントは、numericは正確な計算をするが遅く、double precisionはその逆だとはっきり書いたうえで、金額を扱う値には正確なほうを使うよう案内しています。
最後のピースが按分です。1,000ウォンを3人で分けると、333、333、333で、1ウォンが余ります。この1ウォンを捨てると合計が合わず、全員に切り上げると総額を超えます。最大剰余法は、取り分を切り捨てで配ったあと、残った分を剰余が大きい側から1ずつ配る方式です。こうすると合計がぴったり合い、誰が1ウォン多く受け取ったかもルールで説明できます。
現場での姿
第一に、換算の順序で揉めます。行ごとに換算してウォン単位に丸めて足した値と、先にすべて足してから1回換算した値は違います。どちらも間違った計算ではなく、別の契約です。請求書に行ごとのウォン建ての金額が載る必要があれば前者で、総額だけが載るなら後者です。どちらかを決めずに2つのシステムがそれぞれ組むと、毎月数ウォンの突合項目が生まれます。
第二に、表示形式をあちこちで作ります。画面、請求書、相手機関のファイルがそれぞれ小数点を付けると、円に小数点が付く事故が起きます。パースと表示を1つの関数にまとめて金額がその門を通るようにすれば、通貨表を1か所直すだけで済みます。
第三に、丸めのポリシーがコードにしかありません。担当者が替わると、誰も根拠を知りません。ポリシーファイルに書いておき、その値をレポートにも載せれば、あとでなぜこの数字になったかを説明できます。
実務で本当に大切なこと
- 金額と通貨を決して切り離しません。通貨のない金額は、ただの数字です。
- 保存は整数の最小単位です。表示するときにだけ小数点を作ります。
- 丸める桁と丸めモードはポリシーです。コードではなく文書が先です。
- 按分は、合計から合わせます。余りの1を誰が受け取るかまで、ルールとして書きます。
- 換算は、順序まで契約です。行単位か総額単位かを先に決めます。
次のラボですること
手数料元帳600行と通貨表を自分で作り、同じデータを実数で計算した値と整数で計算した値が、何行分かれるかを数えます。そのあと、最小単位の整数で再ロードしてREALのコピーと比べ、パースと表示を1つのファイルにまとめ、2つの丸めモードの差を数え、最大剰余法で余りまで合う按分を作り、換算の順序による差を記録して、最後にレポート1つにまとめます。