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 +4 -4
- data/Gemfile.lock +1 -1
- 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/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/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/rates_availabilities/bulk_update.rb +14 -8
- data/app/services/spree_cm_commissioner/seat_layouts/grid.rb +56 -0
- 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/config/locales/en.yml +14 -1
- data/config/locales/km.yml +14 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c169ca8f3c24e2294f37a17142112c820953fbd3b109c7e53c3a39ccf19e88e4
|
|
4
|
+
data.tar.gz: ec8aee4c537d0f1f47b705eb4e248a0b148b124979e743adfd02d1b510b4a9f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cb10cc3987eff0d89409c4275cafa26263a9ed349deacb81c816c3fca496927973a640eb1fe875a4e724eebdc43ecc3cec6f5e8626b7f9653e422704bad6553
|
|
7
|
+
data.tar.gz: 1f68fa692abff8ab900ac03cc864b5509270c863b62606b8f2ddca4b98c7f05358b61c82663af1f580ab7a5510f690fc3827d2f07c1cc60c574e6146add476be
|
data/Gemfile.lock
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
# Order Number:
|
|
4
4
|
# <code>R621016753</code>
|
|
5
5
|
#
|
|
6
|
-
#
|
|
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
|
|
@@ -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 ||
|
|
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,
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb
CHANGED
|
@@ -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.
|
|
120
|
-
# synced last
|
|
121
|
-
#
|
|
122
|
-
#
|
|
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
|
-
|
|
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:
|
|
138
|
-
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
251
|
-
#
|
|
252
|
-
def
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
458
|
-
this.
|
|
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.
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
|
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"
|
data/config/locales/km.yml
CHANGED
|
@@ -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: "ααΆαααα"
|
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.
|
|
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-
|
|
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
|