spree_cm_commissioner 1.9.2 → 1.10.0.pre.pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test_and_build_gem.yml +86 -0
  3. data/.gitignore +2 -1
  4. data/Gemfile.lock +22 -1
  5. data/app/controllers/spree/admin/events_controller.rb +47 -0
  6. data/app/controllers/spree/admin/prototypes_controller_decorator.rb +20 -0
  7. data/app/controllers/spree/admin/stock_managements_controller.rb +17 -1
  8. data/app/controllers/spree/api/v2/storefront/accommodations/variants_controller.rb +42 -0
  9. data/app/controllers/spree/api/v2/storefront/accommodations_controller.rb +14 -31
  10. data/app/finders/spree_cm_commissioner/accommodations/find.rb +40 -0
  11. data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +35 -0
  12. data/app/interactors/spree_cm_commissioner/create_event.rb +65 -0
  13. data/app/interactors/spree_cm_commissioner/firebase_email_fetcher.rb +30 -4
  14. data/app/interactors/spree_cm_commissioner/firebase_email_fetcher_cron_executor.rb +23 -0
  15. data/app/interactors/spree_cm_commissioner/inventory_item_syncer.rb +25 -0
  16. data/app/interactors/spree_cm_commissioner/stock/permanent_inventory_items_generator.rb +71 -0
  17. data/app/interactors/spree_cm_commissioner/vattanac_bank_initiator.rb +25 -6
  18. data/app/jobs/spree_cm_commissioner/firebase_email_fetcher_job.rb +9 -0
  19. data/app/jobs/spree_cm_commissioner/inventory_item_syncer_job.rb +7 -0
  20. data/app/jobs/spree_cm_commissioner/stock/permanent_inventory_items_generator_job.rb +9 -0
  21. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +26 -0
  22. data/app/models/concerns/spree_cm_commissioner/product_type.rb +10 -0
  23. data/app/models/spree_cm_commissioner/inventory.rb +11 -0
  24. data/app/models/spree_cm_commissioner/inventory_item.rb +37 -0
  25. data/app/models/spree_cm_commissioner/invite_user_taxon.rb +1 -0
  26. data/app/models/spree_cm_commissioner/line_item_decorator.rb +1 -0
  27. data/app/models/spree_cm_commissioner/order_decorator.rb +15 -0
  28. data/app/models/spree_cm_commissioner/product_decorator.rb +10 -0
  29. data/app/models/spree_cm_commissioner/prototype_decorator.rb +9 -0
  30. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +40 -0
  31. data/app/models/spree_cm_commissioner/redis_stock/inventory_updater.rb +114 -0
  32. data/app/models/spree_cm_commissioner/redis_stock/line_items_cached_inventory_items_builder.rb +42 -0
  33. data/app/models/spree_cm_commissioner/redis_stock/variant_cached_inventory_items_builder.rb +27 -0
  34. data/app/models/spree_cm_commissioner/stock/availability_checker.rb +27 -25
  35. data/app/models/spree_cm_commissioner/stock/availability_validator_decorator.rb +2 -1
  36. data/app/models/spree_cm_commissioner/stock/line_item_availability_checker.rb +3 -3
  37. data/app/models/spree_cm_commissioner/stock/order_availability_checker.rb +44 -0
  38. data/app/models/spree_cm_commissioner/stock_movement_decorator.rb +34 -0
  39. data/app/models/spree_cm_commissioner/taxon_decorator.rb +2 -1
  40. data/app/models/spree_cm_commissioner/user_decorator.rb +5 -1
  41. data/app/models/spree_cm_commissioner/variant_decorator.rb +24 -17
  42. data/app/overrides/spree/admin/prototypes/_form/description.html.erb.deface +6 -0
  43. data/app/overrides/spree/admin/prototypes/_form/icon.html.erb.deface +8 -0
  44. data/app/overrides/spree/admin/prototypes/_form/product_type.html.erb.deface +2 -2
  45. data/app/overrides/spree/admin/shared/sub_menu/_product/events_tab.html.erb.deface +3 -0
  46. data/app/request_schemas/spree_cm_commissioner/accommodation_request_schema.rb +3 -0
  47. data/app/request_schemas/spree_cm_commissioner/application_request_schema.rb +1 -1
  48. data/app/request_schemas/spree_cm_commissioner/variant_request_schema.rb +19 -0
  49. data/app/serializers/spree/v2/storefront/accommodation_serializer.rb +2 -0
  50. data/app/serializers/spree/v2/tenant/payment_method_serializer.rb +12 -0
  51. data/app/serializers/spree/v2/tenant/user_serializer.rb +1 -1
  52. data/app/views/spree/admin/events/_search_form.html.erb +61 -0
  53. data/app/views/spree/admin/events/_tab.html.erb +25 -0
  54. data/app/views/spree/admin/events/_table.html.erb +27 -0
  55. data/app/views/spree/admin/events/index.html.erb +15 -0
  56. data/app/views/spree/admin/stock_managements/_events_popover.html.erb +17 -0
  57. data/app/views/spree/admin/stock_managements/calendar.html.erb +32 -0
  58. data/app/views/spree/admin/stock_managements/index.html.erb +31 -5
  59. data/app/views/spree_cm_commissioner/crew_invite_mailer/_mailer_stylesheets.html.erb +2 -2
  60. data/app/views/spree_cm_commissioner/crew_invite_mailer/send_crew_invite_email.html.erb +1 -1
  61. data/config/initializers/user_manager_decorator.rb +26 -0
  62. data/config/routes.rb +12 -2
  63. data/db/migrate/20250304293518_create_cm_inventory_items.rb +21 -0
  64. data/db/migrate/20250425084929_add_description_to_spree_prototypes.rb +5 -0
  65. data/db/migrate/20250425085938_add_preferences_to_spree_prototypes.rb +5 -0
  66. data/db/migrate/20250428025645_add_slug_to_spree_prototypes.rb +6 -0
  67. data/db/migrate/20250429094228_add_lock_version_to_cm_inventory_items.rb +5 -0
  68. data/docker-compose.yml +1 -1
  69. data/lib/generators/spree_cm_commissioner/install/install_generator.rb +2 -2
  70. data/lib/generators/spree_cm_commissioner/install/templates/app/javascript/{spree_cm_commissioner → spree_dashboard/spree_cm_commissioner}/utilities.js +4 -0
  71. data/lib/spree_cm_commissioner/cached_inventory_item.rb +23 -0
  72. data/lib/spree_cm_commissioner/calendar_event.rb +11 -1
  73. data/lib/spree_cm_commissioner/test_helper/factories/inventory_item_factory.rb +9 -0
  74. data/lib/spree_cm_commissioner/test_helper/factories/variant_factory.rb +28 -6
  75. data/lib/spree_cm_commissioner/version.rb +1 -1
  76. data/lib/spree_cm_commissioner.rb +34 -0
  77. data/lib/tasks/create_default_non_permanent_inventory_items.rake +16 -0
  78. data/lib/tasks/fetch_email.rake +7 -0
  79. data/lib/tasks/generate_inventory_items.rake +7 -0
  80. data/spree_cm_commissioner.gemspec +5 -0
  81. metadata +88 -7
  82. data/app/queries/spree_cm_commissioner/variant_availability/non_permanent_stock_query.rb +0 -45
  83. data/app/queries/spree_cm_commissioner/variant_availability/permanent_stock_query.rb +0 -55
