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.
- checksums.yaml +4 -4
- data/.env.example +4 -6
- data/Gemfile.lock +1 -1
- data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +76 -70
- data/app/controllers/spree/admin/api_caches_controller.rb +26 -0
- data/app/controllers/spree/admin/base_controller_decorator.rb +0 -14
- data/app/controllers/spree/admin/guests_controller.rb +43 -4
- data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +26 -5
- data/app/jobs/spree_cm_commissioner/audit_events/cleanup_expired_job.rb +18 -0
- data/app/models/concerns/spree_cm_commissioner/vendor_preference.rb +1 -1
- data/app/models/spree_cm_commissioner/audit_event.rb +5 -0
- data/app/overrides/spree/admin/menus/index/clear_cache_button.html.erb.deface +4 -0
- data/app/overrides/spree/admin/payment_methods/index/clear_cache_button.html.erb.deface +6 -0
- data/app/overrides/spree/admin/products/edit/clear_cache_button.html.erb.deface +1 -1
- data/app/overrides/spree/admin/taxons/edit/clear_cache_button.html.erb.deface +1 -4
- data/app/overrides/spree/admin/vendors/edit/clear_cache_button.html.erb.deface +5 -0
- data/app/serializers/spree/api/v2/base_serializer_decorator.rb +24 -0
- data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +39 -87
- data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +61 -0
- data/app/services/spree_cm_commissioner/audit_events/cleanup_expired.rb +58 -0
- data/app/services/spree_cm_commissioner/audit_logger.rb +1 -1
- data/app/services/spree_cm_commissioner/guests/build_dynamic_field_attributes.rb +34 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +45 -128
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/seat_layout.rb +36 -148
- data/app/services/spree_cm_commissioner/inventory_items/reset.rb +103 -35
- data/app/services/spree_cm_commissioner/order/guests/update.rb +2 -24
- data/app/services/spree_cm_commissioner/rates_availabilities/bulk_update.rb +14 -8
- data/app/services/spree_cm_commissioner/seat_layouts/grid.rb +56 -0
- data/app/views/spree/admin/guests/_dynamic_fields.html.erb +67 -0
- data/app/views/spree/admin/guests/_form.html.erb +2 -0
- data/app/views/spree/admin/homepage_background/index.html.erb +1 -1
- data/app/views/spree/admin/homepage_section/edit.html.erb +0 -1
- data/app/views/spree/admin/homepage_section/index.html.erb +1 -1
- data/app/views/spree/admin/inventory_items/_details.html.erb +16 -0
- data/app/views/spree/admin/rates_availabilities/_script.html.erb +49 -7
- data/app/views/spree/admin/rates_availabilities/index.html.erb +4 -0
- data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -0
- data/config/locales/en.yml +25 -1
- data/config/locales/km.yml +25 -1
- data/config/routes.rb +5 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +13 -1
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/seat_layout.rb
CHANGED
|
@@ -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
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
301
|
-
process_layer_rows(rows, layer_name, blocks, offsets, current_layer_x_offset, seat_layout)
|
|
196
|
+
private
|
|
302
197
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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] =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<% end %>
|
|
4
4
|
|
|
5
5
|
<% content_for :page_actions do %>
|
|
6
|
-
<%=
|
|
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
|
-
<%=
|
|
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 %>
|