spree_cm_commissioner 2.14.2 → 2.14.4

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +4 -6
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +76 -70
  5. data/app/controllers/spree/admin/api_caches_controller.rb +26 -0
  6. data/app/controllers/spree/admin/base_controller_decorator.rb +0 -14
  7. data/app/controllers/spree/admin/guests_controller.rb +43 -4
  8. data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +26 -5
  9. data/app/jobs/spree_cm_commissioner/audit_events/cleanup_expired_job.rb +18 -0
  10. data/app/models/concerns/spree_cm_commissioner/vendor_preference.rb +1 -1
  11. data/app/models/spree_cm_commissioner/audit_event.rb +5 -0
  12. data/app/overrides/spree/admin/menus/index/clear_cache_button.html.erb.deface +4 -0
  13. data/app/overrides/spree/admin/payment_methods/index/clear_cache_button.html.erb.deface +6 -0
  14. data/app/overrides/spree/admin/products/edit/clear_cache_button.html.erb.deface +1 -1
  15. data/app/overrides/spree/admin/taxons/edit/clear_cache_button.html.erb.deface +1 -4
  16. data/app/overrides/spree/admin/vendors/edit/clear_cache_button.html.erb.deface +5 -0
  17. data/app/serializers/spree/api/v2/base_serializer_decorator.rb +24 -0
  18. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +39 -87
  19. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +61 -0
  20. data/app/services/spree_cm_commissioner/audit_events/cleanup_expired.rb +58 -0
  21. data/app/services/spree_cm_commissioner/audit_logger.rb +1 -1
  22. data/app/services/spree_cm_commissioner/guests/build_dynamic_field_attributes.rb +34 -0
  23. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +45 -128
  24. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/seat_layout.rb +36 -148
  25. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +103 -35
  26. data/app/services/spree_cm_commissioner/order/guests/update.rb +2 -24
  27. data/app/services/spree_cm_commissioner/rates_availabilities/bulk_update.rb +14 -8
  28. data/app/services/spree_cm_commissioner/seat_layouts/grid.rb +56 -0
  29. data/app/views/spree/admin/guests/_dynamic_fields.html.erb +67 -0
  30. data/app/views/spree/admin/guests/_form.html.erb +2 -0
  31. data/app/views/spree/admin/homepage_background/index.html.erb +1 -1
  32. data/app/views/spree/admin/homepage_section/edit.html.erb +0 -1
  33. data/app/views/spree/admin/homepage_section/index.html.erb +1 -1
  34. data/app/views/spree/admin/inventory_items/_details.html.erb +16 -0
  35. data/app/views/spree/admin/rates_availabilities/_script.html.erb +49 -7
  36. data/app/views/spree/admin/rates_availabilities/index.html.erb +4 -0
  37. data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -0
  38. data/config/locales/en.yml +25 -1
  39. data/config/locales/km.yml +25 -1
  40. data/config/routes.rb +5 -1
  41. data/lib/spree_cm_commissioner/version.rb +1 -1
  42. metadata +13 -1
@@ -1,5 +1,6 @@
1
1
  module SpreeCmCommissioner
2
2
  module ApiCaches
3
+ # Clears one record's cached API responses. Every record of a model goes through InvalidateAll.
3
4
  class Invalidate
4
5
  prepend ::Spree::ServiceModule::Base
5
6
 
@@ -16,17 +17,39 @@ module SpreeCmCommissioner
16
17
  'SpreeCmCommissioner::VotingSession' => SpreeCmCommissioner::VotingSession
17
18
  }.freeze
18
19
 
19
- # eg. 'Spree::Product', 123 or 'Spree::Product', nil
20
- def call(model:, id: nil)
20
+ # CloudFront bills per path and only allows `*` at the end, so one prefix per namespace covers
21
+ # every segment's homepage_sections and homepage_background, plus homepage_data, instead of a
22
+ # path per BIT_SEGMENT key.
23
+ HOMEPAGE_SECTION_PATTERNS = [
24
+ '/api/v2/storefront/homepage*',
25
+ '/api/v2/tenant/homepage*'
26
+ ].freeze
27
+
28
+ HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
29
+
30
+ # Deliberately broad, and identical for single-record and all-record
31
+ # invalidation: a payment method is either vendor-owned or store-level, and a
32
+ # store-level one is served for every vendor through the fallback in
33
+ # SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
34
+ # tell which vendors are affected.
35
+ #
36
+ # vendor_id is a query param, so the trailing `*` is what clears every
37
+ # vendor's cached variant of this path in one go.
38
+ #
39
+ # Payment methods change a handful of times a year, so a broad invalidation
40
+ # is cheap.
41
+ PAYMENT_METHOD_PATTERNS = [
42
+ '/api/v2/tenant/payment_methods*',
43
+ '/api/v2/storefront/payment_methods*'
44
+ ].freeze
45
+
46
+ # eg. 'Spree::Product', 123
47
+ def call(model:, id:)
21
48
  model_class = SUPPORTED_MODELS[model]
