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
@@ -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
@@ -21,32 +21,10 @@ module SpreeCmCommissioner
21
21
  editable_attrs = guest.kyc_fields.flat_map { |field| SpreeCmCommissioner::KycBitwise::KYC_FIELD_ATTRIBUTES[field] || [] }
22
22
 
23
23
  filtered = attrs.slice(*editable_attrs)
24
- filtered[:guest_dynamic_fields_attributes] = build_dynamic_field_attributes(guest, attrs[:dynamic_fields])
24
+ filtered[:guest_dynamic_fields_attributes] =
25
+ SpreeCmCommissioner::Guests::BuildDynamicFieldAttributes.call(guest: guest, dynamic_fields: attrs[:dynamic_fields])
25
26
  filtered
26
27
  end
27
-
28
- # Builds the guest_dynamic_fields_attributes array SpreeCmCommissioner::Guests::Update
29
- # expects, restricted to dynamic fields actually enabled for this guest's product (one
30
- # `pluck`/load, not a query per submitted field). Option-based fields (checkbox/
31
- # radio/selection) submit one or more selected option ids and become one
32
- # GuestDynamicField row per selected option; Guests::Update's own auto-cleanup
33
- # destroys any existing rows for options that were deselected.
34
- def build_dynamic_field_attributes(guest, dynamic_fields)
35
- return [] if dynamic_fields.blank?
36
-
37
- enabled_fields = guest.line_item.product.dynamic_fields.index_by(&:id)
38
-
39
- dynamic_fields.flat_map do |dynamic_field_id, value|
40
- dynamic_field = enabled_fields[dynamic_field_id.to_s.to_i]
41
- next [] unless dynamic_field
42
-
43
- if dynamic_field.requires_dynamic_field_options?
44
- Array(value).compact_blank.map { |option_id| { dynamic_field_id: dynamic_field.id, dynamic_field_option_id: option_id } }
45
- else
46
- [{ dynamic_field_id: dynamic_field.id, value: value }]
47
- end
48
- end
49
- end
50
28
  end
51
29
  end
52
30
  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
@@ -0,0 +1,67 @@
1
+ <% answers_by_field_id = @guest_dynamic_fields.group_by(&:dynamic_field_id) %>
2
+ <% i18n_scope = 'views.admin.guests.dynamic_fields' %>
3
+
4
+ <div class="row">
5
+ <div class="col-12">
6
+ <div class="card mb-3">
7
+ <div class="card-header">
8
+ <h5 class="card-title mb-0 h6"><%= I18n.t('title', scope: i18n_scope) %></h5>
9
+ </div>
10
+
11
+ <div class="card-body">
12
+ <% @dynamic_fields.group_by(&:data_fill_stage).each do |stage, dynamic_fields| %>
13
+ <h6 class="text-muted"><%= I18n.t(stage, scope: "#{i18n_scope}.stages") %></h6>
14
+
15
+ <% dynamic_fields.each do |dynamic_field| %>
16
+ <% answers = answers_by_field_id[dynamic_field.id] || [] %>
17
+ <% answer = answers.first %>
18
+ <% input_name = "#{f.object_name}[dynamic_fields][#{dynamic_field.id}]" %>
19
+ <% input_id = "dynamic_field_#{dynamic_field.id}" %>
20
+ <% selected_option_ids = answers.map(&:dynamic_field_option_id) %>
21
+
22
+ <div class="form-group">
23
+ <%= label_tag input_id, dynamic_field.label %>
24
+
25
+ <% case dynamic_field.data_type.to_sym %>
26
+ <% when :text %>
27
+ <%= text_field_tag input_name, answer&.value, id: input_id, class: 'form-control' %>
28
+ <% when :textarea %>
29
+ <%= text_area_tag input_name, answer&.value, id: input_id, rows: 3, class: 'form-control' %>
30
+ <% when :number %>
31
+ <%= number_field_tag input_name, answer&.value, id: input_id, min: 0, class: 'form-control' %>
32
+ <% when :boolean %>
33
+ <%= select_tag input_name,
34
+ options_for_select([[I18n.t('yes', scope: i18n_scope), 'true'], [I18n.t('no', scope: i18n_scope), 'false']], answer&.value),
35
+ include_blank: true, id: input_id, class: 'form-control' %>
36
+ <% when :radio %>
37
+ <% dynamic_field.dynamic_field_options.each do |option| %>
38
+ <div class="radio">
39
+ <label>
40
+ <%= radio_button_tag input_name, option.id, selected_option_ids.include?(option.id) %>
41
+ <%= option.value %>
42
+ </label>
43
+ </div>
44
+ <% end %>
45
+ <% when :selection, :checkbox %>
46
+ <% if dynamic_field.checkbox? || dynamic_field.multiple_select %>
47
+ <% dynamic_field.dynamic_field_options.each do |option| %>
48
+ <div class="checkbox">
49
+ <label>
50
+ <%= check_box_tag "#{input_name}[]", option.id, selected_option_ids.include?(option.id), id: nil %>
51
+ <%= option.value %>
52
+ </label>
53
+ </div>
54
+ <% end %>
55
+ <% else %>
56
+ <%= select_tag input_name,
57
+ options_from_collection_for_select(dynamic_field.dynamic_field_options, :id, :value, selected_option_ids.first),
58
+ include_blank: true, id: input_id, class: 'form-control' %>
59
+ <% end %>
60
+ <% end %>
61
+ </div>
62
+ <% end %>
63
+ <% end %>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
@@ -149,3 +149,5 @@
149
149
  </div>
