spree_cm_commissioner 2.14.1 β†’ 2.14.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d7734080da716bf13a816acbd174d75272acdbbf4fd5a257d5e6c535eb7cece
4
- data.tar.gz: 310a647df40f1c0d6f0cab1a3f676d7f558e07d0a45108addd7a1d552ebd4083
3
+ metadata.gz: c169ca8f3c24e2294f37a17142112c820953fbd3b109c7e53c3a39ccf19e88e4
4
+ data.tar.gz: ec8aee4c537d0f1f47b705eb4e248a0b148b124979e743adfd02d1b510b4a9f9
5
5
  SHA512:
6
- metadata.gz: efb1b8f98edd7bb4a815086717aad5722a2e22ca6dd7c9472df0aa18ce1259cbd26674c45a3b0e8a064ff69e194f6b650bfded02528d7ef6078c74e62aad0fee
7
- data.tar.gz: e578ec7e879cfade2a629c9a452283689942890aae8ad5b82b81bb67dba4f3155aaba401f76d77ae603435614e90a610293f2e1c9fde942627dab76bec5f6241
6
+ metadata.gz: 4cb10cc3987eff0d89409c4275cafa26263a9ed349deacb81c816c3fca496927973a640eb1fe875a4e724eebdc43ecc3cec6f5e8626b7f9653e422704bad6553
7
+ data.tar.gz: 1f68fa692abff8ab900ac03cc864b5509270c863b62606b8f2ddca4b98c7f05358b61c82663af1f580ab7a5510f690fc3827d2f07c1cc60c574e6146add476be
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.14.1)
37
+ spree_cm_commissioner (2.14.3)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -3,17 +3,18 @@
3
3
  # Order Number:
4
4
  # <code>R621016753</code>
5
5
  #
6
- # [ x1 ]
6
+ # πŸŽͺ Event: Temple Run 2023
7
+ # πŸͺ Vendor: Temple Run with Sai
8
+ #
7
9
  # <b>5km Running Ticket</b>
10
+ # Quantity: 1
8
11
  # <i>πŸ‘‰ Distance: 5km, T-shirt: M</i>
9
12
  # <i>πŸ—“οΈ Nov 15, 2023</i>
10
- # <i>πŸͺ Temple Run with Sai</i>
11
13
  #
12
- # [ x2 ]
13
14
  # <b>5km Running Ticket</b>
15
+ # Quantity: 2
14
16
  # <i>πŸ‘‰ Distance: 5km, T-shirt: S</i>
15
17
  # <i>πŸ—“οΈ Nov 15, 2023 -> Nov 17, 2023</i>
16
- # <i>πŸͺ Temple Run with Sai</i>
17
18
  #
18
19
  # <b>πŸ™ Customer Info</b>
19
20
  # Name: Vaneath Awesome
@@ -43,10 +44,31 @@ module SpreeCmCommissioner
43
44
  def body
44
45
  text = []
45
46
  text << "Order Number:\n#{inline_code(order.number)}\n"
47
+ summary = summary_content
48
+ text << "#{summary}\n" if summary.present?
46
49
  text << selected_line_items.map { |item| line_item_content(item) }.compact.join("\n\n")
47
50
  text.compact.join("\n")
48
51
  end
49
52
 
53
+ # Stated once for the whole order rather than per ticket: an order is almost always one
54
+ # event and one vendor, so a per-ticket line would just repeat the same name.
55
+ def summary_content
56
+ line_items = selected_line_items.to_a
57
+ ActiveRecord::Associations::Preloader.new(records: line_items, associations: %i[event vendor]).call
58
+
59
+ [
60
+ names_line('πŸŽͺ Event', line_items.map(&:event)),
61
+ names_line('πŸͺ Vendor', line_items.map(&:vendor))
62
+ ].compact.join("\n").presence
63
+ end
64
+
65
+ def names_line(label, records)
66
+ names = records.compact.map(&:name).compact_blank.uniq
67
+ return nil if names.empty?
68
+
69
+ "#{label}: #{names.map { |name| ERB::Util.html_escape(name) }.join(', ')}"
70
+ end
71
+
50
72
  def line_item_content(line_item)
51
73
  return transit_line_item_content(line_item) if line_item.transit?
52
74
 
@@ -56,7 +78,6 @@ module SpreeCmCommissioner
56
78
  text << "Quantity: #{line_item.quantity}"
57
79
  text << italic("πŸ‘‰ #{line_item.options_text}") if line_item.options_text.present?
58
80
  text << italic(pretty_date_for(line_item)) if pretty_date_for(line_item).present?
59
- text << italic("πŸͺ #{line_item.vendor.name}") if line_item.vendor&.name.present? && vendor.blank?
60
81
  text << seat_labels_text(line_item) if seat_labels_text(line_item).present?
61
82
 
62
83
  text.compact.join("\n")
@@ -0,0 +1,18 @@
1
+ module SpreeCmCommissioner
2
+ module AuditEvents
3
+ class CleanupExpiredJob < SpreeCmCommissioner::ApplicationJob
4
+ # Gap before continuing a backlog, so the Sidekiq thread is free for other jobs in between.
5
+ CONTINUE_DELAY = 10.seconds
6
+
7
+ queue_as :maintenance
8
+
9
+ # Not an ApplicationUniqueJob: `unique :until_executed` would drop the continuation below as a duplicate of the
10
+ # running job. Overlapping with the daily cron is harmless because deleting is idempotent.
11
+ def perform
12
+ result = SpreeCmCommissioner::AuditEvents::CleanupExpired.new.call
13
+
14
+ self.class.set(wait: CONTINUE_DELAY).perform_later if result.value[:more_remaining]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -2,7 +2,7 @@ module SpreeCmCommissioner
2
2
  module VendorPreference
3
3
  extend ActiveSupport::Concern
4
4
 
5
- TELEGRAM_CHAT_TYPE = %i[channel group].freeze
5
+ TELEGRAM_CHAT_TYPE = %i[channel group supergroup].freeze
6
6
 
7
7
  included do
8
8
  preference :telegram_chat_id, :string
@@ -1,5 +1,8 @@
1
1
  module SpreeCmCommissioner
2
2
  class AuditEvent < Base
3
+ # How long AuditLogger keeps an event when the caller gives no retention_days.
4
+ DEFAULT_RETENTION_DAYS = 90
5
+
3
6
  belongs_to :auditable, polymorphic: true, optional: true
4
7
  belongs_to :user, class_name: 'Spree::User', optional: true
5
8
 
@@ -7,5 +10,7 @@ module SpreeCmCommissioner
7
10
 
8
11
  scope :success, -> { where(success: true) }
9
12
  scope :failed, -> { where(success: false) }
13
+
14
+ scope :expired, -> (now = Time.current) { where(retention_until: ...now.to_date) }
10
15
  end
11
16
  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,
@@ -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
 
@@ -36,22 +36,6 @@ module SpreeCmCommissioner
36
36
  # Row 1: [5] [6] [aisle] [7] [8]
37
37
  #
38
38
  class SeatLayout
