Class: Item::InventoryCommitter

Inherits:
Object
  • Object
show all
Defined in:
app/services/item/inventory_committer.rb

Constant Summary collapse

UNCOMMIT_BATCH_SIZE =
100

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(line_item, options = {}) ⇒ InventoryCommitter

Returns a new instance of InventoryCommitter.



118
119
120
121
122
123
124
# File 'app/services/item/inventory_committer.rb', line 118

def initialize(line_item, options = {})
  @line_item = line_item
  @store_item = line_item.store_item
  @item = store_item.item
  @logger = options[:logger] || Rails.logger
  @options = options
end

Instance Attribute Details

#itemObject (readonly)

Returns the value of attribute item.



2
3
4
# File 'app/services/item/inventory_committer.rb', line 2

def item
  @item
end

#line_itemObject (readonly)

Returns the value of attribute line_item.



2
3
4
# File 'app/services/item/inventory_committer.rb', line 2

def line_item
  @line_item
end

#loggerObject (readonly)

Returns the value of attribute logger.



2
3
4
# File 'app/services/item/inventory_committer.rb', line 2

def logger
  @logger
end

#optionsObject (readonly)

Returns the value of attribute options.



2
3
4
# File 'app/services/item/inventory_committer.rb', line 2

def options
  @options
end

#store_itemObject (readonly)

Returns the value of attribute store_item.



2
3
4
# File 'app/services/item/inventory_committer.rb', line 2

def store_item
  @store_item
end

Class Method Details

.commitable?(line_item) ⇒ Boolean

Checks if a line item is commitable or not

Returns:

  • (Boolean)


5
6
7
8
9
10
11
12
# File 'app/services/item/inventory_committer.rb', line 5

def self.commitable?(line_item)
  line_item.is_goods? && # Only solids can be committed
    line_item.catalog_item && # Only those with catalog item
    line_item.quantity.positive? && # You don't commit unless quantities are positive
    (
      !line_item.dropship? || # Drop ship items are not committed, unless...
      (line_item.dropship? && line_item.resource.single_origin?)) # ..It's a dropship but you fufill from our warehouse
end

.consolidate_commit_counts(store_item_ids: nil, logger: Rails.logger) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/services/item/inventory_committer.rb', line 55

def self.consolidate_commit_counts(store_item_ids: nil, logger: Rails.logger)
  logger.info('Beginning item reconcilliation process')
  store_item_ids ||= StoreItem.where(is_discontinued: false).pluck(:id)
  store_item_ids.each_slice(50) do |store_item_ids_batch|
    # Sometimes active record lock
    Retryable.retryable(tries: 3, sleep: lambda { |n| 2**n }, on: [ActiveRecord::Deadlocked]) do |attempt_number, exception|
      msg = "consolidate_commit_counts: attempt #{attempt_number}, exception was #{exception}"
      logger.warn msg
      ErrorReporting.warning(msg) if attempt_number > 1
      store_items = StoreItem.where(id: store_item_ids_batch)
      store_items.lock('FOR UPDATE')
      store_items.each do |si|
        # Optionally if you don't want to log qty committed you can wrap inside PaperTrail.request(enabled: false) do
        qty_committed = InventoryCommit.where(store_item_id: si.id).sum(:quantity)
        si.update_columns(qty_committed:)
      end
      # update_all('qty_committed = coalesce((select sum(quantity) from inventory_commits where store_item_id = store_items.id),0)')
      store_items.includes(:item).where(items: { is_kit: true }).order(:item_id, :store_id).each do |si|
        Item::KitConsolidator.new(si.item, store_id: si.store_id).consolidate_committed_qty.commit
      end
    end
  end
end

.crm_commit(line_items, creator_id: CurrentScope.user_id, expires_on: nil) ⇒ Object

Commit multiple lines and reconcile in one go
Wrapped in retry logic to handle occasional deadlocks when multiple
shipments process concurrently and update the same store_items



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'app/services/item/inventory_committer.rb', line 17

def self.crm_commit(line_items, creator_id: CurrentScope.user_id, expires_on: nil)
  Retryable.retryable(tries: 3, sleep: ->(n) { 2**n }, on: [ActiveRecord::Deadlocked]) do |attempt_number, exception|
    Rails.logger.warn("crm_commit: attempt #{attempt_number}, exception: #{exception}") if attempt_number > 1
    InventoryCommit.transaction(requires_new: true) do
      store_item_ids = []
      # Order by store_item_id to ensure consistent lock ordering and reduce deadlocks
      commitable_items = line_items.select { |li| commitable?(li) }
      commitable_items.sort_by { |li| li.catalog_item.store_item_id }.each do |li|
        store_item_ids << li.catalog_item.store_item_id if new(li, creator_id:, expires_on:).crm_commit
      end
      # Now consolidate at once
      store_item_ids.uniq!
      consolidate_commit_counts(store_item_ids:) if store_item_ids.present?
    end
  end
end

.crm_uncommit(line_items, creator_id: CurrentScope.user_id) ⇒ Object

Uncommit multiple lines in one go with consolidation.
Unlike crm_commit, this does NOT filter by commitable? — any existing
commit must be releasable regardless of the line item's current state
(catalog_item removed, quantity zeroed, etc.).
Wrapped in retry logic to handle occasional deadlocks.



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'app/services/item/inventory_committer.rb', line 39

