spree_cm_commissioner 2.13.7 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/assets/javascripts/spree_cm_commissioner/backend.js +0 -1
  4. data/app/assets/stylesheets/spree_cm_commissioner/backend/calendar.scss +0 -39
  5. data/app/controllers/concerns/spree/admin/rates_availability_cells.rb +16 -0
  6. data/app/controllers/concerns/spree/api/v2/product_list_includes_decorator.rb +1 -0
  7. data/app/controllers/spree/admin/guests_controller.rb +2 -1
  8. data/app/controllers/spree/admin/integrations_controller.rb +16 -7
  9. data/app/controllers/spree/admin/inventory_items_controller.rb +33 -62
  10. data/app/controllers/spree/admin/product_option_types_controller.rb +88 -0
  11. data/app/controllers/spree/admin/rate_plans_controller.rb +125 -0
  12. data/app/controllers/spree/admin/rates_availabilities_controller.rb +142 -0
  13. data/app/controllers/spree/admin/stock_managements_controller.rb +1 -53
  14. data/app/controllers/spree/api/switch_cm/base_controller.rb +109 -0
  15. data/app/controllers/spree/api/switch_cm/tokens_controller.rb +68 -0
  16. data/app/controllers/spree/api/switch_cm/xml/availabilities_controller.rb +39 -0
  17. data/app/controllers/spree/api/switch_cm/xml/base_controller.rb +37 -0
  18. data/app/controllers/spree/api/switch_cm/xml/reservation_summaries_controller.rb +28 -0
  19. data/app/controllers/spree/api/switch_cm/xml/reservations_controller.rb +57 -0
  20. data/app/controllers/spree/api/switch_cm/xml/room_rate_availabilities_controller.rb +56 -0
  21. data/app/controllers/spree/api/switch_cm/xml/room_rates_controller.rb +45 -0
  22. data/app/controllers/spree/api/v2/operator/guests_controller.rb +1 -1
  23. data/app/controllers/spree/api/v2/storefront/accommodations/variants_controller.rb +31 -5
  24. data/app/controllers/spree/api/v2/storefront/inventory_items_controller.rb +2 -2
  25. data/app/controllers/spree/api/v2/tenant/intercity_taxi/draft_orders_controller.rb +0 -1
  26. data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +1 -1
  27. data/app/finders/spree_cm_commissioner/accommodations/find.rb +10 -8
  28. data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +5 -11
  29. data/app/finders/spree_cm_commissioner/rates_availabilities/find_bookings.rb +116 -0
  30. data/app/helpers/spree_cm_commissioner/admin/inventory_items_helper.rb +36 -0
  31. data/app/helpers/spree_cm_commissioner/admin/rates_availabilities_helper.rb +247 -0
  32. data/app/interactors/spree_cm_commissioner/intercity_taxi_order_creator.rb +0 -1
  33. data/app/jobs/spree_cm_commissioner/option_type_variants_public_metadata_updater_job.rb +1 -0
  34. data/app/jobs/spree_cm_commissioner/option_value_variants_public_metadata_updater_job.rb +1 -0
  35. data/app/jobs/spree_cm_commissioner/product_variants_public_metadata_updater_job.rb +12 -0
  36. data/app/models/concerns/spree_cm_commissioner/kyc_bitwise.rb +4 -0
  37. data/app/models/concerns/spree_cm_commissioner/line_item_durationable.rb +1 -10
  38. data/app/models/concerns/spree_cm_commissioner/line_item_guests_concern.rb +33 -3
  39. data/app/models/concerns/spree_cm_commissioner/line_item_rate_plannable.rb +55 -0
  40. data/app/models/concerns/spree_cm_commissioner/line_item_transitable.rb +0 -16
  41. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +22 -4
  42. data/app/models/concerns/spree_cm_commissioner/rate_plan_options_concern.rb +62 -0
  43. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +12 -0
  44. data/app/models/concerns/spree_cm_commissioner/variant_rate_plannable.rb +81 -0
  45. data/app/models/spree_cm_commissioner/address_decorator.rb +2 -0
  46. data/app/models/spree_cm_commissioner/configuration.rb +0 -6
  47. data/app/models/spree_cm_commissioner/exports/import_order_guests_csv.rb +2 -1
  48. data/app/models/spree_cm_commissioner/exports/operator_guest_json_gzip.rb +8 -2
  49. data/app/models/spree_cm_commissioner/guest.rb +1 -1
  50. data/app/models/spree_cm_commissioner/integration.rb +15 -0
  51. data/app/models/spree_cm_commissioner/integrations/switch_cm.rb +96 -0
  52. data/app/models/spree_cm_commissioner/inventory_item.rb +32 -3
  53. data/app/models/spree_cm_commissioner/line_item_decorator.rb +7 -7
  54. data/app/models/spree_cm_commissioner/maintenance_tasks/cache_invalidation.rb +20 -4
  55. data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +3 -2
  56. data/app/models/spree_cm_commissioner/option_type_decorator.rb +2 -1
  57. data/app/models/spree_cm_commissioner/option_value_decorator.rb +3 -0
  58. data/app/models/spree_cm_commissioner/option_value_rate_plan.rb +12 -0
  59. data/app/models/spree_cm_commissioner/order_decorator.rb +1 -0
  60. data/app/models/spree_cm_commissioner/price_decorator.rb +13 -0
  61. data/app/models/spree_cm_commissioner/product_decorator.rb +18 -2
  62. data/app/models/spree_cm_commissioner/product_option_type_decorator.rb +42 -0
  63. data/app/models/spree_cm_commissioner/rate_plan.rb +139 -0
  64. data/app/models/spree_cm_commissioner/rate_plan_options.rb +36 -0
  65. data/app/models/spree_cm_commissioner/rate_restriction.rb +45 -0
  66. data/app/models/spree_cm_commissioner/taxon_decorator.rb +5 -2
  67. data/app/models/spree_cm_commissioner/variant_decorator.rb +36 -5
  68. data/app/models/spree_cm_commissioner/variant_options.rb +3 -2
  69. data/app/models/spree_cm_commissioner/vendor_decorator.rb +1 -0
  70. data/app/overrides/spree/admin/option_types/_form/checkbox.html.erb.deface +0 -8
  71. data/app/overrides/spree/admin/option_types/_form/name.html.erb.deface +1 -1
  72. data/app/overrides/spree/admin/products/_filters/vendor_filter.html.erb.deface +6 -0
  73. data/app/overrides/spree/admin/shared/_product_tabs/rates_availabilities.html.erb.deface +8 -0
  74. data/app/overrides/spree/admin/shared/_product_tabs/stock_managements.html.erb.deface +1 -1
  75. data/app/overrides/spree/admin/variants/index/discontinued_badge.html.erb.deface +13 -0
  76. data/app/overrides/spree/admin/variants/index/option_types_empty_state_link.html.erb.deface +3 -0
  77. data/app/overrides/spree/admin/variants/index/option_types_summary.html.erb.deface +24 -0
  78. data/app/presenters/spree/variants/visible_options_presenter.rb +34 -1
  79. data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +40 -22
  80. data/app/queries/spree_cm_commissioner/single_leg_trips_query.rb +28 -14
  81. data/app/request_schemas/spree_cm_commissioner/intercity_taxi_draft_order_update_schema.rb +0 -1
  82. data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +2 -0
  83. data/app/serializers/spree/v2/storefront/option_type_serializer_decorator.rb +1 -1
  84. data/app/serializers/spree/v2/storefront/product_option_type_serializer.rb +15 -0
  85. data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +15 -0
  86. data/app/serializers/spree/v2/tenant/line_item_serializer.rb +1 -1
  87. data/app/serializers/spree/v2/tenant/option_type_serializer.rb +1 -1
  88. data/app/serializers/spree_cm_commissioner/v2/operator/dynamic_field_option_serializer.rb +9 -0
  89. data/app/serializers/spree_cm_commissioner/v2/operator/dynamic_field_serializer.rb +11 -0
  90. data/app/serializers/spree_cm_commissioner/v2/operator/guest_dynamic_field_serializer.rb +14 -0
  91. data/app/serializers/spree_cm_commissioner/v2/operator/guest_serializer.rb +1 -0
  92. data/app/serializers/spree_cm_commissioner/v2/storefront/accommodation_variant_serializer.rb +44 -0
  93. data/app/serializers/spree_cm_commissioner/v2/storefront/inventory_item_serializer.rb +4 -4
  94. data/app/serializers/spree_cm_commissioner/v2/storefront/rate_plan_serializer.rb +16 -0
  95. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +1 -1
  96. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_item_availability.rb +1 -1
  97. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accept_push.rb +111 -0
  98. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accommodation_lock.rb +43 -0
  99. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/apply_updates.rb +133 -0
  100. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/availability_snapshot.rb +127 -0
  101. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/inventory_update.rb +35 -0
  102. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/pushed_nights.rb +82 -0
  103. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/unseen_room_nights.rb +49 -0
  104. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/validate_updates.rb +153 -0
  105. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/feed.rb +209 -0
  106. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/mark_seen.rb +62 -0
  107. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/reservation.rb +171 -0
  108. data/app/services/spree_cm_commissioner/integrations/switch_cm/room_catalog.rb +88 -0
  109. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_manager.rb +16 -0
  110. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/full_sync_strategy.rb +39 -0
  111. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/incremental_sync_strategy.rb +51 -0
  112. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/availability_parser.rb +103 -0
  113. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/push_response.rb +45 -0
  114. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/reservation_serializer.rb +83 -0
  115. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/response_builder.rb +19 -0
  116. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_item_availability.rb +1 -1
  117. data/app/services/spree_cm_commissioner/intercity_taxi_order/update.rb +1 -3
  118. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb +4 -0
  119. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +4 -0
  120. data/app/services/spree_cm_commissioner/rate_plans/save.rb +65 -0
  121. data/app/services/spree_cm_commissioner/rates_availabilities/bulk_update.rb +225 -0
  122. data/app/services/spree_cm_commissioner/reschedule_requests/split_line_item.rb +5 -0
  123. data/app/services/spree_cm_commissioner/room_types/create.rb +87 -0
  124. data/app/services/spree_cm_commissioner/room_types/update_properties.rb +32 -0
  125. data/app/services/spree_cm_commissioner/room_types.rb +40 -0
  126. data/app/services/spree_cm_commissioner/route_prices/update_price_range.rb +3 -1
  127. data/app/services/spree_cm_commissioner/trips/search.rb +14 -7
  128. data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +5 -1
  129. data/app/views/spree/admin/guests/_check_in_status.html.erb +28 -22
  130. data/app/views/spree/admin/guests/edit.html.erb +29 -26
  131. data/app/views/spree/admin/integrations/_form.html.erb +4 -0
  132. data/app/views/spree/admin/integrations/_switch_cm_fields.html.erb +53 -0
  133. data/app/views/spree/admin/inventory_items/_details.html.erb +56 -0
  134. data/app/views/spree/admin/product_option_types/index.html.erb +79 -0
  135. data/app/views/spree/admin/rate_plans/_form.html.erb +134 -0
  136. data/app/views/spree/admin/rate_plans/_order_form.html.erb +37 -0
  137. data/app/views/spree/admin/rates_availabilities/_block.html.erb +34 -0
  138. data/app/views/spree/admin/rates_availabilities/_bookings.html.erb +52 -0
  139. data/app/views/spree/admin/rates_availabilities/_filters.html.erb +48 -0
  140. data/app/views/spree/admin/rates_availabilities/_legend.html.erb +27 -0
  141. data/app/views/spree/admin/rates_availabilities/_list_pages.html.erb +14 -0
  142. data/app/views/spree/admin/rates_availabilities/_modal_header.html.erb +11 -0
  143. data/app/views/spree/admin/rates_availabilities/_names.html.erb +62 -0
  144. data/app/views/spree/admin/rates_availabilities/_script.html.erb +676 -0
  145. data/app/views/spree/admin/rates_availabilities/_styles.html.erb +159 -0
  146. data/app/views/spree/admin/rates_availabilities/index.html.erb +112 -0
  147. data/app/views/spree/admin/shared/sub_menu/_homepage.html.erb +0 -1
  148. data/app/views/spree/admin/stock_managements/_header.html.erb +0 -14
  149. data/app/views/spree/admin/stock_managements/_inventory_item_stats.html.erb +28 -0
  150. data/app/views/spree/admin/stock_managements/index.html.erb +1 -56
  151. data/config/initializers/spree_permitted_attributes.rb +1 -0
  152. data/config/locales/en.yml +241 -3
  153. data/config/locales/km.yml +241 -4
  154. data/config/routes.rb +36 -17
  155. data/db/migrate/20260928000000_add_oauth_application_id_to_cm_integrations.rb +5 -0
  156. data/db/migrate/20260929000000_create_cm_rate_plans.rb +22 -0
  157. data/db/migrate/20260929000001_add_rate_plan_to_spree_prices.rb +24 -0
  158. data/db/migrate/20260929000002_add_rate_plan_to_spree_line_items.rb +7 -0
  159. data/db/migrate/20260929000003_create_cm_option_value_rate_plans.rb +16 -0
  160. data/db/migrate/20261009000000_create_cm_rate_restrictions.rb +29 -0
  161. data/db/migrate/20261013000001_add_hidden_and_presentation_to_spree_product_option_types.rb +6 -0
  162. data/db/migrate/20261013000002_move_hidden_from_spree_option_types_to_product_option_types.rb +36 -0
  163. data/db/migrate/20261016000001_add_index_to_spree_line_items_number.rb +8 -0
  164. data/lib/spree_cm_commissioner/booking_dates.rb +43 -0
  165. data/lib/spree_cm_commissioner/calendar_event.rb +0 -25
  166. data/lib/spree_cm_commissioner/rate.rb +74 -0
  167. data/lib/spree_cm_commissioner/rate_closings.rb +48 -0
  168. data/lib/spree_cm_commissioner/rates_availability_grid.rb +113 -0
  169. data/lib/spree_cm_commissioner/stay_price.rb +30 -0
  170. data/lib/spree_cm_commissioner/stay_prices.rb +58 -0
  171. data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +6 -0
  172. data/lib/spree_cm_commissioner/test_helper/factories/option_type_factory.rb +6 -0
  173. data/lib/spree_cm_commissioner/test_helper/factories/option_value_rate_plan_factory.rb +6 -0
  174. data/lib/spree_cm_commissioner/test_helper/factories/rate_plan_factory.rb +14 -0
  175. data/lib/spree_cm_commissioner/test_helper/factories/variant_factory.rb +9 -0
  176. data/lib/spree_cm_commissioner/version.rb +1 -1
  177. data/lib/spree_cm_commissioner.rb +6 -0
  178. metadata +94 -32
  179. data/app/assets/javascripts/spree_cm_commissioner/calendar.js +0 -59
  180. data/app/controllers/spree/admin/calendars/base_controller.rb +0 -27
  181. data/app/controllers/spree/admin/calendars/orders_controller.rb +0 -27
  182. data/app/controllers/spree/admin/homepage_feed_controller.rb +0 -36
  183. data/app/controllers/spree/admin/metafields_controller.rb +0 -69
  184. data/app/models/concerns/spree_cm_commissioner/metafield.rb +0 -15
  185. data/app/models/spree_cm_commissioner/homepage_feed.rb +0 -4
  186. data/app/overrides/spree/admin/shared/_main_menu/sidebar_calendars.html.erb.deface +0 -5
  187. data/app/overrides/spree/admin/shared/_product_tabs/metafields.html.erb.deface +0 -15
  188. data/app/serializers/spree_cm_commissioner/v2/storefront/taxon_include_product_serializer.rb +0 -13
  189. data/app/serializers/spree_cm_commissioner/v2/storefront/vendor_include_product_serializer.rb +0 -14
  190. data/app/services/spree_cm_commissioner/feed/taxon_include_product.rb +0 -20
  191. data/app/services/spree_cm_commissioner/feed/taxon_product.rb +0 -54
  192. data/app/services/spree_cm_commissioner/feed/vendor_include_product.rb +0 -20
  193. data/app/services/spree_cm_commissioner/feed/vendor_product.rb +0 -52
  194. data/app/services/spree_cm_commissioner/homepage_data_loader.rb +0 -112
  195. data/app/services/spree_cm_commissioner/metafields/product_metadata_service.rb +0 -32
  196. data/app/views/shared/_calendar.html.erb +0 -27
  197. data/app/views/shared/calendar/_order_popover.html.erb +0 -18
  198. data/app/views/shared/calendar/_orders_sidebar.html.erb +0 -41
  199. data/app/views/spree/admin/calendars/orders/index.html.erb +0 -22
  200. data/app/views/spree/admin/homepage_feed/_form.html.erb +0 -24
  201. data/app/views/spree/admin/homepage_feed/edit.html.erb +0 -17
  202. data/app/views/spree/admin/inventory_items/prices.html.erb +0 -43
  203. data/app/views/spree/admin/inventory_items/stocks.html.erb +0 -34
  204. data/app/views/spree/admin/metafields/_private_metafields_form.html.erb +0 -71
  205. data/app/views/spree/admin/metafields/_public_metafields_form.html.erb +0 -64
  206. data/app/views/spree/admin/metafields/edit.html.erb +0 -27
  207. data/app/views/spree/admin/shared/sub_menu/_calendar.html.erb +0 -3
  208. data/app/views/spree/admin/stock_managements/calendar.html.erb +0 -310