39
- # Layout rendering constants
40
- BLOCK_WIDTH = 50
41
- BLOCK_HEIGHT = 50
42
- BLOCK_GAP = 10
43
- # Gap between layers (e.g. lower/upper deck) placed side by side - see
44
- # #to_internal_blocks_attributes. Matches SyncSeatLayout#calculate_layout_dimensions's
45
- # own `layer_gap`, kept as a literal there too for the same reason noted on
46
- # LAYER_TOP_MARGIN below.
47
- LAYER_GAP = 100
48
- DEFAULT_CANVAS_WIDTH = 400
49
- DEFAULT_CANVAS_HEIGHT = 400
50
- # Matches SyncSeatLayout#calculate_layout_dimensions's own `padding` - kept as a literal
51
- # there too since that method already owns the canvas height/width calculation and this is
52
- # the one place outside it that also needs the same top-margin value.
53
- LAYER_TOP_MARGIN = 100
54
-
55
39
  attr_reader :id, :from_stop_id, :to_stop_id, :on_date, :duration,
56
40
  :min_number_of_seats_remaining, :number_of_seats_remaining,
57
41
  :layouts, :stop_times, :trip
@@ -183,39 +167,6 @@ module SpreeCmCommissioner
183
167
  seats.group_by(&:seat_type)
184
168
  end
185
169
 
186
- # Get the number of rows in the layout
187
- #
188
- # @return [Integer] Number of rows in the primary layout
189
- #
190
- # Example:
191
- # # Layout with 12 rows:
192
- # # Row 0: [1] [2] [aisle] [3] [4]
193
- # # Row 1: [5] [6] [aisle] [7] [8]
194
- # # ...
195
- # # Row 11: [45] [46] [aisle] [47] [48]
196
- #
197
- # seat_layout.row_count # => 12
198
- #
199
- def row_count
200
- primary_layout.values.first&.size || 0
201
- end
202
-
203
- # Get the number of columns in the layout (including aisles)
204
- #
205
- # @return [Integer] Number of columns in the first row
206
- #
207
- # Example:
208
- # # Layout: [seat, seat, aisle, seat, seat]
209
- # seat_layout.column_count # => 5
210
- #
211
- # # Layout: [seat, seat, aisle, seat]
212
- # seat_layout.column_count # => 4
213
- #
214
- def column_count
215
- first_row = primary_layout.values.first&.first
216
- first_row&.size || 0
217
- end
218
-
219
170
  # Get count of available seats (status = 0)
220
171
  #
221
172
  # @return [Integer] Number of seats with status 0 (available)
@@ -231,84 +182,32 @@ module SpreeCmCommissioner
231
182
  seats.count(&:available?)
232
183
  end
233
184
 
234
- # Convert BookMeBus seat layout to BookMe+ Block attributes
235
- #
236
- # Transforms the 2D seat layout into an array of block hashes ready for
237
- # creating SpreeCmCommissioner::Block records. Each seat becomes a block
238
- # with calculated x,y coordinates based on its row/column position.
239
- #
240
- # Coordinate Calculation:
241
- # x = column_index * (block_width + gap)
242
- # y = row_index * (block_height + gap)
243
- #
244
- # @param seat_layout [SpreeCmCommissioner::SeatLayout] The internal seat layout model
245
- # @return [Array<Hash>] Array of block attribute hashes
246
- #
247
- # Example:
248
- # internal_layout = SpreeCmCommissioner::SeatLayout.create!(
249
- # name: "VIP Bus Layout",
250
- # layoutable: vehicle_type
251
- # )
252
- #
253
- # blocks_attrs = external_seat_layout.to_internal_blocks_attributes(internal_layout)
254
- # # => [
255
- # # {
256
- # # label: "1",
257
- # # block_type: :seat,
258
- # # width: 50, height: 50,
259
- # # x: 0, y: 0, # Row 0, Column 0
260
- # # rotation: 0,
261
- # # seat_layout: internal_layout
262
- # # },
263
- # # {
264
- # # label: "2",
265
- # # block_type: :seat,
266
- # # width: 50, height: 50,
267
- # # x: 60, y: 0, # Row 0, Column 1 (50 + 10 gap)
268
- # # rotation: 0,
269
- # # seat_layout: internal_layout
270
- # # },
271
- # # # Column 2 (aisle) is skipped
272
- # # {
273
- # # label: "3",
274
- # # block_type: :seat,
275
- # # width: 50, height: 50,
276
- # # x: 180, y: 0, # Row 0, Column 3
277
- # # rotation: 0,
278
- # # seat_layout: internal_layout
279
- # # }
280
- # # ]
281
- #
282
- # # Create blocks
283
- # blocks_attrs.each { |attrs| SpreeCmCommissioner::Block.create!(attrs) }
284
- #
285
- # Visual Layout:
286
- # (0,0) (60,0) (180,0) (240,0)
287
- # [1] [2] [aisle] [3] [4]
288
- #
289
- # (0,60) (60,60) (180,60) (240,60)
290
- # [5] [6] [aisle] [7] [8]
291
- #
292
- def to_internal_blocks_attributes(seat_layout)
293
- blocks = []
294
- offsets = calculate_centering_offsets(seat_layout)
295
- # Assumes every layer shares the first layer's column count, same as
296
- # #calculate_centering_offsets and SyncSeatLayout#calculate_layout_dimensions.
297
- layer_width = column_count * (BLOCK_WIDTH + BLOCK_GAP)
298
- current_layer_x_offset = 0
185
+ # The layout as the BookMe+ seat layout builder would draw it (see SeatLayouts::Grid), so synced layouts look
186
+ # like hand-built ones: one section per layer, in BookMeBus's order, named with its layer key (UnstockInventory
187
+ # sends that name back as the reservation `layer`). Each layer is measured on its own. In a layer with sleepers
188
+ # every BookMeBus row takes two grid rows, so a sleeper is 40x90 like the builder's "Sleeping seat".
189
+ #
190
+ # @return [Hash] { width:, height:, sections: [{ name:, x:, y:, width:, height:, blocks: [...] }] }, block x/y
191
+ # relative to their section
192
+ def builder_layout
193
+ @builder_layout ||= build_builder_layout
194
+ end
299
195
 
300
- primary_layout.each do |layer_name, rows|
301
- process_layer_rows(rows, layer_name, blocks, offsets, current_layer_x_offset, seat_layout)
196
+ private
302
197
 
303
- # Place the next layer to the right, so double-decker buses show their decks
304
- # side by side instead of one on top of the other.
305
- current_layer_x_offset += layer_width + LAYER_GAP
198
+ def build_builder_layout
199
+ layers = primary_layout.map { |layer_name, rows| [layer_name, Array(rows)] }
200
+ sizes = layers.map do |_, rows|
201
+ grid.section_size(rows: rows.size * rows_per_seat_row(rows), columns: rows.map { |row| Array(row).size }.max.to_i)
306
202
  end
203
+ origins = grid.section_origins(sizes)
307
204
 
308
- blocks
309
- end
205
+ sections = layers.each_with_index.map do |(layer_name, rows), index|
206
+ { name: layer_name, **origins[index], **sizes[index], blocks: section_blocks(layer_name, rows) }
207
+ end
310
208
 
311
- private
209
+ { **grid.canvas_size(sections), sections: sections }
210
+ end
312
211
 
313
212
  def extract_seats_from_layout(layout)
314
213
  seats = []
@@ -332,45 +231,34 @@ module SpreeCmCommissioner
332
231
  seats
333
232
  end
334
233
 