150
150
  </div>
151
151
  </div>
152
+
153
+ <%= render partial: 'spree/admin/guests/dynamic_fields', locals: { f: f } if @dynamic_fields.present? %>
@@ -3,7 +3,7 @@
3
3
  <% end %>
4
4
 
5
5
  <% content_for :page_actions do %>
6
- <%= button_link_to Spree.t(:clear_cache), admin_invalidate_api_caches_path(model: SpreeCmCommissioner::HomepageBackground.name), method: :post, class: "btn btn-outline-primary" %>
6
+ <%= render 'spree/admin/shared/api_cache_button', model: SpreeCmCommissioner::HomepageBackground.name %>
7
7
  <%= button_link_to Spree.t(:new_homepage_background), new_admin_homepage_feed_homepage_background_path, { class: "btn-success", icon: 'add.svg', id: 'admin_new_homepage_background' } %>
8
8
  <% end %>
9
9
 
@@ -4,7 +4,6 @@
4
4
  <%= link_to plural_resource_name(SpreeCmCommissioner::HomepageSection), spree.admin_homepage_feed_homepage_section_index_url %> /
5
5
  <%= Spree.t(:edit_section_relatable) %>
6
6
  </div>
7
- <%= button_link_to Spree.t(:clear_cache), admin_invalidate_api_caches_path(model: SpreeCmCommissioner::HomepageSection.name, id: @object.id), method: :post, class: "btn btn-outline-primary" %>
8
7
  </div>
9
8
  <% end %>
10
9
 
@@ -18,7 +18,7 @@
18
18
  <% end %>
19
19
 
20
20
  <% content_for :page_actions do %>
21
- <%= button_link_to Spree.t(:clear_cache), admin_invalidate_api_caches_path(model: SpreeCmCommissioner::HomepageSection.name), method: :post, class: "btn btn-outline-primary" %>
21
+ <%= render 'spree/admin/shared/api_cache_button', model: SpreeCmCommissioner::HomepageSection.name, tenant_id: selected_tenant&.id %>
22
22
  <%= button_link_to Spree.t(:new_homepage_section), new_admin_homepage_feed_homepage_section_path(tenant_id: params[:tenant_id]), { class: "btn-success", icon: 'add.svg', id: 'admin_new_homepage_section' } %>
23
23
  <% end %>
24
24
 
@@ -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 %>