22
49
 
23
50
  return failure(nil, 'Model is not supported') if model_class.nil?
24
51
 
25
- if id.present?
26
- invalidate_single_record(model_class: model_class, id: id)
27
- else
28
- invalidate_all_records(model_class: model_class)
29
- end
52
+ invalidate_single_record(model_class: model_class, id: id)
30
53
  end
31
54
 
32
55
  # eg. 'Spree::Product', 123 => ['/api/v2/tenant/products/123', '/api/v2/tenant/products/slug-of-product', ...]
@@ -38,7 +61,10 @@ module SpreeCmCommissioner
38
61
 
39
62
  refresh_record_cache_key(record)
40
63
 
41
- api_patterns = single_api_pattern[record.class.name].map do |pattern|
64
+ # The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
65
+ # model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
66
+ patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
67
+ api_patterns = patterns.map do |pattern|
42
68
  pattern.gsub(/:(\w+)/) do
43
69
  key = Regexp.last_match(1)
44
70
  record.public_send(key).to_s
@@ -50,26 +76,13 @@ module SpreeCmCommissioner
50
76
  success(record: record, api_patterns: api_patterns)
51
77
  end
52
78
 
53
- # eg. 'Spree::Product' => ['/api/v2/tenant/products/*', '/api/v2/storefront/products/*']
54
- def invalidate_all_records(model_class:)
55
- model_class.find_each do |record|
56
- refresh_record_cache_key(record)
57
- end
58
-
59
- enqueue_invalidate(invalidate_uris[model_class.name])
60
-
61
- success(api_patterns: invalidate_uris[model_class.name])
62
- end
63
-
64
79
  def single_api_pattern
65
80
  {
66
- 'SpreeCmCommissioner::HomepageSection' => homepage_section_patterns,
67
- 'SpreeCmCommissioner::HomepageBackground' => homepage_background_patterns,
81
+ 'SpreeCmCommissioner::HomepageSection' => HOMEPAGE_SECTION_PATTERNS,
82
+ 'SpreeCmCommissioner::HomepageBackground' => HOMEPAGE_BACKGROUND_PATTERNS,
68
83
  'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/:id*'],
69
- 'Spree::Menu' => [
70
- '/api/v2/storefront/menus/:id*',
71
- '/api/v2/storefront/homepage_data*'
72
- ],
84
+ # The app reads menus from homepage_data; nothing calls /menus any more.
85
+ 'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
73
86
  'Spree::Taxon' => [
74
87
  '/api/v2/tenant/taxons/:id*',
75
88
  '/api/v2/tenant/taxons/:permalink*',
@@ -92,50 +105,12 @@ module SpreeCmCommissioner
92
105
  '/api/v2/storefront/accommodations/:id*',
93
106
  '/api/v2/storefront/accommodations/:slug*'
94
107
  ],
95
- 'Spree::PaymentMethod' => payment_method_patterns
96
- }
97
- end
98
-
99
- def invalidate_uris
100
- {
101
- 'SpreeCmCommissioner::HomepageSection' => homepage_section_patterns,
102
- 'SpreeCmCommissioner::HomepageBackground' => homepage_background_patterns,
103
- 'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/*'],
104
- 'Spree::Menu' => ['/api/v2/storefront/menus/*'],
105
- 'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
106
- 'Spree::Product' => [
107
- '/api/v2/tenant/products/*',
108
- '/api/v2/storefront/products/*'
109
- ],
110
- 'Spree::Vendor' => [
111
- '/api/v2/tenant/vendors/*',
112
- '/api/v2/storefront/vendors/*',
113
- '/api/v2/storefront/accommodations/*'
114
- ],
115
- 'Spree::PaymentMethod' => payment_method_patterns
108
+ 'Spree::PaymentMethod' => PAYMENT_METHOD_PATTERNS
116
109
  }