335
- # x centers the *whole* row of side-by-side layers within the canvas width (canvas_width
336
- # already accounts for every layer's width plus the gaps between them - see
337
- # SyncSeatLayout#calculate_layout_dimensions). For a single-layer bus this simplifies to
338
- # exactly the old single-layer centering, so that overwhelmingly common case is
339
- # unaffected. y is just a small constant top margin - layers don't stack vertically, so
340
- # there's no per-layer vertical slot to account for.
341
- def calculate_centering_offsets(seat_layout)
342
- layer_count = primary_layout.keys.count
343
- layer_width = column_count * (BLOCK_WIDTH + BLOCK_GAP)
344
- total_width = (layer_count * layer_width) + ((layer_count - 1) * LAYER_GAP)
345
- canvas_width = seat_layout.width || DEFAULT_CANVAS_WIDTH
234
+ def grid
235
+ SpreeCmCommissioner::SeatLayouts::Grid
236
+ end
346
237
 
347
- {
348
- x: ((canvas_width - total_width) / 2.0).round,
349
- y: LAYER_TOP_MARGIN
350
- }
238
+ def rows_per_seat_row(rows)
239
+ rows.flatten.compact.any? { |seat_data| Seat.new(seat_data).block_type == :sleeping_seat } ? 2 : 1
351
240
  end
352
241
 
353
- def process_layer_rows(rows, layer_name, blocks, offsets, layer_x_offset, seat_layout) # rubocop:disable Metrics/ParameterLists
354
- rows.each_with_index do |row, row_idx|
355
- row.each_with_index do |seat_data, col_idx|
356
- next if seat_data.nil? # Skip aisles
242
+ def section_blocks(layer_name, rows)
243
+ row_unit = rows_per_seat_row(rows)
244
+
245
+ rows.each_with_index.flat_map do |row, row_idx|
246
+ Array(row).each_with_index.filter_map do |seat_data, col_idx|
247
+ next if seat_data.nil? # aisle or gap
357
248
 
358
249
  seat = Seat.new(seat_data)
359
- blocks << build_block_attributes(seat, layer_name, row_idx, col_idx, offsets, layer_x_offset, seat_layout)
250
+ rect = grid.block_rect(row: row_idx * row_unit, column: col_idx, row_span: seat.block_type == :sleeping_seat ? 2 : 1)
251
+ build_block_attributes(seat, layer_name, rect)
360
252
  end
361
253
  end
362
254
  end
363
255
 