@@ -0,0 +1,71 @@
1
+ module SpreeCmCommissioner
2
+ module Stock
3
+ class PermanentInventoryItemsGenerator < BaseInteractor
4
+ delegate :variant_ids, to: :context
5
+
6
+ def variants_per_batch = 1000
7
+
8
+ def call
9
+ variants.in_batches(of: variants_per_batch) do |batch|
10
+ generate_inventory_items_for_batch(batch)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def generate_inventory_items_for_batch(batch)
17
+ total_on_hand_by_variant = total_on_hand_for(batch)
18
+ batch.each do |variant|
19
+ count_on_hand = total_on_hand_by_variant[variant.id] || 0
20
+ generate_inventory_items_for_variant(variant, count_on_hand)
21
+ end
22
+ end
23
+
24
+ def generate_inventory_items_for_variant(variant, count_on_hand)
25
+ inventory_dates_for(variant).each do |inventory_date|
26
+ next if inventory_exist?(variant, inventory_date)
27
+
28
+ create_inventory_item(variant, inventory_date, count_on_hand)
29
+ end
30
+ end
31
+
32
+ def inventory_dates_for(variant)
33
+ start_date = Time.zone.tomorrow
34
+ end_date = Time.zone.today + variant.pre_inventory_days
35
+
36
+ (start_date..end_date)
37
+ end
38
+
39
+ def inventory_exist?(variant, inventory_date)
40
+ variant.inventory_items.exists?(inventory_date: inventory_date)
41
+ end
42
+
43
+ def create_inventory_item(variant, inventory_date, count_on_hand)
44
+ variant.inventory_items.create!(
45
+ inventory_date: inventory_date,
46
+ quantity_available: count_on_hand,
47
+ max_capacity: count_on_hand,
48
+ product_type: variant.product_type
49
+ )
50
+ end
51
+
52
+ # Returns a hash: { variant_id => total_on_hand, ... }
53
+ def total_on_hand_for(variants)
54
+ variant_ids = variants.pluck(:id)
55
+
56
+ Spree::StockItem
57
+ .joins(:stock_location)
58
+ .where(deleted_at: nil, variant_id: variant_ids)
59
+ .where(spree_stock_locations: { active: true })
60
+ .group(:variant_id)
61
+ .sum(:count_on_hand)
62
+ end
63
+
64
+ def variants
65
+ scope = Spree::Variant.active.with_permanent_stock.where(is_master: false).includes(:product)
66
+ scope = scope.where(id: variant_ids) if variant_ids.present?
67
+ scope
68
+ end
69
+ end
70
+ end
71
+ end
@@ -25,10 +25,8 @@ module SpreeCmCommissioner
25
25
  end
