Class: HhPackage::RestaurantPackage

Inherits:
ApplicationRecord show all
Includes:
IdentityCache, SoftDelete
Defined in:
app/models/hh_package/restaurant_package.rb

Constant Summary collapse

AGENDA_MODEL_LAST_MODIFIED_AT =

main logic for each package availability on the first level is in Agenda model so we need to refresh the cache if we update #available_times logic

'v2025-03-08'
SHOW_TO_ALL_USERS_MODE =
'Show to all users'.freeze
PREVIEW_MODE =
'Preview Mode'.freeze
HIDDEN_MODE =
'Hidden'.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from SoftDelete

#soft_deleted?, #soft_destroy

Methods inherited from ApplicationRecord

sync_carrierwave_url

Instance Attribute Details

#is_visible_for_staffBoolean

is_visible_for_staff column is in preview mode. It's a symbol indicating that the admin is still reviewing a package. The package has not been published yet.

Returns:

  • (Boolean)

    true if the package is visible for admin [preview mode], false otherwise.



72
# File 'app/models/hh_package/restaurant_package.rb', line 72

before_save :setup_slug, if: :slug_is_blank?

#skip_validationObject

Returns the value of attribute skip_validation.



64
65
66
# File 'app/models/hh_package/restaurant_package.rb', line 64

def skip_validation
  @skip_validation
end

Instance Method Details

#agenda_settingObject



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'app/models/hh_package/restaurant_package.rb', line 326

def agenda_setting
  package.agendas.flat_map do |agenda|
    attributes = agenda.attributes.slice('start_time', 'end_time', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun',
                                         'exception_occurrences', 'single_occurrences')
    attributes['all_day'] = agenda.all_day.presence || false

    attributes['exception_occurrences'].map! do |exception_occurrence|
      if exception_occurrence.respond_to?(:[]) && exception_occurrence['start_date'].present?
        exception_occurrence['start_date'] = exception_occurrence['start_date'].to_date
      end
      if exception_occurrence.respond_to?(:[]) && exception_occurrence['end_date'].present?
        exception_occurrence['end_date'] = exception_occurrence['end_date'].to_date
      end
      exception_occurrence['all_day'] = ActiveModel::Type::Boolean.new.cast(exception_occurrence['all_day'])
      exception_occurrence
    end

    attributes['single_occurrences'].map! do |single_occurrence|
      if single_occurrence['start_date'].present?
        single_occurrence['start_date'] = single_occurrence['start_date'].to_date
      end
      if single_occurrence['end_date'].present?
        single_occurrence['end_date'] = single_occurrence['end_date'].to_date
      end
      single_occurrence['all_day'] = ActiveModel::Type::Boolean.new.cast(single_occurrence['all_day'])
      single_occurrence
    end
    attributes
  end
end

#available_at?(date, time, time_zone) ⇒ Boolean

Returns:

  • (Boolean)


357
358
359
360
361
362
363
364
# File 'app/models/hh_package/restaurant_package.rb', line 357

def available_at?(date, time, time_zone)
  requested_time = Time.use_zone time_zone do
    Time.zone.parse("#{date} #{time}")
  end
  package.agendas.select do |agenda|
    agenda.available_at?(start_date, end_date, time_zone, requested_time)
  end.present?
end

#available_times(date, time_zone) ⇒ Array

This method returns available times for the given date. without checking restaurant level availability It uses the package's agendas to determine the available times.

Examples:

available_times(Date.tomorrow, 'Asia/Bangkok')
=> ["13:30", "14:00"]

Parameters:

  • date (Date)

    the date to check the availability

  • time_zone (String)

    the time zone of the restaurant

Returns:

  • (Array)

    sorted array of available times in HH:MM format for the given date.



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'app/models/hh_package/restaurant_package.rb', line 211