364
- def build_block_attributes(seat, layer_name, row_idx, col_idx, offsets, layer_x_offset, seat_layout) # rubocop:disable Metrics/ParameterLists
256
+ def build_block_attributes(seat, layer_name, rect)
365
257
  {
366
258
  label: seat.label,
367
259
  block_type: seat.block_type,
368
- width: BLOCK_WIDTH,
369
- height: BLOCK_HEIGHT,
370
- x: (col_idx * (BLOCK_WIDTH + BLOCK_GAP)) + layer_x_offset + offsets[:x],
371
- y: (row_idx * (BLOCK_HEIGHT + BLOCK_GAP)) + offsets[:y],
372
260
  rotation: 0,
373
- seat_layout: seat_layout,
261
+ **rect,
374
262
  metadata: {
375
263
  layer: layer_name,
376
264
  seat_type: seat.seat_type,
@@ -4,31 +4,35 @@ module SpreeCmCommissioner
4
4
  prepend ::Spree::ServiceModule::Base
5
5
  extend SpreeCmCommissioner::ServiceModuleThrowable
6
6
 
7
- def call(inventory_item:)
8
- max_capacity = variant_total_inventory(inventory_item)
9
- total_purchases = variant_total_purchases(inventory_item)
10
- # Everything an operator has taken off public sale, by either mechanism: one ReservedBlock per
11
- # seat for seat-selection products, and quantity_locked for everything else. Both must be
12
- # subtracted or a reset hands withheld units straight back to the public.
13
- #
14
- # locked_count is self-healing because the rows ARE the record of what was deducted.
15
- # quantity_locked is not β€” it is a counter with no rows behind it, so a reset preserves it
16
- # rather than recomputing it. It is deliberately left out of the update below.
17
- total_locked = inventory_item.locked_count + inventory_item.quantity_locked
18
- quantity_available = [max_capacity - total_purchases - total_locked, 0].max
19
- quantity_on_hold, quantity_locked_on_hold = variant_total_on_hold(inventory_item)
20
-
21
- updated = inventory_item.update(
22
- max_capacity: max_capacity,
23
- quantity_available: quantity_available,
24
- quantity_on_hold: quantity_on_hold,
25
- quantity_locked_on_hold: quantity_locked_on_hold
26
- )
27
- return failure(nil, :failed_to_update_inventory_item) unless updated
7
+ # Fails with this as its value when the item's Redis counts moved while it was being recounted, so a bulk
8
+ # recount can tell "selling right now, try again" apart from a real failure.
9
+ BUSY = :busy
28
10
 
29
- reset_redis_inventory(inventory_item)
11
+ # Recounts under the item's row lock, so a concurrent adjust_quantity! or BulkAdjustQuantities* write
12
+ # can't land between the read and the update and be overwritten.
13
+ #
14
+ # The row lock doesn't stop holds: InventoryHolds::Acquire increments Redis inside a transaction that
15
+ # commits later, and booking is enforced from Redis. A recount that overwrote Redis from the committed
16
+ # holds it read would hand an in-flight hold's units back to public sale. So Redis is snapshotted before
17
+ # the counts are read and written only if no key moved since (see #write_redis); otherwise the DB update
18
+ # is rolled back and this fails with BUSY. What's left is a hold whose Redis increment lands before the
19
+ # snapshot and whose commit lands after the counts are read: one hold's commit latency, not the recount's.
20
+ def call(inventory_item:)
21
+ outcome = nil
22
+
23
+ # requires_new, as in MoveToLocked: a Rollback in a bare nested transaction is swallowed, which would
24
+ # keep the DB counts of a recount whose Redis write was refused.
25
+ inventory_item.transaction(requires_new: true) do
26
+ inventory_item.lock!
27
+ outcome = recount_and_write_redis(inventory_item)
28
+ raise ActiveRecord::Rollback unless outcome == :reset
29
+ end
30
30
 
31
- success(nil)
31
+ case outcome
32
+ when :reset then success(nil)
33
+ when BUSY then failure(BUSY, I18n.t('inventory_item.reset.busy'))
34
+ else failure(nil, outcome)
35
+ end
32
36
  end
33
37
 
34
38
  def variant_total_inventory(inventory_item)
@@ -60,12 +64,12 @@ module SpreeCmCommissioner
60
64
  # A locked-stock line holds against the locked counter, so counting it here would inflate the
61
65
  # public one and under-report public availability: exactly the drift this service exists to
62
66
  # correct.
67
+ #
68
+ # Active holds as a subquery rather than plucked ids: a bulk recount runs this per item, and every
69
+ # active hold in the system would otherwise be loaded each time.
63
70
  def variant_total_on_hold(inventory_item)
64
- active_hold_order_ids = SpreeCmCommissioner::InventoryHold.active_or_payment_locked.pluck(:order_id)
65
- return [0, 0] if active_hold_order_ids.empty?
66
-
67
71
  scope = Spree::LineItem
68
- .where(order_id: active_hold_order_ids)
72
+ .where(order_id: SpreeCmCommissioner::InventoryHold.active_or_payment_locked.select(:order_id))
69
73
  .joins(variant: :inventory_items)
70
74
  .where(cm_inventory_items: { id: inventory_item.id })
71
75
 
@@ -85,18 +89,82 @@ module SpreeCmCommissioner
85
89
 
86
90
  private
87
91
 
92
+ # Runs inside the locked transaction. The snapshot comes first: one taken after the counts are read would
93
+ # already include a hold those counts missed, and the write would pass.
94
+ def recount_and_write_redis(inventory_item)
95
+ snapshot = redis_values(inventory_item)
96
+ return :failed_to_update_inventory_item unless recount(inventory_item)
97
+ return BUSY unless write_redis(inventory_item, snapshot)
98
+
99
+ :reset
100
+ end
101
+
102
+ def recount(inventory_item)
103
+ max_capacity = variant_total_inventory(inventory_item)
104
+ total_purchases = variant_total_purchases(inventory_item)
105
+ # Everything an operator has taken off public sale, by either mechanism: one ReservedBlock per
106
+ # seat for seat-selection products, and quantity_locked for everything else. Both must be
107
+ # subtracted or a reset hands withheld units straight back to the public.
108
+ #
109
+ # locked_count is self-healing because the rows ARE the record of what was deducted.
110
+ # quantity_locked is not β€” it is a counter with no rows behind it, so a reset preserves it
111
+ # rather than recomputing it. It is deliberately left out of the update below.
112
+ total_locked = inventory_item.locked_count + inventory_item.quantity_locked
113
+ quantity_available = [max_capacity - total_purchases - total_locked, 0].max
114
+ quantity_on_hold, quantity_locked_on_hold = variant_total_on_hold(inventory_item)
115
+
116
+ inventory_item.update(
117
+ max_capacity: max_capacity,
118
+ quantity_available: quantity_available,
119
+ quantity_on_hold: quantity_on_hold,
120
+ quantity_locked_on_hold: quantity_locked_on_hold
121
+ )
122
+ end
123
+
124
+ # Both pools, in the order #write_redis takes them.
125
+ def redis_keys(inventory_item)
126
+ [
127
+ inventory_item.redis_key, inventory_item.redis_hold_key,
128
+ inventory_item.redis_key(locked: true), inventory_item.redis_hold_key(locked: true)
129
+ ]
130
+ end
131
+
132
+ # '' for a missing key, which is what the script compares a missing key as.
133
+ def redis_values(inventory_item)
134
+ SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.mget(*redis_keys(inventory_item)) }.map(&:to_s)
135
+ end
136
+
88
137
  # Reseeds BOTH pools. Leaving the locked keys stale would let an agent's allotment drift away
89
138
  # from the row that a reset just corrected β€” and since availability is read from Redis, the
90
139
  # stale value is the one that would be enforced.
91
- def reset_redis_inventory(inventory_item)
92
- ttl = inventory_item.redis_expired_in
93
-
94
- SpreeCmCommissioner.inventory_redis_pool.with do |redis|
95
- redis.setex(inventory_item.redis_key, ttl, inventory_item.quantity_available)
96
- redis.setex(inventory_item.redis_hold_key, ttl, inventory_item.quantity_on_hold)
97
- redis.setex(inventory_item.redis_key(locked: true), ttl, inventory_item.quantity_locked)
98
- redis.setex(inventory_item.redis_hold_key(locked: true), ttl, inventory_item.quantity_locked_on_hold)
140
+ #
141
+ # Atomically, and only while every key still holds its snapshot: a hold, release or sale since then has
142
+ # moved one, and the counts just read may not include it.
143
+ def write_redis(inventory_item, snapshot)
144
+ values = [
145
+ inventory_item.quantity_available, inventory_item.quantity_on_hold,
146
+ inventory_item.quantity_locked, inventory_item.quantity_locked_on_hold
147
+ ]
148
+
149
+ result = SpreeCmCommissioner.inventory_redis_pool.with do |redis|
150
+ redis.eval(compare_and_set_script, keys: redis_keys(inventory_item), argv: [*snapshot, *values, inventory_item.redis_expired_in])
99
151
  end
152
+
153
+ result == 1
154
+ end
155
+
156
+ def compare_and_set_script
157
+ <<~LUA
158
+ -- ARGV = [snapshot_1..snapshot_n, value_1..value_n, ttl]
159
+ local n = #KEYS
160
+ for i = 1, n do
161
+ if (redis.call('GET', KEYS[i]) or '') ~= ARGV[i] then return 0 end
162
+ end
163
+ for i = 1, n do
164
+ redis.call('SET', KEYS[i], ARGV[n + i], 'EX', tonumber(ARGV[2 * n + 1]))
165
+ end
166
+ return 1
167
+ LUA
100
168
  end
101
169
  end
102
170
  end
@@ -22,6 +22,9 @@ module SpreeCmCommissioner
22
22
 
23
23
  ACTIONS = %w[set_price use_base_price set_stock reset_stock close reopen].freeze
24
24
 
25
+ # Each recount takes its date's row lock and runs a few queries, one date after another in the request.
26
+ MAX_RECOUNT = 200
27
+
25
28
  def call(product:, store:, cells:, action:, values: {}, user: nil) # rubocop:disable Metrics/ParameterLists
26
29
  @product = product
27
30
  @store = store
@@ -46,7 +49,7 @@ module SpreeCmCommissioner
46
49
  end
47
50
 
48
51
  updated = send(action)
49
- success(updated: updated, skipped: cells.size - updated)
52
+ success({ updated: updated, skipped: cells.size - updated, busy: @busy }.compact)
50
53
  rescue ActiveRecord::RecordInvalid, ArgumentError => e
51
54
  failure(nil, e.message)
52
55
  end
@@ -115,17 +118,20 @@ module SpreeCmCommissioner
115
118
  end
116
119
 
117
120
  # Rebuilds each date's available and on-hold counts from stock items and orders (InventoryItems::Reset), for
118
- # when they've drifted. Base resets the undated item a product without dates sells from.
121
+ # when they've drifted, and undoes stock set on the date (Use Base stock). Base resets the undated item a
122
+ # product without dates sells from.
123
+ #
124
+ # A date being booked while it's recounted is left as it was and counted in `busy`, so the rest still apply.
119
125
  def reset_stock
120
126
  items = stock_cells.filter_map { |cell| cell[:inventory_item] || undated_inventory_item(cell[:variant]) }
121
- errors = items.filter_map do |item|
122
- result = InventoryItems::Reset.call(inventory_item: item)
123
- result.error.to_s unless result.success?
124
- end
127
+ raise ArgumentError, I18n.t('rates_availabilities.bulk.recount_limit', max: MAX_RECOUNT) if items.size > MAX_RECOUNT
125
128
 
126
- raise ArgumentError, errors.uniq.to_sentence if errors.any?
129
+ results = items.map { |item| InventoryItems::Reset.call(inventory_item: item) }
130
+ busy, failed = results.reject(&:success?).partition { |result| result.value == InventoryItems::Reset::BUSY }
131
+ raise ArgumentError, failed.map { |result| result.error.to_s }.uniq.to_sentence if failed.any?
127
132
 
128
- items.size
133
+ @busy = busy.size if busy.any?
134
+ items.size - busy.size
129
135
  end
130
136
 
131
137
  def undated_inventory_item(variant)
@@ -0,0 +1,56 @@
1
+ module SpreeCmCommissioner
2
+ module SeatLayouts
3
+ # The geometry the BookMe+ seat layout builder ("Add Section") produces, so a generated layout looks exactly like a
4
+ # hand-built one. Keep in step with cm-market-server: SeatLayoutable#block_types (block_size/spacing) and the builder
5
+ # JS in vendor/javascript/lit_components: cm_seat_section_grid_builder_modal/services/{assign_block,get_section_size}.js,
6
+ # cm_seat_layout_builder/services/get_new_section_position.js and models/seat_layout.js#calculateElementBounds.
7
+ module Grid
8
+ BLOCK_SIZE = 40
9
+ SPACING = 10
10
+
11
+ module_function
12
+
13
+ # A block at grid (row, column) inside its section; x/y are relative to the section's origin.
14
+ def block_rect(row:, column:, row_span: 1, column_span: 1)
15
+ {
16
+ x: (column * BLOCK_SIZE) + ((column + 1) * SPACING),
17
+ y: (row * BLOCK_SIZE) + ((row + 1) * SPACING),
18
+ width: span_size(column_span),
19
+ height: span_size(row_span)
20
+ }
21
+ end
22
+
23
+ def section_size(rows:, columns:)
24
+ {
25
+ width: (columns * (BLOCK_SIZE + SPACING)) + SPACING,
26
+ height: (rows * (BLOCK_SIZE + SPACING)) + SPACING
27
+ }
28
+ end
29
+
30
+ # Sections sit side by side, left to right, SPACING apart and SPACING from the canvas edge.
31
+ def section_origins(sizes)
32
+ x = SPACING
33
+
34
+ sizes.map do |size|
35
+ origin = { x: x, y: SPACING }
36
+ x += size[:width] + SPACING
37
+ origin
38
+ end
39
+ end
40
+
41
+ # The canvas wraps its sections with the same margin on every side (their smallest x/y).
42
+ def canvas_size(rects)
43
+ return { width: 0, height: 0 } if rects.empty?
44
+
45
+ {
46
+ width: rects.map { |rect| rect[:x] + rect[:width] }.max + rects.pluck(:x).min,
47
+ height: rects.map { |rect| rect[:y] + rect[:height] }.max + rects.pluck(:y).min
48
+ }
49
+ end
50
+
51
+ def span_size(span)
52
+ (BLOCK_SIZE * span) + (SPACING * (span - 1))
53
+ end
54
+ end
55
+ end
56
+ end
@@ -54,3 +54,19 @@
54
54
  <%= render 'spree/admin/rates_availabilities/list_pages', records: inventory_items %>
55
55
  <% end %>
56
56
  </div>
57
+
58
+ <%# Every row, across pages. Over BulkUpdate's cap it's turned off rather than refused after the confirm. %>
59
+ <% if inventory_items.any? %>
60
+ <% max_recount = SpreeCmCommissioner::RatesAvailabilities::BulkUpdate::MAX_RECOUNT %>
61
+ <% too_many = inventory_items.total_count > max_recount %>
62
+ <div class="modal-footer">
63
+ <small class="mr-auto text-muted" data-ra-recount-all-hint>
64
+ <%= svg_icon name: 'info-circle.svg', width: '13', height: '13' %> <%= t('rates_availabilities.stock_details.recount_all_hint') %>
65
+ <% if too_many %><br><%= t('rates_availabilities.stock_details.recount_all_limit', max: max_recount) %><% end %>
66
+ </small>
67
+ <button type="button" class="btn btn-outline-secondary text-nowrap" data-ra-recount-all <%= 'disabled' if too_many %>
68
+ data-ra-confirm="<%= t('rates_availabilities.stock_details.recount_all_confirm', count: inventory_items.total_count) %>">
69
+ <%= svg_icon name: 'arrow-counterclockwise.svg', width: '14', height: '14' %> <%= t('rates_availabilities.stock_details.recount_all') %>
70
+ </button>
71
+ </div>
72
+ <% end %>
@@ -6,6 +6,8 @@
6
6
  selected: t('rates_availabilities.bulk.selected'),
7
7
  saving: t('rates_availabilities.bulk.saving'),
8
8
  done: t('rates_availabilities.bulk.done'),
9
+ busy: t('rates_availabilities.bulk.busy'),
10
+ useBaseStockConfirm: t('rates_availabilities.bulk.use_base_stock_confirm'),
9
11
  failed: t('rates_availabilities.modal.failed')
10
12
  }.to_json) %>;