26
26
 
27
27
  def verify_signature
28
- public_key = ENV['VATTANAC_PUBLIC_KEY'].presence || Rails.application.credentials.vattanac.public_key
29
-
30
28
  rsa_service = SpreeCmCommissioner::RsaService.new(
31
- public_key: public_key
29
+ public_key: vattanac_public_key
32
30
  )
33
31
 
34
32
  return if rsa_service.verify(context.encrypted_data, context.signature)
@@ -37,8 +35,6 @@ module SpreeCmCommissioner
37
35
  end
38
36
 
39
37
  def decrypt_payload
40
- aes_key = ENV['VATTANAC_AES_SECRET_KEY'].presence || Rails.application.credentials.vattanac.aes_secret_key
41
-
42
38
  context.fail!(message: 'Invalid AES key length', status: :unprocessable_entity) unless aes_key
43
39
 
44
40
  begin
@@ -99,18 +95,41 @@ module SpreeCmCommissioner
99
95
 
100
96
  def construct_data
101
97
  user = context.user
102
- context.data = {
98
+
99
+ raw_data = {
103
100
  sessionId: session_id,
104
101
  name: user.full_name,
105
102
  phone: user.phone_number,
106
103
  email: user.email,
107
104
  webUrl: "#{Spree::Store.default.formatted_url}/vattanac_bank_web_app?session_id=#{session_id}"
108
105
  }
106
+
107
+ json_data = raw_data.to_json
108
+
109
+ encrypted_data = SpreeCmCommissioner::AesEncryptionService.encrypt(json_data, aes_key)
110
+
111
+ rsa_service = SpreeCmCommissioner::RsaService.new(private_key: bookmeplus_private_key)
112
+
113
+ signed_data = rsa_service.sign(encrypted_data)
114
+
115
+ context.data = signed_data
109
116
  end
110
117
 
111
118
  def session_id
112
119
  payload = { user_id: context.user.id }
113
120
  SpreeCmCommissioner::UserSessionJwtToken.encode(payload, context.user.reload.secure_token)
114
121
  end
122
+
123
+ def aes_key
124
+ ENV['VATTANAC_AES_SECRET_KEY'].presence || Rails.application.credentials.vattanac.aes_secret_key
125
+ end
126
+
127
+ def bookmeplus_private_key
128
+ ENV['BOOKMEPLUS_PRIVATE_KEY'].presence || Rails.application.credentials.bookmeplus.private_key
129
+ end
130
+
131
+ def vattanac_public_key
132
+ ENV['VATTANAC_PUBLIC_KEY'].presence || Rails.application.credentials.vattanac.public_key
133
+ end
115
134
  end
116
135
  end
@@ -0,0 +1,9 @@
1
+ module SpreeCmCommissioner
2
+ class FirebaseEmailFetcherJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform
6
+ SpreeCmCommissioner::FirebaseEmailFetcherCronExecutor.call
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ module SpreeCmCommissioner
2
+ class InventoryItemSyncerJob < ApplicationUniqueJob
3
+ def perform(inventory_id_and_quantities:)
4
+ InventoryItemSyncer.call(inventory_id_and_quantities:)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module SpreeCmCommissioner
2
+ module Stock
3
+ class PermanentInventoryItemsGeneratorJob < ApplicationUniqueJob
4
+ def perform
5
+ SpreeCmCommissioner::Stock::PermanentInventoryItemsGenerator.call
6
+ end
7
+ end
8
+ end
9
+ end
@@ -13,10 +13,13 @@ module SpreeCmCommissioner
13
13
  state_machine.after_transition to: :complete, do: :notify_order_complete_telegram_notification_to_user, unless: :subscription?
14
14
  state_machine.after_transition to: :complete, do: :send_order_complete_telegram_alert_to_vendors, unless: :need_confirmation?
15
15
  state_machine.after_transition to: :complete, do: :send_order_complete_telegram_alert_to_store, unless: :need_confirmation?
16
+ state_machine.around_transition to: :complete, do: :handle_unstock_in_redis
16
17
 
17
18
  state_machine.after_transition to: :resumed, do: :precalculate_conversion
19
+ state_machine.around_transition to: :resumed, do: :handle_unstock_in_redis
18
20
 
19
21
  state_machine.after_transition to: :canceled, do: :precalculate_conversion
22
+ state_machine.after_transition to: :canceled, do: :restock_inventory_in_redis!
20
23
 
21
24
  scope :accepted, -> { where(request_state: 'accepted') }
22
25
 
@@ -66,6 +69,29 @@ module SpreeCmCommissioner
66
69
  end
67
70
  end
68
71
 
72
+ def handle_unstock_in_redis
73
+ ActiveRecord::Base.transaction do
74
+ yield # Equal to block.call
75
+
76
+ # After the transition is complete, the following code will execute first before proceeding to other `after_transition` callbacks.
77
+ # This ensures that if `unstock_inventory_in_redis!` fails, the state will be rolled back,
78
+ # and neither the `finalize!` method nor any notifications will be triggered.
79
+ # The payment will be reversed in vPago gem, and `Spree::Checkout::Complete` will be called, which checks `order.reload.complete?`.
80
+ # This is critical because if the order state is complete, the payment will be marked as paid.
81
+ CmAppLogger.log(label: 'order_state_machine_before_unstock', data: { order_id: id, state: state })
82
+ unstock_inventory_in_redis!
83
+ # We rollback only order state, and we keep payment state as it is.
84
+ # We implement payment in vPago gem, and it will be reversed in the gem.
85
+ # Some bank has api for refund, but some don't have the api to refund yet. So we keep the payment state as it is and refund manually.
86
+ CmAppLogger.log(label: 'order_state_machine_after_unstock', data: { order_id: id, state: state })
87
+ end
88
+ rescue StandardError => e
89
+ CmAppLogger.log(label: 'order_state_machine',
90
+ data: { order_id: id, error: e.message, type: e.class.name, backtrace: e.backtrace.first(5).join("\n") }
91
+ )
92
+ raise e
93
+ end
94
+
69
95
  def generate_bib_number
70
96
  line_items.find_each(&:generate_remaining_guests)
71
97
 
@@ -6,10 +6,20 @@ module SpreeCmCommissioner
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  PRODUCT_TYPES = %i[accommodation service ecommerce transit].freeze
9
+ PERMANENT_STOCK_PRODUCT_TYPES = %w[accommodation service transit].freeze
10
+ PRE_INVENTORY_DAYS = { 'transit' => 90, 'accommodation' => 365, 'service' => 30 }.freeze
9
11
 
10
12
  included do
11
13
  enum product_type: PRODUCT_TYPES if table_exists? && column_names.include?('product_type')
12
14
  enum primary_product_type: PRODUCT_TYPES if table_exists? && column_names.include?('primary_product_type')
13
15
  end
16
+
17
+ def permanent_stock?
18
+ PERMANENT_STOCK_PRODUCT_TYPES.include?(product_type)
19
+ end
20
+
21
+ def pre_inventory_days
22
+ PRE_INVENTORY_DAYS[product_type]
23
+ end
14
24
  end
15
25
  end
@@ -0,0 +1,11 @@
1
+ module SpreeCmCommissioner
2
+ class Inventory
3
+ include ActiveModel::Model
4
+
5
+ attr_accessor :variant_id, :inventory_date, :quantity_available, :max_capacity, :product_type
6
+
7
+ validates :variant_id, presence: true
8
+ validates :quantity_available, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
9
+ validates :max_capacity, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ module SpreeCmCommissioner
2
+ class InventoryItem < ApplicationRecord
3
+ include SpreeCmCommissioner::ProductType
4
+
5
+ # Association
6
+ belongs_to :variant, class_name: 'Spree::Variant'
7
+
8
+ # Validation
9
+ validates :quantity_available, numericality: { greater_than_or_equal_to: 0 }
10
+ validates :max_capacity, numericality: { greater_than_or_equal_to: 0 } # Originally inventory of each variant.
11
+ validates :inventory_date, presence: true, if: -> { permanent_stock? }
12
+ validates :variant_id, uniqueness: { scope: :inventory_date, message: -> (object, _data) { "The variant is taken on #{object.inventory_date}" } }
13
+
14
+ # Scope
15
+ scope :for_product, -> (type) { where(product_type: type) }
16
+ scope :active, -> { where(inventory_date: nil).or(where('inventory_date >= ?', Time.zone.today)) }
17
+
18
+ def adjust_quantity!(quantity)
19
+ with_lock do
20
+ self.max_capacity = max_capacity + quantity
21
+ self.quantity_available = quantity_available + quantity
22
+
23
+ save!
24
+ end
25
+ end
26
+
27
+ def active?
28
+ inventory_date.nil? || inventory_date >= Time.zone.today
29
+ end
30
+
31
+ def redis_expired_in
32
+ expired_in = 31_536_000 # 1 year for normal stock
33
+ expired_in = Time.parse(inventory_date.to_s).end_of_day.to_i - Time.zone.now.to_i if inventory_date.present?
34
+ [expired_in, 0].max
35
+ end
36
+ end
37
+ end
@@ -2,6 +2,7 @@ module SpreeCmCommissioner
2
2
  class InviteUserTaxon < SpreeCmCommissioner::Base
3
3
  belongs_to :user_taxon, class_name: 'SpreeCmCommissioner::UserTaxon'
4
4
  belongs_to :invite, class_name: 'SpreeCmCommissioner::Invite'
5
+ has_one :inviter, class_name: 'Spree::User', through: :invite
5
6
  after_create :send_crew_invite_email
6
7
 
7
8
  def send_crew_invite_email
@@ -9,6 +9,7 @@ module SpreeCmCommissioner
9
9
  base.has_one :google_wallet, class_name: 'SpreeCmCommissioner::GoogleWallet', through: :product
10
10
 
11
11
  base.has_many :option_types, through: :product
12
+ base.has_many :inventory_items, through: :variant
12
13
  base.has_many :taxons, class_name: 'Spree::Taxon', through: :product
13
14
  base.has_many :guests, class_name: 'SpreeCmCommissioner::Guest', dependent: :destroy
14
15
  base.has_many :pending_guests, pending_guests_query, class_name: 'SpreeCmCommissioner::Guest', dependent: :destroy
@@ -58,6 +58,13 @@ module SpreeCmCommissioner
58
58
  end
59
59
  end
60
60
 
61
+ # override
62
+ # spree use this method to check stock availability & consider whether :order can continue to next state.
63
+ def insufficient_stock_lines
64
+ checker = SpreeCmCommissioner::Stock::OrderAvailabilityChecker.new(self)
65
+ checker.insufficient_stock_lines
66
+ end
67
+
61
68
  def ticket_seller_user?
62
69
  return false if user.nil?
63
70
 
@@ -194,6 +201,14 @@ module SpreeCmCommissioner
194
201
 
195
202
  private
196
203
 
204
+ def unstock_inventory_in_redis!
205
+ SpreeCmCommissioner::RedisStock::InventoryUpdater.new(line_item_ids).unstock!
206
+ end
207
+
208
+ def restock_inventory_in_redis!
209
+ SpreeCmCommissioner::RedisStock::InventoryUpdater.new(line_item_ids).restock!
210
+ end
211
+
197
212
  # override :spree_api
198
213
  def webhook_payload_body
199
214
  resource_serializer.new(
@@ -33,6 +33,7 @@ module SpreeCmCommissioner
33
33
  base.has_one :google_wallet, class_name: 'SpreeCmCommissioner::GoogleWallet', dependent: :destroy
34
34
 
35
35
  base.has_many :complete_line_items, through: :classifications, source: :line_items
36
+ base.has_many :inventory_items, through: :variants
36
37
 
37
38
  base.has_many :product_places, class_name: 'SpreeCmCommissioner::ProductPlace', dependent: :destroy
38
39
  base.has_many :places, through: :product_places
@@ -57,6 +58,9 @@ module SpreeCmCommissioner
57
58
  base.scope :subscribable, -> { where(subscribable: 1) }
58
59
 
59
60
  base.validate :validate_event_taxons, if: -> { taxons.event.present? }
61
+
62
+ base.validate :validate_product_date, if: -> { available_on.present? && discontinue_on.present? }
63
+
60
64
  base.validates :commission_rate, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }, allow_nil: true
61
65
 
62
66
  base.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status vendor_id short_name route_type]
