1
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?

割り勘計算機を書いたら、端数 1 円の扱いと「単位揃え」が面白い問題だった

1
Posted at

きっかけ

飲み会の会計で「1 人 3,487 円です」と言われると、幹事は大抵 3,500 円か 4,000 円で切ります。現実の割り勘は**「端数をどうするか」と「いくら単位で揃えるか」の組み合わせ**で決まっていて、単純に「合計 ÷ 人数」では終わらない。

さらに、「男女で 1.5:1」とか「後輩は少なめ」みたいな重み付きシナリオもよくあります。これら全部を 1 つのツールに収めたかった。

作ったもの

割り勘計算機https://sen.ltd/portfolio/warikan/

スクリーンショット

3 モード搭載:

  • 均等: 合計を人数で単純に割る
  • 男女比: 「A 人と B 人を 1.5:1 で」みたいな比率
  • 重み付き: 1, 1, 1.5, 2 のようにカンマ区切りで個別指定

オプション:

  • 端数処理: 切り上げ / 切り捨て / 四捨五入
  • 単位揃え: 1 円 / 10 円 / 100 円 / 1000 円
  • 徴収合計と差額: 「全員分で合計 14,000 円、実際より 13 円多い」みたいに表示

vanilla JS + HTML + CSS、ゼロ依存、ビルド不要。ロジックは 70 行、node --test で 12 ケース。

roundTo(value, unit, mode) の 3 引数が全て

割り勘計算で一番考えないといけないのは、「いくら単位で揃えるか」と「端数をどう処理するか」は直交だということ。100 円単位で「切り上げ」もあれば、1 円単位で「四捨五入」もある。両軸で組み合わせなのでヘルパー 1 つに集約:

function roundTo(value, unit, mode) {
  const n = value / unit
  let rounded
  if (mode === 'floor') rounded = Math.floor(n)
  else if (mode === 'round') rounded = Math.round(n)
  else rounded = Math.ceil(n)  // default ceil
  return rounded * unit
}

ポイントは value / unit してから丸めて、また * unit で戻すこと。3487 円を 100 円単位で切り上げたいなら 3487 / 100 = 34.87Math.ceil(34.87) = 3535 * 100 = 3500。単純な発想ですが、Math.ceil(3487 / 100) * 100 という 1 行で表現するより、単位を 1 つの変数にしたほうがテストしやすいし UI の input も楽です。

デフォルトを切り上げにしたのは、「足りない」より「多い」ほうが幹事の心理的負担が少ないから。opts.rounding = 'ceil' を省略してもちゃんと切り上げで動く。

均等割は diff を一緒に返すのが UX

単純な均等割の結果を「1 人 3,500 円」とだけ返しても、ユーザーは心の中で「4 人だから 14,000 円…あれ、元は 13,948 円だったから 52 円多い…」と計算し直します。これを事前に計算して一緒に返す:

export function splitEvenly(total, count, opts = {}) {
  if (!Number.isFinite(total) || total < 0) return { error: 'invalid total' }
  if (!Number.isInteger(count) || count <= 0) return { error: 'invalid count' }
  const rounding = opts.rounding ?? 'ceil'
  const unit = opts.unit ?? 1
  const per = roundTo(total / count, unit, rounding)
  const totalCollected = per * count
  const diff = totalCollected - total
  return { per, count, total, totalCollected, diff, rounding, unit }
}

UI には「1 人 3,500 円 / 徴収合計 14,000 円 / 差額 +52 円」という 3 つの数字を同時に出します。幹事が「あ、+52 円は私が飲み会に貢献した分」と理解できる表示にしたかった(あるいは「-50 円足りないから自分が多めに出す」)。

差額のサイン(正 / 負)も意味が違うので、UI 側で正なら緑(多めに集まった)、負なら赤(足りない)で着色しています。

重み付き: 比率を正規化して各人に配分

weights = [1, 1, 1.5, 2] みたいな配列を受け取ったら、合計で正規化して各人の取り分を出す:

export function splitWeighted(total, weights, opts = {}) {
  const sum = weights.reduce((a, b) => a + b, 0)
  const shares = weights.map((w) => roundTo((total * w) / sum, unit, rounding))
  const totalCollected = shares.reduce((a, b) => a + b, 0)
  const diff = totalCollected - total
  return { shares, weights: weights.slice(), total, totalCollected, diff, rounding, unit }
}

sum で正規化するので、[1, 1, 1.5, 2][2, 2, 3, 4]同じ結果を返します。ユーザーが「1 倍 / 1 倍 / 1.5 倍 / 2 倍」で入力しても「10% / 10% / 15% / 20%」みたいに入力してもいい。

男女比モードはこの関数の薄いラッパー:

export function splitByRatio(total, countA, countB, ratio, opts = {}) {
  const weights = [
    ...Array(countA).fill(ratio),
    ...Array(countB).fill(1),
  ]
  return splitWeighted(total, weights, opts)
}

「男 3 人 女 2 人 比率 1.5:1」なら weights = [1.5, 1.5, 1.5, 1, 1] を組み立てて重み付き関数に投げるだけ。モードを 3 つに見せていますが、裏では splitWeighted が 1 つあれば全部まかなえるという構造です。

NaN と 0 除算は早めに弾く

ユーザー入力は何が飛んでくるか分からないので、全関数の冒頭でガードを入れます:

if (!Number.isFinite(total) || total < 0) return { error: 'invalid total' }
if (!Number.isInteger(count) || count <= 0) return { error: 'invalid count' }

Number.isFinite は NaN と Infinity を両方弾きます。Number.isInteger はさらに小数も弾く。count <= 0 をチェックしないと 0 除算で Infinity が下流に流れて UI が壊れるので、早めにキャッチ。

重み付き関数では「負の重み」もダメなのでチェック:

if (weights.some((w) => !Number.isFinite(w) || w <= 0)) {
  return { error: 'weights must be positive numbers' }
}

some 1 発で配列を全走査、1 つでも悪いのがあればエラー。エラーを早く返して UI に回すのが、純粋関数 + { error } フィールドの気持ちよさ。例外を投げて catch する流れより、データフローとして素直。

テスト

node --test で 12 ケース。期待動作と、よくある typo を捕まえる型のテスト:

test('evenly splits 10000 between 4', () => {
  const r = splitEvenly(10000, 4)
  assert.equal(r.per, 2500)
  assert.equal(r.diff, 0)
})

test('ceil rounding when 10000 split by 3', () => {
  const r = splitEvenly(10000, 3, { rounding: 'ceil' })
  assert.equal(r.per, 3334)
  assert.equal(r.diff, 2)  // 3334 * 3 = 10002
})

test('unit 100 rounds up to nearest 100', () => {
  const r = splitEvenly(14200, 4, { rounding: 'ceil', unit: 100 })
  assert.equal(r.per, 3600)
})

test('weighted 1:1:1.5:2 sums to total', () => {
  const r = splitWeighted(10000, [1, 1, 1.5, 2])
  assert.equal(r.shares.length, 4)
  assert.equal(r.shares.reduce((a, b) => a + b, 0), r.totalCollected)
})

test('count=0 returns error', () => {
  assert.ok(splitEvenly(1000, 0).error)
})

「合計が変わらない」系のテスト(重みを 2 倍しても結果が同じ)も入れると正規化の実装ミスが検出できます。

おわりに

SEN 合同会社の ポートフォリオシリーズ 100+ の 11 件目です。

「こういう割り方がある」系のアイデア、Issue で歓迎です。

1
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
1
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?