11
13
 
@@ -228,7 +230,8 @@
228
230
  // close each rate on its own, and they'd stay closed after the date is reopened from its stock cell alone. A
229
231
  // price cell closed only by its date or the base rate (ra-closed-day alone) is in neither: it has no closing
230
232
  // of its own to reopen, and closing it would add one that shows only once those reopen.
231
- // `datePrices` have their own price for the date, which Use Base price clears.
233
+ // `datePrices` have their own price for the date, which Use Base price clears. `dateStock` is what Use Base
234
+ // stock recounts: Base stock has nothing to fall back to.
232
235
  groups() {
233
236
  const cells = this.cells();
234
237
  const ofType = (type) => cells.filter((cell) => cell.dataset.cell === type);
@@ -247,7 +250,8 @@
247
250
  prices: prices,
248
251
  open: dated.filter((cell) => !isClosed(cell) && closesOnItsOwn(cell)),
249
252
  closed: dated.filter(isClosed),
250
- datePrices: prices.filter((cell) => cell.classList.contains('ra-date-price'))
253
+ datePrices: prices.filter((cell) => cell.classList.contains('ra-date-price')),
254
+ dateStock: stock.filter((cell) => cell.dataset.date)
251
255
  };
252
256
  }
253
257
 
@@ -341,8 +345,9 @@
341
345
  this.load();
342
346
  }
343
347
 