@@ -93,6 +97,12 @@ module SpreeCmCommissioner
93
97
  errors.add(:taxons, 'Event Taxon can\'t not be more than 1') if taxons.event.size > 1
94
98
  errors.add(:taxons, 'Must add event date to taxon') if taxons.event.first.from_date.nil? || taxons.event.first.to_date.nil?
95
99
  end
100
+
101
+ def validate_product_date
102
+ return unless discontinue_on < available_on
103
+
104
+ errors.add(:discontinue_on, 'must be after the available on date')
105
+ end
96
106
  end
97
107
  end
98
108
 
@@ -1,6 +1,9 @@
1
1
  module SpreeCmCommissioner
2
2
  module PrototypeDecorator
3
3
  def self.prepended(base)
4
+ base.extend FriendlyId
5
+ base.friendly_id :name, use: :slugged
6
+
4
7
  base.include SpreeCmCommissioner::ProductType
5
8
 
6
9
  base.has_many :variant_kind_option_types, -> { where(kind: :variant).order(:position) },
@@ -10,6 +13,12 @@ module SpreeCmCommissioner
10
13
  through: :option_type_prototypes, source: :option_type
11
14
 
12
15
  base.has_many :option_values, through: :option_types
16
+
17
+ base.preference :icon, :string
18
+
19
+ def icon
20
+ preferred_icon
21
+ end
13
22
  end
