STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

モバイルオーダーの予約システムで複雑な時間の計算を集合演算で解決した

こんにちは、Webアプリケーションエンジニアのsomeziです。

皆さんは予約システムをつくったことはありますか?私は現在モバイルオーダーを開発しており、その中で時間を指定せずに最短で受け取れるように注文する「即時注文」と、あらかじめ決められた時間に受け取るように注文する「予約注文」をつくりました。

本記事では、即時注文と予約注文を実装する際に「このパターンでこの時間注文できるんだっけ??」と何度も混乱した経験から、時間の集合演算というアプローチでリファクタリングすることで技術的に乗り越えた話を紹介します。

※世間一般でイメージする予約システムとはちょっと違いますが、広義の意味では予約システムかなと思っています。また、即時注文は最速の予約注文と解釈しています。

stores.fun

時間を扱うって難しい

即時注文は実装済みで、新しく予約注文をつくると聞いた時に「簡単でしょ〜」と思っていました。営業時間を設定して、その中で注文を受け付ければ良いだけだと思っていたからです。しかし実際に作ってみると、考慮すべき設定値がどんどん増えて想像以上に複雑になりました。

実際は24時間受付可能などもう少しあるのですが、時間をつくっている主な設定値は下記です。

ユーザが管理画面から事前に設定

  • 営業時間*1
    • ex. 毎週月曜日9:00-18:00は開店
    • ex. 毎週日曜日9:00-15:00, 17:00-22:00は開店
  • 特別休業日
    • ex. 2025/06/26(水) 15:00-18:00は閉店

ユーザがキッチンディスプレイアプリ(KDS)*2から仕事中に操作

  • 開店・閉店の強制操作
    • ex. 今忙しいから一時閉店する
    • ex. 今日は準備早く終わったから30分前だけど今から開店する

これらの設定値が組み合わさると、「今、注文を受け付けられるのか」の判定が非常に複雑になりました。新しい設定値が追加されるたびに既存のロジックを理解し直し、頭の中だけでは混乱するので全パターンを図解していました。特に辛かったのは、「このパターンは前回確認したから大丈夫」と思っていても、微妙に違うパターンで予期しない動作をしてしまうことでした。一度理解したはずなのに毎回混乱し、とても時間がかかっていました。

before: 優先度による複雑な条件分岐

最初は優先度をつけて現在は開店時間なのか判別する方法を採用していました。図解するとこうなります。

優先度順に見ていく

実際のコードからコアな部分を抜粋して見やすく改変したものになります。

即時注文のクラス

module MobileOrder
  class OrderableReceptionTime
    # 今、即時注文できるか
    def now_is_instant_orderable?
      # キッチンディスプレイ(KDS)アプリから操作していたらその結果を優先的に返す
      return kds_override if kds_override.present? && Time.current < kds_override.expires_at

      # holidaysはユーザが管理画面で設定する特別休業日
      # 現在が特別休業日と重なっていれば閉店にする
      overlapped = holidays.any? do |holiday|
        holiday.start_time <= now && now <= holiday.end_time
      end
      return false if overlapped

      # weekly_scheduleはユーザが管理画面で設定する営業時間
      # 営業時間のどれか1つに現在が含まれていなければ閉店にする
      covered = weekly_schedule.hours.any? do |hour|
        hour.start_time <= now && now <= hour.end_time
      end
      return false unless covered

      true
    end
  end
end

予約注文のクラス

module MobileOrder
  class ReservableDateTime
    # 利用可能な予約日を取得する
    def available_reservation_time(date:)
      timeslots = build_timeslot(date:, interval_minutes: 60)

      # キッチンディスプレイ(KDS)アプリで閉店の操作をしていたら
      if kds_override.accept_status != "OPEN"
        kds_override.expires_at
      end => kds_override_threshold

      timeslots.filter do |timeslot|
        # 過去時間は不要なので除外
        next false if timeslot[:from] < Time.current
        # 強制閉店の有効期限内は閉店なので除外
        next false if kds_override_threshold.present? && timeslot[:from] < kds_override_threshold

        true
      end
    end

    private

    # その日の時間枠を組み立てる
    def build_timeslot(date:, interval_minutes:)
      # weekly_scheduleはユーザが管理画面で設定する営業時間
      # 営業時間でループして他の時間に関する処理があるかチェックする
      weekly_schedule(date).hours.flat_map do |hours|
        times = (hours.start_time.to_i..hours.end_time.to_i).step(interval_minutes.minutes).map { |t| Time.zone.at(t) }
        timeslots = times.each_cons(2).map { |from, to| {from:, to:} }

        timeslots = timeslots.reject do |timeslot|
          # holidaysはユーザが管理画面で設定する特別休業日
          # 現在の時間が特別休業日に含まれている場合は除外
          holidays.any? do |holiday|
            timeslot[:from] >= holiday.start_time && timeslot[:to] <= holiday.end_time
          end
        end

        timeslots
      end
    end
  end
end

見ての通り、すべてのデータを見ていて条件分岐だらけです。新しい設定値が追加されるたびに、既存の条件分岐を理解し直す必要があり、あとから気がついたのですが実際にバグも発生していました。即時注文と予約注文で微妙に異なるロジックになり、コードの重複も発生していました。

解決策: 集合演算による時間の計算

そこで優先度による条件分岐を辞めて発想を転換しました。開店時間と閉店時間をそれぞれ和集合としてデータをかき集め、開店時間と閉店時間の差集合が最終的な開店時間になります。図解するとこうなります。

TimeRangeSetによる開店と閉店

時間を集合として扱うことで、複雑な条件分岐が簡単な足し引きの計算に変わります 🎉

TimeRangeSetクラスの威力