def available_times(date, time_zone)
  ElasticAPM.with_span('RestaurantPackage#available_times', 'app', subtype: 'method', action: 'query') do |_span|
    # Add context for better tracing
    ElasticAPM.set_label(:restaurant_package_id, id)
    ElasticAPM.set_label(:restaurant_id, restaurant_id)
    ElasticAPM.set_label(:date, date.to_s)
    ElasticAPM.set_label(:timezone, time_zone)

    # get start time and end time in UTC
    start_time = end_time = nil
    Time.use_zone(time_zone) do
      unless date.is_a?(Date)
        date = Time.zone.parse(date).to_date
      end

      start_time = Time.zone.parse(date.to_s).beginning_of_day.in_time_zone('UTC').to_i
      end_time = Time.zone.parse(date.to_s).end_of_day.in_time_zone('UTC').to_i

      iso8601_strings = []

      ElasticAPM.with_span('Redis fetch available times', 'db', subtype: 'redis', action: 'query') do
        $inv_redis.with do |redis|
          write_available_times(redis) if redis.zcard(inventory_cache_key).zero?

          # Retrieve ISO8601 strings within the given date range
          iso8601_strings = redis.zrangebyscore(inventory_cache_key, start_time, end_time)
        end
      end

      # Parse ISO8601 strings to Time objects and format as HH:MM
      mapped_iso8601_strings = ElasticAPM.with_span('Format available times', 'app', subtype: 'method') do
        iso8601_strings.map do |iso8601|
          Time.zone.parse(iso8601).strftime('%H:%M')
        end
      end

      # Sort the times in ascending order and return
      mapped_iso8601_strings.sort.uniq
    end
  end
end

#available_times_by_dates(dates, time_zone) ⇒ Array

Returns available times for the given date.

Examples:

available_times(Date.tomorrow, 'Asia/Bangkok')
=> {
  "2024-11-24" => ["13:30", "14:00"],
  "2024-11-25" => ["13:30", "14:00"]
}

Parameters:

  • dates (Array)

    the dates to check the availability

  • time_zone (String)

    the time zone of the restaurant

Returns:

  • (Array)

    available times for the given date.



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'app/models/hh_package/restaurant_package.rb', line 175

def available_times_by_dates(dates, time_zone)
  ElasticAPM.with_span('RestaurantPackage#available_times_by_dates', 'app', subtype: 'method',
                                                                            action: 'query') do |_span|
    # Add metadata to help with debugging
    ElasticAPM.set_label(:restaurant_package_id, id)
    ElasticAPM.set_label(:restaurant_id, restaurant_id)
    ElasticAPM.set_label(:dates_count, dates.size)

    # Initialize result hash
    result = {}

    # Process each date and collect available times
    dates.each do |date|
      # Normalize date to string format if it's already a Date object
      date_str = date.is_a?(Date) ? date.to_s : date

      # Get available times for this specific date using the existing method
      ElasticAPM.with_span("Get available times for #{date_str}", 'app', subtype: 'method') do
        times = available_times(date, time_zone)
        result[date_str] = times if times.present?
      end
    end

    result
  end
end

#comemore_payless_expiryObject



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'app/models/hh_package/restaurant_package.rb', line 396

def comemore_payless_expiry
  return nil unless restaurant
  return nil unless package.dynamic_price_comemore_payless?
  return nil unless package.pricing_model_and_dynamic_pricing_type == :per_person_and_normal_price

  pricing_groups = package&.pricing_groups

  current_date = Time.now_in_tz(restaurant.time_zone).to_date
  pricing_group = pricing_groups&.find_by('end_date >= ?', current_date) ||
    pricing_groups&.find_by(end_date: nil)

  # use the end date of the pricing group if it is not nil
  # else use the end date of the restaurant package
  pricing_group&.end_date.presence || end_date
end

#determine_inventory_class(restaurant) ⇒ Object



391
392
393
394
# File 'app/models/hh_package/restaurant_package.rb', line 391

def determine_inventory_class(restaurant)
  is_dine_in = restaurant.use_third_party_inventory? ? nil : package.for_dine_in?
  InventoryWrapper.new(restaurant_id: restaurant.id, is_dine_in: is_dine_in).inv_model
end

#fetch_packageObject



156
157
158
159
160
161
162
163
164
# File 'app/models/hh_package/restaurant_package.rb', line 156

