保守サポート時間の前払いパック(10h/20h/30hを購入し、使った分を消化していく仕組み)を実装したときの設計メモです。「残り時間」をどこに持たせるかで、後々の運用のしやすさが変わります。
選択肢: カラムに持つか、都度計算するか
| 方式 | やること | 弱点 |
|---|---|---|
remaining_hoursカラムを持つ |
消化記録を追加・削除するたびに更新する | 更新を1箇所でも忘れるとズレる |
| 都度計算する | 消化記録の合計から都度引き算する | 毎回集計クエリが走る |
小規模(顧客数十件、パックも同程度)なシステムでは、後者を選びました。
実装: 3つのメソッドで完結させる
// app/Models/SupportHourPackage.php
public function usages(): HasMany
{
return $this->hasMany(SupportHourUsage::class);
}
public function usedHours(): float
{
return (float) $this->usages()->sum('used_hours');
}
public function remainingHours(): float
{
return max(0, (float) $this->hours_purchased - $this->usedHours());
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
/**
* active: 期限内かつ残時間あり / expired: 期限切れ / exhausted: 残時間なし
*/
public function status(): string
{
if ($this->isExpired()) {
return 'expired';
}
if ($this->remainingHours() <= 0) {
return 'exhausted';
}
return 'active';
}
usedHours()は消化記録テーブル(support_hour_usages)からSUMを取るだけ、remainingHours()は購入時間からそれを引くだけです。status()はこの2つを組み合わせて、期限切れ・使い切り・利用中の3状態を判定します。
remainingHours()でmax(0, ...)にしているのは、万が一消化記録の合計が購入時間を超えてしまっても(運用ミスなどで)、残時間がマイナス表示にならないようにするためです。実際のデータ不整合そのものは直す必要がありますが、画面表示だけは壊れないようにしています。
この方式が壊れない理由: 「足すだけ」の記録テーブル
この設計が安全に機能するのは、消化記録(support_hour_usages)が追加専用のテーブルになっているためです。消化した時間を後から編集・削除する運用がなく、常に「新しい消化記録を1件足す」操作だけで運用が回ります。追加専用なら、SUMを都度取っても値が揺れる心配がありません。
もし「消化記録を後から修正する」運用が必要になったら、この前提が崩れるので、キャッシュカラムを持つ設計に切り替える判断が必要になります。今の規模と運用ではそこまでの複雑さは不要、というのがこのときの判断でした。
いつカラムに切り替えるべきか
計算方式が辛くなるのは、主に次の場面です。
- 一覧画面で大量のパックを同時に表示し、それぞれの残時間を出す必要がある(N+1的な集計クエリが並ぶ)
- 集計対象の行数が数万件規模になり、都度の
SUMが重くなる
このプロジェクトの規模(顧客数十件)では、どちらにも該当しませんでした。**「今困っていないなら、まだ正規化を崩さない」**という判断です。将来この規模を超えたら、remaining_hoursカラムを追加し、消化記録の作成・削除時にオブザーバーで更新する形に切り替える設計変更をすればよいので、今から複雑にしておく理由がありません。
まとめ
- 「残量」や「状態」は、都度計算で出せるうちは正規化を崩さない
- 計算方式が安全に機能するのは、元になる記録が追加専用(追記のみ)である場合
- 規模が大きくなって計算コストが問題になったら、そのときにキャッシュカラムへ切り替える。先回りして複雑にしない
この記事はZennにも投稿しています。