344
- // A newer request wins, as answers can arrive out of order while the search is typed.
345
- load() {
348
+ // A newer request wins, as answers can arrive out of order while the search is typed. `notice` ({ text, level })
349
+ // goes above the reloaded list: the bulk bar, where a save reports, is under the modal.
350
+ load(notice = null) {
346
351
  const content = this.sharedModal.content;
347
352
  const seq = ++this.seq;
348
353
  const current = () => seq === this.seq && this.isShown;
@@ -359,6 +364,14 @@
359
364
  content.innerHTML = html;
360
365
  // Sync icons are popovers on stock managements, which nothing initializes in fetched HTML.
361
366
  content.querySelectorAll('[data-toggle="popover"]').forEach((el) => { el.title = el.dataset.content; });
367
+ const body = content.querySelector('.modal-body');
368
+ if (notice && body) {
369
+ const alert = document.createElement('div');
370
+ alert.className = 'alert alert-' + notice.level + ' py-2 small';
371
+ alert.setAttribute('role', 'status');
372
+ alert.textContent = notice.text;
373
+ body.prepend(alert);
374
+ }
362
375
  const search = content.querySelector('[data-ra-list-search]');
363
376
  if (searching && search) {
364
377
  search.focus();
@@ -424,6 +437,8 @@
424
437
  content.querySelector('[data-ra-bulk-count]').textContent = 'Β· ' + groups[config.group].length;
425
438
  const useBase = content.querySelector('[data-ra-use-base]');
426
439
  if (useBase) useBase.hidden = !groups.datePrices.length;
440
+ const useBaseStock = content.querySelector('[data-ra-use-base-stock]');
441
+ if (useBaseStock) useBaseStock.hidden = !groups.dateStock.length;
427
442
  setTimeout(() => { const input = content.querySelector('input'); if (input) input.focus(); }, 300);
428
443
  }
429
444
 
@@ -438,11 +453,35 @@
438
453
  this.update('use_base_price', {}, this.cellSelection.groups().datePrices.map(cellData));
439
454
  }
440
455
 
456
+ // Recounting a date undoes the stock set on it, so this is Set stock's way back to Base, as Use Base price is
457
+ // for prices.
458
+ useBaseStock() {
459
+ const cells = this.cellSelection.groups().dateStock;
460
+ if (!window.confirm(T.useBaseStockConfirm.replace('%{count}', cells.length))) return;
461
+ this.sharedModal.hide();
462
+ this.update('reset_stock', {}, cells.map(cellData));
463
+ }
464
+
441
465
  recount(button) {
442
466
  if (!window.confirm(button.dataset.raConfirm)) return;
443
467
  this.update('reset_stock', {}, [{ type: 'stock', variant_id: button.dataset.variantId, rate_plan_id: null, date: button.dataset.date }]);
444
468
  }
445
469
 
470
+ // Every row of the open stock details: one stock cell per variant and date it was opened for, as its price
471
+ // cells list the same rows but reset_stock only takes stock cells.
472
+ recountAll(button) {
473
+ if (!window.confirm(button.dataset.raConfirm)) return;
474
+ const rows = new Map(this.cellListModal.request.cells.map((cell) => [cell.variant_id + '|' + cell.date, cell]));
475
+ const cells = [...rows.values()].map((cell) => ({ type: 'stock', variant_id: cell.variant_id, rate_plan_id: null, date: cell.date }));
476
+ this.update('reset_stock', {}, cells);
477
+ }
478
+
479
+ // In the bar, and above an open list modal, which covers the bar.
480
+ report(text, level) {
481
+ this.message(text, level === 'danger');
482
+ if (this.cellListModal.isShown) this.cellListModal.load({ text: text, level: level });
483
+ }
484
+
446
485
  // `cells` defaults to the selection. Reloads the grid, and an open list modal, as their counts change.
447
486
  update(action, values, cells = this.cellSelection.groups().all.map(cellData)) {
448
487
  this.bar.classList.add('ra-busy');
@@ -454,11 +493,12 @@
454
493
  }).then((response) => response.json().then((body) => {
455
494
  if (!response.ok) throw new Error(body.error);
456
495
  return this.monthTables.reload().then(() => {
457
- if (this.cellListModal.isShown) this.cellListModal.load();
458
- this.message(T.done.replace('%{updated}', body.updated).replace('%{skipped}', body.skipped));
496
+ const done = T.done.replace('%{updated}', body.updated).replace('%{skipped}', body.skipped);
497
+ if (body.busy) this.report(done + '. ' + T.busy.replace('%{count}', body.busy), 'warning');
498
+ else this.report(done, 'success');
459
499
  });
460
500
  })).catch((error) => {
461
- this.message(error.message, true);
501
+ this.report(error.message, 'danger');
462
502
  }).finally(() => {
463
503
  this.bar.classList.remove('ra-busy');
464
504
  });
@@ -593,10 +633,12 @@
593
633
  ['[data-ra-select-date]', (el, event) => this.cellSelection.selectDate(el, event.shiftKey)],
594
634
  ['[data-ra-bulk]', (el) => this.bulkEditBar.start(el.dataset.raBulk)],
595
635
  ['[data-ra-use-base]', () => this.bulkEditBar.useBasePrice()],
636
+ ['[data-ra-use-base-stock]', () => this.bulkEditBar.useBaseStock()],
596
637
  ['[data-ra-list]', (el) => this.cellListModal.open(this.monthTables.root.dataset[el.dataset.raList + 'Url'])],
597
638
  ['[data-ra-list-filter]', (el) => this.cellListModal.setFilter(el.dataset.raListFilter)],
598
639
  ['[data-ra-list-page]', (el) => this.cellListModal.setPage(el)],
599
640
  ['[data-ra-recount]', (el) => this.bulkEditBar.recount(el)],
641
+ ['[data-ra-recount-all]', (el) => this.bulkEditBar.recountAll(el)],
600
642
  ['[data-ra-modal-url]', (el) => this.ratePlanForms.open(el.dataset.raModalUrl)],
601
643
  ['[data-ra-move]', (el) => this.ratePlanForms.moveItem(el)],
602
644
  ['[data-ra-delete-url]', (el) => this.ratePlanForms.destroy(el)]
@@ -102,6 +102,10 @@
102
102
  <small class="text-muted"><%= t('rates_availabilities.bulk.stock_hint') %></small>
103
103
  </div>
104
104
  <div class="modal-footer">
105
+ <%# Only while dated stock is selected (BulkEditBar#openForm); recounts those dates, undoing the stock set on them. %>
106
+ <button type="button" class="mr-auto btn btn-outline-secondary" data-ra-use-base-stock hidden title="<%= t('rates_availabilities.bulk.use_base_stock_hint') %>">
107
+ <%= t('rates_availabilities.bulk.use_base_stock') %>
108
+ </button>
105
109
  <button type="button" class="btn btn-outline-secondary" data-ra-dismiss><%= t('rates_availabilities.rate_plan_form.cancel') %></button>
106
110
  <button type="submit" class="btn btn-primary"><%= t('rates_availabilities.rate_plan_form.save') %></button>
107
111
  </div>
@@ -1220,6 +1220,10 @@ en:
1220
1220
  convert:
1221
1221
  hold_expired: "Your reservation expired before payment completed. Please try booking again."
1222
1222
 
1223
+ inventory_item:
1224
+ reset:
1225
+ busy: "Stock changed while it was being recounted, as someone is booking it. Try again in a moment."
1226
+
1223
1227
  # Displayed as-is on stock-status badges pushed to Firestore (see
1224
1228
  # SpreeCmCommissioner::Products::PublishStockStatusToFirestore) β€” not read via I18n at request
1225
1229
  # time, so every locale we publish for must have an entry here.
@@ -1636,7 +1640,12 @@ en:
1636
1640
  close: "Close sale"
1637
1641
  reopen: "Reopen sale"
1638
1642
  reset_stock: "Recount stock"
1639
- reset_stock_confirm: "Recount stock? Available and on-hold are recalculated from base stock and orders."
1643
+ reset_stock_confirm: "Recount stock? Available and on-hold are recalculated from Base stock and orders, which undoes stock set on the date."
1644
+ recount_limit: "Recount up to %{max} dates at a time"
1645
+ busy: "%{count} were being booked and were left as they were; try them again in a moment."
1646
+ use_base_stock: "Use Base stock"
1647
+ use_base_stock_hint: "Recount the selected dates from Base stock and orders, undoing the stock set on them"
1648
+ use_base_stock_confirm: "Recount %{count} selected dates from Base stock and orders? Stock set on them is undone."
1640
1649
  deselect: "Deselect all"
1641
1650
  selected: "%{count} selected"