117
110
  end
118
111
 
119
112
  private
120
113
 
121
- # Deliberately broad, and identical for single-record and all-record
122
- # invalidation: a payment method is either vendor-owned or store-level, and a
123
- # store-level one is served for every vendor through the fallback in
124
- # SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
125
- # tell which vendors are affected.
126
- #
127
- # vendor_id is a query param, so the trailing `*` is what clears every
128
- # vendor's cached variant of this path in one go.
129
- #
130
- # Payment methods change a handful of times a year, so a broad invalidation
131
- # is cheap.
132
- def payment_method_patterns
133
- [
134
- '/api/v2/tenant/payment_methods*',
135
- '/api/v2/storefront/payment_methods*'
136
- ]
137
- end
138
-
139
114
  # eg. updated_at: Fri, 26 Dec 2025 10:40:56.505450000 +07 +07:00
140
115
  def refresh_record_cache_key(record)
141
116
  # rubocop:disable Rails/SkipsModelValidations
@@ -147,29 +122,6 @@ module SpreeCmCommissioner
147
122
  def enqueue_invalidate(patterns)
148
123
  SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: patterns)
149
124
  end
150
-
151
- # Every key of BIT_SEGMENT (general, ticket, ..., ferry).
152
- def homepage_segment
153
- SpreeCmCommissioner::HomepageSectionBitwise::BIT_SEGMENT.keys
154
- end
155
-
156
- def homepage_section_patterns
157
- homepage_segment.flat_map do |segment|
158
- [
159
- "/api/v2/storefront/homepage/#{segment}/homepage_sections*",
160
- "/api/v2/tenant/homepage/#{segment}/homepage_sections*"
161
- ]
162
- end
163
- end
164
-
165
- def homepage_background_patterns
166
- patterns = homepage_segment.map do |segment|
167
- "/api/v2/storefront/homepage/#{segment}/homepage_background*"
168
- end
169
-
170
- patterns << '/api/v2/storefront/homepage_data*'
171
- patterns
172
- end
173
125
  end
174
126
  end
175
127
  end