@@ -12,20 +12,14 @@ module SpreeCmCommissioner
12
12
 
13
13
  def execute
14
14
  Spree::Variant
15
- .joins(:inventory_items)
16
15
  .where(vendor_id: vendor_id)
17
- .where(inventory_items: { inventory_date: stay_dates })
18
- .where('CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER) +
19
- CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER) >= ?', number_of_guests
20
- )
21
- .where('inventory_items.quantity_available > 0')
22
- .distinct
16
+ .bookable_on_every_date(dates)
17
+ .with_guest_capacity(number_of_guests)
18
+ .with_open_rate_on_every_date(dates)
23
19
  end
24
20
 
25
- private
26
-
27
- def stay_dates
28
- from_date..to_date
21
+ def dates
22
+ @dates ||= SpreeCmCommissioner::BookingDates.nights(check_in: from_date, check_out: to_date)
29
23
  end
30
24
  end
31
25
  end
@@ -0,0 +1,116 @@
1
+ module SpreeCmCommissioner
2
+ module RatesAvailabilities
3
+ # The line items behind cells of the Rates & Availability grid. A stock cell covers every line item of its
4
+ # variant, a rate plan's price cell only that plan's, and the variant's own price cell those sold without one.
5
+ # A date matches every stay or booking it falls in, by the same rule InventoryItems::Reset counts with, so
6
+ # Confirmed adds up to what the stock cell has sold. Base on a dated product means bookings not over yet.
7
+ class FindBookings
8
+ FILTERS = %w[confirmed unpaid in_checkout canceled carts all].freeze
9
+
10
+ # `cells` are the grid's: { type:, variant_id:, rate_plan_id:, date: } with date '' for Base.
11
+ def initialize(product:, cells:, filter: 'confirmed', query: nil)
12
+ @product = product
13
+ @cells = cells
14
+ @filter = FILTERS.include?(filter) ? filter : 'confirmed'
15
+ @query = query.to_s.strip
16
+ end
17
+
18
+ attr_reader :filter
19
+
20
+ def execute
21
+ by_filter(searched, filter)
22
+ .includes({ rate_plan: { option_values: :option_type } }, variant: { option_values: :option_type }, order: %i[bill_address user])
23
+ .order(:from_date, :id)
24
+ end
25
+
26
+ # Line items per filter, for the quick filter chips. The search applies; the chosen filter doesn't. One pass,
27
+ # a COUNT FILTER per chip, each taking its condition from by_filter so the chips and their lists agree.
28
+ def counts
29
+ columns = FILTERS.map do |name|
30
+ where_clause = by_filter(Spree::LineItem.unscoped, name).where_clause
31
+ where_clause.empty? ? Arel.star.count : Arel.star.count.filter(where_clause.ast)
32
+ end
33
+ FILTERS.zip(searched.pick(*columns) || Array.new(FILTERS.size, 0)).to_h
34
+ end
35
+
36
+ # Which of these line items' orders are carts holding stock, for their In checkout badge.
37
+ def held_order_ids_among(line_items)
38
+ held_order_ids.where(order_id: line_items.map(&:order_id)).pluck(:order_id).to_set
39
+ end
40
+
41
+ private
42
+
43
+ def by_filter(scope, name)
44
+ case name
45
+ when 'confirmed' then scope.merge(Spree::LineItem.affecting_inventory)
46
+ when 'unpaid' then scope.merge(Spree::LineItem.affecting_inventory).where.not(spree_orders: { payment_state: 'paid' })
47
+ when 'in_checkout' then scope.where(order_id: held_order_ids)
48
+ when 'canceled' then scope.where.not(spree_orders: { canceled_at: nil })
49
+ # Unfinished orders that hold nothing, apart from the bookings so they can be cleaned up.
50
+ when 'carts' then scope.where(spree_orders: { completed_at: nil }).where.not(order_id: held_order_ids)
51
+ else scope
52
+ end
53
+ end
54
+
55
+ def held_order_ids
56
+ SpreeCmCommissioner::InventoryHold.active_or_payment_locked.select(:order_id)
57
+ end
58
+
59
+ def searched
60
+ return cells_scope if @query.blank?
61
+
62
+ term = "%#{Spree::LineItem.sanitize_sql_like(@query)}%"
63
+ cells_scope.left_joins(order: :bill_address).where(
64
+ 'spree_orders.number ILIKE :term OR spree_orders.email ILIKE :term OR spree_orders.phone_number ILIKE :term ' \
65
+ "OR spree_addresses.phone ILIKE :term OR CONCAT_WS(' ', spree_addresses.firstname, spree_addresses.lastname) ILIKE :term",
66
+ term: term
67
+ )
68
+ end
69
+
70
+ # One scope per variant and row over its dates, OR'd together. Archived orders are carts gone stale
71
+ # (Orders::DailyArchiveInactiveOrders), already hidden from their guests, so they're left out here too.
72
+ def cells_scope
73
+ scopes = groups.map { |(variant_id, row), dates| group_scope(variant_id, row, dates) }
74
+ return Spree::LineItem.none if scopes.empty?
75
+
76
+ scopes.reduce(:or).joins(:order).where(spree_orders: { archived_at: nil })
77
+ end
78
+
79
+ # { [variant_id, row] => dates }, where row is :all for stock, nil for the variant's own prices, else a rate
80
+ # plan id, and a date is nil for Base. Cells of other products' variants are dropped.
81
+ def groups
82
+ @cells.select { |cell| variant_ids.include?(cell[:variant_id].to_i) }
83
+ .group_by { |cell| [cell[:variant_id].to_i, cell[:type] == 'stock' ? :all : cell[:rate_plan_id].presence&.to_i] }
84
+ .transform_values { |cells| cells.map { |cell| cell[:date].presence && Date.parse(cell[:date]) }.uniq }
85
+ end
86
+
87
+ def group_scope(variant_id, row, dates)
88
+ scope = Spree::LineItem.where(variant_id: variant_id)
89
+ scope = scope.where(rate_plan_id: row) unless row == :all
90
+ condition = dates.include?(nil) ? base_condition : dates_condition(dates.sort)
91
+ condition ? scope.where(*condition) : scope
92
+ end
93
+
94
+ # Base on a dated product is the bookings not over yet; on any other, every booking.
95
+ def base_condition
96
+ ['spree_line_items.to_date >= ?', Time.zone.today] if @product.permanent_stock?
97
+ end
98
+
99
+ # The range check is a cheap first cut before EXISTS, and lets a (variant_id, to_date) index skip old stays if
100
+ # a variant's history ever grows large enough to need one. EXISTS then keeps only stays covering one of the
101
+ # dates, not those that merely span a gap between two.
102
+ def dates_condition(dates)
103
+ [
104
+ 'spree_line_items.to_date >= ? AND spree_line_items.from_date <= ? ' \
105
+ 'AND EXISTS (SELECT 1 FROM unnest(ARRAY[?]::date[]) AS night(day) ' \
106
+ 'WHERE night.day BETWEEN spree_line_items.from_date AND spree_line_items.to_date)',
107
+ dates.first, dates.last, dates.map(&:iso8601)
108
+ ]
109
+ end
110
+
111
+ def variant_ids
112
+ @variant_ids ||= Spree::Variant.where(product_id: @product.id).ids
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,36 @@
1
+ module SpreeCmCommissioner
2
+ module Admin
3
+ # Inventory item counts as the stock managements page and the Rates & Availability stock details show them.
4
+ module InventoryItemsHelper
5
+ # The cloud icon after a count: whether Redis has the same value. A popover on stock managements; fetched
6
+ # HTML has nothing to initialize popovers, so the grid's script copies the message into a title.
7
+ def inventory_item_sync_icon(value, cached_value)
8
+ synced = value == cached_value
9
+ message = if synced
10
+ t('inventory_item_stats.synced', value: cached_value)
11
+ else
12
+ t('inventory_item_stats.out_of_sync', cached: cached_value, value: value)
13
+ end
14
+ icon = svg_icon(
15
+ name: synced ? 'cloud-check.svg' : 'cloud-slash.svg', width: '14', height: '14', classes: synced ? 'text-success' : 'text-danger'
16
+ )
17
+
18
+ content_tag(:span, icon, type: 'button', data: { toggle: 'popover', trigger: 'hover', placement: 'right', content: message })
19
+ end
20
+
21
+ # Recounts in place through the grid's bulk_update, unlike stock managements' bulk_reset link, which reloads.
22
+ def inventory_item_recount_button(inventory_item)
23
+ label = t('rates_availabilities.bulk.reset_stock')
24
+ data = {
25
+ ra_recount: true, variant_id: inventory_item.variant_id, date: inventory_item.inventory_date&.iso8601.to_s,
26
+ ra_confirm: t('rates_availabilities.bulk.reset_stock_confirm')
27
+ }
28
+
29
+ content_tag(
30
+ :button, svg_icon(name: 'arrow-counterclockwise.svg', width: '14', height: '14'),
31
+ type: 'button', class: 'icon_link btn btn-sm outline text-dark m-0 p-0 border-0', title: label, aria: { label: label }, data: data
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,247 @@
1
+ module SpreeCmCommissioner
2
+ module Admin
3
+ # Cells of the Rates & Availability grid. Helpers, not partials: a page renders up to tens of thousands of cells.
4
+ module RatesAvailabilitiesHelper
5
+ # A dated product's Base column has no inventory item, only the capacity new dates start from.
6
+ def rates_availability_stock_cell(grid, variant, date)
7
+ item = grid.inventory_item(variant, date)
8
+ data = rates_availability_cell_data('stock', variant, nil, date)
9
+ return content_tag(:td, grid.base_stock(variant), class: 'ra-cell ra-base', data: data) if item.nil? && date.nil?
10
+ unless item
11
+ return content_tag(:td, '—', class: rates_availability_cell_class(date, 'text-muted'), title: t('rates_availabilities.not_generated'))
12
+ end
13
+
14
+ synced = grid.synced?(item)
15
+ content_tag(
16
+ :td, rates_availability_stock_content(item),
17
+ class: rates_availability_cell_class(
18
+ date, ('ra-closed' if item.closed), ('ra-sold-out' if !item.closed && item.quantity_available.zero?), ('ra-out-of-sync' unless synced)
19
+ ),
20
+ title: rates_availability_stock_title(item, synced),
21
+ data: data
22
+ )
23
+ end
24
+
25
+ def rates_availability_stock_content(item)
26
+ quantity = "#{item.quantity_available}/#{item.max_capacity}"
27
+ meter = rates_availability_stock_meter(item)
28
+ return safe_join([quantity, meter].compact) unless item.closed
29
+
30
+ label = content_tag(:span, t('rates_availabilities.closed_label'), class: 'ra-closed-label')
31
+ safe_join([label, content_tag(:small, quantity), meter].compact)
32
+ end
33
+
34
+ # The gap between max and available is what's sold plus what's locked for a private sale (holds are counted
35
+ # apart, and setting stock moves both numbers), so sold leaves the lock out to tell the two apart.
36
+ def rates_availability_stock_sold(item)
37
+ [item.max_capacity - item.quantity_available - item.quantity_locked, 0].max
38
+ end
39
+
40
+ # A bar under the quantity filling with what's sold, then what's locked; none until something is.
41
+ def rates_availability_stock_meter(item)
42
+ sold = rates_availability_stock_sold(item)
43
+ locked = item.quantity_locked
44
+ return if item.max_capacity <= 0 || (sold + locked).zero?
45
+
46
+ segments = { 'ra-meter-sold' => sold, 'ra-meter-locked' => locked }.filter_map do |css, quantity|
47
+ content_tag(:span, nil, class: css, style: "width: #{(quantity * 100.0 / item.max_capacity).round(1)}%") if quantity.positive?
48
+ end
49
+ content_tag(:span, safe_join(segments), class: 'ra-meter')
50
+ end
51
+
52
+ def rates_availability_stock_title(item, synced)
53
+ [
54
+ t(
55
+ 'rates_availabilities.stock_tooltip',
56
+ available: item.quantity_available, max: item.max_capacity, sold: rates_availability_stock_sold(item),
57
+ on_hold: item.quantity_on_hold, locked: item.quantity_locked
58
+ ),
59
+ (t('rates_availabilities.closed') if item.closed),
60
+ (t('rates_availabilities.out_of_sync') unless synced)
61
+ ].compact.join(' · ')
62
+ end
63
+
64
+ # `index` is the column: 0 is Base, then one per date. A derived rate plan has no Base price of its own
65
+ # (it's calculated), so only its date cells can be selected, to override the calculated price. A closed date
66
+ # (the variant's, for every rate) keeps its prices editable. A rate closed by its own closing
67
+ # (RatesAvailabilityGrid#rate_closing) is ra-rate-closed, so it can be reopened from its cell; one closed only
68
+ # by its date or by following the base rate is ra-closed-day alone, and its title says where it reopens.
69
+ def rates_availability_price_cell(grid, row, index, date)
70
+ closed = date && grid.inventory_item(row.variant, date)&.closed
71
+ rate_closing = grid.rate_closing(row, date)
72
+ state = rates_availability_price_state(row, date)
73
+ data = rates_availability_cell_data('price', row.variant, row.rate_plan, date) unless row.rate_plan&.kind_derived? && date.nil?
74
+
75
+ content_tag(
76
+ :td, rates_availability_price_content(row.amounts[index], row.compare_at_amounts[index], grid.currency),
77
+ class: rates_availability_cell_class(
78
+ date, ("ra-#{state.to_s.dasherize}" if state), ('ra-closed-day' if closed || rate_closing), ('ra-rate-closed' if rate_closing == :closed)
79
+ ),
80
+ title: rates_availability_price_title(grid, row, state, closed, rate_closing), data: data
81
+ )
82
+ end
83
+
84
+ def rates_availability_price_title(grid, row, state, closed, rate_closing)
85
+ [
86
+ state == :derived ? rates_availability_rate_plan_hint(row.rate_plan) : (t("rates_availabilities.#{state}") if state),
87
+ (t('rates_availabilities.date_closed') if closed),
88
+ (rates_availability_rate_closing_title(grid, row, rate_closing) if rate_closing)
89
+ ].compact.join(' · ').presence
90
+ end
91
+
92
+ # The base price isn't sold while the variant has active rate plans, so closing it means closing the ones
93
+ # calculated from it, which its title says.
94
+ def rates_availability_rate_closing_title(grid, row, rate_closing)
95
+ derived = row.rate_plan.nil? && grid.rate_plans(row.variant).any? { |rate_plan| rate_plan.active? && rate_plan.kind_derived? }
96
+ return t('rates_availabilities.base_rate_closed') if derived
97
+
98
+ t("rates_availabilities.rate_#{rate_closing}")
99
+ end
100
+
101
+ # The price, with what guests see crossed out above it.
102
+ def rates_availability_price_content(amount, compare_at, currency)
103
+ return '—' if amount.nil?
104
+
105
+ crossed_out = content_tag(:s, Spree::Money.new(compare_at, currency: currency).to_s, class: 'ra-compare-at') if compare_at
106
+ safe_join([crossed_out, Spree::Money.new(amount, currency: currency).to_s].compact)
107
+ end
108
+
109
+ # The strings rates_availability.js shows, as it's a plain asset with no access to I18n.
110
+ def rates_availability_script_translations
111
+ {
112
+ selected: t('rates_availabilities.bulk.selected'),
113
+ saving: t('rates_availabilities.bulk.saving'),
114
+ done: t('rates_availabilities.bulk.done'),
115
+ failed: t('rates_availabilities.modal.failed')
116
+ }
117
+ end
118
+
119
+ # What a selectable cell sends to bulk_update.
120
+ def rates_availability_cell_data(type, variant, rate_plan, date)
121
+ { cell: type, variant_id: variant.id, rate_plan_id: rate_plan&.id, date: date&.iso8601.to_s }
122
+ end
123
+
124
+ # :date_price for a date with its own price, else :derived for a calculated one, else :uses_base on a date.
125
+ def rates_availability_price_state(row, date)
126
+ return :date_price if date && row.stay_price.date_price?(date)
127
+ return :derived if row.rate_plan&.kind_derived?
128
+
129
+ :uses_base if date
130
+ end
131
+
132
+ # The variant's own prices are only sold while it has no active rate plan, so the label says which.
133
+ def rates_availability_price_label(grid, row)
134
+ return row.rate_plan.display_name if row.rate_plan
135
+
136
+ key = grid.rate_plans(row.variant).any?(&:active?) ? 'base_price' : 'default_rate'
137
+ content_tag(:span, t("rates_availabilities.#{key}"), title: t("rates_availabilities.#{key}_hint"))
138
+ end
139
+
140
+ def rates_availability_rate_plan_hint(rate_plan)
141
+ if rate_plan.kind_independent?
142
+ t('rates_availabilities.independent')
143
+ elsif rate_plan.adjustment_percent?
144
+ t('rates_availabilities.derived_percent', value: format('%+g', rate_plan.adjustment_value))
145
+ else
146
+ t('rates_availabilities.derived_amount', value: format('%+g', rate_plan.adjustment_value), currency: rate_plan.currency)
147
+ end
148
+ end
149
+
150
+ def rates_availability_cell_class(date, *extra)
151
+ [
152
+ 'ra-cell',
153
+ ('ra-base' if date.nil?),
154
+ ('ra-weekend' if date&.on_weekend?),
155
+ ('ra-today' if date == Time.zone.today),
156
+ ('ra-past' if date && date < Time.zone.today),
157
+ *extra
158
+ ].compact.join(' ')
159
+ end
160
+
161
+ def rates_availability_variant_name(variant)
162
+ variant.is_master? ? variant.product.name : variant.options_text
163
+ end
164
+
165
+ # "Deluxe · Fri 10 Oct 2026", or "3 variants · 9 Oct – 12 Oct 2026 · 4 dates" for a wider selection.
166
+ def rates_availability_selection_subtitle(product, cells)
167
+ variant_ids = cells.map { |cell| cell[:variant_id].to_i }.uniq
168
+ dates = cells.filter_map { |cell| cell[:date].presence && Date.parse(cell[:date]) }.uniq.sort
169
+ variant = product.variants_including_master.includes(option_values: :option_type).find_by(id: variant_ids.first) if variant_ids.one?
170
+
171
+ parts = [variant ? rates_availability_variant_name(variant) : t('rates_availabilities.selection.variants', count: variant_ids.size)]
172
+ parts << t('rates_availabilities.selection.base') if dates.empty? && product.permanent_stock?
173
+ parts.concat(rates_availability_selection_dates(dates))
174
+ parts.join(' · ')
175
+ end
176
+
177
+ def rates_availability_selection_dates(dates)
178
+ return [] if dates.empty?
179
+ return [l(dates.first, format: '%a %-d %b %Y')] if dates.one?
180
+
181
+ ["#{l(dates.first, format: '%-d %b')} – #{l(dates.last, format: '%-d %b %Y')}", t('rates_availabilities.selection.dates', count: dates.size)]
182
+ end
183
+
184
+ # The guest's name (else email), and the order's phone (else the billing one).
185
+ def rates_availability_booking_guest(order)
186
+ phone = order.phone_number.presence || order.bill_address&.phone.presence
187
+ [order.name.presence || order.email, phone].compact.join(' · ')
188
+ end
189
+
190
+ # When a placed order was placed, else when the cart was started, so a stale cart is told from a live one.
191
+ def rates_availability_booking_time(order)
192
+ key, time = order.completed_at ? ['placed', order.completed_at] : ['created', order.created_at]
193
+ t("rates_availabilities.bookings.#{key}", time: l(time, format: '%-d %b %Y, %H:%M'))
194
+ end
195
+
196
+ # The state that explains the line's effect on stock, then what's unusual about it.
197
+ def rates_availability_booking_badges(line_item, held_order_ids)
198
+ order = line_item.order
199
+ badges = [rates_availability_booking_state(order, held_order_ids)]
200
+ badges << [order.payment_state.humanize, order.paid? ? 'success' : 'warning'] if order.payment_state.present? && order.canceled_at.nil?
201
+ badges << [t('rates_availabilities.bookings.ticket_transfer'), 'info'] if order.ticket_transfer?
202
+ badges << [t('rates_availabilities.bookings.private_sale'), 'dark'] if line_item.locked_stock?
203
+ safe_join(badges.map { |label, style| content_tag(:span, label, class: "badge badge-#{style}") }, ' ')
204
+ end
205
+
206
+ def rates_availability_booking_state(order, held_order_ids)
207
+ state, style =
208
+ if order.canceled_at
209
+ %w[canceled secondary]
210
+ elsif order.completed_at
211
+ %w[complete success]
212
+ elsif held_order_ids.include?(order.id)
213
+ %w[in_checkout warning]
214
+ else
215
+ %w[cart light]
216
+ end
217
+ [t("rates_availabilities.bookings.states.#{state}"), style]
218
+ end
219
+
220
+ # A hotel's last night is `to_date`, so checkout is the day after.
221
+ def rates_availability_booking_dates(line_item)
222
+ return if line_item.from_date.blank?
223
+
224
+ from = line_item.from_date.to_date
225
+ case line_item.product_type.to_s
226
+ when 'accommodation'
227
+ nights = line_item.date_range.size
228
+ checkout = line_item.to_date.to_date + 1
229
+ "#{l(from, format: '%-d %b')} → #{l(checkout, format: '%-d %b')} · #{t('rates_availabilities.bookings.nights', count: nights)}"
230
+ when 'service'
231
+ days = line_item.date_range.size
232
+ "#{l(from, format: '%-d %b')} – #{l(line_item.to_date.to_date, format: '%-d %b')} · #{t('rates_availabilities.bookings.days', count: days)}"
233
+ else
234
+ l(line_item.from_date, format: '%a %-d %b %H:%M')
235
+ end
236
+ end
237
+
238
+ # The variant only when the selection spans several, the rate plan when the line has one.
239
+ def rates_availability_booking_rate(line_item, with_variant:)
240
+ parts = []
241
+ parts << rates_availability_variant_name(line_item.variant) if with_variant
242
+ parts << line_item.rate_plan.display_name if line_item.rate_plan
243
+ parts.join(' · ').presence
244
+ end
245
+ end
246
+ end
247
+ end
@@ -46,7 +46,6 @@ module SpreeCmCommissioner
46
46
  :pickup_lng,
47
47
  :drop_off_lat,
48
48
  :drop_off_lng,
49
- :passenger_count,
50
49
  :pickup_oob_confirmed,
51
50
  :drop_off_oob_confirmed,
52
51
  :distance_km,
@@ -7,6 +7,7 @@ module SpreeCmCommissioner
7
7
  return if option_type.blank?
8
8
 
9
9
  option_type.variants.find_each(&:set_options_to_public_metadata!)
10
+ option_type.rate_plans.find_each(&:cache_options!)
10
11
  end
11
12
  end
12
13
  end
@@ -7,6 +7,7 @@ module SpreeCmCommissioner
7
7
  return if option_value.blank?
8
8
 
9
9
  option_value.variants.find_each(&:set_options_to_public_metadata!)
10
+ option_value.rate_plans.find_each(&:cache_options!)
10
11
  end
11
12
  end
12
13
  end
@@ -0,0 +1,12 @@
1
+ module SpreeCmCommissioner
2
+ class ProductVariantsPublicMetadataUpdaterJob < ApplicationUniqueJob
3
+ def perform(options = {})
4
+ product = ::Spree::Product.find_by(id: options[:product_id])
5
+
6
+ # job can be queued with invalid id or record is created but rolled back or deleted.
7
+ return if product.blank?
8
+
9
+ product.variants_including_master.find_each(&:set_options_to_public_metadata!)
10
+ end
11
+ end
12
+ end
@@ -36,6 +36,10 @@ module SpreeCmCommissioner
36
36
  guest_id_card
37
37
  ].freeze