1642
1651
  saving: "Saving…"
@@ -1720,6 +1729,10 @@ en:
1720
1729
  title: "Stock details"
1721
1730
  date: "Date"
1722
1731
  none: "No inventory for the selected dates."
1732
+ recount_all: "Recount all"
1733
+ recount_all_hint: "Recalculates from Base stock and orders: fixes drifted counts and undoes stock set on these dates."
1734
+ recount_all_confirm: "Recount all %{count} rows? Available and on-hold are recalculated from Base stock and orders, which undoes stock set on those dates."
1735
+ recount_all_limit: "Select up to %{max} rows to recount them all at once"
1723
1736
  bookings:
1724
1737
  title: "Bookings"
1725
1738
  open: "Bookings"
@@ -740,6 +740,10 @@ km:
740
740
  convert:
741
741
  hold_expired: "αž€αžΆαžšαž€αž€αŸ‹αžšαž”αžŸαŸ‹αž’αŸ’αž“αž€αž”αžΆαž“αž•αž»αžαž€αŸ†αžŽαžαŸ‹αž˜αž»αž“αž–αŸαž›αž‘αžΌαž‘αžΆαžαŸ‹αž”αŸ’αžšαžΆαž€αŸ‹αž”αžΆαž“αž”αž‰αŸ’αž…αž”αŸ‹αŸ” αžŸαžΌαž˜αž–αŸ’αž™αžΆαž™αžΆαž˜αž€αž€αŸ‹αž˜αŸ’αžαž„αž‘αŸ€αžαŸ”"
742
742
 
743
+ inventory_item:
744
+ reset:
745
+ busy: "αžŸαŸ’αžαž»αž€αž”αžΆαž“αž•αŸ’αž›αžΆαžŸαŸ‹αž”αŸ’αžαžΌαžšαž–αŸαž›αž€αŸ†αž–αž»αž„αžšαžΆαž”αŸ‹αž‘αžΎαž„αžœαž·αž‰ αž–αŸ’αžšαŸ„αŸ‡αž˜αžΆαž“αž’αŸ’αž“αž€αž€αŸ†αž–αž»αž„αž€αž€αŸ‹αŸ” αžŸαžΌαž˜αž–αŸ’αž™αžΆαž™αžΆαž˜αž˜αŸ’αžαž„αž‘αŸ€αžαž”αž“αŸ’αžαž·αž…αž‘αŸ€αžαŸ”"
746
+
743
747
  stock_status:
744
748
  pending_hold: "αžαž˜αŸ’αžšαžΌαžœαž€αžΆαžšαžαŸ’αž–αžŸαŸ‹"
745
749
  sold_out: "αž›αž€αŸ‹αž’αžŸαŸ‹"
@@ -1176,7 +1180,12 @@ km:
1176
1180
  close: "αž”αž·αž‘αž€αžΆαžšαž›αž€αŸ‹"
1177
1181
  reopen: "αž”αžΎαž€αž€αžΆαžšαž›αž€αŸ‹αž‘αžΎαž„αžœαž·αž‰"
1178
1182
  reset_stock: "αžšαžΆαž”αŸ‹αžŸαŸ’αžαž»αž€αž‘αžΎαž„αžœαž·αž‰"
1179
- reset_stock_confirm: "αžšαžΆαž”αŸ‹αžŸαŸ’αžαž»αž€αž‘αžΎαž„αžœαž·αž‰? αž…αŸ†αž“αž½αž“αž“αŸ…αžŸαž›αŸ‹ αž“αž·αž„αž€αŸ†αž–αž»αž„αžšαž€αŸ’αžŸαžΆαž‘αž»αž€αž“αžΉαž„αžαŸ’αžšαžΌαžœαž‚αžŽαž“αžΆαž‘αžΎαž„αžœαž·αž‰αž–αžΈαžŸαŸ’αžαž»αž€αž˜αžΌαž›αžŠαŸ’αž‹αžΆαž“ αž“αž·αž„αž€αžΆαžšαž€αž˜αŸ’αž˜αŸ‰αž„αŸ‹αŸ”"
1183
+ reset_stock_confirm: "αžšαžΆαž”αŸ‹αžŸαŸ’αžαž»αž€αž‘αžΎαž„αžœαž·αž‰? αž…αŸ†αž“αž½αž“αž“αŸ…αžŸαž›αŸ‹ αž“αž·αž„αž€αŸ†αž–αž»αž„αžšαž€αŸ’αžŸαžΆαž‘αž»αž€αž“αžΉαž„αžαŸ’αžšαžΌαžœαž‚αžŽαž“αžΆαž‘αžΎαž„αžœαž·αž‰αž–αžΈαžŸαŸ’αžαž»αž€αž˜αžΌαž›αžŠαŸ’αž‹αžΆαž“ αž“αž·αž„αž€αžΆαžšαž€αž˜αŸ’αž˜αŸ‰αž„αŸ‹ αžŠαŸ‚αž›αž›αž»αž”αžŸαŸ’αžαž»αž€αžŠαŸ‚αž›αž”αžΆαž“αž€αŸ†αžŽαžαŸ‹αž›αžΎαžαŸ’αž„αŸƒαž“αŸ„αŸ‡αŸ”"
1184
+ recount_limit: "αž’αžΆαž…αžšαžΆαž”αŸ‹αžŸαŸ’αžαž»αž€αž‘αžΎαž„αžœαž·αž‰αž”αžΆαž“αž™αŸ‰αžΆαž„αž…αŸ’αžšαžΎαž“ %{max} αžαŸ’αž„αŸƒαž€αŸ’αž“αž»αž„αž˜αž½αž™αž›αžΎαž€"
1185
+ busy: "%{count} αž€αŸ†αž–αž»αž„αžαŸ’αžšαžΌαžœαž”αžΆαž“αž€αž€αŸ‹ αž αžΎαž™αž˜αž·αž“αžαŸ’αžšαžΌαžœαž”αžΆαž“αž”αŸ’αžαžΌαžšαž‘αŸαŸ” αžŸαžΌαž˜αž–αŸ’αž™αžΆαž™αžΆαž˜αž˜αŸ’αžαž„αž‘αŸ€αžαž”αž“αŸ’αžαž·αž…αž‘αŸ€αžαŸ”"
1186
+ use_base_stock: "αž”αŸ’αžšαžΎαžŸαŸ’αžαž»αž€αž˜αžΌαž›αžŠαŸ’αž‹αžΆαž“"
1187
+ use_base_stock_hint: "αžšαžΆαž”αŸ‹αžŸαŸ’αžαž»αž€αžαŸ’αž„αŸƒαžŠαŸ‚αž›αž”αžΆαž“αž‡αŸ’αžšαžΎαžŸαž‘αžΎαž„αžœαž·αž‰αž–αžΈαžŸαŸ’αžαž»αž€αž˜αžΌαž›αžŠαŸ’αž‹αžΆαž“ αž“αž·αž„αž€αžΆαžšαž€αž˜αŸ’αž˜αŸ‰αž„αŸ‹ αžŠαŸ„αž™αž›αž»αž”αžŸαŸ’αžαž»αž€αžŠαŸ‚αž›αž”αžΆαž“αž€αŸ†αžŽαžαŸ‹αž›αžΎαžαŸ’αž„αŸƒαž‘αžΆαŸ†αž„αž“αŸ„αŸ‡"
1188
+ use_base_stock_confirm: "αžšαžΆαž”αŸ‹αžŸαŸ’αžαž»αž€ %{count} αžαŸ’αž„αŸƒαžŠαŸ‚αž›αž”αžΆαž“αž‡αŸ’αžšαžΎαžŸαž‘αžΎαž„αžœαž·αž‰αž–αžΈαžŸαŸ’αžαž»αž€αž˜αžΌαž›αžŠαŸ’αž‹αžΆαž“ αž“αž·αž„αž€αžΆαžšαž€αž˜αŸ’αž˜αŸ‰αž„αŸ‹? αžŸαŸ’αžαž»αž€αžŠαŸ‚αž›αž”αžΆαž“αž€αŸ†αžŽαžαŸ‹αž›αžΎαžαŸ’αž„αŸƒαž‘αžΆαŸ†αž„αž“αŸ„αŸ‡αž“αžΉαž„αžαŸ’αžšαžΌαžœαž›αž»αž”αŸ”"
1180
1189
  deselect: "αžŠαž€αž€αžΆαžšαž‡αŸ’αžšαžΎαžŸαž‘αžΆαŸ†αž„αž’αžŸαŸ‹"