@@ -0,0 +1,61 @@
1
+ module SpreeCmCommissioner
2
+ module ApiCaches
3
+ # Clears every record of a model for one storefront. `tenant` is required so a clear-all never
4
+ # pays for both namespaces by accident: nil is the BookMe+ storefront (/api/v2/storefront/
5
+ # paths), a SpreeCmCommissioner::Tenant is that tenant's API (/api/v2/tenant/ paths).
6
+ #
7
+ # Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
8
+ # today; taking the record keeps callers the same if tenant paths ever carry it.
9
+ #
10
+ # eg. InvalidateAll.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: nil)
11
+ class InvalidateAll
12
+ prepend ::Spree::ServiceModule::Base
13
+
14
+ TENANT_PREFIX = '/api/v2/tenant/'.freeze
15
+
16
+ API_PATTERNS = {
17
+ 'SpreeCmCommissioner::HomepageSection' => Invalidate::HOMEPAGE_SECTION_PATTERNS,
18
+ 'SpreeCmCommissioner::HomepageBackground' => Invalidate::HOMEPAGE_BACKGROUND_PATTERNS,
19
+ 'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/*'],
20
+ 'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
21
+ 'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
22
+ 'Spree::Product' => [
23
+ '/api/v2/tenant/products/*',
24
+ '/api/v2/storefront/products/*'
25
+ ],
26
+ 'Spree::Vendor' => [
27
+ '/api/v2/tenant/vendors/*',
28
+ '/api/v2/storefront/vendors/*',
29
+ '/api/v2/storefront/accommodations/*'
30
+ ],
31
+ 'Spree::PaymentMethod' => Invalidate::PAYMENT_METHOD_PATTERNS
32
+ }.freeze
33
+
34
+ def call(model:, tenant:)
35
+ model_class = Invalidate::SUPPORTED_MODELS[model] if API_PATTERNS.key?(model)
36
+ return failure(nil, 'Model is not supported') if model_class.nil?
37
+ return failure(nil, 'tenant must be a SpreeCmCommissioner::Tenant or nil') unless tenant.nil? || tenant.is_a?(SpreeCmCommissioner::Tenant)
38
+
39
+ refresh_records_cache_key(model_class, tenant)
40
+
41
+ api_patterns = API_PATTERNS[model].select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }
42
+
43
+ # Empty when the model has no API in this namespace, e.g. homepage backgrounds for a tenant,
44
+ # and CloudFront rejects an empty batch.
45
+ SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: api_patterns) if api_patterns.any?
46
+
47
+ success(api_patterns: api_patterns)
48
+ end
49
+
50
+ private
51
+
52
+ # updated_at keys Spree's serializer and collection caches, so only this storefront's records
53
+ # are touched when the model is tenant-scoped.
54
+ def refresh_records_cache_key(model_class, tenant)
55
+ scope = model_class.column_names.include?('tenant_id') ? model_class.where(tenant_id: tenant&.id) : model_class.all
56
+
57
+ scope.find_each(&:touch)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,58 @@
1
+ module SpreeCmCommissioner
2
+ module AuditEvents
3
+ class CleanupExpired
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ BATCH_SIZE = 1_000
7
+ TIME_BUDGET = 30.seconds
8
+
9
+ # Deletes expired audit events chunk by chunk until none are left or the time budget is spent, so a large backlog
10
+ # never pins a Sidekiq thread past its shutdown window. CleanupExpiredJob schedules the next run while
11
+ # `more_remaining` is true.
12
+ #
13
+ # Safe to delete without callbacks: nothing references cm_audit_events (checked in cm-market-server/db/schema.rb).
14
+ # Errors are not rescued on purpose: a failed drain must surface to Sidekiq (retry + alert), not end quietly.
15
+ def call(batch_size: BATCH_SIZE, time_budget: TIME_BUDGET)
16
+ started_at = monotonic_now
17
+ deleted_count = 0
18
+ batches = 0
19
+ more_remaining = false
20
+
21
+ loop do
22
+ deleted = delete_expired_chunk(batch_size)
23
+ deleted_count += deleted
24
+ batches += 1 if deleted.positive?
25
+
26
+ break if deleted < batch_size
27
+
28
+ if monotonic_now - started_at >= time_budget.to_f
29
+ more_remaining = true
30
+ break
31
+ end
32
+ end
33
+
34
+ elapsed_ms = ((monotonic_now - started_at) * 1000).round
35
+ log_completion(deleted_count: deleted_count, batches: batches, elapsed_ms: elapsed_ms, more_remaining: more_remaining)
36
+
37
+ success(deleted_count: deleted_count, batches: batches, more_remaining: more_remaining)
38
+ end
39
+
40
+ private
41
+
42
+ # One `DELETE ... WHERE id IN (SELECT id ... LIMIT n)`: no rows are loaded into Ruby.
43
+ def delete_expired_chunk(batch_size)
44
+ SpreeCmCommissioner::AuditEvent
45
+ .where(id: SpreeCmCommissioner::AuditEvent.expired.limit(batch_size).select(:id))
46
+ .delete_all
47
+ end
48
+
49
+ def monotonic_now
50
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ end
52
+
53
+ def log_completion(**data)
54
+ CmAppLogger.log(label: 'SpreeCmCommissioner::AuditEvents::CleanupExpired completed', data: data)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -30,7 +30,7 @@ module SpreeCmCommissioner
30
30
  attempt: 0,
31
31
  retention_days: nil
32
32
  )
33
- retention_until = (retention_days || 90).days.from_now.to_date
33
+ retention_until = (retention_days || SpreeCmCommissioner::AuditEvent::DEFAULT_RETENTION_DAYS).days.from_now.to_date
34
34
 