時間の範囲を集合として扱うためのTimeRangeSetクラスを作りました。TimeRangeSetが今回の解決策のコア部分です。

時間の集合として扱うクラス

class TimeRangeSet
  def +(other)
    raise ArgumentError, "argument must be TimeRangeSet" unless other.is_a?(TimeRangeSet)

    # 和集合なので純粋に + ができる
    new_ranges = @ranges + other.to_a
    self.class.new(*new_ranges)
  end

  def -(other)
    raise ArgumentError, "argument must be TimeRangeSet" unless other.is_a?(TimeRangeSet)

    # 差集合を求める
    # 9:00-18:00 と 10:00-16:00 の差集合は 9:00-10:00 と 16:00-18:00 になるので返り値が複数になる
    result = other.reduce(@ranges.dup) do |current_ranges, other_time_range|
      current_ranges.flat_map { |time_range| split_range(time_range, other_time_range) }
    end

    self.class.new(*result)
  end

  # いずれかの時間に引数が含まれているか
  def cover?(time)
    @ranges.any? { |range| range.cover?(time) }
  end
end

開店時間と閉店時間となるデータから最終的に開店時間を求めるクラス

module MobileOrder
  class PickupTime
    # 即時予約が可能な時間をつくる
    def instant_orderable_time_range_set
      open_time_ranges = []
      # キッチンディスプレイ(KDS)アプリからの操作の有効期限まで開店になる
      if open_kds_override.present?
        range = Range.new(Time.current.beginning_of_day, open_kds_override.expires_at)
        open_time_ranges << range
      end

      reservable_time_range_set + TimeRangeSet.new(open_time_ranges)
    end

    # 予約注文が可能な時間をつくる
    def reservable_time_range_set(date = Time.current.to_date)
      open_time_ranges = []
      # weekly_scheduleはユーザが管理画面で設定する営業時間
      weekly_schedule(date).hours.flat_map do |hour|
        open_time_ranges << Range.new(hour.start_time, hour.end_time)
      end

      closed_time_ranges = []
      # キッチンディスプレイ(KDS)アプリからの操作の有効期限まで閉店になる
      if not_open_kds_override.present?
        range = Range.new(date.beginning_of_day, not_open_kds_override.expires_at)
        closed_time_ranges << range
      end
      # holidaysはユーザが管理画面で設定する特別休業日
      holidays(date).each do |holiday|
        closed_time_ranges << Range.new(holiday.start_time, holiday.end_time)
      end

      # 開店と閉店の差集合
      TimeRangeSet.new(open_time_ranges) - TimeRangeSet.new(closed_time_ranges)
    end
  end
end

開店時間と閉店時間をそれぞれかき集めるだけなので簡単ですね!

after: 驚くほどシンプルに

即時注文と予約注文の両方で共通のPickupTimeクラスを使うことで、ロジックが驚くほどシンプルになりました。

即時注文のクラス

module MobileOrder
  class OrderableReceptionTime
    # 今、即時注文できるか
    def now_is_instant_orderable?
      # 即時予約の時間枠を取得する
      instant_orderable_time_range_set = MobileOrder::PickupTime.new.instant_orderable_time_range_set
      # 今は即時予約の時間に含まれるか
      instant_orderable_time_range_set.cover?(Time.current)
    end
  end
end

予約注文のクラス

module MobileOrder
  class ReservableDateTime
    # 指定日に予約可能な時間枠をつくる
    def available_reservation_time(date:)
      timeslots = build_timeslot(date:, interval_minutes: 60)

      timeslots.filter do |timeslot|
        # 過去時間は不要なので除外
        next false if timeslot[:from] < Time.current

        true
      end
    end

    private

    # その日の時間枠を組み立てる
    def build_timeslot(date:, interval_minutes:)
      # 予約注文できる時間を取得する
      reservable_time_range_set = MobileOrder::PickupTime.new.reservable_time_range_set(date)
      # 予約注文できる時間を分割して、指定された間隔で時間枠を作成する
      reservable_time_range_set.flat_map do |time_range|
        times = (time_range.begin.to_i..time_range.end.to_i).step(interval_minutes.minutes).map { |t| Time.zone.at(t) }
        times.each_cons(2).map { |from, to| {from:, to:} }
      end
    end
  end
end

なんと!二重ループがなくなりメソッドの中身がかなり減りました! 複雑な条件分岐が「開店時間に現在時刻が含まれるか」という単純な判定に置き換わっています。

予約注文もreservable_time_range_setメソッドから時間枠を組み立てるだけのシンプルなロジックになりました。

まとめ

優先度として考えるのではなく時間の集合という概念で扱うことでロジックがシンプルになりました。もし、新しくこの項目をつくって「この時間は閉店にしたい」と要件が変わっても閉店時間をかき集めるロジックに追加するだけで済みます。beforeのときは「このときどうなるんだっけ??」と毎回混乱することもなくなりました。

予約システムってよくある機能なので一見簡単そうに見えて、実際に開発するととても難しいです。難しいですが、今回は集合演算というアプローチで技術的に乗り越えることができました。最初は「いけるやろ!」と思って突っ走って最後にリファクタリングしたので最初から集合演算にしておけば、、、となった点は反省です。

というわけで本記事は以上になります。今後もモバイルオーダーの機能を拡充していくので機能追加に耐えられるように技術で乗り越えていきます!

また STORES ではエンジニアを絶賛募集中です。 ぜひ採用サイトにも遊びに来てください。

jobs.st.inc

*1:購入者がユーザの店舗で商品を受け取れる時間なので正確には営業時間ではないですが、本記事ではわかりやすい営業時間と表記します

*2:キッチンディスプレイアプリ については過去の記事で詳しく紹介していますproduct.st.inc product.st.inc