Class: VendorsService::InventorySync::InventoryFetcherService
- Inherits:
-
Object
- Object
- VendorsService::InventorySync::InventoryFetcherService
- Includes:
- CountryEmailHelper, ElasticAPM::SpanHelpers, SupplierConfig
- Defined in:
- app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb
Overview
Service for fetching inventory availability from external suppliers with fallback strategy.
This service handles the common logic for fetching inventory across different suppliers:
-
Party size fallback strategy (tries multiple party sizes to maximize availability)
-
Missing timeslot tracking and retry logic across different party sizes
-
Zero inventory notification when no timeslots found after all attempts
-
ElasticAPM instrumentation and business logging with JSON format for OpenSearch
-
Common validation, error handling, and HTTP request utilities via execute_parallel_requests
-
Shared error classes for consistent error handling across suppliers
-
Restaurant timeslot generation based on opening hours and time intervals
Defined Under Namespace
Classes: RateLimitExceededError, SupplierApiError
Constant Summary
Constants included from SupplierConfig
SupplierConfig::SUPPLIER_CONFIG
Instance Attribute Summary collapse
-
#config ⇒ Object
readonly
Returns the value of attribute config.
-
#restaurant ⇒ Object
readonly
Returns the value of attribute restaurant.
-
#restaurant_tz ⇒ Object
readonly
Returns the value of attribute restaurant_tz.
-
#supplier ⇒ Object
readonly
Returns the value of attribute supplier.
Instance Method Summary collapse
-
#execute_parallel_requests(dates) {|date_block, hydra, availabilities| ... } ⇒ Array<Hash>
Executes parallel HTTP requests using Typhoeus::Hydra with error handling and retry logic.
-
#fetch_availability(dates_within_range) ⇒ Array<Hash>
Fetches inventory availability from supplier for the given dates using party size fallback strategy.
-
#generate_partysize_array ⇒ Array<Integer>
Generates a partysize array using restaurant's largest_table and supplier's min_seat configuration.
-
#generate_restaurant_open_hours_timeslots(dates_within_range) ⇒ Array<String>
Generates all possible restaurant timeslots based on opening hours and time intervals.
-
#handle_api_response(response, availabilities, response_parser, context = {}) ⇒ void
Handles API response with retry logic for rate limiting using Retriable gem.
-
#initialize(restaurant:, supplier:) ⇒ InventoryFetcherService
constructor
A new instance of InventoryFetcherService.
-
#log_final_results(missing_dates, all_inventory_data, partysize_array) ⇒ void
Logs final results of the availability fetching process with comprehensive metrics.
-
#queue_request_with_error_handling(request, hydra, availabilities, response_parser, context = {}) ⇒ void
Queues a single HTTP request with comprehensive error handling and retry logic.
-
#report_and_log_error(message, **context) ⇒ void
Helper to report error to APM.
-
#send_staff_notification_email(subject, body, _log_message, _additional_log_context = {}) ⇒ void
Sends staff notification email using StaffMailer with predefined recipients.
-
#send_zero_inventory_notification(dates, party_sizes_attempted) ⇒ void
Sends notification email when no inventory is available after all party size attempts.
-
#validate_dates(dates) ⇒ Array<String>
Validates array of dates are all in YYYY-MM-DD string format.
Methods included from CountryEmailHelper
Methods included from SupplierConfig
Constructor Details
#initialize(restaurant:, supplier:) ⇒ InventoryFetcherService
Returns a new instance of InventoryFetcherService.
34 35 36 37 38 39 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 34 def initialize(restaurant:, supplier:) @restaurant = restaurant @supplier = supplier.to_sym @config = get_supplier_config(supplier, { restaurant_id: restaurant&.id }) @restaurant_tz = Time.find_zone(restaurant.time_zone) end |
Instance Attribute Details
#config ⇒ Object (readonly)
Returns the value of attribute config.
32 33 34 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 32 def config @config end |
#restaurant ⇒ Object (readonly)
Returns the value of attribute restaurant.
32 33 34 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 32 def restaurant @restaurant end |
#restaurant_tz ⇒ Object (readonly)
Returns the value of attribute restaurant_tz.
32 33 34 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 32 def restaurant_tz @restaurant_tz end |
#supplier ⇒ Object (readonly)
Returns the value of attribute supplier.
32 33 34 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 32 def supplier @supplier end |
Instance Method Details
#execute_parallel_requests(dates) {|date_block, hydra, availabilities| ... } ⇒ Array<Hash>
Executes parallel HTTP requests using Typhoeus::Hydra with error handling and retry logic. Provides common functionality for supplier API calls with date batching.
324 325 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 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 324 def execute_parallel_requests(dates, &_block) validated_dates = validate_dates(dates) max_days_per_request = config[:max_availability_days_per_request] availabilities = [] hydra = Typhoeus::Hydra.new date_blocks = validated_dates.each_slice(max_days_per_request).to_a # Queue requests for each date block date_blocks.each do |date_block| yield(date_block, hydra, availabilities) if block_given? end begin hydra.run rescue StandardError => e report_and_log_error( "#{supplier.capitalize} parallel requests failed: #{e.}", dates_count: validated_dates.count, exception_class: e.class.name, exception_message: e., ) raise end availabilities end |
#fetch_availability(dates_within_range) ⇒ Array<Hash>
Fetches inventory availability from supplier for the given dates using party size fallback strategy.
The method tries different party sizes (largest to smallest) to maximize availability. For each party size, it fetches availability and removes successfully found dates from the missing list. Sends notification for zero inventory scenarios.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 55 def fetch_availability(dates_within_range) # Generate partysize array using simplified logic partysize_array = generate_partysize_array all_inventory_data = [] missing_dates = generate_restaurant_open_hours_timeslots(dates_within_range) # Try each party size in the array, starting from largest partysize_array.each_with_index do |party_size, _index| break if missing_dates.empty? begin # Fetch inventory availability for current party size and remaining dates inventory_data = config[:inv_service].new(restaurant, dates_within_range, party_size).fetch_timeslots_with_availability if inventory_data.present? # Extract dates that have available inventory from the array of hashes available_dates = inventory_data.filter_map { |slot| slot[:datetime] if slot.is_a?(Hash) && slot[:datetime] } # Add only new inventory data that doesn't already exist (based on datetime) existing_datetimes = all_inventory_data.pluck(:datetime).to_set new_inventory_data = inventory_data.reject { |slot| existing_datetimes.include?(slot[:datetime]) } all_inventory_data.concat(new_inventory_data) # Remove successfully fetched dates from missing_dates missing_dates = missing_dates - available_dates dates_within_range = extract_unique_dates_from_timeslots(missing_dates) end rescue StandardError => e APMErrorHandler.report( "Error fetching inventory for party size #{party_size}: #{e.}", context: { restaurant_id: restaurant.id, supplier: supplier, party_size: party_size, missing_dates_count: missing_dates.size, exception_class: e.class.name, exception_message: e., }, ) # Continue to next party size on error next end end # Log final results log_final_results(missing_dates, all_inventory_data, partysize_array) # Check if no inventory was found after all attempts and send notification if all_inventory_data.blank? && dates_within_range.count > 1 send_zero_inventory_notification(dates_within_range, partysize_array) end all_inventory_data end |
#generate_partysize_array ⇒ Array<Integer>
Generates a partysize array using restaurant's largest_table and supplier's min_seat configuration. Creates a descending array of party sizes to try, with fallback values if configuration is invalid.
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 123 def generate_partysize_array # Get supplier config for min_seat_method min_seat = config[:min_seat_method].call(restaurant) largest_table = restaurant&.largest_table.to_i # Set defaults if values are invalid min_seat = 2 if min_seat <= 0 largest_table = 10 if largest_table <= 0 partysize_candidates = [ largest_table, min_seat + 8, min_seat + 4, min_seat + 2, min_seat, ] # Remove values greater than largest_table and take unique values partysize_candidates. select { |size| size <= largest_table }. uniq. sort. reverse rescue StandardError => e APMErrorHandler.report( "Error generating partysize array: #{e.}", context: { restaurant_id: restaurant.id, supplier: supplier, largest_table: restaurant&.largest_table, exception_class: e.class.name, exception_message: e., }, ) # Return fallback array [15, 10, 6, 4, 2] end |
#generate_restaurant_open_hours_timeslots(dates_within_range) ⇒ Array<String>
Generates all possible restaurant timeslots based on opening hours and time intervals.
Creates a comprehensive list of potential reservation timeslots by:
-
Finding valid restaurant packages (valid_to_have_agendas)
-
Getting opening hours from HhPackage::OpeningHour
-
Using broadest opening window (minimum open, maximum close)
-
Applying restaurant's time interval (15min or 30min from start_time_interval)
-
Converting from restaurant timezone to UTC ISO8601 format
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 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 314 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 239 def generate_restaurant_open_hours_timeslots(dates_within_range) restaurant_package_ids = restaurant.restaurant_packages.valid_to_have_agendas.pluck(:id) if restaurant_package_ids.empty? return [] end # Fetch opening hours for valid packages opening_hours = HhPackage::OpeningHour.where(restaurant_package_id: restaurant_package_ids) if opening_hours.empty? return [] end # Calculate broadest opening window min_open_time = opening_hours.minimum(:open) max_close_time = opening_hours.maximum(:close) if min_open_time.nil? || max_close_time.nil? return [] end # Determine time interval (30min or 15min) interval_minutes = restaurant.start_time_interval&.include?('30min') ? 30 : 15 all_timeslots = [] # Generate timeslots for each date dates_within_range.each do |date_string| date_obj = Date.parse(date_string) # Parse opening hours (format: "HH:MM") open_hour, open_minute = min_open_time.split(':').map(&:to_i) close_hour, close_minute = max_close_time.split(':').map(&:to_i) # Create opening and closing times in restaurant timezone open_time = restaurant_tz.local(date_obj.year, date_obj.month, date_obj.day, open_hour, open_minute) close_time = restaurant_tz.local(date_obj.year, date_obj.month, date_obj.day, close_hour, close_minute) # Handle overnight restaurants (close time next day) if close_time <= open_time close_time += 1.day end # Generate timeslots within opening hours current_time = open_time date_timeslots = [] while current_time < close_time # Convert to UTC and format as ISO8601 utc_time = current_time.utc formatted_time = utc_time.iso8601(3) # 3 for milliseconds precision date_timeslots << formatted_time current_time += interval_minutes.minutes end all_timeslots.concat(date_timeslots) end all_timeslots rescue StandardError => e = "#{self.class}#generate_restaurant_open_hours_timeslots: Error generating timeslots" APMErrorHandler.report( "#{}: #{e.}", context: { restaurant_id: restaurant.id, supplier: supplier, dates_count: dates_within_range&.size, exception_class: e.class.name, exception_message: e., }, ) [] end |
#handle_api_response(response, availabilities, response_parser, context = {}) ⇒ void
This method returns an undefined value.
Handles API response with retry logic for rate limiting using Retriable gem. Provides consistent response handling across all suppliers with APM instrumentation.
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 390 def handle_api_response(response, availabilities, response_parser, context = {}) cooldown_seconds = config[:rate_limit_cooldown_seconds] Retriable.retriable( on: RateLimitExceededError, base_interval: cooldown_seconds, tries: 2, ) do response_body = parse_json_response(response.body) case response.code when 200 availability_data = response_parser.call(response_body) availabilities.concat(availability_data) when 429 = 'Too many requests, please try again later.' raise RateLimitExceededError, else = (response_body, supplier) report_and_log_error( "#{supplier.capitalize} API error: #{}", status: response.code, error_message: , response: response_body, **context, ) raise SupplierApiError, end end rescue RateLimitExceededError => e report_and_log_error( "#{supplier.capitalize} API rate limit exceeded: #{e.}", status: response.code, response: parse_json_response(response.body), exception_class: e.class.name, exception_message: e., **context, ) raise end |
#log_final_results(missing_dates, all_inventory_data, partysize_array) ⇒ void
This method returns an undefined value.
Logs final results of the availability fetching process with comprehensive metrics. Provides summary of successful fetches, missing timeslots, and success rate calculation.
455 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 455 def log_final_results(missing_dates, all_inventory_data, partysize_array); end |
#queue_request_with_error_handling(request, hydra, availabilities, response_parser, context = {}) ⇒ void
This method returns an undefined value.
Queues a single HTTP request with comprehensive error handling and retry logic. Handles rate limiting, API errors, and response parsing consistently across suppliers.
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 363 def queue_request_with_error_handling(request, hydra, availabilities, response_parser, context = {}) request.on_complete do |response| handle_api_response(response, availabilities, response_parser, context) rescue StandardError => e report_and_log_error( "#{supplier.capitalize} API unknown error: #{e.}", exception_class: e.class.name, exception_message: e., **context, ) raise SupplierApiError, e. end hydra.queue(request) end |
#report_and_log_error(message, **context) ⇒ void
This method returns an undefined value.
Helper to report error to APM. Centralizes error reporting with consistent context handling and automatic context building.
464 465 466 467 468 469 470 471 472 473 474 475 476 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 464 def report_and_log_error(, **context) # Build base context with restaurant and supplier info base_context = { restaurant_id: restaurant.id, supplier: supplier, }.compact # Merge with any additional context provided full_context = base_context.merge(context) # Report to APM only APMErrorHandler.report(, context: full_context) end |
#send_staff_notification_email(subject, body, _log_message, _additional_log_context = {}) ⇒ void
This method returns an undefined value.
Sends staff notification email using StaffMailer with predefined recipients. Centralizes email delivery and business logging for all notification types. Uses country-based email routing for merchant notifications.
216 217 218 219 220 221 222 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 216 def send_staff_notification_email(subject, body, , _additional_log_context = {}) account_manager = restaurant.user&.email merchant_email = merchant_email_for_country(restaurant) receivers = [merchant_email, ::VENDOR_TEAM_EMAIL, account_manager].compact.uniq StaffMailer.notify_html(subject, body, receivers).deliver_later! end |
#send_zero_inventory_notification(dates, party_sizes_attempted) ⇒ void
This method returns an undefined value.
Sends notification email when no inventory is available after all party size attempts. Only called when dates.count > 1 and all_inventory_data is blank.
169 170 171 172 173 174 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 201 202 203 204 205 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 169 def send_zero_inventory_notification(dates, party_sizes_attempted) APMErrorHandler.report( 'Supplier inventory is not available', context: { supplier: supplier, restaurant_id: restaurant.id, restaurant_name: restaurant.name_en, party_sizes_attempted: party_sizes_attempted, dates: dates, dates_count: dates.count, }, ) subject = "Zero Inventory for #{supplier} restaurant-#{restaurant.id}" body = <<~BODY #{supplier} API returns ZERO Inventory after attempting all party sizes for #{dates.count} days for<br><br> <strong>Restaurant Details:</strong><br> restaurant_name: #{restaurant.name_en}<br> restaurant_id: #{restaurant.id}<br> supplier: #{supplier}<br><br> <strong>Request Details:</strong><br> API was called for #{dates.count} days, date ranges from #{dates.first} to #{dates.last}<br><br> Party sizes attempted: #{party_sizes_attempted.join(', ')}<br> BODY send_staff_notification_email( subject, body, 'Zero inventory notification sent', { dates_count: dates.count, party_sizes_attempted: party_sizes_attempted, }, ) end |
#validate_dates(dates) ⇒ Array<String>
Validates array of dates are all in YYYY-MM-DD string format.
438 439 440 441 442 443 444 445 |
# File 'app/services/vendors_service/inventory_sync/inventory_fetcher_service.rb', line 438 def validate_dates(dates) Array(dates).each do |date| unless date.is_a?(String) && date.match?(/\A\d{4}-\d{2}-\d{2}\z/) raise ArgumentError, "Invalid date format. Use 'YYYY-MM-DD'." end end dates end |