35
35
  SpreeCmCommissioner::AuditEventJob.perform_later(
36
36
  event_type: event_type,
@@ -0,0 +1,34 @@
1
+ module SpreeCmCommissioner
2
+ module Guests
3
+ # Option-based fields (checkbox/radio/selection) become one GuestDynamicField row per selected
4
+ # option id; Guests::Update's own auto-cleanup destroys rows for deselected options. Fields not
5
+ # enabled on the guest's product are dropped.
6
+ class BuildDynamicFieldAttributes
7
+ def self.call(guest:, dynamic_fields:)
8
+ new(guest, dynamic_fields).call
9
+ end
10
+
11
+ def initialize(guest, dynamic_fields)
12
+ @guest = guest
13
+ @dynamic_fields = dynamic_fields
14
+ end
15
+
16
+ def call
17
+ return [] if @dynamic_fields.blank?
18
+
19
+ enabled_fields = @guest.line_item.product.dynamic_fields.index_by(&:id)
20
+
21
+ @dynamic_fields.flat_map do |dynamic_field_id, value|
22
+ dynamic_field = enabled_fields[dynamic_field_id.to_s.to_i]
23
+ next [] unless dynamic_field
24
+
25
+ if dynamic_field.requires_dynamic_field_options?
26
+ Array(value).compact_blank.map { |option_id| { dynamic_field_id: dynamic_field.id, dynamic_field_option_id: option_id } }
27
+ else
28
+ [{ dynamic_field_id: dynamic_field.id, value: value }]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -116,34 +116,31 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
116
116
 
117
117
  # vehicle_type is a shared BookMeBus category (many different physical buses/trips can
118
118
  # report the same vehicle_type_id), but the seat map we just fetched is specific to one
119
- # trip's stop segment. Re-writing this shared template on every poll means whichever trip
120
- # synced last silently overwrites (and, via cleanup_stale_blocks!, deletes seats from) the
121
- # template every other trip of that vehicle_type duplicates from on creation. Only seed it
122
- # once; each trip's own duplicated SeatLayout is what stays continuously in sync afterward
123
- # (see duplicate_to_trip_if_needed!/sync_trip_seat_layout!).
119
+ # trip's stop segment. Adding or deleting seats on this shared template on every poll would let
120
+ # whichever trip synced last rewrite the template every other trip duplicates from, so its seats
121
+ # are only created once. Its geometry (sections, padding, spacing) is re-applied every time though,
122
+ # so templates synced before the builder standard heal on their next sync.
124
123
  if seat_layout.persisted?
124
+ apply_builder_layout!(seat_layout, external_seat_layout, create_missing: false)
125
125
  backfill_vehicle_type_seats!(vehicle_type, seat_layout)
126
126
  return seat_layout
127
127
  end
128
128
 
129
- # Calculate dimensions based on layout
130
- dimensions = calculate_layout_dimensions(external_seat_layout)
129
+ builder_layout = external_seat_layout.builder_layout
131
130
 
132
- # Update seat layout attributes
133
131
  @sync_result.track(:seat_layout, seat_layout) do |tracker|
134
132
  seat_layout.assign_attributes(
135
133
  name: build_seat_layout_name(vehicle_type, external_seat_layout),
136
134
  status: :active,
137
- width: dimensions[:width],
138
- height: dimensions[:height],
135
+ width: builder_layout[:width],
136
+ height: builder_layout[:height],
139
137
  total_seats: external_seat_layout.seats.count,
140
138
  layoutable: vehicle_type
141
139
  )
142
140
  tracker.save_if_changed!(seat_layout)
143
141
  end
144
142
 
145
- # Sync blocks (seats) for this layout - no variant for templates
146
- sync_blocks!(seat_layout: seat_layout, external_seat_layout: external_seat_layout, variant: nil)
143
+ apply_builder_layout!(seat_layout, external_seat_layout, create_missing: true)
147
144
 
148
145
  backfill_vehicle_type_seats!(vehicle_type, seat_layout)
149
146
 
@@ -219,6 +216,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
219
216
 
220
217
  return if trip_seat_layout.blank?
221
218
 
219
+ apply_builder_layout!(trip_seat_layout, external_seat_layout, create_missing: false)
220
+
222
221
  # Assign variants to sellable blocks
223
222
  trip_seat_layout.blocks.each do |block|
224
223
  next unless block.sellable?
@@ -245,134 +244,52 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
245
244
  "#{vehicle_type.name} Layout (#{seat_count} seats)"
246
245
  end
247
246
 
248
- # Calculate layout dimensions based on external seat layout
247
+ # Lays the seats out exactly as the BookMe+ seat layout builder would (Resources::SeatLayout#builder_layout): one
248
+ # section per BookMeBus layer, named with the layer key (UnstockInventory sends it back as the reservation layer),
249
+ # seats matched by label so block ids, variants and reserved blocks survive. Idempotent: positions are a pure
250
+ # function of BookMeBus's grid, so a re-sync writes nothing once a layout matches.
249
251
  #
250
- # @param external_seat_layout [Resources::SeatLayout]
251
- # @return [Hash] Hash with :width and :height keys
252
- def calculate_layout_dimensions(external_seat_layout)
253
- block_width = 50
254
- block_height = 50
255
- gap = 10
256
- padding = 200
257
- layer_gap = 100
258
-
259
- rows = external_seat_layout.row_count
260
- columns = external_seat_layout.column_count
261
- layer_count = external_seat_layout.primary_layout.keys.count
262
-
263
- # Layers (e.g. lower/upper deck) sit side by side, not stacked - see
264
- # Resources::SeatLayout#to_internal_blocks_attributes for the matching block placement.
265
- width = (layer_count * columns * (block_width + gap)) + ((layer_count - 1) * layer_gap) + padding
266
- height = (rows * (block_height + gap)) + padding
267
-
268
- { width: width, height: height }
269
- end
270
-
271
- # Sync blocks for a seat layout
272
- #
273
- # This method creates or updates Block records from the external seat layout.
274
- # It handles multi-layer layouts (e.g., double-decker buses) and assigns
275
- # coordinates based on row/column positions.
276
- #
277
- # @param seat_layout [SpreeCmCommissioner::SeatLayout] The internal seat layout
278
- # @param external_seat_layout [Resources::SeatLayout] External seat layout data
279
- # @param variant [Spree::Variant] Optional variant to assign to blocks
280
- def sync_blocks!(seat_layout:, external_seat_layout:, variant: nil)
281
- # Get block attributes from external layout
282
- blocks_attrs = external_seat_layout.to_internal_blocks_attributes(seat_layout)
283
-
284
- # Track existing blocks by label for updates
285
- existing_blocks = seat_layout.blocks.index_by(&:label)
286
- seat_sections_by_layer = build_seat_sections_by_layer(seat_layout, blocks_attrs)
287
-
288
- blocks_attrs.each do |block_attrs|
289
- label = block_attrs[:label]
290
- block = existing_blocks[label] || seat_layout.blocks.build
291
- section = seat_sections_by_layer[block_attrs.dig(:metadata, :layer)]
292
-
293
- @sync_result.track(:block, block) do |tracker|
294
- # Only assign variant to sellable blocks (seat, sleeping_seat, standing)
295
- block_variant = SpreeCmCommissioner::Block.sellable?(block_attrs[:block_type]) ? variant : nil
296
-
297
- block.assign_attributes(
298
- label: block_attrs[:label],
299
- block_type: block_attrs[:block_type],
300
- width: block_attrs[:width],
301
- height: block_attrs[:height],
302
- # cm-block renders position:absolute inside its parent cm-seat-section (also
303
- # position:absolute), so a sectioned block's x/y must be relative to the section's
304
- # own origin - not the canvas-absolute value every block starts with - or the section's
305
- # offset gets applied twice and seats render far outside their section.
306
- x: block_attrs[:x] - (section&.x || 0),
307
- y: block_attrs[:y] - (section&.y || 0),
308
- rotation: block_attrs[:rotation],
309
- seat_layout: seat_layout,
310
- seat_section: section,
311
- variant: block_variant
312
- )
313
-
314
- tracker.save_if_changed!(block)
252
+ # create_missing: true builds a new template's seats; false only re-positions seats that already exist (a shared
253
+ # template or a trip copy must not gain or lose seats from one trip's segment).
254
+ def apply_builder_layout!(seat_layout, external_seat_layout, create_missing:)
255
+ builder_layout = external_seat_layout.builder_layout
256
+ blocks_by_label = seat_layout.blocks.index_by(&:label)
257
+
258
+ builder_layout[:sections].each do |section_attrs|
259
+ section = sync_seat_section!(seat_layout, section_attrs)
260
+
261
+ section_attrs[:blocks].each do |block_attrs|
262
+ block = blocks_by_label[block_attrs[:label]] || (seat_layout.blocks.build if create_missing)
263
+ next if block.nil?
264
+
265
+ @sync_result.track(:block, block) do |tracker|
266
+ block.assign_attributes(
267
+ block_attrs.slice(:label, :block_type, :width, :height, :x, :y, :rotation).merge(seat_layout: seat_layout, seat_section: section)
268
+ )
269
+ tracker.save_if_changed!(block)
270
+ end
315
271
  end
316
272
  end
317
273
 
318
- # Remove blocks that no longer exist in the external layout
319
- cleanup_stale_blocks!(seat_layout: seat_layout, synced_labels: blocks_attrs.pluck(:label))
320
- end
274
+ remove_empty_stale_sections!(seat_layout, builder_layout[:sections].pluck(:name))
321
275
 
322
- # A single-layer bus (the overwhelming common case - "BUS" only) keeps its blocks
323
- # top-level (no seat_section), matching prior behavior exactly. Only a genuinely
324
- # multi-layer layout (double-decker: "BUS" + "UPPER_DECK") gets one SeatSection per
325
- # layer, so `block.seat_section&.name` reports the real deck instead of always
326
- # falling back to the 'BUS' default UnstockInventory uses when it's nil. A single layer's real name is
327
- # kept in the template mapping's `layers` payload instead - UnstockInventory#single_layer_name reads it.
328
- #
329
- # @return [Hash{String => SpreeCmCommissioner::SeatSection}] section per layer name
330
- def build_seat_sections_by_layer(seat_layout, blocks_attrs)
331
- attrs_by_layer = blocks_attrs.group_by { |attrs| attrs.dig(:metadata, :layer) }
332
- return {} if attrs_by_layer.size <= 1
333
-
334
- attrs_by_layer.each_with_object({}) do |(layer_name, attrs_for_layer), sections|
335
- next if layer_name.blank?
336
-
337
- sections[layer_name] = find_or_build_seat_section!(seat_layout, layer_name, attrs_for_layer)
276
+ @sync_result.track(:seat_layout, seat_layout) do |tracker|
277
+ seat_layout.assign_attributes(width: builder_layout[:width], height: builder_layout[:height])
278
+ tracker.save_if_changed!(seat_layout)
338
279
  end
339
280
  end
340
281
 
341
- # Section geometry is a bounding box around its layer's blocks so it renders sanely,
342
- # not just a placeholder - SeatSection requires positive width/height.
343
- def find_or_build_seat_section!(seat_layout, layer_name, attrs_for_layer)
344
- section = seat_layout.seat_sections.find_or_initialize_by(name: layer_name)
345
-
346
- min_x = attrs_for_layer.pluck(:x).min
347
- min_y = attrs_for_layer.pluck(:y).min
348
- max_x = attrs_for_layer.map { |a| a[:x] + a[:width] }.max
349
- max_y = attrs_for_layer.map { |a| a[:y] + a[:height] }.max
350
-
351
- section.assign_attributes(x: min_x, y: min_y, width: max_x - min_x, height: max_y - min_y, rotation: 0)
282
+ def sync_seat_section!(seat_layout, section_attrs)
283
+ section = seat_layout.seat_sections.find_or_initialize_by(name: section_attrs[:name])
284
+ section.assign_attributes(section_attrs.slice(:x, :y, :width, :height).merge(rotation: 0))
352
285
  section.save! if section.new_record? || section.changed?
353
286
  section
354
287
  end
355
288
 
356
- # Remove blocks that are no longer in the external layout
357
- #
358
- # @param seat_layout [SpreeCmCommissioner::SeatLayout] The seat layout
359
- # @param synced_labels [Array<String>] Labels of blocks that were synced
360
- def cleanup_stale_blocks!(seat_layout:, synced_labels:)
361
- synced_labels_set = synced_labels.to_set
362
-
363
- seat_layout.blocks.each do |block|
364
- next if synced_labels_set.include?(block.label)
365
-
366
- # Only delete blocks that don't have active reservations
367
- if block.respond_to?(:all_reserved_blocks) && block.all_reserved_blocks.reserved_or_on_hold.exists?
368
- Rails.logger.warn("[SyncSeatLayout] Skipping deletion of block #{block.label} - has active reservations")
369
- next
370
- end
371
-
372
- @sync_result.track(:block, block) do |_tracker|
373
- block.destroy!
374
- @sync_result.increment_metric(:block, :deleted)
375
- end
289
+ # A layer BookMeBus no longer reports. Kept while it still holds seats, so nothing with a reservation disappears.
290
+ def remove_empty_stale_sections!(seat_layout, layer_names)
291
+ seat_layout.seat_sections.where.not(name: layer_names).find_each do |section|
292
+ section.destroy! if section.blocks.empty?
376
293
  end
377
294
  end
378
295