def self.crm_uncommit(line_items, creator_id: CurrentScope.user_id)
  Retryable.retryable(tries: 3, sleep: ->(n) { 2**n }, on: [ActiveRecord::Deadlocked]) do |attempt_number, exception|
    Rails.logger.warn("crm_uncommit: attempt #{attempt_number}, exception: #{exception}") if attempt_number > 1
    InventoryCommit.transaction(requires_new: true) do
      store_item_ids = []
      line_item_ids = line_items.map(&:id)
      commits = InventoryCommit.where(
        'line_item_id IN (:ids) OR kit_line_item_id IN (:ids)', ids: line_item_ids
      )
      store_item_ids = commits.pluck(:store_item_id).uniq
      commits.destroy_all
      consolidate_commit_counts(store_item_ids:) if store_item_ids.present?
    end
  end
end

.kit_commit(line_item:, expires_on:, creator_id:, deficit: false) ⇒ Object



158
159
160
161
162
163
164
165
# File 'app/services/item/inventory_committer.rb', line 158

def self.kit_commit(line_item:, expires_on:, creator_id:, deficit: false)
  line_item.children.each do |child|
    raise "store item is missing for component #{child.item.sku}" unless child.store_item

    line_item_commit(line_item: child, store_item: child.store_item, quantity: child.quantity, expires_on:, creator_id:, kit_line_item: line_item, deficit:)
  end
  line_item_commit(line_item:, store_item: line_item.store_item, quantity: line_item.quantity, expires_on:, creator_id:, deficit:)
end

.line_item_commit(line_item:, store_item:, quantity:, expires_on: nil, creator_id: nil, kit_line_item: nil, deficit: false) ⇒ Object



145
146
147
148
149
150
151
152
153
154
155
156
# File 'app/services/item/inventory_committer.rb', line 145

def self.line_item_commit(line_item:, store_item:, quantity:, expires_on: nil, creator_id: nil, kit_line_item: nil, deficit: false)
  ic = InventoryCommit.find_or_initialize_by(
    store_item:,
    line_item:
  )
  ic.quantity = quantity
  ic.expires_on = expires_on
  ic.kit_line_item = kit_line_item
  ic.creator_id = creator_id
  ic.deficit = deficit
  ic.save!
end

.line_item_uncommit(line_item:, store_item:) ⇒ Object



167
168
169
170
171
# File 'app/services/item/inventory_committer.rb', line 167

def self.line_item_uncommit(line_item:, store_item:)
  InventoryCommit.where('line_item_id = :line_item_id or kit_line_item_id = :line_item_id', { line_item_id: line_item.id }).each do |ic|
    ic.destroy
  end
end

.uncommit_expiredObject



79
80
81
82
83
84
85
86
87
88
89
# File 'app/services/item/inventory_committer.rb', line 79

def self.uncommit_expired
  resources = []
  InventoryCommit.where(InventoryCommit[:expires_on].lt(Date.current)).each do |ic|
    resources << ic.line_item.resource
    Item::InventoryCommitter.new(ic.line_item).crm_uncommit
  end
  resources.uniq.each do |r|
    # send a notification email to the rep that line item commits have expired
    InternalMailer.inventory_commit_expiration_notification(r).deliver_later
  end
end

.uncommit_invoicedObject



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'app/services/item/inventory_committer.rb', line 93

def self.uncommit_invoiced
  # Pass 1: line items assigned to an invoiced delivery
  InventoryCommit
    .joins(line_item: :delivery)
    .where(deliveries: { state: 'invoiced' })
    .distinct
    .pluck(:line_item_id)
    .each_slice(UNCOMMIT_BATCH_SIZE) do |ids|
      crm_uncommit(LineItem.where(id: ids))
    end

  # Pass 2: line items never assigned to a delivery on orders in terminal states.
  # These are invisible to Pass 1 because the INNER JOIN excludes NULL delivery_id.
  InventoryCommit
    .joins(:line_item)
    .joins("INNER JOIN orders ON orders.id = line_items.resource_id AND line_items.resource_type = 'Order'")
    .where(line_items: { delivery_id: nil })
    .where(orders: { state: %w[invoiced shipped cancelled fraudulent] })
    .distinct
    .pluck(:line_item_id)
    .each_slice(UNCOMMIT_BATCH_SIZE) do |ids|
      crm_uncommit(LineItem.where(id: ids))
    end
end

Instance Method Details

#crm_commitObject



126
127
128
129
130
131
132
133
134
135
136
137
# File 'app/services/item/inventory_committer.rb', line 126

def crm_commit
  return false unless self.class.commitable?(@line_item)
  return false if (@store_item.qty_available + @line_item.inventory_commits.sum(:quantity)) < @line_item.quantity

  InventoryCommit.transaction(requires_new: true) do
    if line_item.has_children?
      self.class.kit_commit(line_item:, expires_on: options[:expires_on], creator_id: options[:creator_id])
    else
      self.class.line_item_commit(line_item:, store_item:, quantity: line_item.quantity, expires_on: options[:expires_on], creator_id: options[:creator_id])
    end
  end
end

#crm_uncommitObject



139
140
141
142
143
# File 'app/services/item/inventory_committer.rb', line 139

def crm_uncommit
  InventoryCommit.transaction(requires_new: true) do
    self.class.line_item_uncommit(line_item:, store_item:)
  end
end