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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
module ApiCaches
|
|
3
|
+
# Clears one record's cached API responses. Every record of a model goes through InvalidateAll.
|
|
3
4
|
class Invalidate
|
|
4
5
|
prepend ::Spree::ServiceModule::Base
|
|
5
6
|
|
|
@@ -16,17 +17,39 @@ module SpreeCmCommissioner
|
|
|
16
17
|
'SpreeCmCommissioner::VotingSession' => SpreeCmCommissioner::VotingSession
|
|
17
18
|
}.freeze
|
|
18
19
|
|
|
19
|
-
#
|
|
20
|
-
|
|
20
|
+
# CloudFront bills per path and only allows `*` at the end, so one prefix per namespace covers
|
|
21
|
+
# every segment's homepage_sections and homepage_background, plus homepage_data, instead of a
|
|
22
|
+
# path per BIT_SEGMENT key.
|
|
23
|
+
HOMEPAGE_SECTION_PATTERNS = [
|
|
24
|
+
'/api/v2/storefront/homepage*',
|
|
25
|
+
'/api/v2/tenant/homepage*'
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
|
|
29
|
+
|
|
30
|
+
# Deliberately broad, and identical for single-record and all-record
|
|
31
|
+
# invalidation: a payment method is either vendor-owned or store-level, and a
|
|
32
|
+
# store-level one is served for every vendor through the fallback in
|
|
33
|
+
# SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
|
|
34
|
+
# tell which vendors are affected.
|
|
35
|
+
#
|
|
36
|
+
# vendor_id is a query param, so the trailing `*` is what clears every
|
|
37
|
+
# vendor's cached variant of this path in one go.
|
|
38
|
+
#
|
|
39
|
+
# Payment methods change a handful of times a year, so a broad invalidation
|
|
40
|
+
# is cheap.
|
|
41
|
+
PAYMENT_METHOD_PATTERNS = [
|
|
42
|
+
'/api/v2/tenant/payment_methods*',
|
|
43
|
+
'/api/v2/storefront/payment_methods*'
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# eg. 'Spree::Product', 123
|
|
47
|
+
def call(model:, id:)
|
|
21
48
|
model_class = SUPPORTED_MODELS[model]
|
|
22
49
|
|
|
23
50
|
return failure(nil, 'Model is not supported') if model_class.nil?
|
|
24
51
|
|
|
25
|
-
|
|
26
|
-
invalidate_single_record(model_class: model_class, id: id)
|
|
27
|
-
else
|
|
28
|
-
invalidate_all_records(model_class: model_class)
|
|
29
|
-
end
|
|
52
|
+
invalidate_single_record(model_class: model_class, id: id)
|
|
30
53
|
end
|
|
31
54
|
|
|
32
55
|
# eg. 'Spree::Product', 123 => ['/api/v2/tenant/products/123', '/api/v2/tenant/products/slug-of-product', ...]
|
|
@@ -38,7 +61,10 @@ module SpreeCmCommissioner
|
|
|
38
61
|
|
|
39
62
|
refresh_record_cache_key(record)
|
|
40
63
|
|
|
41
|
-
|
|
64
|
+
# The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
|
|
65
|
+
# model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
|
|
66
|
+
patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
|
|
67
|
+
api_patterns = patterns.map do |pattern|
|
|
42
68
|
pattern.gsub(/:(\w+)/) do
|
|
43
69
|
key = Regexp.last_match(1)
|
|
44
70
|
record.public_send(key).to_s
|
|
@@ -50,26 +76,13 @@ module SpreeCmCommissioner
|
|
|
50
76
|
success(record: record, api_patterns: api_patterns)
|
|
51
77
|
end
|
|
52
78
|
|
|
53
|
-
# eg. 'Spree::Product' => ['/api/v2/tenant/products/*', '/api/v2/storefront/products/*']
|
|
54
|
-
def invalidate_all_records(model_class:)
|
|
55
|
-
model_class.find_each do |record|
|
|
56
|
-
refresh_record_cache_key(record)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
enqueue_invalidate(invalidate_uris[model_class.name])
|
|
60
|
-
|
|
61
|
-
success(api_patterns: invalidate_uris[model_class.name])
|
|
62
|
-
end
|
|
63
|
-
|
|
64
79
|
def single_api_pattern
|
|
65
80
|
{
|
|
66
|
-
'SpreeCmCommissioner::HomepageSection' =>
|
|
67
|
-
'SpreeCmCommissioner::HomepageBackground' =>
|
|
81
|
+
'SpreeCmCommissioner::HomepageSection' => HOMEPAGE_SECTION_PATTERNS,
|
|
82
|
+
'SpreeCmCommissioner::HomepageBackground' => HOMEPAGE_BACKGROUND_PATTERNS,
|
|
68
83
|
'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/:id*'],
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
'/api/v2/storefront/homepage_data*'
|
|
72
|
-
],
|
|
84
|
+
# The app reads menus from homepage_data; nothing calls /menus any more.
|
|
85
|
+
'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
|
|
73
86
|
'Spree::Taxon' => [
|
|
74
87
|
'/api/v2/tenant/taxons/:id*',
|
|
75
88
|
'/api/v2/tenant/taxons/:permalink*',
|
|
@@ -92,50 +105,12 @@ module SpreeCmCommissioner
|
|
|
92
105
|
'/api/v2/storefront/accommodations/:id*',
|
|
93
106
|
'/api/v2/storefront/accommodations/:slug*'
|
|
94
107
|
],
|
|
95
|
-
'Spree::PaymentMethod' =>
|
|
96
|
-
}
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def invalidate_uris
|
|
100
|
-
{
|
|
101
|
-
'SpreeCmCommissioner::HomepageSection' => homepage_section_patterns,
|
|
102
|
-
'SpreeCmCommissioner::HomepageBackground' => homepage_background_patterns,
|
|
103
|
-
'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/*'],
|
|
104
|
-
'Spree::Menu' => ['/api/v2/storefront/menus/*'],
|
|
105
|
-
'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
|
|
106
|
-
'Spree::Product' => [
|
|
107
|
-
'/api/v2/tenant/products/*',
|
|
108
|
-
'/api/v2/storefront/products/*'
|
|
109
|
-
],
|
|
110
|
-
'Spree::Vendor' => [
|
|
111
|
-
'/api/v2/tenant/vendors/*',
|
|
112
|
-
'/api/v2/storefront/vendors/*',
|
|
113
|
-
'/api/v2/storefront/accommodations/*'
|
|
114
|
-
],
|
|
115
|
-
'Spree::PaymentMethod' => payment_method_patterns
|
|
108
|
+
'Spree::PaymentMethod' => PAYMENT_METHOD_PATTERNS
|
|
116
109
|
}
|
|
117
110
|
end
|
|
118
111
|
|
|
119
112
|
private
|
|
120
113
|
|
|
121
|
-
# Deliberately broad, and identical for single-record and all-record
|
|
122
|
-
# invalidation: a payment method is either vendor-owned or store-level, and a
|
|
123
|
-
# store-level one is served for every vendor through the fallback in
|
|
124
|
-
# SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
|
|
125
|
-
# tell which vendors are affected.
|
|
126
|
-
#
|
|
127
|
-
# vendor_id is a query param, so the trailing `*` is what clears every
|
|
128
|
-
# vendor's cached variant of this path in one go.
|
|
129
|
-
#
|
|
130
|
-
# Payment methods change a handful of times a year, so a broad invalidation
|
|
131
|
-
# is cheap.
|
|
132
|
-
def payment_method_patterns
|
|
133
|
-
[
|
|
134
|
-
'/api/v2/tenant/payment_methods*',
|
|
135
|
-
'/api/v2/storefront/payment_methods*'
|
|
136
|
-
]
|
|
137
|
-
end
|
|
138
|
-
|
|
139
114
|
# eg. updated_at: Fri, 26 Dec 2025 10:40:56.505450000 +07 +07:00
|
|
140
115
|
def refresh_record_cache_key(record)
|
|
141
116
|
# rubocop:disable Rails/SkipsModelValidations
|
|
@@ -147,29 +122,6 @@ module SpreeCmCommissioner
|
|
|
147
122
|
def enqueue_invalidate(patterns)
|
|
148
123
|
SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: patterns)
|
|
149
124
|
end
|
|
150
|
-
|
|
151
|
-
# Every key of BIT_SEGMENT (general, ticket, ..., ferry).
|
|
152
|
-
def homepage_segment
|
|
153
|
-
SpreeCmCommissioner::HomepageSectionBitwise::BIT_SEGMENT.keys
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def homepage_section_patterns
|
|
157
|
-
homepage_segment.flat_map do |segment|
|
|
158
|
-
[
|
|
159
|
-
"/api/v2/storefront/homepage/#{segment}/homepage_sections*",
|
|
160
|
-
"/api/v2/tenant/homepage/#{segment}/homepage_sections*"
|
|
161
|
-
]
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def homepage_background_patterns
|
|
166
|
-
patterns = homepage_segment.map do |segment|
|
|
167
|
-
"/api/v2/storefront/homepage/#{segment}/homepage_background*"
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
patterns << '/api/v2/storefront/homepage_data*'
|
|
171
|
-
patterns
|
|
172
|
-
end
|
|
173
125
|
end
|
|
174
126
|
end
|
|
175
127
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module ApiCaches
|
|
3
|
+
# Clears every record of a model for one storefront. `tenant` is required so a clear-all never
|
|
4
|
+
# pays for both namespaces by accident: nil is the BookMe+ storefront (/api/v2/storefront/
|
|
5
|
+
# paths), a SpreeCmCommissioner::Tenant is that tenant's API (/api/v2/tenant/ paths).
|
|
6
|
+
#
|
|
7
|
+
# Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
|
|
8
|
+
# today; taking the record keeps callers the same if tenant paths ever carry it.
|
|
9
|
+
#
|
|
10
|
+
# eg. InvalidateAll.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: nil)
|
|
11
|
+
class InvalidateAll
|
|
12
|
+
prepend ::Spree::ServiceModule::Base
|
|
13
|
+
|
|
14
|
+
TENANT_PREFIX = '/api/v2/tenant/'.freeze
|
|
15
|
+
|
|
16
|
+
API_PATTERNS = {
|
|
17
|
+
'SpreeCmCommissioner::HomepageSection' => Invalidate::HOMEPAGE_SECTION_PATTERNS,
|
|
18
|
+
'SpreeCmCommissioner::HomepageBackground' => Invalidate::HOMEPAGE_BACKGROUND_PATTERNS,
|
|
19
|
+
'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/*'],
|
|
20
|
+
'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
|
|
21
|
+
'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
|
|
22
|
+
'Spree::Product' => [
|
|
23
|
+
'/api/v2/tenant/products/*',
|
|
24
|
+
'/api/v2/storefront/products/*'
|
|
25
|
+
],
|
|
26
|
+
'Spree::Vendor' => [
|
|
27
|
+
'/api/v2/tenant/vendors/*',
|
|
28
|
+
'/api/v2/storefront/vendors/*',
|
|
29
|
+
'/api/v2/storefront/accommodations/*'
|
|
30
|
+
],
|
|
31
|
+
'Spree::PaymentMethod' => Invalidate::PAYMENT_METHOD_PATTERNS
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def call(model:, tenant:)
|
|
35
|
+
model_class = Invalidate::SUPPORTED_MODELS[model] if API_PATTERNS.key?(model)
|
|
36
|
+
return failure(nil, 'Model is not supported') if model_class.nil?
|
|
37
|
+
return failure(nil, 'tenant must be a SpreeCmCommissioner::Tenant or nil') unless tenant.nil? || tenant.is_a?(SpreeCmCommissioner::Tenant)
|
|
38
|
+
|
|
39
|
+
refresh_records_cache_key(model_class, tenant)
|
|
40
|
+
|
|
41
|
+
api_patterns = API_PATTERNS[model].select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }
|
|
42
|
+
|
|
43
|
+
# Empty when the model has no API in this namespace, e.g. homepage backgrounds for a tenant,
|
|
44
|
+
# and CloudFront rejects an empty batch.
|
|
45
|
+
SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: api_patterns) if api_patterns.any?
|
|
46
|
+
|
|
47
|
+
success(api_patterns: api_patterns)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# updated_at keys Spree's serializer and collection caches, so only this storefront's records
|
|
53
|
+
# are touched when the model is tenant-scoped.
|
|
54
|
+
def refresh_records_cache_key(model_class, tenant)
|
|
55
|
+
scope = model_class.column_names.include?('tenant_id') ? model_class.where(tenant_id: tenant&.id) : model_class.all
|
|
56
|
+
|
|
57
|
+
scope.find_each(&:touch)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module AuditEvents
|
|
3
|
+
class CleanupExpired
|
|
4
|
+
prepend ::Spree::ServiceModule::Base
|
|
5
|
+
|
|
6
|
+
BATCH_SIZE = 1_000
|
|
7
|
+
TIME_BUDGET = 30.seconds
|
|
8
|
+
|
|
9
|
+
# Deletes expired audit events chunk by chunk until none are left or the time budget is spent, so a large backlog
|
|
10
|
+
# never pins a Sidekiq thread past its shutdown window. CleanupExpiredJob schedules the next run while
|
|
11
|
+
# `more_remaining` is true.
|
|
12
|
+
#
|
|
13
|
+
# Safe to delete without callbacks: nothing references cm_audit_events (checked in cm-market-server/db/schema.rb).
|
|
14
|
+
# Errors are not rescued on purpose: a failed drain must surface to Sidekiq (retry + alert), not end quietly.
|
|
15
|
+
def call(batch_size: BATCH_SIZE, time_budget: TIME_BUDGET)
|
|
16
|
+
started_at = monotonic_now
|
|
17
|
+
deleted_count = 0
|
|
18
|
+
batches = 0
|
|
19
|
+
more_remaining = false
|
|
20
|
+
|
|
21
|
+
loop do
|
|
22
|
+
deleted = delete_expired_chunk(batch_size)
|
|
23
|
+
deleted_count += deleted
|
|
24
|
+
batches += 1 if deleted.positive?
|
|
25
|
+
|
|
26
|
+
break if deleted < batch_size
|
|
27
|
+
|
|
28
|
+
if monotonic_now - started_at >= time_budget.to_f
|
|
29
|
+
more_remaining = true
|
|
30
|
+
break
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
elapsed_ms = ((monotonic_now - started_at) * 1000).round
|
|
35
|
+
log_completion(deleted_count: deleted_count, batches: batches, elapsed_ms: elapsed_ms, more_remaining: more_remaining)
|
|
36
|
+
|
|
37
|
+
success(deleted_count: deleted_count, batches: batches, more_remaining: more_remaining)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# One `DELETE ... WHERE id IN (SELECT id ... LIMIT n)`: no rows are loaded into Ruby.
|
|
43
|
+
def delete_expired_chunk(batch_size)
|
|
44
|
+
SpreeCmCommissioner::AuditEvent
|
|
45
|
+
.where(id: SpreeCmCommissioner::AuditEvent.expired.limit(batch_size).select(:id))
|
|
46
|
+
.delete_all
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def monotonic_now
|
|
50
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def log_completion(**data)
|
|
54
|
+
CmAppLogger.log(label: 'SpreeCmCommissioner::AuditEvents::CleanupExpired completed', data: data)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -30,7 +30,7 @@ module SpreeCmCommissioner
|
|
|
30
30
|
attempt: 0,
|
|
31
31
|
retention_days: nil
|
|
32
32
|
)
|
|
33
|
-
retention_until = (retention_days ||
|
|
33
|
+
retention_until = (retention_days || SpreeCmCommissioner::AuditEvent::DEFAULT_RETENTION_DAYS).days.from_now.to_date
|
|
34
34
|
|
|
35
35
|
SpreeCmCommissioner::AuditEventJob.perform_later(
|
|
36
36
|
event_type: event_type,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Guests
|
|
3
|
+
# Option-based fields (checkbox/radio/selection) become one GuestDynamicField row per selected
|
|
4
|
+
# option id; Guests::Update's own auto-cleanup destroys rows for deselected options. Fields not
|
|
5
|
+
# enabled on the guest's product are dropped.
|
|
6
|
+
class BuildDynamicFieldAttributes
|
|
7
|
+
def self.call(guest:, dynamic_fields:)
|
|
8
|
+
new(guest, dynamic_fields).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(guest, dynamic_fields)
|
|
12
|
+
@guest = guest
|
|
13
|
+
@dynamic_fields = dynamic_fields
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return [] if @dynamic_fields.blank?
|
|
18
|
+
|
|
19
|
+
enabled_fields = @guest.line_item.product.dynamic_fields.index_by(&:id)
|
|
20
|
+
|
|
21
|
+
@dynamic_fields.flat_map do |dynamic_field_id, value|
|
|
22
|
+
dynamic_field = enabled_fields[dynamic_field_id.to_s.to_i]
|
|
23
|
+
next [] unless dynamic_field
|
|
24
|
+
|
|
25
|
+
if dynamic_field.requires_dynamic_field_options?
|
|
26
|
+
Array(value).compact_blank.map { |option_id| { dynamic_field_id: dynamic_field.id, dynamic_field_option_id: option_id } }
|
|
27
|
+
else
|
|
28
|
+
[{ dynamic_field_id: dynamic_field.id, value: value }]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
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
|
|