def fetch_package
  @fetch_package ||= begin
    Rails.cache.fetch "#{cache_key}/fetch_package", expires_in: CACHEFLOW.generate_expiry do
      package
    end
  rescue TypeError
    package
  end
end

#inventory_cache_keyObject



319
320
321
322
323
324
# File 'app/models/hh_package/restaurant_package.rb', line 319

def inventory_cache_key
  @inventory_cache_key ||=
    "available_times_v2_version_#{AGENDA_MODEL_LAST_MODIFIED_AT}:{restaurant_package_#{id}}:variables_#{CityHash.hash32([
                                                                                                                          agenda_setting, start_date, end_date
                                                                                                                        ])}"
end

#inventory_cache_ttlObject



315
316
317
# File 'app/models/hh_package/restaurant_package.rb', line 315

def inventory_cache_ttl
  (end_date + 1.day).to_time
end

#setup_slugObject



366
367
368
369
370
371
372
373
374
# File 'app/models/hh_package/restaurant_package.rb', line 366

def setup_slug
  new_slug = nil

  loop do
    new_slug = "#{restaurant_id}-#{package.slug_code}-#{package_order_number}"
    break if HhPackage::RestaurantPackage.find_by(slug: new_slug).blank?
  end
  self.slug = new_slug
end

#statusObject



380
381
382
383
384
385
386
387
388
389
# File 'app/models/hh_package/restaurant_package.rb', line 380

def status
  # Show to all user = Show on Client Side
  # Preview Mode = Show on client Side only if log-in by @hungryhub email account
  # Hidden = Hide on Client Side

  return PREVIEW_MODE if active? && is_visible_for_staff?
  return SHOW_TO_ALL_USERS_MODE if active? && !is_visible_for_staff?

  HIDDEN_MODE
end

#write_available_times(redis_connection = nil) ⇒ Object

store available times in redis in UTC time zone



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'app/models/hh_package/restaurant_package.rb', line 254

def write_available_times(redis_connection = nil)
  ElasticAPM.with_span('RestaurantPackage#write_available_times', 'app', subtype: 'method',
                                                                         action: 'write') do |_span|
    # Add context for better tracing
    ElasticAPM.set_label(:restaurant_package_id, id)
    ElasticAPM.set_label(:restaurant_id, restaurant_id)
    ElasticAPM.set_label(:date_range, "#{start_date} to #{end_date}")

    time_zone = ActiveSupport::TimeZone['UTC']
    Time.use_zone(time_zone) do
      # need to use self.start_date because this method is called within a block
      # which causing `start_date` is nil
      # rubocop:disable Style/RedundantSelf
      if self.start_date.past?
        self.start_date = Time.now_in_tz(time_zone).to_date
      end

      data = ElasticAPM.with_span('Generate agenda occurrences', 'app', subtype: 'method') do
        package.agendas.flat_map do |agenda|
          # need to use self.start_date because this method is called within a block
          # which causing `start_date` is nil
          # we use `restaurant.time_zone` because admin input the data in restaurant's time zone
          agenda.schedule_obj(self.start_date, self.end_date, restaurant.time_zone)&.all_occurrences
        end.compact
      end
      # rubocop:enable Style/RedundantSelf

      logic = lambda do |redis|
        data.each do |available_time|
          # Convert available_time to UTC first
          available_time = available_time.in_time_zone(time_zone)

          # Store timestamp as score for sorting
          score = available_time.to_i

          # Store ISO8601 string as member (preserves timezone)
          member = available_time.iso8601

          redis.zadd(inventory_cache_key, score, member)
        end

        redis.expire(inventory_cache_key, inventory_cache_ttl.to_i)
      end

      ElasticAPM.with_span('Write to Redis', 'db', subtype: 'redis', action: 'write') do
        if redis_connection.present?
          redis_connection.pipelined do
            logic.call(redis_connection)
          end
        else
          $inv_redis.with do |redis|
            redis.pipelined do
              logic.call(redis)
            end
          end
        end
      end
    end
  end
end