spree_cm_commissioner 2.14.6 → 2.14.8
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/controllers/concerns/spree_cm_commissioner/content_cachable.rb +9 -1
- data/app/controllers/concerns/spree_cm_commissioner/guest_owner_authorization.rb +30 -0
- data/app/controllers/spree/admin/stock_managements_controller.rb +12 -1
- data/app/controllers/spree/api/v2/organizer/invites_controller.rb +5 -1
- data/app/controllers/spree/api/v2/storefront/guests_controller.rb +13 -7
- data/app/controllers/spree/api/v2/storefront/id_cards_controller.rb +6 -4
- data/app/controllers/spree/api/v2/storefront/products_controller_decorator.rb +1 -1
- data/app/controllers/spree/api/v2/storefront/s3_signed_urls_controller.rb +4 -3
- data/app/controllers/spree/api/v2/tenant/base_controller.rb +23 -3
- data/app/controllers/spree/api/v2/tenant/cart_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/id_cards_controller.rb +6 -4
- data/app/controllers/spree/api/v2/tenant/payment_methods_controller.rb +1 -2
- data/app/controllers/spree/api/v2/tenant/products_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/s3_signed_urls_controller.rb +4 -3
- data/app/interactors/spree_cm_commissioner/guest_id_card_manager.rb +18 -9
- data/app/interactors/spree_cm_commissioner/id_card_image_updater.rb +6 -3
- data/app/interactors/spree_cm_commissioner/image_saver.rb +4 -3
- data/app/interactors/spree_cm_commissioner/profile_image_updater.rb +6 -3
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -9
- data/app/models/concerns/spree_cm_commissioner/sales_channel_bitwise.rb +44 -0
- data/app/models/concerns/spree_cm_commissioner/user_uploaded_image.rb +17 -0
- data/app/models/spree_cm_commissioner/back_image.rb +2 -0
- data/app/models/spree_cm_commissioner/front_image.rb +2 -0
- data/app/models/spree_cm_commissioner/integration.rb +1 -1
- data/app/models/spree_cm_commissioner/integration_mapping.rb +27 -0
- data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +1 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +11 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +4 -0
- data/app/models/spree_cm_commissioner/s3_presigned_url.rb +8 -7
- data/app/models/spree_cm_commissioner/s3_presigned_url_builder.rb +9 -3
- data/app/models/spree_cm_commissioner/tenant.rb +3 -0
- data/app/models/spree_cm_commissioner/user_profile.rb +2 -0
- data/app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface +13 -0
- data/app/overrides/spree/admin/products/_form/sellable_on_platform.html.erb.deface +12 -0
- data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +4 -1
- data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +28 -17
- data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +5 -7
- data/app/services/spree_cm_commissioner/draft_orders/create.rb +6 -3
- data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +30 -4
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_inventory.rb +33 -11
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_product.rb +3 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +7 -3
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +44 -32
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_variant.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +12 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +2 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_product.rb +2 -1
- data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_product.rb +2 -1
- data/app/views/spree/admin/integrations/_book_me_bus_v1_fields.html.erb +10 -0
- data/app/views/spree/admin/integrations/_form.html.erb +1 -1
- data/config/initializers/spree_permitted_attributes.rb +4 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/km.yml +1 -0
- data/config/routes.rb +14 -6
- data/db/migrate/20261103000001_add_sales_channel_to_spree_products.rb +14 -0
- data/lib/spree_cm_commissioner/s3_url_generator.rb +13 -1
- data/lib/spree_cm_commissioner/tenant_slug_constraint.rb +23 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- data/lib/spree_cm_commissioner.rb +1 -0
- metadata +9 -9
- data/app/controllers/spree/api/v2/organizer/images_controller.rb +0 -56
- data/app/controllers/spree/api/v2/organizer/s3_signed_urls_controller.rb +0 -29
- data/app/controllers/spree/api/v2/organizer/ticket_images_controller.rb +0 -27
- data/app/controllers/spree/api/v2/organizer/tickets_controller.rb +0 -97
- data/app/serializers/spree/v2/organizer/image_serializer.rb +0 -11
- data/app/serializers/spree/v2/organizer/s3_signed_url_serializer.rb +0 -10
- data/app/serializers/spree/v2/organizer/ticket_serializer.rb +0 -9
|
@@ -6,6 +6,7 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1 < SpreeCmCommissioner::Inte
|
|
|
6
6
|
store_private_metadata :access_token, :string
|
|
7
7
|
store_private_metadata :token_expires_at, :datetime
|
|
8
8
|
store_private_metadata :operator_id, :string
|
|
9
|
+
store_private_metadata :sync_days, :integer, default: 7
|
|
9
10
|
# override
|
|
10
11
|
def sync_manager
|
|
11
12
|
SpreeCmCommissioner::Integrations::BookMeBusV1::SyncManager.new(integration: self)
|
|
@@ -190,6 +190,17 @@ module SpreeCmCommissioner
|
|
|
190
190
|
super
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
# Must live here, not in OrderStateMachine: that concern is included (below Spree::Order),
|
|
194
|
+
# while Spree::Emails::OrderDecorator is prepended above it and never calls super, so an
|
|
195
|
+
# override there is silently dead.
|
|
196
|
+
def deliver_order_confirmation_email
|
|
197
|
+
return if requested?
|
|
198
|
+
return if any_app_only_products?
|
|
199
|
+
return if products.none?(&:enable_confirmation_email?)
|
|
200
|
+
|
|
201
|
+
super
|
|
202
|
+
end
|
|
203
|
+
|
|
193
204
|
def any_app_only_products?
|
|
194
205
|
if products.loaded?
|
|
195
206
|
products.any?(&:purchasable_on_app?)
|
|
@@ -143,6 +143,7 @@ module SpreeCmCommissioner
|
|
|
143
143
|
base.store_public_metadata :enable_inventory_hold, :boolean, default: false
|
|
144
144
|
base.store_public_metadata :enable_telegram_alert, :boolean, default: true
|
|
145
145
|
base.store_public_metadata :enable_send_receipt, :boolean, default: true
|
|
146
|
+
base.store_public_metadata :enable_confirmation_email, :boolean, default: true
|
|
146
147
|
base.store_public_metadata :enable_social_sharing_email, :boolean, default: false
|
|
147
148
|
|
|
148
149
|
base.store_private_metadata :advertise_weight, :integer, default: 1
|
|
@@ -169,6 +170,9 @@ module SpreeCmCommissioner
|
|
|
169
170
|
base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant'
|
|
170
171
|
base.before_save :set_tenant
|
|
171
172
|
|
|
173
|
+
# Included after set_tenant so its before_save sees the resolved tenant_id.
|
|
174
|
+
base.include SpreeCmCommissioner::SalesChannelBitwise
|
|
175
|
+
|
|
172
176
|
# Create maintaining task to purge product related caches
|
|
173
177
|
base.after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
|
|
174
178
|
|
|
@@ -11,25 +11,26 @@ module SpreeCmCommissioner
|
|
|
11
11
|
URI.parse(url).host
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# content_length_range: [Range, nil] byte range S3 enforces on the upload itself; nil means no limit.
|
|
15
|
+
def initialize(bucket_name, object_key, uuid = nil, content_length_range: nil)
|
|
15
16
|
@id = uuid.presence || generate_secure_random
|
|
16
17
|
|
|
17
18
|
if object_key.blank?
|
|
18
19
|
@error_message = I18n.t('s3_signed_url.missing_file_name')
|
|
19
20
|
else
|
|
20
|
-
@s3_direct_post = presigned_post(bucket_name, object_key)
|
|
21
|
+
@s3_direct_post = presigned_post(bucket_name, object_key, content_length_range)
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
private
|
|
25
26
|
|
|
26
|
-
def presigned_post(bucket_name, object_key)
|
|
27
|
+
def presigned_post(bucket_name, object_key, content_length_range)
|
|
28
|
+
options = { key: object_key, success_action_status: '201' }
|
|
29
|
+
options[:content_length_range] = content_length_range if content_length_range
|
|
30
|
+
|
|
27
31
|
Aws::S3::Resource.new
|
|
28
32
|
.bucket(bucket_name)
|
|
29
|
-
.presigned_post(
|
|
30
|
-
key: object_key,
|
|
31
|
-
success_action_status: '201'
|
|
32
|
-
)
|
|
33
|
+
.presigned_post(**options)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def generate_secure_random
|
|
@@ -6,13 +6,15 @@ module SpreeCmCommissioner
|
|
|
6
6
|
new(options).call
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
# {:bucket_name?, (:object_key | :file_name)? }
|
|
9
|
+
# {:bucket_name?, (:object_key | :file_name)?, :content_length_range? }
|
|
10
|
+
# bucket_name/object_key are for trusted server-side callers only (admin media uploads); API
|
|
11
|
+
# controllers must pass just file_name, so callers can only write under uploads/<uuid>/.
|
|
10
12
|
def initialize(options)
|
|
11
13
|
@options = options
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def call
|
|
15
|
-
SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid)
|
|
17
|
+
SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid, content_length_range: options[:content_length_range])
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
private
|
|
@@ -20,7 +22,11 @@ module SpreeCmCommissioner
|
|
|
20
22
|
def object_key
|
|
21
23
|
return options[:object_key] if options[:object_key].present?
|
|
22
24
|
|
|
23
|
-
"
|
|
25
|
+
# basename so a file_name like "../x" or "a/b" can't move the key out of its uuid folder.
|
|
26
|
+
file_name = File.basename(options[:file_name].to_s)
|
|
27
|
+
return nil if file_name.blank? || %w[. .. /].include?(file_name)
|
|
28
|
+
|
|
29
|
+
"uploads/#{uuid}/#{file_name}"
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def bucket_name
|
|
@@ -21,6 +21,9 @@ module SpreeCmCommissioner
|
|
|
21
21
|
allow_blank: true,
|
|
22
22
|
format: { without: %r{\Ahttps?://}, message: :format }
|
|
23
23
|
|
|
24
|
+
# The slug is the first segment of /api/v2/:tenant_slug/..., so it must not shadow a v2 namespace.
|
|
25
|
+
validates :slug, exclusion: { in: SpreeCmCommissioner::TenantSlugConstraint::RESERVED_SLUGS }
|
|
26
|
+
|
|
24
27
|
validates :sms_sender_id, length: { maximum: 11 }, allow_blank: true
|
|
25
28
|
|
|
26
29
|
validates :default_currency, inclusion: { in: -> (tenant) { tenant.supported_currencies_list } }, allow_blank: true
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
|
|
2
|
+
|
|
3
|
+
<div>
|
|
4
|
+
<%= f.field_container :enable_confirmation_email do %>
|
|
5
|
+
<%= f.label :enable_confirmation_email do %>
|
|
6
|
+
<%= f.check_box :enable_confirmation_email %>
|
|
7
|
+
<%= Spree.t(:enable_confirmation_email) %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<small class="form-text text-muted">
|
|
10
|
+
When enabled, customers receive the booking confirmation email after their order is completed.
|
|
11
|
+
</small>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
|
|
2
|
+
|
|
3
|
+
<%# BookMe+'s own products are always sold on the platform, so only tenant products get the choice. %>
|
|
4
|
+
<% if f.object.vendor&.tenant_id.present? %>
|
|
5
|
+
<div data-hook="admin_product_form_sellable_on_platform">
|
|
6
|
+
<%= f.field_container :sellable_on_platform do %>
|
|
7
|
+
<%= f.check_box :sellable_on_platform %>
|
|
8
|
+
<%= f.label :sellable_on_platform, 'Also sell on BookMe+' %>
|
|
9
|
+
<%= f.error_message_on :sales_channel %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
@@ -4,7 +4,10 @@ module Spree
|
|
|
4
4
|
module TaxonSerializerDecorator
|
|
5
5
|
def self.prepended(base)
|
|
6
6
|
base.has_many :vendors, serializer: ::Spree::V2::Storefront::VendorSerializer
|
|
7
|
-
|
|
7
|
+
# The app lists an event section's tickets from this relation, so products a tenant keeps off BookMe+ are dropped here.
|
|
8
|
+
base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer do |taxon|
|
|
9
|
+
taxon.visible_products.sellable_on(:platform)
|
|
10
|
+
end
|
|
8
11
|
base.has_many :places, serializer: ::Spree::V2::Storefront::PlaceSerializer
|
|
9
12
|
|
|
10
13
|
base.has_one :vendor, serializer: ::Spree::V2::Storefront::VendorSerializer
|
|
@@ -4,13 +4,13 @@ module SpreeCmCommissioner
|
|
|
4
4
|
# MaintenanceTasks::CacheInvalidation sends. Every record of a model goes through InvalidateAll.
|
|
5
5
|
#
|
|
6
6
|
# Only the record's own storefront is cleared, as InvalidateAll does: its tenant's API
|
|
7
|
-
# (/api/v2
|
|
8
|
-
# is billed, and the other storefront's copy, if anything ever fetched one, expires
|
|
9
|
-
# TTL anyway.
|
|
7
|
+
# (/api/v2/<tenant slug>/ paths) when it belongs to a tenant, the BookMe+ storefront otherwise.
|
|
8
|
+
# Each path is billed, and the other storefront's copy, if anything ever fetched one, expires
|
|
9
|
+
# within a CDN TTL anyway. Legacy /api/v2/tenant/ paths are never CDN-cached, so never cleared.
|
|
10
10
|
class Invalidate
|
|
11
11
|
prepend ::Spree::ServiceModule::Base
|
|
12
12
|
|
|
13
|
-
TENANT_PREFIX = '/api/v2/
|
|
13
|
+
TENANT_PREFIX = '/api/v2/:tenant_slug/'.freeze
|
|
14
14
|
|
|
15
15
|
SUPPORTED_MODELS = {
|
|
16
16
|
'SpreeCmCommissioner::HomepageSection' => SpreeCmCommissioner::HomepageSection,
|
|
@@ -30,7 +30,7 @@ module SpreeCmCommissioner
|
|
|
30
30
|
# path per BIT_SEGMENT key.
|
|
31
31
|
HOMEPAGE_SECTION_PATTERNS = [
|
|
32
32
|
'/api/v2/storefront/homepage*',
|
|
33
|
-
'/api/v2/
|
|
33
|
+
'/api/v2/:tenant_slug/homepage*'
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
36
|
HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
|
|
@@ -42,12 +42,13 @@ module SpreeCmCommissioner
|
|
|
42
42
|
# which vendors are affected.
|
|
43
43
|
#
|
|
44
44
|
# vendor_id is a query param, so the trailing `*` is what clears every
|
|
45
|
-
# vendor's cached variant of this path in one go.
|
|
45
|
+
# vendor's cached variant of this path in one go. CloudFront allows `*` only at the end, so the
|
|
46
|
+
# tenant path expands to one path per tenant.
|
|
46
47
|
#
|
|
47
48
|
# Payment methods change a handful of times a year, so a broad invalidation
|
|
48
49
|
# is cheap.
|
|
49
50
|
PAYMENT_METHOD_PATTERNS = [
|
|
50
|
-
'/api/v2/
|
|
51
|
+
'/api/v2/:tenant_slug/payment_methods*',
|
|
51
52
|
'/api/v2/storefront/payment_methods*'
|
|
52
53
|
].freeze
|
|
53
54
|
|
|
@@ -60,7 +61,7 @@ module SpreeCmCommissioner
|
|
|
60
61
|
invalidate_single_record(model_class: model_class, id: id)
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
# eg. 'Spree::Product', 123 => ['/api/v2/
|
|
64
|
+
# eg. 'Spree::Product', 123 => ['/api/v2/hangmeas/products/123*', '/api/v2/hangmeas/products/slug-of-product*']
|
|
64
65
|
def invalidate_single_record(model_class:, id:)
|
|
65
66
|
return failure(nil, "#{model_class.name} ID is required") if id.blank?
|
|
66
67
|
|
|
@@ -79,17 +80,27 @@ module SpreeCmCommissioner
|
|
|
79
80
|
# The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
|
|
80
81
|
# model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
|
|
81
82
|
patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
|
|
82
|
-
return patterns if patterns.equal?(PAYMENT_METHOD_PATTERNS)
|
|
83
|
+
return expand_tenant_slug(patterns, SpreeCmCommissioner::Tenant.pluck(:slug)) if patterns.equal?(PAYMENT_METHOD_PATTERNS)
|
|
83
84
|
|
|
84
85
|
tenant = tenant_of(record)
|
|
85
86
|
patterns.select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }.map do |pattern|
|
|
86
87
|
pattern.gsub(/:(\w+)/) do
|
|
87
88
|
key = Regexp.last_match(1)
|
|
88
|
-
record.public_send(key).to_s
|
|
89
|
+
key == 'tenant_slug' ? tenant.slug : record.public_send(key).to_s
|
|
89
90
|
end
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
|
|
94
|
+
# One copy of each `:tenant_slug` pattern per tenant; storefront patterns pass through as is.
|
|
95
|
+
def expand_tenant_slug(patterns, slugs)
|
|
96
|
+
slugs = slugs.compact_blank
|
|
97
|
+
patterns.flat_map do |pattern|
|
|
98
|
+
next [pattern] unless pattern.start_with?(TENANT_PREFIX)
|
|
99
|
+
|
|
100
|
+
slugs.map { |slug| pattern.sub(':tenant_slug', slug) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
93
104
|
# A product, vendor or homepage section has its own tenant; a taxon, show or voting session has
|
|
94
105
|
# its vendor's. Menus, seat layouts and homepage backgrounds are BookMe+ only.
|
|
95
106
|
def tenant_of(record)
|
|
@@ -106,23 +117,23 @@ module SpreeCmCommissioner
|
|
|
106
117
|
# The app reads menus from homepage_data; nothing calls /menus any more.
|
|
107
118
|
'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
|
|
108
119
|
'Spree::Taxon' => [
|
|
109
|
-
'/api/v2/
|
|
110
|
-
'/api/v2/
|
|
120
|
+
'/api/v2/:tenant_slug/taxons/:id*',
|
|
121
|
+
'/api/v2/:tenant_slug/taxons/:permalink*',
|
|
111
122
|
'/api/v2/storefront/taxons/:id*',
|
|
112
123
|
'/api/v2/storefront/taxons/:permalink*'
|
|
113
124
|
],
|
|
114
125
|
'Spree::Product' => [
|
|
115
|
-
'/api/v2/
|
|
116
|
-
'/api/v2/
|
|
126
|
+
'/api/v2/:tenant_slug/products/:id*',
|
|
127
|
+
'/api/v2/:tenant_slug/products/:slug*',
|
|
117
128
|
'/api/v2/storefront/products/:id*',
|
|
118
129
|
'/api/v2/storefront/products/:slug*'
|
|
119
130
|
],
|
|
120
|
-
'SpreeCmCommissioner::Show' => ['/api/v2/
|
|
131
|
+
'SpreeCmCommissioner::Show' => ['/api/v2/:tenant_slug/shows/:slug*'],
|
|
121
132
|
'SpreeCmCommissioner::VotingSession' => [
|
|
122
|
-
'/api/v2/
|
|
133
|
+
'/api/v2/:tenant_slug/voting_sessions/:id/voting_contestants*'
|
|
123
134
|
],
|
|
124
135
|
'Spree::Vendor' => [
|
|
125
|
-
'/api/v2/
|
|
136
|
+
'/api/v2/:tenant_slug/vendors/:id*',
|
|
126
137
|
'/api/v2/storefront/vendors/:id*',
|
|
127
138
|
'/api/v2/storefront/accommodations/:id*',
|
|
128
139
|
'/api/v2/storefront/accommodations/:slug*'
|
|
@@ -4,10 +4,7 @@ module SpreeCmCommissioner
|
|
|
4
4
|
# serve them, which MaintenanceTasks::CacheInvalidation sends, as for Invalidate. `tenant` is
|
|
5
5
|
# required so a clear-all never pays for both namespaces by accident: nil is the BookMe+
|
|
6
6
|
# storefront (/api/v2/storefront/ paths), a SpreeCmCommissioner::Tenant is that tenant's API
|
|
7
|
-
# (/api/v2
|
|
8
|
-
#
|
|
9
|
-
# Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
|
|
10
|
-
# today; taking the record keeps callers the same if tenant paths ever carry it.
|
|
7
|
+
# (/api/v2/<tenant slug>/ paths).
|
|
11
8
|
#
|
|
12
9
|
# eg. InvalidateAll.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: nil)
|
|
13
10
|
class InvalidateAll
|
|
@@ -20,11 +17,11 @@ module SpreeCmCommissioner
|
|
|
20
17
|
'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
|
|
21
18
|
'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
|
|
22
19
|
'Spree::Product' => [
|
|
23
|
-
'/api/v2/
|
|
20
|
+
'/api/v2/:tenant_slug/products/*',
|
|
24
21
|
'/api/v2/storefront/products/*'
|
|
25
22
|
],
|
|
26
23
|
'Spree::Vendor' => [
|
|
27
|
-
'/api/v2/
|
|
24
|
+
'/api/v2/:tenant_slug/vendors/*',
|
|
28
25
|
'/api/v2/storefront/vendors/*',
|
|
29
26
|
'/api/v2/storefront/accommodations/*'
|
|
30
27
|
],
|
|
@@ -44,7 +41,8 @@ module SpreeCmCommissioner
|
|
|
44
41
|
|
|
45
42
|
# Side-effect free, so the clear-cache dialog can show what a clear would send.
|
|
46
43
|
def patterns_for(model, tenant:)
|
|
47
|
-
API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
|
|
44
|
+
patterns = API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
|
|
45
|
+
tenant ? Invalidate.new.expand_tenant_slug(patterns, [tenant.slug]) : patterns
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
# updated_at keys Spree's serializer and collection caches, so only this storefront's records
|
|
@@ -72,7 +72,8 @@ module SpreeCmCommissioner
|
|
|
72
72
|
device_id: options[:device_id],
|
|
73
73
|
last_ip_address: options[:last_ip_address]
|
|
74
74
|
)
|
|
75
|
-
|
|
75
|
+
sellable_variants = store.variants.joins(:product).merge(Spree::Product.sellable_on(Spree::Product.sales_channel_for(tenant)))
|
|
76
|
+
line_items_params.each { |line_item_params| build_line_item!(order, sellable_variants, line_item_params) }
|
|
76
77
|
|
|
77
78
|
ActiveRecord::Base.transaction do
|
|
78
79
|
raise StandardError, order_error_message(order) unless order.save
|
|
@@ -105,8 +106,10 @@ module SpreeCmCommissioner
|
|
|
105
106
|
|
|
106
107
|
# Builds (unsaved) an order.line_items member — persisted together with the order via
|
|
107
108
|
# order.save's cascading autosave of new has_many records, same as TransitOrder::Create.
|
|
108
|
-
def build_line_item!(order,
|
|
109
|
-
variant =
|
|
109
|
+
def build_line_item!(order, sellable_variants, line_item_params)
|
|
110
|
+
variant = sellable_variants.find_by(id: line_item_params[:variant_id])
|
|
111
|
+
raise StandardError, I18n.t('spree_cm_commissioner.draft_order.variant_not_available') if variant.nil?
|
|
112
|
+
|
|
110
113
|
options = (line_item_params[:options] || {}).to_h.with_indifferent_access
|
|
111
114
|
permitted_opts = ::Spree::PermittedAttributes.line_item_attributes.flatten.index_with { |attribute| options[attribute] }
|
|
112
115
|
opts = permitted_opts.merge(currency: order.currency).compact
|
|
@@ -35,10 +35,10 @@ module SpreeCmCommissioner
|
|
|
35
35
|
return success(:skipped) unless firestore_available?
|
|
36
36
|
|
|
37
37
|
products = active_products(taxon).to_a
|
|
38
|
-
return success(:skipped) if products.empty?
|
|
39
|
-
|
|
40
|
-
sums = aggregate_quantities(products)
|
|
41
38
|
existing_products = existing_products_data(taxon)
|
|
39
|
+
return success(:skipped) if products.empty? && existing_products.empty?
|
|
40
|
+
|
|
41
|
+
sums = products.empty? ? {} : aggregate_quantities(products)
|
|
42
42
|
now = Time.zone.now
|
|
43
43
|
|
|
44
44
|
changed = {}
|
|
@@ -51,6 +51,8 @@ module SpreeCmCommissioner
|
|
|
51
51
|
changed[product.id.to_s] = build_entry(product, status, quantities, existing_products, now)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
prune_stale_entries(changed, products, existing_products)
|
|
55
|
+
|
|
54
56
|
return success(:unchanged) if changed.empty?
|
|
55
57
|
|
|
56
58
|
event_document(taxon).set({ products: changed, server_time: now }, merge: true)
|
|
@@ -66,6 +68,20 @@ module SpreeCmCommissioner
|
|
|
66
68
|
|
|
67
69
|
private
|
|
68
70
|
|
|
71
|
+
# The write below is always `merge: true`, which only ever adds or updates map entries — it
|
|
72
|
+
# never drops one. So a product that stops being active (unlisted/unlinked from its section,
|
|
73
|
+
# discontinued, status flipped off — see `active_products`) would otherwise keep its last
|
|
74
|
+
# stock status in Firestore forever, and the app would go on rendering the removed product.
|
|
75
|
+
# Deleting the `products.<id>` field (nested FieldValue.delete, carried in the same merge set)
|
|
76
|
+
# is what actually removes it. existing_products is read even when no product is active, so an
|
|
77
|
+
# event whose every ticket got unlisted still gets its stale entries cleared.
|
|
78
|
+
def prune_stale_entries(changed, products, existing_products)
|
|
79
|
+
active_ids = products.map { |product| product.id.to_s }
|
|
80
|
+
(existing_products.keys - active_ids).each do |stale_id|
|
|
81
|
+
changed[stale_id] = firestore.field_delete
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
69
85
|
# `quantities` is the [available, on_hold, locked, locked_on_hold] tuple from
|
|
70
86
|
# `aggregate_quantities`. `previous_status` is read back from what's already stored, not
|
|
71
87
|
# recomputed — it's the bucket this product was in on the last publish, if any.
|
|
@@ -117,6 +133,16 @@ module SpreeCmCommissioner
|
|
|
117
133
|
# true. Caught via a real event whose products all had available_on: nil: the poll job was
|
|
118
134
|
# returning :skipped for it on every cycle, so Firestore just sat frozen with stale data.
|
|
119
135
|
#
|
|
136
|
+
# `preview: false` for the same reason unlisted products are excluded: events/{event_id} is
|
|
137
|
+
# publicly readable (no auth on the Firestore rule), so a preview product — active but held
|
|
138
|
+
# back from the public pending a PreviewRole, product_decorator.rb `publicly_available` — must
|
|
139
|
+
# not leak a live stock-status entry there before launch.
|
|
140
|
+
#
|
|
141
|
+
# `offline: false` because an offline product is deliberately not inventory-monitored (the
|
|
142
|
+
# admin form labels it "not monitored in Inventory Monitor", and InventoryItems finders like
|
|
143
|
+
# RecentlyChangedFinder already filter it out) — its Redis pools aren't maintained, so any
|
|
144
|
+
# stock status published for it would be meaningless.
|
|
145
|
+
#
|
|
120
146
|
# `where(status: 'active')` instead of core's `Product.active` scope: that scope also joins
|
|
121
147
|
# variants/prices in to filter by currency (`with_currency`), and `spree_variants` carries its
|
|
122
148
|
# own `available_on`/`discontinue_on` columns — once that join is present, the raw,
|
|
@@ -133,7 +159,7 @@ module SpreeCmCommissioner
|
|
|
133
159
|
# is excluded here too rather than still getting a live stock-status entry in Firestore.
|
|
134
160
|
def active_products(taxon)
|
|
135
161
|
now = Time.zone.now
|
|
136
|
-
taxon.event_products.ecommerce.where(status: 'active')
|
|
162
|
+
taxon.event_products.ecommerce.where(status: 'active', preview: false, offline: false)
|
|
137
163
|
.where('available_on IS NULL OR available_on <= ?', now)
|
|
138
164
|
.where('discontinue_on IS NULL OR discontinue_on > ?', now)
|
|
139
165
|
.where(id: taxon.children_classifications.where(visible: true).select(:product_id))
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_inventory.rb
CHANGED
|
@@ -41,31 +41,53 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
|
|
|
41
41
|
available_seats = external_trip.available_seats
|
|
42
42
|
return if available_seats.nil? # unknown, not zero - don't zero out real inventory
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
sync_stock_item(stock_item, vehicle_capacity(external_trip, available_seats.to_i))
|
|
45
|
+
sync_inventory_item_for_date(stock_item.variant, external_trip, available_seats.to_i)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# count_on_hand is one number shared by every date and seeds new inventory items, so it holds the vehicle's seats.
|
|
49
|
+
# A date's own seats would rewrite it (and enqueue VendorJob) on most polls, and could seed new dates with 0.
|
|
50
|
+
def vehicle_capacity(external_trip, available_seats)
|
|
51
|
+
number_of_seats = external_trip.number_of_seats.to_i
|
|
52
|
+
|
|
53
|
+
number_of_seats.positive? ? number_of_seats : available_seats
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def sync_stock_item(stock_item, seats)
|
|
57
|
+
difference = seats - stock_item.count_on_hand.to_i
|
|
46
58
|
return if difference.zero?
|
|
47
59
|
|
|
48
60
|
@sync_result.track(:stock_item, stock_item) do
|
|
49
61
|
stock_item.adjust_count_on_hand(difference)
|
|
50
62
|
@sync_result.increment_metric(:inventory, :adjusted, difference.abs)
|
|
51
63
|
end
|
|
52
|
-
|
|
53
|
-
adjust_inventory_item_for_date(stock_item.variant, external_trip, difference)
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# StockItem.count_on_hand isn't date-aware and isn't read for real availability checks
|
|
57
|
-
# (see docs/concepts/inventory-system.md)
|
|
58
|
-
#
|
|
59
|
-
# If
|
|
60
|
-
#
|
|
61
|
-
def
|
|
67
|
+
# (see docs/concepts/inventory-system.md), so each date's InventoryItem is set to that date's own seats_remaining,
|
|
68
|
+
# never moved by its difference from another date's seats: an item seeded from a different count (0 after a sold-out
|
|
69
|
+
# first date) would go negative and fail the whole trip's sync. If the item doesn't exist yet, the permanent items
|
|
70
|
+
# job seeds it later from count_on_hand.
|
|
71
|
+
def sync_inventory_item_for_date(variant, external_trip, available_seats)
|
|
62
72
|
trip_date = parse_trip_date(external_trip.on_date)
|
|
63
73
|
return unless trip_date
|
|
64
74
|
|
|
65
75
|
inventory_item = variant.inventory_items.find_by(inventory_date: trip_date)
|
|
66
|
-
return
|
|
76
|
+
return if inventory_item.nil? || inventory_item.quantity_available == seats_for_sale(inventory_item, available_seats)
|
|
77
|
+
|
|
78
|
+
inventory_item.with_lock do
|
|
79
|
+
SpreeCmCommissioner::InventoryItems::AdjustAvailableQuantity.call!(
|
|
80
|
+
inventory_item: inventory_item,
|
|
81
|
+
delta: seats_for_sale(inventory_item, available_seats) - inventory_item.quantity_available,
|
|
82
|
+
caller_source: self.class.name,
|
|
83
|
+
lock: false
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
67
87
|
|
|
68
|
-
|
|
88
|
+
# BookMeBus doesn't know about seats an operator withheld here, so its count still includes them.
|
|
89
|
+
def seats_for_sale(inventory_item, available_seats)
|
|
90
|
+
[available_seats - inventory_item.quantity_locked - inventory_item.locked_count, 0].max
|
|
69
91
|
end
|
|
70
92
|
|
|
71
93
|
def parse_trip_date(date_string)
|
|
@@ -33,7 +33,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
|
|
|
33
33
|
status: :active,
|
|
34
34
|
vendor: @vendor,
|
|
35
35
|
product_type: :transit,
|
|
36
|
-
|
|
36
|
+
# Keeps the first value: a fresh time counts as a change, so every poll would save the product and purge its API cache.
|
|
37
|
+
available_on: product.available_on || Time.current,
|
|
37
38
|
shipping_category: default_shipping_category,
|
|
38
39
|
stores: [Spree::Store.default],
|
|
39
40
|
option_types: [seat_type_option].compact,
|
|
@@ -42,7 +43,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
|
|
|
42
43
|
tracker.save_if_changed!(product)
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
mapping.
|
|
46
|
+
mapping.confirm_active!(external_payload: { trip_id: external_trip.trip_id })
|
|
46
47
|
product
|
|
47
48
|
end
|
|
48
49
|
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb
CHANGED
|
@@ -135,11 +135,12 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
|
|
|
135
135
|
tracker.save_if_changed!(trip_stop)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
mapping.
|
|
138
|
+
mapping.confirm_active!(
|
|
139
139
|
external_payload: stop_time_data.merge(
|
|
140
140
|
'stop_name' => stop_data['name'],
|
|
141
141
|
'location_name' => location_data['name']
|
|
142
|
-
)
|
|
142
|
+
),
|
|
143
|
+
ignoring: SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip::VOLATILE_PAYLOAD_KEYS
|
|
143
144
|
)
|
|
144
145
|
|
|
145
146
|
mapping
|
|
@@ -177,7 +178,10 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
|
|
|
177
178
|
tracker.save_if_changed!(stop_vendor_place)
|
|
178
179
|
end
|
|
179
180
|
|
|
180
|
-
mapping.
|
|
181
|
+
mapping.confirm_active!(
|
|
182
|
+
external_payload: external_stop.to_h.merge('place_type' => external_stop.vendor_place_type.to_s),
|
|
183
|
+
ignoring: SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip::VOLATILE_PAYLOAD_KEYS
|
|
184
|
+
)
|
|
181
185
|
|
|
182
186
|
# Return the appropriate place based on stop type:
|
|
183
187
|
# - BRANCHES: return location_vendor_place.place (the city/location)
|