Class: SpreeCmCommissioner::InventoryItem
- Includes:
- ProductType, StoreMetadata
- Defined in:
- app/models/spree_cm_commissioner/inventory_item.rb
Constant Summary collapse
- MAX_DISPLAY_STOCK =
20- LOW_STOCK_THRESHOLD =
20
Constants included from ProductType
ProductType::PERMANENT_STOCK_PRODUCT_TYPES, ProductType::PRE_INVENTORY_DAYS, ProductType::PRODUCT_TYPES
Instance Method Summary collapse
- #active? ⇒ Boolean
-
#adjust_quantity!(quantity) ⇒ Object
This method is only used when admin update stock.
- #adjust_quantity_in_redis(quantity, locked: false) ⇒ Object
-
#locked_count ⇒ Object
How many seats an operator has taken off sale for this date.
- #price_in(currency) ⇒ Object
- #public_quantity_available ⇒ Object
-
#publish_live_stock_status_to_firestore ⇒ Object
The single trigger point for real-time Firestore stock-status publishing — catches every stock-mutation path that eventually saves an InventoryItem row: order complete (unstock), order cancel (restock), hold acquire/release/convert (all synced async via InventoryItems::BulkAdjustQuantities(OnHold), which uses per-record
update!, so this callback does fire for those, just after that job's own async DB sync lands), and admin stock edits viaadjust_quantity!. - #quantity_in_redis ⇒ Object
-
#redis_expired_in ⇒ Object
1 year expiry, whether the inventory item is permanent stock or not.
- #redis_hold_key(locked: false) ⇒ Object
-
#redis_key(locked: false) ⇒ Object
Stock and hold keys.
-
#schedule_product_cache_invalidation ⇒ Object
Only for ecommerce: the app uses product.in_stock? (derived from quantity_available) for product cards.
-
#total_quantity_before_last_save ⇒ Object
Both pools as they stood before this save.
Methods included from ProductType
#permanent_stock?, #pre_inventory_days
Instance Method Details
#active? ⇒ Boolean
164 165 166 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 164 def active? inventory_date.nil? || inventory_date >= Time.zone.today end |
#adjust_quantity!(quantity) ⇒ Object
This method is only used when admin update stock
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 108 def adjust_quantity!(quantity) with_lock do # IMPORTANT: Apply quantity changes directly without defensive clamping. # The model validation will catch any attempts to go negative, surfacing bugs # in upstream logic rather than silently losing data. # # ❌ DO NOT use defensive clamping like: # self.max_capacity = [max_capacity + quantity, 0].max # # Why? Clamping masks bugs. Validation errors are better than silent data loss. # See: /docs/lessons-learned/inventory-consistency-issues.md#lesson-learned-async-job-validation-strategy self.max_capacity = max_capacity + quantity self.quantity_available = quantity_available + quantity save! # When user has been searched or booked a product, it has cached the quantity in redis, # So we need to update redis cache if inventory key has been created in redis adjust_quantity_in_redis(quantity) end end |
#adjust_quantity_in_redis(quantity, locked: false) ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 143 def adjust_quantity_in_redis(quantity, locked: false) SpreeCmCommissioner.inventory_redis_pool.with do |redis| # Always update Redis cache, even if it doesn't exist yet. # This prevents admin adjustments from being lost when cache is later initialized. script = <<~LUA local key = KEYS[1] local increment = tonumber(ARGV[1]) local expiry = tonumber(ARGV[2]) local current = tonumber(redis.call('GET', key) or 0) local new_value = current + increment if new_value < 0 then new_value = 0 end redis.call('SET', key, new_value, 'EX', expiry) return new_value LUA redis.eval(script, keys: [redis_key(locked: locked)], argv: [quantity, redis_expired_in]) end end |
#locked_count ⇒ Object
How many seats an operator has taken off sale for this date.
Counts rows rather than a cached counter, so it can't drift. .size avoids re-querying a
preloaded association.
99 100 101 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 99 def locked_count locked_reserved_blocks.size end |
#price_in(currency) ⇒ Object
103 104 105 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 103 def price_in(currency) prices.detect { |price| price.currency == currency } || prices.build(currency: currency) end |
#public_quantity_available ⇒ Object
91 92 93 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 91 def public_quantity_available [quantity_available, MAX_DISPLAY_STOCK].min end |
#publish_live_stock_status_to_firestore ⇒ Object
The single trigger point for real-time Firestore stock-status publishing — catches every
stock-mutation path that eventually saves an InventoryItem row: order complete (unstock),
order cancel (restock), hold acquire/release/convert (all synced async via
InventoryItems::BulkAdjustQuantities(OnHold), which uses per-record update!, so this callback
does fire for those, just after that job's own async DB sync lands), and admin stock edits via
adjust_quantity!. A burst of concurrent changes to the same event collapses into one in-flight
publish, since PublishStockStatusesToFirestoreJob is unique per event_id (ApplicationUniqueJob,
until_executed).
Ecommerce-only and quantity-changed-only, same guard shape as schedule_product_cache_invalidation above, but checking all four quantity columns (not just available/locked) since quantity_on_hold alone can flip the published bucket to/from pending_hold.
71 72 73 74 75 76 77 78 79 80 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 71 def publish_live_stock_status_to_firestore return unless ecommerce? return unless saved_change_to_quantity_available? || saved_change_to_quantity_on_hold? || saved_change_to_quantity_locked? || saved_change_to_quantity_locked_on_hold? event_id = variant.product.event_id return if event_id.nil? SpreeCmCommissioner::PublishStockStatusesToFirestoreJob.perform_later(event_id: event_id) end |
#quantity_in_redis ⇒ Object
139 140 141 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 139 def quantity_in_redis SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.get(redis_key).to_i } end |
#redis_expired_in ⇒ Object
1 year expiry, whether the inventory item is permanent stock or not.
Why even for permanent stock? Because if we expire it too soon (e.g. right after inventory_date), admin usually still need to look up past/old inventory items or adjust stock when needed.
172 173 174 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 172 def redis_expired_in 31_536_000 end |
#redis_hold_key(locked: false) ⇒ Object
135 136 137 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 135 def redis_hold_key(locked: false) locked ? "inventory:locked_on_hold:#{id}" : "inventory:on_hold:#{id}" end |
#redis_key(locked: false) ⇒ Object
Stock and hold keys. locked: true addresses the withheld pool — see LineItem#locked_stock?,
which is what every caller on the checkout path passes.
131 132 133 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 131 def redis_key(locked: false) locked ? "inventory:locked:#{id}" : "inventory:#{id}" end |
#schedule_product_cache_invalidation ⇒ Object
Only for ecommerce: the app uses product.in_stock? (derived from quantity_available) for product cards. Other types (e.g. accommodation, bus) have many inventory items (including daily-generated ones), so invalidating per item would flood the task queue.
This is temporary until we have a more robust cache invalidation strategy in place.
45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 45 def schedule_product_cache_invalidation return unless ecommerce? return unless saved_change_to_quantity_available? || saved_change_to_quantity_locked? # Only fires on an in_stock status transition (0 ↔ N); non-zero to non-zero changes are skipped. # # Measured across BOTH pools, because Variant#in_stock? counts both. Watching quantity_available # alone would miss an agent buying out the locked pool while public stock was already 0 — the # column never moves, so no task is scheduled and the card keeps advertising stock that is gone. return if total_quantity_before_last_save.positive? == (quantity_available + quantity_locked).positive? SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: variant.product) end |
#total_quantity_before_last_save ⇒ Object
Both pools as they stood before this save. saved_change_to_* is nil for a column that did not change, so an unchanged pool falls back to its current value rather than to zero.
84 85 86 87 88 89 |
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 84 def total_quantity_before_last_save available_before = saved_change_to_quantity_available&.first || quantity_available locked_before = saved_change_to_quantity_locked&.first || quantity_locked available_before.to_i + locked_before.to_i end |