38
38
 
39
+ # Pre-checked for a room type that has no KYC configured yet — the fields most hotels collect
40
+ # at check-in, so organizers who don't know what to pick get a sensible starting point.
41
+ ROOM_TYPE_COMMON_FIELDS = %i[guest_name guest_phone_number guest_id_card].freeze
42
+
39
43
  # Maps each KYC bit field to the actual Guest attribute(s) it stores.
40
44
  KYC_FIELD_ATTRIBUTES = {
41
45
  guest_name: %i[first_name last_name],
@@ -22,18 +22,9 @@ module SpreeCmCommissioner
22
22
  end
23
23
 
24
24
  def date_range
25
- return [] unless date_present?
26
25
  return [] unless permanent_stock?
27
26
 
28
- # For transit products (e.g., bus, train), only return the departure day,
29
- # as the booking represents a single trip rather than a duration.
30
- return [from_date.to_date] if transit?
31
-
32
- # For products with a stay or usage period (e.g., hotel, car rental, activities),
33
- # return the full date range from start to end, inclusive.
34
- return (from_date.to_date..to_date.to_date).to_a if service? || accommodation?
35
-
36
- []
27
+ SpreeCmCommissioner::BookingDates.for_product_type(product_type, from_date: from_date, to_date: to_date)
37
28
  end
38
29
 
39
30
  def checkin_date
@@ -1,9 +1,11 @@
1
1
  # Number of guests are depend on the variant, but user is allowed to input custom number of guests
2
- # which will be stored in public_metadata.
2
+ # which will be stored in public_metadata. 'number_of_guests' is a headcount with no adult/kid
3
+ # split (e.g. intercity taxi passengers) and wins over the adults/kids pair.
3
4
  #
4
5
  # metadata {
5
6
  # 'number_of_adults': 2,
6
7
  # 'number_of_kids': 1,
8
+ # 'number_of_guests': 3,
7
9
  # }
8
10
  module SpreeCmCommissioner
9
11
  module LineItemGuestsConcern
@@ -12,11 +14,26 @@ module SpreeCmCommissioner
12
14
  included do
13
15
  validate :validate_total_adults, if: -> { valid_integer_metadata?('number_of_adults') }
14
16
  validate :validate_total_kids, if: -> { valid_integer_metadata?('number_of_kids') }
17
+ validate :validate_number_of_guests, if: -> { public_metadata.key?('number_of_guests') }
15
18
  end
16
19
 
17
20
  def number_of_adults = metadata_guest_count('number_of_adults') || (variant.number_of_adults * quantity)
18
21
  def number_of_kids = metadata_guest_count('number_of_kids') || (variant.number_of_kids * quantity)
19
- def number_of_guests = number_of_adults + number_of_kids
22
+
23
+ # Without an adults/kids override, scale the variant's own headcount so a variant's
24
+ # number-of-guests option is honoured; for variants without it this equals adults + kids.
25
+ def number_of_guests
26
+ metadata_guest_count('number_of_guests') ||
27
+ (adults_or_kids_metadata? ? number_of_adults + number_of_kids : variant.number_of_guests * quantity)
28
+ end
29
+
30
+ def number_of_guests=(value)
31
+ if value.nil?
32
+ public_metadata.delete('number_of_guests')
33
+ else
34
+ public_metadata['number_of_guests'] = value.to_s
35
+ end
36
+ end
20
37
 
21
38
  def allowed_extra_adults = variant.allowed_extra_adults * quantity
22
39
  def allowed_extra_kids = variant.allowed_extra_kids * quantity
@@ -28,7 +45,9 @@ module SpreeCmCommissioner
28
45
 
29
46
  # True when the guest count is set via the public_metadata override (accommodation/transit),
30
47
  # i.e. decoupled from quantity rather than derived from variant.number_of_guests * quantity.
31
- def custom_guest_count? = public_metadata['number_of_adults'].to_i.positive? || public_metadata['number_of_kids'].to_i.positive?
48
+ def custom_guest_count?
49
+ %w[number_of_guests number_of_adults number_of_kids].any? { |key| public_metadata[key].to_i.positive? }
50
+ end
32
51
 
33
52
  def generate_remaining_guests
34
53
  return if remaining_total_guests.zero?
@@ -86,6 +105,10 @@ module SpreeCmCommissioner
86
105
  value.present? && value.to_s.match?(/\A-?\d+\z/)
87
106
  end
88
107
 
108
+ def adults_or_kids_metadata?
109
+ valid_integer_metadata?('number_of_adults') || valid_integer_metadata?('number_of_kids')
110
+ end
111
+
89
112
  # public_metadata override for a guest count, only when it's a non-negative integer;
90
113
  # nil otherwise so callers fall back to the variant default. Keeps number_of_guests >= 0
91
114
  # (a negative would make custom_guest_count? false and spin the bump loop in Guests::Create).
@@ -103,5 +126,12 @@ module SpreeCmCommissioner
103
126
  def validate_total_kids
104
127
  errors.add(:quantity, 'exceed_total_kids') if public_metadata['number_of_kids'].to_i > allowed_total_kids
105
128
  end
129
+
130
+ # Not capped against the variant: e.g. intercity taxi capacity comes from the vehicle's seats.
131
+ def validate_number_of_guests
132
+ return if valid_integer_metadata?('number_of_guests') && public_metadata['number_of_guests'].to_i.positive?
133
+
134
+ errors.add(:number_of_guests, :greater_than, count: 0)
135
+ end
106
136
  end
107
137
  end
@@ -0,0 +1,55 @@
1
+ module SpreeCmCommissioner
2
+ # The rate plan a line item is sold on. Without one, the line item sells at the variant's own price.
3
+ module LineItemRatePlannable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ belongs_to :rate_plan, class_name: 'SpreeCmCommissioner::RatePlan', optional: true, inverse_of: :line_items
8
+
9
+ validate :ensure_rate_plan_sellable, if: -> { rate_plan.present? && rate_or_dates_changed? }
10
+ validate :ensure_rate_open, if: :rate_or_dates_changed?
11
+ end
12
+
13
+ private
14
+
15
+ # Rechecked when the dates move too: the new nights are priced from the same rate plan, and one without a
16
+ # price would sell for nothing. Unchanged bookings aren't, so archiving a rate plan doesn't break them.
17
+ def rate_or_dates_changed?
18
+ new_record? || rate_plan_id_changed? || from_date_changed? || to_date_changed?
19
+ end
20
+
21
+ # One unit's price on `date`, or for products without dates when `date` is nil.
22
+ def unit_price_amount(currency, base_price, date: nil)
23
+ return rate_plan.price_for_date(date: date, currency: currency) if rate_plan.present?
24
+
25
+ price = variant.price_for_date(date: date, currency: currency) if date.present?
26
+ (price || base_price).amount
27
+ end
28
+
29
+ # A client could otherwise send a cheaper rate plan from another variant, or one the vendor stopped
30
+ # selling. A date without a price (an independent rate plan missing one, or a fixed amount adjustment
31
+ # in another currency) would sell for nothing.
32
+ def ensure_rate_plan_sellable
33
+ if rate_plan.variant_id != variant_id || !rate_plan.active?
34
+ errors.add(:rate_plan, :invalid)
35
+ elsif rate_plan_price_missing?
36
+ errors.add(:rate_plan, :price_missing)
37
+ end
38
+ end
39
+
40
+ # A channel manager or the admin can close one rate on a date (RateRestriction) while the room stays open for
41
+ # the others, so the room's own availability check doesn't catch it. Without a rate plan, it's the base rate.
42
+ def ensure_rate_open
43
+ dates = date_range
44
+ return if dates.empty?
45
+
46
+ closed = SpreeCmCommissioner::RateClosings.new(variant_ids: [variant_id], dates: dates).closed?(variant_id, rate_plan)
47
+ errors.add(:base, :rate_closed) if closed
48
+ end
49
+
50
+ # Products without dates have one price to check, the rate plan's base price.
51
+ def rate_plan_price_missing?
52
+ rate_plan.price_for_dates(dates: date_range.presence || [nil], currency: currency).nil?
53
+ end
54
+ end
55
+ end