少し前、嘖嘖(Zeczec)で「端数切り上げ」機能を見かけました。たとえば会計で 1359 元になったとき、端数切り上げボタンを押すと 1400 元になります。面白い機能ですが、実際に実装してみると、端数切り上げは思ったより難しいことがわかりました。技術面以外の配慮事項もたくさんあります。ここでは、実装中に直面した問題と、その過程、そして最終的な解決策を記録しておきます!

嘖嘖の端数切り上げ機能は、大きく 2 種類の戦略を使っているように見えます。1 つ目は、先頭の桁を繰り上げる方法です。たとえば 1001 は直接 2000 に、3499 は 4000 になり、1000 未満の金額はすべて 1000 元に切り上げられます。2 つ目は幸運数字の考え方で、「端数切り上げ」を繰り返しクリックすると、6666、8888 などの整数ではない数字が出てきます。

私は嘖嘖の幸運数字はおもしろいと思いますが、先頭の桁をそのまま繰り上げる方法は、バグなのか意図的なのか本当にわかりません……。1001 元の商品を買ったのに切り上げで 2000 元になったり、199 元の商品が 1000 元になったりするのは、少し大げさだと思います。私が考える端数切り上げの最初の制約は、「切り上げによる割増額が元の金額を超えてはならない」ということです。たとえば 399 元の商品は 400 や 500 に切り上げられますが、1000 元にしてはいけません。1000 - 399 で 601 元の割増になり、商品そのものより高くなってしまうからです!

最初に考えたのは、先頭の桁か 2 桁目から繰り上げる方法でした。たとえば 199 は先頭の桁が 1 なので、2 に繰り上げ、残りの桁を 0 で埋めて 200 にします。この方法に従うと、10599 は 20000 または 11000 になります。見たところ妥当に思えますよね? しかし問題があります! この方法では「切り上げの割増額が元の金額を超える」ことが起こり得ます。消費者から見ると、これはとても奇妙なことです。

その後、物事を複雑に考えすぎていたことに気づきました。端数切り上げは基本的に割り算で実現できます。100 で割って小数点以下を無条件に切り上げ、その後 100 を掛けて元の桁に戻すのです。この方法の利点は、切り上げを百の位までに抑えられるため、極端な結果にならないことです。たとえば 1999 / 100 = 19.99 で、無条件に切り上げると 20 になり、20 * 100 = 2000 となって、基本的な端数切り上げが完成します。この方法では次のような結果が得られます。

  • 199 => 200
  • 799 => 800
JS の例のコードは次のとおりです。
Math.ceil(1999 / 100) * 100
この方法なら、切り上げの範囲を百の位に抑えられ、実装も簡単です。ただし、次の数字の場合はどうすればよいでしょうか?
  • 15000
  • 17500
  • 1500
このような場合、消費者が入力した金額はすでに整数です。どう対応すべきでしょうか?

最初は、数字がすでに整数なら、もう切り上げる必要はないと考えていました。しかし、よく考えてみると、消費者は数字がすでに整数だと知っていながら、なぜ切り上げボタンを押すのでしょうか? それは、その数字がまだ「キリが悪い」と感じているということです! あるいは、ただ少し多く払いたいだけかもしれません(笑)。それなら、その願いを叶えてあげるべきです。ここでは、右から見て最初の「0 以外の」数字を探し、その桁を繰り上げる方法を選びました。例:

  • 15000 => 16000
右から数えると、最初の 0 以外の数字は 5 なので、繰り上げると 6 になり、残りの桁を補って 16000 になります。

もう一つの方法も考え方は似ていますが、数字は最初の 0 以外の数字の一つ前の位に補います。例:

  • 15000 => 20000
この 2 つの方法のどちらを使うかは、基本的にどれだけ思い切れるかで決まります。前提は「ユーザーはすでに整数だと知っているのに、もう一度押す理由は何か?」ということです。

この方法の JS コードは次のとおりです。処理は 2 段階に分かれており、数字が「100 の倍数の整数ではない」場合は百の位まで切り上げる方法を使い、そうでなければ最初の 0 以外の数字を切り上げる方法を使います。ここでは正規表現で、右から最初の 0 以外の数字の位置を判定しています。

function happy_money(num) {
  // すでに百の位まで切り上げ済みの整数の場合
  if(Math.floor(num / 100) == num/100) {
  // 右から数えて最初の 0 以外の数字の位置
    var posi = /[1-9][0]*$/.exec(Number(num).toString())[0].length - 1;
    return num + Math.pow(10,posi);
  } else {
    // 123 -> 200
    // 123001 -> 123100
    return Math.ceil(num / 100) * 100;
  }
}

幸運数字に興味があるなら、原理は基本的に、あらかじめ多くの幸運数字を入れた配列を作っておき、消費者の金額に最も近い幸運数字を探して、その数字に移動する、というものです。

私は自分でテストするとき、次の数字を使って、期待どおりの結果になるか、調整が必要かを確かめる習慣があります。

  • 99
  • 199
  • 150
  • 1500
  • 20000
  • 2010100
最終的にどの方法を選ぶか、あるいは複数の方法を組み合わせるかは、使用場面によります。たとえば単価 10 万元の商品であれば、1000 元以内の整数に切り上げるのはまだよさそうに見えます(?)。また、公益系のプロジェクトであれば、幸運数字やランダムな数字、より大きな切り上げを使っても問題ないかもしれません。ただし、低単価の商品を、あまりに高い金額に切り上げるのは気が引けますよね。