1181
1190
  selected: "αž”αžΆαž“αž‡αŸ’αžšαžΎαžŸ %{count}"
1182
1191
  saving: "αž€αŸ†αž–αž»αž„αžšαž€αŸ’αžŸαžΆαž‘αž»αž€β€¦"
@@ -1260,6 +1269,10 @@ km:
1260
1269
  title: "αž–αŸαžαŸŒαž˜αžΆαž“αž›αž˜αŸ’αž’αž·αžαžŸαŸ’αžαž»αž€"
1261
1270
  date: "αžαŸ’αž„αŸƒ"
1262
1271
  none: "αž‚αŸ’αž˜αžΆαž“αžŸαŸ’αžαž»αž€αžŸαž˜αŸ’αžšαžΆαž”αŸ‹αžαŸ’αž„αŸƒαžŠαŸ‚αž›αž”αžΆαž“αž‡αŸ’αžšαžΎαžŸαž‘αŸαŸ”"
1272
+ recount_all: "αžšαžΆαž”αŸ‹αž‘αžΆαŸ†αž„αž’αžŸαŸ‹αž‘αžΎαž„αžœαž·αž‰"
1273
+ recount_all_hint: "αž‚αžŽαž“αžΆαž‘αžΎαž„αžœαž·αž‰αž–αžΈαžŸαŸ’αžαž»αž€αž˜αžΌαž›αžŠαŸ’αž‹αžΆαž“ αž“αž·αž„αž€αžΆαžšαž€αž˜αŸ’αž˜αŸ‰αž„αŸ‹αŸ– αž€αŸ‚αž…αŸ†αž“αž½αž“αžŠαŸ‚αž›αžαž»αžŸ αž“αž·αž„αž›αž»αž”αžŸαŸ’αžαž»αž€αžŠαŸ‚αž›αž”αžΆαž“αž€αŸ†αžŽαžαŸ‹αž›αžΎαžαŸ’αž„αŸƒαž‘αžΆαŸ†αž„αž“αŸαŸ‡αŸ”"
1274
+ recount_all_confirm: "αžšαžΆαž”αŸ‹αžŸαŸ’αžαž»αž€αž‘αžΆαŸ†αž„ %{count} αž‡αž½αžšαž‘αžΎαž„αžœαž·αž‰? αž…αŸ†αž“αž½αž“αž“αŸ…αžŸαž›αŸ‹ αž“αž·αž„αž€αŸ†αž–αž»αž„αžšαž€αŸ’αžŸαžΆαž‘αž»αž€αž“αžΉαž„αžαŸ’αžšαžΌαžœαž‚αžŽαž“αžΆαž‘αžΎαž„αžœαž·αž‰αž–αžΈαžŸαŸ’αžαž»αž€αž˜αžΌαž›αžŠαŸ’αž‹αžΆαž“ αž“αž·αž„αž€αžΆαžšαž€αž˜αŸ’αž˜αŸ‰αž„αŸ‹ αžŠαŸ‚αž›αž›αž»αž”αžŸαŸ’αžαž»αž€αžŠαŸ‚αž›αž”αžΆαž“αž€αŸ†αžŽαžαŸ‹αž›αžΎαžαŸ’αž„αŸƒαž‘αžΆαŸ†αž„αž“αŸ„αŸ‡αŸ”"
1275
+ recount_all_limit: "αž‡αŸ’αžšαžΎαžŸαž˜αž·αž“αž›αžΎαžŸ %{max} αž‡αž½αžš αžŠαžΎαž˜αŸ’αž”αžΈαžšαžΆαž”αŸ‹αž‘αžΆαŸ†αž„αž’αžŸαŸ‹αž‘αžΎαž„αžœαž·αž‰αž€αŸ’αž“αž»αž„αž˜αž½αž™αž›αžΎαž€"
1263
1276
  bookings:
1264
1277
  title: "αž€αžΆαžšαž€αž€αŸ‹"
1265
1278
  open: "αž€αžΆαžšαž€αž€αŸ‹"
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.14.1'.freeze
2
+ VERSION = '2.14.3'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.14.1
4
+ version: 2.14.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-22 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -1370,6 +1370,7 @@ files:
1370
1370
  - app/jobs/spree_cm_commissioner/application_unique_job.rb
1371
1371
  - app/jobs/spree_cm_commissioner/async_operation_job.rb
1372
1372
  - app/jobs/spree_cm_commissioner/audit_event_job.rb
1373
+ - app/jobs/spree_cm_commissioner/audit_events/cleanup_expired_job.rb
1373
1374
  - app/jobs/spree_cm_commissioner/cancel_import_order_job.rb
1374
1375
  - app/jobs/spree_cm_commissioner/chatrace_order_creator_job.rb
1375
1376
  - app/jobs/spree_cm_commissioner/completion_steps/regenerate_for_line_items_job.rb
@@ -2315,6 +2316,7 @@ files:
2315
2316
  - app/services/spree_cm_commissioner/agency_users/destroy.rb
2316
2317
  - app/services/spree_cm_commissioner/api_caches/invalidate.rb
2317
2318
  - app/services/spree_cm_commissioner/assign_product_to_section/update.rb
2319
+ - app/services/spree_cm_commissioner/audit_events/cleanup_expired.rb
2318
2320
  - app/services/spree_cm_commissioner/audit_logger.rb
2319
2321
  - app/services/spree_cm_commissioner/calculate_distance.rb
2320
2322
  - app/services/spree_cm_commissioner/cart/add_guest.rb
@@ -2638,6 +2640,7 @@ files:
2638
2640
  - app/services/spree_cm_commissioner/routes/create.rb
2639
2641
  - app/services/spree_cm_commissioner/routes/update.rb
2640
2642
  - app/services/spree_cm_commissioner/rsa_service.rb
2643
+ - app/services/spree_cm_commissioner/seat_layouts/grid.rb
2641
2644
  - app/services/spree_cm_commissioner/seat_layouts/sanitize_svg.rb
2642
2645
  - app/services/spree_cm_commissioner/seat_layouts/update.rb
2643
2646
  - app/services/spree_cm_commissioner/seats/apply_locks.rb