14
23
  end
15
24
  end
@@ -0,0 +1,40 @@
1
+ module SpreeCmCommissioner
2
+ module RedisStock
3
+ class CachedInventoryItemsBuilder
4
+ attr_reader :inventory_items
5
+
6
+ def initialize(inventory_items)
7
+ @inventory_items = inventory_items
8
+ end
9
+
10
+ # output: [ CachedInventoryItem(...), CachedInventoryItem(...) ]
11
+ def call
12
+ keys = inventory_items.map { |item| "inventory:#{item.id}" }
13
+ return [] unless keys.any?
14
+
15
+ counts = SpreeCmCommissioner.redis_pool.with { |redis| redis.mget(*keys) }
16
+ inventory_items.map.with_index do |inventory_item, i|
17
+ ::SpreeCmCommissioner::CachedInventoryItem.new(
18
+ inventory_key: keys[i],
19
+ active: inventory_item.active?,
20
+ quantity_available: cache_inventory(keys[i], inventory_item, counts[i]),
21
+ inventory_item_id: inventory_item.id,
22
+ variant_id: inventory_item.variant_id
23
+ )
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def cache_inventory(key, inventory_item, count_in_redis)
30
+ return count_in_redis.to_i if count_in_redis.present?
31
+
32
+ SpreeCmCommissioner.redis_pool.with do |redis|
33
+ redis.set(key, inventory_item.quantity_available, ex: inventory_item.redis_expired_in)
34
+ end
35
+
36
+ inventory_item.quantity_available
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,114 @@
1
+ module SpreeCmCommissioner
2
+ module RedisStock
3
+ class InventoryUpdater
4
+ class UnableToRestock < StandardError; end
5
+ class UnableToUnstock < StandardError; end
6
+
7
+ def initialize(line_item_ids)
8
+ @line_item_ids = line_item_ids
9
+ end
10
+
11
+ def unstock!
12
+ keys, quantities, inventory_ids = extract_inventory_data
13
+
14
+ raise UnableToUnstock, Spree.t(:insufficient_stock_lines_present) unless unstock(keys, quantities)
15
+
16
+ inventory_id_and_quantities = inventory_ids.map.with_index do |inventory_id, i|
17
+ { inventory_id: inventory_id, quantity: -quantities[i] }
18
+ end
19
+
20
+ schedule_sync_inventory(inventory_id_and_quantities)
21
+ end
22
+
23
+ def restock!
24
+ keys, quantities, inventory_ids = extract_inventory_data
25
+
26
+ raise UnableToRestock unless restock(keys, quantities)
27
+
28
+ inventory_id_and_quantities = inventory_ids.map.with_index do |inventory_id, i|
29
+ { inventory_id: inventory_id, quantity: quantities[i] }
30
+ end
31
+
32
+ schedule_sync_inventory(inventory_id_and_quantities)
33
+ end
34
+
35
+ private
36
+
37
+ def line_items
38
+ @line_items ||= Spree::LineItem.where(id: @line_item_ids)
39
+ end
40
+
41
+ def unstock(keys, quantities)
42
+ SpreeCmCommissioner.redis_pool.with do |redis|
43
+ redis.eval(unstock_redis_script, keys: keys, argv: quantities)
44
+ end.positive?
45
+ end
46
+
47
+ def restock(keys, quantities)
48
+ SpreeCmCommissioner.redis_pool.with do |redis|
49
+ redis.eval(restock_redis_script, keys: keys, argv: quantities)
50
+ end.positive?
51
+ end
52
+
53
+ # Return: [CachedInventoryItem(...), CachedInventoryItem(...)]
54
+ def cached_inventory_items
55
+ @cached_inventory_items ||= SpreeCmCommissioner::RedisStock::LineItemsCachedInventoryItemsBuilder.new(line_item_ids: @line_item_ids)
56
+ .call.values.flatten
57
+ end
58
+
59
+ def extract_inventory_data
60
+ keys = []
61
+ quantities = []
62
+ inventory_ids = []
63
+
64
+ cached_inventory_items.each do |cached_inventory_item|
65
+ keys << cached_inventory_item.inventory_key
66
+ quantities << line_items.find { |item| item.variant_id == cached_inventory_item.variant_id }.quantity
67
+ inventory_ids << cached_inventory_item.inventory_item_id
68
+ end
69
+
70
+ [keys, quantities, inventory_ids]
71
+ end
72
+
73
+ def unstock_redis_script
74
+ <<~LUA
75
+ local keys = KEYS
76
+ local quantities = ARGV
77
+
78
+ -- Check availability first
79
+ for i, key in ipairs(keys) do
80
+ local current = tonumber(redis.call('GET', key) or 0)
81
+ if current - tonumber(quantities[i]) < 0 then
82
+ return 0
83
+ end
84
+ end
85
+
86
+ -- Apply updates
87
+ for i, key in ipairs(keys) do
88
+ redis.call('DECRBY', key, tonumber(quantities[i]))
89
+ end
90
+
91
+ return 1
92
+ LUA
93
+ end
94
+
95
+ def restock_redis_script
96
+ <<~LUA
97
+ local keys = KEYS
98
+ local quantities = ARGV
99
+
100
+ -- Apply restock updates
101
+ for i, key in ipairs(keys) do
102
+ redis.call('INCRBY', key, tonumber(quantities[i]))
103
+ end
104
+
105
+ return 1
106
+ LUA
107
+ end
108
+
109
+ def schedule_sync_inventory(inventory_id_and_quantities)
110
+ SpreeCmCommissioner::InventoryItemSyncerJob.perform_later(inventory_id_and_quantities:)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,42 @@
1
+ module SpreeCmCommissioner
2
+ module RedisStock
3
+ class LineItemsCachedInventoryItemsBuilder
4
+ attr_reader :line_item_ids
5
+
6
+ def initialize(line_item_ids:)
7
+ @line_item_ids = line_item_ids
8
+ end
9
+
10
+ # return list of inventory items group by :line_item_id:
11
+ # {
12
+ # 1: [ CachedInventoryItem(...), CachedInventoryItem(...) ],
13
+ # 2: [ CachedInventoryItem(...), CachedInventoryItem(...) ],
14
+ # }
15
+ def call
16
+ cached_inventory_items.group_by do |cached_inventory_item|
17
+ line_item = line_items.find { |item| item.variant_id == cached_inventory_item.variant_id }
18
+ line_item.id
19
+ end
20
+ end
21
+
22
+ def cached_inventory_items
23
+ @cached_inventory_items ||= SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(inventory_items)
24
+ .call
25
+ end
26
+
27
+ def inventory_items
28
+ @inventory_items ||= line_items.flat_map do |line_item|
29
+ # TODO: N+1, we could fix but have a product_type in line item
30
+ # then include inventory_items in line item directly #2581
31
+ scope = line_item.inventory_items
32
+ scope = scope.where(inventory_date: line_item.date_range) if line_item.permanent_stock?
33
+ scope
34
+ end
35
+ end
36
+
37
+ def line_items
38
+ @line_items ||= Spree::LineItem.where(id: line_item_ids).includes(variant: %i[product])
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ module SpreeCmCommissioner
2
+ module RedisStock
3
+ class VariantCachedInventoryItemsBuilder
4
+ attr_reader :variant_id, :from_date, :to_date
5
+
6
+ def initialize(variant_id:, from_date: nil, to_date: nil)
7
+ @variant_id = variant_id
8
+ @from_date = from_date
9
+ @to_date = to_date
10
+ end
11
+
12
+ # output: [ CachedInventoryItem(...), CachedInventoryItem(...) ]
13
+ def call
14
+ ::SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(inventory_items).call
15
+ end
16
+
17
+ def inventory_items
18
+ variant = Spree::Variant.find(variant_id)
19
+
20
+ inventory_items = variant.inventory_items
21
+ inventory_items.where(inventory_date: from_date..to_date) if variant.permanent_stock?
22
+
23
+ inventory_items
24
+ end
25
+ end
26
+ end
27
+ end