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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/assets/javascripts/spree_cm_commissioner/backend.js +0 -1
- data/app/assets/stylesheets/spree_cm_commissioner/backend/calendar.scss +0 -39
- data/app/controllers/concerns/spree/admin/rates_availability_cells.rb +16 -0
- data/app/controllers/concerns/spree/api/v2/product_list_includes_decorator.rb +1 -0
- data/app/controllers/spree/admin/guests_controller.rb +2 -1
- data/app/controllers/spree/admin/integrations_controller.rb +16 -7
- data/app/controllers/spree/admin/inventory_items_controller.rb +33 -62
- data/app/controllers/spree/admin/product_option_types_controller.rb +88 -0
- data/app/controllers/spree/admin/rate_plans_controller.rb +125 -0
- data/app/controllers/spree/admin/rates_availabilities_controller.rb +142 -0
- data/app/controllers/spree/admin/stock_managements_controller.rb +1 -53
- data/app/controllers/spree/api/switch_cm/base_controller.rb +109 -0
- data/app/controllers/spree/api/switch_cm/tokens_controller.rb +68 -0
- data/app/controllers/spree/api/switch_cm/xml/availabilities_controller.rb +39 -0
- data/app/controllers/spree/api/switch_cm/xml/base_controller.rb +37 -0
- data/app/controllers/spree/api/switch_cm/xml/reservation_summaries_controller.rb +28 -0
- data/app/controllers/spree/api/switch_cm/xml/reservations_controller.rb +57 -0
- data/app/controllers/spree/api/switch_cm/xml/room_rate_availabilities_controller.rb +56 -0
- data/app/controllers/spree/api/switch_cm/xml/room_rates_controller.rb +45 -0
- data/app/controllers/spree/api/v2/operator/guests_controller.rb +1 -1
- data/app/controllers/spree/api/v2/storefront/accommodations/variants_controller.rb +31 -5
- data/app/controllers/spree/api/v2/storefront/inventory_items_controller.rb +2 -2
- data/app/controllers/spree/api/v2/tenant/intercity_taxi/draft_orders_controller.rb +0 -1
- data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +1 -1
- data/app/finders/spree_cm_commissioner/accommodations/find.rb +10 -8
- data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +5 -11
- data/app/finders/spree_cm_commissioner/rates_availabilities/find_bookings.rb +116 -0
- data/app/helpers/spree_cm_commissioner/admin/inventory_items_helper.rb +36 -0
- data/app/helpers/spree_cm_commissioner/admin/rates_availabilities_helper.rb +247 -0
- data/app/interactors/spree_cm_commissioner/intercity_taxi_order_creator.rb +0 -1
- data/app/jobs/spree_cm_commissioner/option_type_variants_public_metadata_updater_job.rb +1 -0
- data/app/jobs/spree_cm_commissioner/option_value_variants_public_metadata_updater_job.rb +1 -0
- data/app/jobs/spree_cm_commissioner/product_variants_public_metadata_updater_job.rb +12 -0
- data/app/models/concerns/spree_cm_commissioner/kyc_bitwise.rb +4 -0
- data/app/models/concerns/spree_cm_commissioner/line_item_durationable.rb +1 -10
- data/app/models/concerns/spree_cm_commissioner/line_item_guests_concern.rb +33 -3
- data/app/models/concerns/spree_cm_commissioner/line_item_rate_plannable.rb +55 -0
- data/app/models/concerns/spree_cm_commissioner/line_item_transitable.rb +0 -16
- data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +22 -4
- data/app/models/concerns/spree_cm_commissioner/rate_plan_options_concern.rb +62 -0
- data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +12 -0
- data/app/models/concerns/spree_cm_commissioner/variant_rate_plannable.rb +81 -0
- data/app/models/spree_cm_commissioner/address_decorator.rb +2 -0
- data/app/models/spree_cm_commissioner/configuration.rb +0 -6
- data/app/models/spree_cm_commissioner/exports/import_order_guests_csv.rb +2 -1
- data/app/models/spree_cm_commissioner/exports/operator_guest_json_gzip.rb +8 -2
- data/app/models/spree_cm_commissioner/guest.rb +1 -1
- data/app/models/spree_cm_commissioner/integration.rb +15 -0
- data/app/models/spree_cm_commissioner/integrations/switch_cm.rb +96 -0
- data/app/models/spree_cm_commissioner/inventory_item.rb +32 -3
- data/app/models/spree_cm_commissioner/line_item_decorator.rb +7 -7
- data/app/models/spree_cm_commissioner/maintenance_tasks/cache_invalidation.rb +20 -4
- data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +3 -2
- data/app/models/spree_cm_commissioner/option_type_decorator.rb +2 -1
- data/app/models/spree_cm_commissioner/option_value_decorator.rb +3 -0
- data/app/models/spree_cm_commissioner/option_value_rate_plan.rb +12 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/price_decorator.rb +13 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +18 -2
- data/app/models/spree_cm_commissioner/product_option_type_decorator.rb +42 -0
- data/app/models/spree_cm_commissioner/rate_plan.rb +139 -0
- data/app/models/spree_cm_commissioner/rate_plan_options.rb +36 -0
- data/app/models/spree_cm_commissioner/rate_restriction.rb +45 -0
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +5 -2
- data/app/models/spree_cm_commissioner/variant_decorator.rb +36 -5
- data/app/models/spree_cm_commissioner/variant_options.rb +3 -2
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +1 -0
- data/app/overrides/spree/admin/option_types/_form/checkbox.html.erb.deface +0 -8
- data/app/overrides/spree/admin/option_types/_form/name.html.erb.deface +1 -1
- data/app/overrides/spree/admin/products/_filters/vendor_filter.html.erb.deface +6 -0
- data/app/overrides/spree/admin/shared/_product_tabs/rates_availabilities.html.erb.deface +8 -0
- data/app/overrides/spree/admin/shared/_product_tabs/stock_managements.html.erb.deface +1 -1
- data/app/overrides/spree/admin/variants/index/discontinued_badge.html.erb.deface +13 -0
- data/app/overrides/spree/admin/variants/index/option_types_empty_state_link.html.erb.deface +3 -0
- data/app/overrides/spree/admin/variants/index/option_types_summary.html.erb.deface +24 -0
- data/app/presenters/spree/variants/visible_options_presenter.rb +34 -1
- data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +40 -22
- data/app/queries/spree_cm_commissioner/single_leg_trips_query.rb +28 -14
- data/app/request_schemas/spree_cm_commissioner/intercity_taxi_draft_order_update_schema.rb +0 -1
- data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +2 -0
- data/app/serializers/spree/v2/storefront/option_type_serializer_decorator.rb +1 -1
- data/app/serializers/spree/v2/storefront/product_option_type_serializer.rb +15 -0
- data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +15 -0
- data/app/serializers/spree/v2/tenant/line_item_serializer.rb +1 -1
- data/app/serializers/spree/v2/tenant/option_type_serializer.rb +1 -1
- data/app/serializers/spree_cm_commissioner/v2/operator/dynamic_field_option_serializer.rb +9 -0
- data/app/serializers/spree_cm_commissioner/v2/operator/dynamic_field_serializer.rb +11 -0
- data/app/serializers/spree_cm_commissioner/v2/operator/guest_dynamic_field_serializer.rb +14 -0
- data/app/serializers/spree_cm_commissioner/v2/operator/guest_serializer.rb +1 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/accommodation_variant_serializer.rb +44 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/inventory_item_serializer.rb +4 -4
- data/app/serializers/spree_cm_commissioner/v2/storefront/rate_plan_serializer.rb +16 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_item_availability.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accept_push.rb +111 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accommodation_lock.rb +43 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/apply_updates.rb +133 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/availability_snapshot.rb +127 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/inventory_update.rb +35 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/pushed_nights.rb +82 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/unseen_room_nights.rb +49 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/validate_updates.rb +153 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/feed.rb +209 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/mark_seen.rb +62 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/reservation.rb +171 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/room_catalog.rb +88 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_manager.rb +16 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/full_sync_strategy.rb +39 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/incremental_sync_strategy.rb +51 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/availability_parser.rb +103 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/push_response.rb +45 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/reservation_serializer.rb +83 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/response_builder.rb +19 -0
- data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_item_availability.rb +1 -1
- data/app/services/spree_cm_commissioner/intercity_taxi_order/update.rb +1 -3
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb +4 -0
- data/app/services/spree_cm_commissioner/inventory_items/reset.rb +4 -0
- data/app/services/spree_cm_commissioner/rate_plans/save.rb +65 -0
- data/app/services/spree_cm_commissioner/rates_availabilities/bulk_update.rb +225 -0
- data/app/services/spree_cm_commissioner/reschedule_requests/split_line_item.rb +5 -0
- data/app/services/spree_cm_commissioner/room_types/create.rb +87 -0
- data/app/services/spree_cm_commissioner/room_types/update_properties.rb +32 -0
- data/app/services/spree_cm_commissioner/room_types.rb +40 -0
- data/app/services/spree_cm_commissioner/route_prices/update_price_range.rb +3 -1
- data/app/services/spree_cm_commissioner/trips/search.rb +14 -7
- data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +5 -1
- data/app/views/spree/admin/guests/_check_in_status.html.erb +28 -22
- data/app/views/spree/admin/guests/edit.html.erb +29 -26
- data/app/views/spree/admin/integrations/_form.html.erb +4 -0
- data/app/views/spree/admin/integrations/_switch_cm_fields.html.erb +53 -0
- data/app/views/spree/admin/inventory_items/_details.html.erb +56 -0
- data/app/views/spree/admin/product_option_types/index.html.erb +79 -0
- data/app/views/spree/admin/rate_plans/_form.html.erb +134 -0
- data/app/views/spree/admin/rate_plans/_order_form.html.erb +37 -0
- data/app/views/spree/admin/rates_availabilities/_block.html.erb +34 -0
- data/app/views/spree/admin/rates_availabilities/_bookings.html.erb +52 -0
- data/app/views/spree/admin/rates_availabilities/_filters.html.erb +48 -0
- data/app/views/spree/admin/rates_availabilities/_legend.html.erb +27 -0
- data/app/views/spree/admin/rates_availabilities/_list_pages.html.erb +14 -0
- data/app/views/spree/admin/rates_availabilities/_modal_header.html.erb +11 -0
- data/app/views/spree/admin/rates_availabilities/_names.html.erb +62 -0
- data/app/views/spree/admin/rates_availabilities/_script.html.erb +676 -0
- data/app/views/spree/admin/rates_availabilities/_styles.html.erb +159 -0
- data/app/views/spree/admin/rates_availabilities/index.html.erb +112 -0
- data/app/views/spree/admin/shared/sub_menu/_homepage.html.erb +0 -1
- data/app/views/spree/admin/stock_managements/_header.html.erb +0 -14
- data/app/views/spree/admin/stock_managements/_inventory_item_stats.html.erb +28 -0
- data/app/views/spree/admin/stock_managements/index.html.erb +1 -56
- data/config/initializers/spree_permitted_attributes.rb +1 -0
- data/config/locales/en.yml +241 -3
- data/config/locales/km.yml +241 -4
- data/config/routes.rb +36 -17
- data/db/migrate/20260928000000_add_oauth_application_id_to_cm_integrations.rb +5 -0
- data/db/migrate/20260929000000_create_cm_rate_plans.rb +22 -0
- data/db/migrate/20260929000001_add_rate_plan_to_spree_prices.rb +24 -0
- data/db/migrate/20260929000002_add_rate_plan_to_spree_line_items.rb +7 -0
- data/db/migrate/20260929000003_create_cm_option_value_rate_plans.rb +16 -0
- data/db/migrate/20261009000000_create_cm_rate_restrictions.rb +29 -0
- data/db/migrate/20261013000001_add_hidden_and_presentation_to_spree_product_option_types.rb +6 -0
- data/db/migrate/20261013000002_move_hidden_from_spree_option_types_to_product_option_types.rb +36 -0
- data/db/migrate/20261016000001_add_index_to_spree_line_items_number.rb +8 -0
- data/lib/spree_cm_commissioner/booking_dates.rb +43 -0
- data/lib/spree_cm_commissioner/calendar_event.rb +0 -25
- data/lib/spree_cm_commissioner/rate.rb +74 -0
- data/lib/spree_cm_commissioner/rate_closings.rb +48 -0
- data/lib/spree_cm_commissioner/rates_availability_grid.rb +113 -0
- data/lib/spree_cm_commissioner/stay_price.rb +30 -0
- data/lib/spree_cm_commissioner/stay_prices.rb +58 -0
- data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/option_type_factory.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/option_value_rate_plan_factory.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/rate_plan_factory.rb +14 -0
- data/lib/spree_cm_commissioner/test_helper/factories/variant_factory.rb +9 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- data/lib/spree_cm_commissioner.rb +6 -0
- metadata +94 -32
- data/app/assets/javascripts/spree_cm_commissioner/calendar.js +0 -59
- data/app/controllers/spree/admin/calendars/base_controller.rb +0 -27
- data/app/controllers/spree/admin/calendars/orders_controller.rb +0 -27
- data/app/controllers/spree/admin/homepage_feed_controller.rb +0 -36
- data/app/controllers/spree/admin/metafields_controller.rb +0 -69
- data/app/models/concerns/spree_cm_commissioner/metafield.rb +0 -15
- data/app/models/spree_cm_commissioner/homepage_feed.rb +0 -4
- data/app/overrides/spree/admin/shared/_main_menu/sidebar_calendars.html.erb.deface +0 -5
- data/app/overrides/spree/admin/shared/_product_tabs/metafields.html.erb.deface +0 -15
- data/app/serializers/spree_cm_commissioner/v2/storefront/taxon_include_product_serializer.rb +0 -13
- data/app/serializers/spree_cm_commissioner/v2/storefront/vendor_include_product_serializer.rb +0 -14
- data/app/services/spree_cm_commissioner/feed/taxon_include_product.rb +0 -20
- data/app/services/spree_cm_commissioner/feed/taxon_product.rb +0 -54
- data/app/services/spree_cm_commissioner/feed/vendor_include_product.rb +0 -20
- data/app/services/spree_cm_commissioner/feed/vendor_product.rb +0 -52
- data/app/services/spree_cm_commissioner/homepage_data_loader.rb +0 -112
- data/app/services/spree_cm_commissioner/metafields/product_metadata_service.rb +0 -32
- data/app/views/shared/_calendar.html.erb +0 -27
- data/app/views/shared/calendar/_order_popover.html.erb +0 -18
- data/app/views/shared/calendar/_orders_sidebar.html.erb +0 -41
- data/app/views/spree/admin/calendars/orders/index.html.erb +0 -22
- data/app/views/spree/admin/homepage_feed/_form.html.erb +0 -24
- data/app/views/spree/admin/homepage_feed/edit.html.erb +0 -17
- data/app/views/spree/admin/inventory_items/prices.html.erb +0 -43
- data/app/views/spree/admin/inventory_items/stocks.html.erb +0 -34
- data/app/views/spree/admin/metafields/_private_metafields_form.html.erb +0 -71
- data/app/views/spree/admin/metafields/_public_metafields_form.html.erb +0 -64
- data/app/views/spree/admin/metafields/edit.html.erb +0 -27
- data/app/views/spree/admin/shared/sub_menu/_calendar.html.erb +0 -3
- 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
|
-
.
|
|
18
|
-
.
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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?
|
|
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
|