Class: SpreeCmCommissioner::InventoryItem

Inherits:
Base
  • Object
show all
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

Methods included from ProductType

#permanent_stock?, #pre_inventory_days

Instance Method Details

#active?Boolean

Returns:

  • (Boolean)


169
170
171
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 169

def active?
  inventory_date.nil? || inventory_date >= Time.zone.today
end

#adjust_quantity!(quantity) ⇒ Object

This method is only used when admin update stock



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 110

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!

    # Deliberately still inside with_lock, unlike the checkout-path deductors (e.g.
    # InventoryItems::BulkAdjustQuantities): this is a rare admin-only edit, not a
    # high-contention hot path, so keeping it in the same transaction as `save!` means a
    # Redis failure rolls the DB change back too, instead of leaving the DB and Redis counts
    # out of sync with nothing to reconcile them.
    adjust_quantity_in_redis(quantity)
  end
end

#adjust_quantity_in_redis(quantity, locked: false) ⇒ Object



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 148

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_countObject

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.



101
102
103
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 101

def locked_count
  locked_reserved_blocks.size
end

#price_in(currency) ⇒ Object



105
106
107
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 105

def price_in(currency)
  prices.detect { |price| price.currency == currency } || prices.build(currency: currency)
end

#public_quantity_availableObject



93
94
95
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 93

def public_quantity_available
  [quantity_available, MAX_DISPLAY_STOCK].min
end

#publish_live_stock_status_to_firestoreObject

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.



73
74
75
76
77
78
79
80
81
82
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 73

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_redisObject



144
145
146
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 144

def quantity_in_redis
  SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.get(redis_key).to_i }
end

#redis_expired_inObject

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.



177
178
179
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 177

def redis_expired_in
  31_536_000
end

#redis_hold_key(locked: false) ⇒ Object



140
141
142
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 140

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.



136
137
138
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 136

def redis_key(locked: false)
  locked ? "inventory:locked:#{id}" : "inventory:#{id}"
end

#schedule_product_cache_invalidationObject

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.



47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 47

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_saveObject

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.



86
87
88
89
90
91
# File 'app/models/spree_cm_commissioner/inventory_item.rb', line 86

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