Class: ItemLedgerEntry

Inherits:
ApplicationRecord show all
Defined in:
app/models/item_ledger_entry.rb

Overview

== Schema Information

Table name: item_ledger_entries
Database name: primary

id :integer not null, primary key
category :string(255)
currency :string(255)
description :string(255)
gl_date :date
location :string(255)
new_qty_on_hand :integer
new_unit_cogs :decimal(10, 4)
old_qty_on_hand :integer
old_unit_cogs :decimal(10, 4)
quantity :integer
quantity_eval :integer
total_cost :decimal(10, 4)
unit_cost :decimal(10, 4)
created_at :datetime
updated_at :datetime
business_unit_id :integer
creator_id :integer
cycle_count_item_id :integer
delivery_id :integer
invoice_id :integer
item_id :integer
item_kit_id :integer
landed_cost_id :integer
ledger_company_account_id :integer
ledger_detail_project_id :integer
ledger_transaction_id :integer
reversed_entry_id :integer
rma_item_id :integer
shipment_receipt_item_id :integer
store_id :integer
updater_id :integer

Indexes

idx_gl_date (gl_date)
idx_store_id_gl_date (store_id,gl_date)
idx_store_id_item_id_invoice_id (store_id,item_id,invoice_id)
idx_store_id_location (store_id,location)
index_item_ledger_entries_on_cycle_count_item_id (cycle_count_item_id)
index_item_ledger_entries_on_delivery_id (delivery_id)
index_item_ledger_entries_on_invoice_id (invoice_id)
index_item_ledger_entries_on_item_id (item_id)
index_item_ledger_entries_on_landed_cost_id (landed_cost_id)
index_item_ledger_entries_on_ledger_transaction_id (ledger_transaction_id)
index_item_ledger_entries_on_reversed_entry_id (reversed_entry_id)
index_item_ledger_entries_on_rma_item_id (rma_item_id)
index_on_rma_item_id_where_is_null (rma_item_id) WHERE (reversed_entry_id IS NULL)
shipment_receipt_item_id_category (shipment_receipt_item_id,category)

Defined Under Namespace

Classes: ItemLedgerEntryError

Constant Summary collapse

TYPE_ABBREVIATIONS =

Short codes shown in the inventory ledger UI and exports, keyed
by category (the inventory transaction type). See
category_type_abbreviation_for_select for how they're surfaced.

{ 'PO_RECEIPT' => 'POR', 'PO_RECEIPT_VOID' => 'PORV', 'PO_LANDED_COST' => 'POLC', 'INVOICE' => 'INV', 'INVOICE_KIT' => 'INV_KIT', 'ISSUE_TO_ACCOUNT' => 'ITA', 'INVENTORY_ADJUSTMENT' => 'IA', 'LOCATION_TRANSFER' => 'LT',
'ITEM_RECLASSIFICATION' => 'IR', 'RMA_RECEIPT' => 'RMA', 'RMA_RECEIPT_KIT' => 'RMA_KIT', 'COGS_ADJUSTMENT' => 'COGS', 'TOTAL_COST_ADJUSTMENT' => 'TCA', 'CYCLE_COUNT' => 'CC', 'REVERSAL' => 'REV', 'STORE_TRANSFER' => 'ST', 'STORE_TRANSFER_KIT' => 'ST_KIT' }.freeze

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Has one collapse

Has and belongs to many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#allow_kitsObject

Returns the value of attribute allow_kits.



97
98
99
# File 'app/models/item_ledger_entry.rb', line 97

def allow_kits
  @allow_kits
end

#categoryObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#currencyObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#gl_dateObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#item_idObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#locationObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#previous_quantityObject

Returns the value of attribute previous_quantity.



97
98
99
# File 'app/models/item_ledger_entry.rb', line 97

def previous_quantity
  @previous_quantity
end

#quantityObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#quantity_evalObject (readonly)



87
# File 'app/models/item_ledger_entry.rb', line 87

validates :quantity_eval, presence: { if: proc { |ile| %w[PO_LANDED_COST COGS_ADJUSTMENT].include?(ile.category) } }

#reversed_entry_idObject (readonly)



88
# File 'app/models/item_ledger_entry.rb', line 88

validates :reversed_entry_id, presence: { if: proc { |ile| ile.category == 'REVERSAL' } }

#skip_consolidate_kit_parentsObject

Returns the value of attribute skip_consolidate_kit_parents.



97
98
99
# File 'app/models/item_ledger_entry.rb', line 97

def skip_consolidate_kit_parents
  @skip_consolidate_kit_parents
end

#store_idObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#total_costObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

#unit_costObject (readonly)



85
# File 'app/models/item_ledger_entry.rb', line 85

validates :category, :store_id, :item_id, :quantity, :gl_date, :location, :unit_cost, :total_cost, :currency, presence: true

Class Method Details

.category_type_abbreviation_for_selectArray<Array(String, String)>

["<Pretty Name> [ABBR]", category] pairs from
TYPE_ABBREVIATIONS for the inventory-history filter dropdown.

Returns:

  • (Array<Array(String, String)>)


120
121
122
# File 'app/models/item_ledger_entry.rb', line 120

def self.category_type_abbreviation_for_select
  TYPE_ABBREVIATIONS.map { |doc, abbr| ["#{doc.titleize} [#{abbr}]", doc] }
end

.cogs_adjustment(store, line_items, gl_date, description = nil) ⇒ void

This method returns an undefined value.

Re-cost on-hand stock to a new unit COGS without changing
quantity. The total-cost delta of the revaluation is captured
so #cogs_adjustment_action can post it to the inventory-
revaluation account.

Parameters:

  • store (Store)
  • line_items (Hash{Integer=>Hash})

    each row has :store_item_id and :new_cogs

  • gl_date (Date)
  • description (String, nil) (defaults to: nil)


444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
# File 'app/models/item_ledger_entry.rb', line 444

def self.cogs_adjustment(store, line_items, gl_date, description = nil)
  ItemLedgerEntry.transaction do
    line_items.each do |_index, attrs|
      store_item = StoreItem.find(attrs[:store_item_id])
      item = store_item.item
      new_cogs = BigDecimal(attrs[:new_cogs])
      if store_item.qty_on_hand == 0
        quantity = 0
        unit_cost = 0
        total_cost = 0
      else
        quantity = store_item.qty_on_hand

        existing_value = quantity * store_item.unit_cogs
        new_value = quantity * new_cogs

        total_cost = new_value - existing_value
        unit_cost = total_cost / quantity
      end

      ile = ItemLedgerEntry.new(store_id: store.id,
                                item_id: item.id,
                                category: 'COGS_ADJUSTMENT',
                                gl_date:,
                                quantity: 0,
                                quantity_eval: quantity,
                                location: store_item.location,
                                currency: store.company.currency,
                                unit_cost:,
                                total_cost:,
                                description:,
                                new_unit_cogs: new_cogs)
      ile.save!
    end
  end
end

.consolidate_kits(kit_item:, store_id:, async: true, async_delay: 5) ⇒ void

This method returns an undefined value.

Class-level entry point for kit consolidation. With async
off, runs Item::KitConsolidator inline; otherwise enqueues
KitConsolidationWorker (with a small delay so the parent
ILE's DB commit is visible).

Parameters:

  • kit_item (Item)
  • store_id (Integer)
  • async (Boolean) (defaults to: true)
  • async_delay (Integer) (defaults to: 5)

    seconds to defer the worker



759
760
761
762
763
764
765
766
767
768
769
770
771
# File 'app/models/item_ledger_entry.rb', line 759

def self.consolidate_kits(kit_item:, store_id:, async: true, async_delay: 5)
  # add a delay here to allow the db commit to finish
  if async
    if async_delay.positive?
      KitConsolidationWorker.perform_in(async_delay.seconds, kit_item.id, store_id)
    else
      KitConsolidationWorker.perform_async(kit_item.id, store_id)
    end
  else
    # populate_components removed, now is not the time in item ledger to do this
    Item::KitConsolidator.new(kit_item, store_id:).consolidate_qty.consolidate_unit_cogs.commit
  end
end

.inventory_adjustment(store, line_items, gl_date, description = nil, allow_kits = false) ⇒ void

This method returns an undefined value.

Cycle-count / write-off entry-point. Explodes any kit rows
into their components first (kits are not stocked), then
creates one INVENTORY_ADJUSTMENT ILE per component and
consolidates kit parents at the end.

Parameters:

  • store (Store)
  • line_items (Hash{Integer=>Hash})
  • gl_date (Date)
  • description (String, nil) (defaults to: nil)
  • allow_kits (Boolean) (defaults to: false)

    passed through to children

Raises:



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
# File 'app/models/item_ledger_entry.rb', line 337

def self.inventory_adjustment(store, line_items, gl_date, description = nil, allow_kits = false)
  ItemLedgerEntry.transaction do
    adjustments = []
    line_items.each do |_index, attrs|
      master_store_item = StoreItem.find(attrs[:store_item_id])
      item = master_store_item.item
      master_qty_adjustment = attrs[:qty].to_i
      serial_number_ids = [attrs[:serial_number_id].presence].compact.presence

      # User wants to adjust a kit, so we explode the kit
      if item.kit_target_item_relations.present?
        item.kit_target_item_relations.each_with_object({}) do |tir, _hsh|
          target_item = tir.target_item
          store_item = target_item.store_items.where(store_id: master_store_item.store_id, location: master_store_item.location).first
          unless store_item
            raise ItemLedgerEntryError,
                  "Could not find kit component #{target_item.sku} from #{item.sku} in target store id #{master_store_item.store_id} and location #{master_store_item.location}"
          end
          if store_item.requires_serial_number
            raise ItemLedgerEntryError,
                  "At least one kit component #{target_item.sku} requires serial number, adjustments must be made at the individual component level"
          end

          adjustments << {
            store_item:,
            quantity: master_qty_adjustment * tir.quantity,
            allow_kits: false,
            from_kit: true,
            serial_number_ids:
          }
        end
      else
        adjustments << {
          store_item: master_store_item,
          quantity: master_qty_adjustment,
          allow_kits: false,
          from_kit: false,
          serial_number_ids:
        }
      end
    end

    # Prevent potential issues with adjusting kit and components at the same time
    if adjustments.any? { |a| a[:from_kit] } && !adjustments.all? { |a| a[:from_kit] }
      raise ItemLedgerEntryError,
            'Mixed kit and non kit components in inventory adjustment is not allowed in a single adjustment transaction to ensure integrity'
    end

    kit_parents = []
    # Now proceed to record our adjustment
    adjustments.each do |adjustment|
      store_item = adjustment[:store_item]
      quantity = adjustment[:quantity]
      item = store_item.item
      # We will find the topmost kit parents and consolidate those which
      # will trickle down
      item.kit_parents.each do |kpi|
        if kpi.is_kit?
          kit_parents += kpi.kit_parents.to_a
        else
          kit_parents << kpi
        end
      end
      store = store_item.store
      allow_kits = adjustment[:allow_kits]
      serial_number_ids = adjustment[:serial_number_ids]
      ItemLedgerEntry.inventory_adjustment_individual(store_id: store_item.store_id,
                                                      item_id: item.id,
                                                      gl_date:,
                                                      quantity:,
                                                      location: store_item.location,
                                                      currency: store.company.currency,
                                                      unit_cost: store_item.unit_cogs,
                                                      total_cost: store_item.unit_cogs * quantity,
                                                      description:,
                                                      allow_kits:,
                                                      serial_number_ids:,
                                                      skip_consolidate_kit_parents: true)
    end
    # Consolidate our kit parents in one shot
    kit_parents.uniq.each do |kit_item|
      consolidate_kits(kit_item:, store_id: store.id, async: false, async_delay: 0)
    end
  end
end

.inventory_adjustment_individual(attributes = {}) ⇒ ItemLedgerEntry

Low-level per-component variant used by inventory_adjustment
— defaults category and total_cost and creates the ILE.

Parameters:

  • attributes (Hash) (defaults to: {})

Returns:



428
429
430
431
432
# File 'app/models/item_ledger_entry.rb', line 428

def self.inventory_adjustment_individual(attributes = {})
  attributes[:category] ||= 'INVENTORY_ADJUSTMENT'
  attributes[:total_cost] ||= attributes[:unit_cost] * attributes[:quantity]
  ItemLedgerEntry.create!(attributes)
end

.issue_to_account(store, line_items, company_account, business_unit = nil, gl_date = nil, project = nil, description = nil) ⇒ void

This method returns an undefined value.

Pull stock out of inventory and expense it to a specific GL
account (e.g. samples, R&D consumption). Each line creates an
ISSUE_TO_ACCOUNT ILE which then drives the GL posting via
#issue_to_account_action.

Parameters:

  • store (Store)
  • line_items (Hash{Integer=>Hash})
  • company_account (LedgerCompanyAccount)

    target expense account

  • business_unit (BusinessUnit, nil) (defaults to: nil)
  • gl_date (Date, nil) (defaults to: nil)
  • project (LedgerDetailProject, nil) (defaults to: nil)
  • description (String, nil) (defaults to: nil)


301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'app/models/item_ledger_entry.rb', line 301

def self.(store, line_items, , business_unit = nil, gl_date = nil, project = nil, description = nil)
  ItemLedgerEntry.transaction do
    line_items.each do |_index, attrs|
      store_item = StoreItem.find(attrs[:store_item_id])
      item = store_item.item
      quantity = attrs[:qty].to_i
      ItemLedgerEntry.create!(store_id: store.id,
                              item_id: item.id,
                              category: 'ISSUE_TO_ACCOUNT',
                              gl_date:,
                              quantity: quantity * -1,
                              location: store_item.location,
                              currency: store.company.currency,
                              unit_cost: store_item.unit_cogs,
                              total_cost: store_item.unit_cogs * (quantity * -1),
                              description:,
                              ledger_company_account: ,
                              ledger_detail_project: project,
                              business_unit:,
                              serial_number_ids: attrs[:serial_number_id].blank? ? nil : [attrs[:serial_number_id]])
    end
  end
end

.item_reclassification(store, from_line_items, to_line_items, gl_date, description = nil, allow_kits = false) ⇒ void

This method returns an undefined value.

Move stock value between SKUs (e.g. when a SKU is split or
consolidated). from_line_items are removed at their current
COGS; to_line_items receive value pro-rated by their target
COGS so the total cost stays balanced. Posts a self-balancing
ITEM_RECLASSIFICATION GL transaction.

Parameters:

  • store (Store)
  • from_line_items (Hash{Integer=>Hash})
  • to_line_items (Hash{Integer=>Hash})
  • gl_date (Date)
  • description (String, nil) (defaults to: nil)
  • allow_kits (Boolean) (defaults to: false)

Raises:



527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
# File 'app/models/item_ledger_entry.rb', line 527

def self.item_reclassification(store, from_line_items, to_line_items, gl_date, description = nil, allow_kits = false)
  ItemLedgerEntry.transaction do
     = LedgerCompanyAccount.(store.company_id, store.)
     = LedgerCompanyAccount.(store.company_id, store.)
    raise ItemLedgerEntryError, 'Unable to find Purchased Finished Goods company account' if .nil?
    raise ItemLedgerEntryError, 'Unable to find Services-Pending Fulfillment Debit company account' if .nil?

     = nil

    # record what the total value is of all the items we are reclassifying
    total_value = BigDecimal(0)
    currency = store.company.currency
    entries = []
    from_line_items_qty = 0

    from_line_items.each do |_index, attrs|
      store_item = StoreItem.find(attrs[:store_item_id])
      quantity = attrs[:qty].to_i
      store_item_value = store_item.unit_cogs * quantity
      raise ItemLedgerEntryError, "From store item #{store_item.id} must have a cogs value > 0" if store_item_value == 0

      total_value += store_item_value
      from_line_items_qty += quantity
      entries << ItemLedgerEntry.create!(store_id: store.id,
                                         item_id: store_item.item_id,
                                         category: 'ITEM_RECLASSIFICATION',
                                         gl_date:,
                                         quantity: quantity * -1,
                                         location: store_item.location,
                                         currency:,
                                         unit_cost: store_item.unit_cogs,
                                         total_cost: store_item_value * -1,
                                         description:,
                                         allow_kits:,
                                         serial_number_ids: attrs[:serial_number_id].blank? ? nil : [attrs[:serial_number_id]])
      if .nil? && (store_item.item.tax_class == 'svc')
         = 
      elsif .nil?
         = 
      end
    end

    # record how many items in total we are reclassifying TO
    # we will use this later to work out what the new cogs should be
    to_total_cost = BigDecimal(0)
    to_line_items.each do |_index, attrs|
      store_item = StoreItem.where(store_id: store.id, item_id: attrs[:item_id], location: attrs[:location]).first_or_create
      raise ItemLedgerEntryError, 'Store item must be present in target store and location.' if store_item.nil?
      raise ItemLedgerEntryError, "To store item #{store_item.id} must have a cogs value.positive? if stock is present" if (store_item.unit_cogs == 0) && (store_item.qty_on_hand >= 1)

      attrs[:store_item] = store_item
      cogs_to_use = store_item.unit_cogs.zero? ? (total_value / from_line_items_qty) : store_item.unit_cogs
      to_total_cost += cogs_to_use * attrs[:qty].to_i
    end

    total_assigned_value = BigDecimal(0)

    to_line_items.each do |_index, attrs|
      item = Item.find(attrs[:item_id])
      quantity = attrs[:qty].to_i
      location = attrs[:location]
      store_item = attrs[:store_item]
      cogs_to_use = store_item.unit_cogs.zero? ? (total_value / from_line_items_qty) : store_item.unit_cogs
      cogs_percentage = ((cogs_to_use * attrs[:qty].to_i) / to_total_cost) * 100
      unit_cogs = ((total_value / 100) * cogs_percentage) / quantity
      entries << ItemLedgerEntry.create!(store_id: store.id,
                                         item_id: item.id,
                                         category: 'ITEM_RECLASSIFICATION',
                                         gl_date:,
                                         quantity:,
                                         location:,
                                         currency:,
                                         unit_cost: unit_cogs,
                                         total_cost: unit_cogs * quantity,
                                         description:,
                                         allow_kits:)
      total_assigned_value += (unit_cogs * quantity)
    end

    transaction = LedgerTransaction.new(transaction_type: 'ITEM_RECLASSIFICATION',
                                        transaction_date: gl_date,
                                        description:,
                                        currency:,
                                        company: store.company)

    # withdraw funds from the purchased finished goods account
    transaction.ledger_entries << LedgerEntry.new(ledger_company_account_id: .id, currency:,
                                                  amount: total_assigned_value * -1, description:)

    # and add them back
    transaction.ledger_entries << LedgerEntry.new(ledger_company_account_id: .id, currency:, amount: total_assigned_value,
                                                  description:)

    transaction.save!

    entries.each { |e| e.update!(ledger_transaction: transaction) }
  end
end

.location_transfer(store, line_items, gl_date, description = nil) ⇒ void

This method returns an undefined value.

Move stock between locations within the same Store: posts
paired LOCATION_TRANSFER ILEs (out / in) and a balanced
LedgerTransaction that nets to zero against the goods or
services GL account.

Parameters:

  • store (Store)
  • line_items (Hash{Integer=>Hash})

    form-style nested attrs keyed by row index

  • gl_date (Date)
  • description (String, nil) (defaults to: nil)

Raises:



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'app/models/item_ledger_entry.rb', line 135

def self.location_transfer(store, line_items, gl_date, description = nil)
  ItemLedgerEntry.transaction do
    company = store.company
    currency = company.currency
     = LedgerCompanyAccount.(company.id, store.)
     = LedgerCompanyAccount.(company.id, store.)
    raise ItemLedgerEntryError, 'Unable to find Purchased Finished Goods company account' if .nil?
    raise ItemLedgerEntryError, 'Unable to find Services-Pending Fulfillment Debit company account' if .nil?

    line_items.each do |_index, attrs|
      store_item = StoreItem.find(attrs[:store_item_id])
      item = store_item.item
      quantity = attrs[:qty].to_i
      entry1 = ItemLedgerEntry.create!(store_id: store.id,
                                       item_id: item.id,
                                       category: 'LOCATION_TRANSFER',
                                       gl_date:,
                                       quantity: quantity * -1,
                                       location: store_item.location,
                                       currency:,
                                       unit_cost: store_item.unit_cogs,
                                       total_cost: store_item.unit_cogs * (quantity * -1),
                                       description:,
                                       serial_number_ids: attrs[:serial_number_id].blank? ? nil : [attrs[:serial_number_id]])
      entry2 = ItemLedgerEntry.create!(store_id: store.id,
                                       item_id: item.id,
                                       category: 'LOCATION_TRANSFER',
                                       gl_date:,
                                       quantity: quantity.to_i,
                                       location: attrs[:new_location],
                                       currency:,
                                       unit_cost: store_item.unit_cogs,
                                       total_cost: store_item.unit_cogs * quantity.to_i,
                                       description:,
                                       serial_number_ids: attrs[:serial_number_id].blank? ? nil : [attrs[:serial_number_id]])

      # now post to the account ledger to remove/add funds from purchased finished goods
      transaction = LedgerTransaction.new(transaction_type: 'LOCATION_TRANSFER',
                                          transaction_date: gl_date,
                                          description:,
                                          currency:,
                                          company:)

       = if item.tax_class == 'svc'
                         
                       else
                         
                       end

      # withdraw funds from the purchased finished goods account
      transaction.ledger_entries << LedgerEntry.new(ledger_company_account_id: .id, currency:,
                                                    amount: store_item.unit_cogs * (quantity.to_i * -1), description:)

      # and add them back
      transaction.ledger_entries << LedgerEntry.new(ledger_company_account_id: .id, currency:,
                                                    amount: store_item.unit_cogs * quantity.to_i, description:)

      transaction.save!

      entry1.update!(ledger_transaction: transaction)
      entry2.update!(ledger_transaction: transaction)
    end
  end
end

.not_reversed_or_reversalActiveRecord::Relation<ItemLedgerEntry>

A relation of ItemLedgerEntries that are not reversed or reversal. Active Record Scope

Returns:

See Also:



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

scope :not_reversed_or_reversal, -> {
  joins('LEFT OUTER JOIN item_ledger_entries reversals ON reversals.reversed_entry_id = item_ledger_entries.id').where('item_ledger_entries.reversed_entry_id is null and reversals.id is null')
}

.process_intracompany_st_delivery(delivery) ⇒ void

This method returns an undefined value.

Write the STORE_TRANSFER / STORE_TRANSFER_KIT ILEs at
ship-time for a same-company Delivery: releases reserved
catalog items and records the outflow at the from-store's
current unit_cogs. Linked to the GL transaction made by
LedgerTransaction.process_intracompany_st_delivery.

Parameters:



668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
# File 'app/models/item_ledger_entry.rb', line 668

def self.process_intracompany_st_delivery(delivery)
  ItemLedgerEntry.transaction do
    # release/uncommit catalog items, but skip kit consolidation as this will be done in the consolidate_kits after_create callback anyway
    delivery.uncommit_catalog_items
    delivery.line_items.non_shipping.eager_load(catalog_item: :store_item).where(store_items: { permanent_qty_available: nil }).each do |li|
      if li.item.is_kit?
        # li.item.kit_target_item_relations.each do |ki|
        #   store_item = ki.target_item.store_item_for(delivery.order.from_store_id, 'AVAILABLE')
        #   ItemLedgerEntry.create!(store: delivery.order.from_store, item: ki.target_item, item_kit: li.item, category: 'STORE_TRANSFER', gl_date: delivery.shipped_date, quantity: -(ki.quantity * li.quantity), currency: delivery.order.currency, unit_cost: store_item.unit_cogs, total_cost: -(store_item.unit_cogs * ki.quantity * li.quantity), location: 'AVAILABLE', delivery: delivery, ledger_transaction: delivery.ledger_transactions.first, serial_number_ids: (ki.target_item.require_reservation? ? li.serial_number_ids : nil))
        # end
        ItemLedgerEntry.create!(store: delivery.order.from_store, item: li.item, category: 'STORE_TRANSFER_KIT', gl_date: delivery.shipped_date,
                                quantity: 0, quantity_eval: -li.quantity, currency: delivery.order.currency, unit_cost: 0, total_cost: 0, location: 'AVAILABLE', delivery:, ledger_transaction: delivery.ledger_transactions.first)
      else
        unit_cost = li.parent.present? ? StoreItem.where(store_id: delivery.order.from_store_id, item_id: li.item_id).first.unit_cogs : li.unit_cogs
        total_cost = unit_cost * li.quantity
        ItemLedgerEntry.create!(store: delivery.order.from_store, item: li.item, item_kit: li.parent&.item, category: 'STORE_TRANSFER',
                                gl_date: delivery.shipped_date, quantity: li.quantity * -1, currency: delivery.order.currency, unit_cost:, total_cost: total_cost * -1, location: 'AVAILABLE', delivery:, ledger_transaction: delivery.ledger_transactions.first, serial_number_ids: li.serial_number_ids)
      end
    end
  end
end

.process_invoice(invoice) ⇒ void

This method returns an undefined value.

Write the inventory-side ILEs for an invoiced Invoice: one
INVOICE ILE per shipped line item (or INVOICE_KIT plus
per-component INVOICE ILEs for kits) linked back to the GL
transaction created by LedgerTransaction.process_invoice.

Parameters:



633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
# File 'app/models/item_ledger_entry.rb', line 633

def self.process_invoice(invoice)
  return if invoice.item_ledger_entries.any?

  ItemLedgerEntry.transaction do
    store = if invoice.invoice_type == Invoice::MI
              invoice.store
            else
              invoice.order_id.present? ? invoice.order.store : invoice.line_items.parents_only.eager_load(catalog_item: :store_item).where(cm_category: 'Item').first.store_item.store
            end
    invoice.line_items.parents_only.eager_load(catalog_item: :store_item).where(cm_category: 'Item').each do |li|
      if li.has_children?
        li.children.eager_load(catalog_item: :store_item).where(store_items: { permanent_qty_available: nil }).each do |child|
          store_id = invoice.order_id.present? ? invoice.order.store_id : li.store_item.store_id
          store_item = child.store_item_id.nil? ? child.item.store_items.active.available.where(store_id:).first : child.store_item
          ItemLedgerEntry.create!(store:, item: child.item, item_kit: li.item, category: 'INVOICE', gl_date: invoice.gl_date,
                                  quantity: -child.quantity, currency: invoice.currency, unit_cost: store_item.unit_cogs, total_cost: -(store_item.unit_cogs * child.quantity), location: store_item.location, invoice:, ledger_transaction: invoice.ledger_transactions.first, serial_number_ids: child.serial_number_ids)
        end
        ItemLedgerEntry.create!(store:, item: li.item, category: 'INVOICE_KIT', gl_date: invoice.gl_date, quantity: 0,
                                quantity_eval: -li.quantity, currency: invoice.currency, unit_cost: 0, total_cost: 0, location: li.store_item.location, invoice:, ledger_transaction: invoice.ledger_transactions.first, serial_number_ids: li.serial_number_ids)
      else
        ItemLedgerEntry.create!(store:, item: li.item, category: 'INVOICE', gl_date: invoice.gl_date, quantity: li.quantity * -1,
                                currency: invoice.currency, unit_cost: li.unit_cogs, total_cost: -(li.unit_cogs * li.quantity), location: li.store_item.location, invoice:, ledger_transaction: invoice.ledger_transactions.first, serial_number_ids: li.serial_number_ids)
      end
    end
  end
end

.store_transfer(store:, line_items:, new_store:, shipping_option_id:, gl_date:, address_id:, description: nil, fulfillment_order_reference: nil) ⇒ Order

Kick off a Store-Transfer (ST) workflow between two Stores:
creates an ST Order from store to new_store, populates
line items at unit_cogs, retrieves shipping rates, builds
the Delivery, and links a PurchaseOrder on the receiving
side. The actual ILEs are written by
process_intracompany_st_delivery when the delivery ships.

Parameters:

  • store (Store)

    origin

  • line_items (Hash{Integer=>Hash})
  • new_store (Store)

    destination

  • shipping_option_id (Integer, nil)
  • gl_date (Date)
  • address_id (Integer)
  • description (String, nil) (defaults to: nil)
  • fulfillment_order_reference (String, nil) (defaults to: nil)

    linked customer order, if any

Returns:

  • (Order)

    the new ST order

Raises:



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'app/models/item_ledger_entry.rb', line 217

def self.store_transfer(store:, line_items:, new_store:, shipping_option_id:, gl_date:, address_id:, description: nil, fulfillment_order_reference: nil)
  ItemLedgerEntry.transaction do
    from_company = store.company
    new_store.company
    preferred_shipping_option = shipping_option_id.blank? ? nil : ShippingOption.find(shipping_option_id)

    fulfillment_order_id = nil
    if fulfillment_order_reference.present?
      fulfillment_order = Order.where(reference_number: fulfillment_order_reference).first
      fulfillment_order_id = fulfillment_order.id if fulfillment_order.present?
    end
    new_store_sold_to_customer = StoreTransfer.where(from_store_id: store.id).where(to_store_id: new_store.id).first.sold_to_customer
    # create an ST for the current store, selling to the new store and move it to the at_warehouse state
    so = Order.new(customer: new_store_sold_to_customer, # Store's consignee party is the customer for this store transfer
                   shipping_address_id: address_id,
                   currency: from_company.currency,
                   disable_auto_coupon: true,
                   shipping_method: preferred_shipping_option.try(:name) || new_store.preferred_inbound_shipping_option.try(:name) || store.preferred_outbound_shipping_option.try(:name) || new_store.preferred_outbound_shipping_option.try(:name),
                   order_type: Invoice::ST,
                   from_store: store,
                   to_store: new_store,
                   parent_id: fulfillment_order_id,
                   shipment_reference_number: fulfillment_order_reference)
    so.save!
    fulfillment_order.presence&.quick_note(so.reference_number)
    line_items.each do |_index, attrs|
      store_item = StoreItem.find(attrs[:store_item_id])
      item = store_item.item

      quantity = attrs[:qty].to_i
      cat_item = store.primary_catalog.catalog_items.joins(:store_item).find_by(store_items: { item_id: item.id })
      if cat_item.nil?
        raise ItemLedgerEntryError,
              "Unable to find given item SKU: #{item.sku} / ID: #{item.id} in origin store's primary catalog NAME: #{store.primary_catalog.name} / ID: #{store.primary_catalog_id}."
      end

      dest_cat_item = new_store.primary_catalog.catalog_items.joins(:store_item).find_by(store_items: { item_id: item.id })
      if dest_cat_item.nil?
        raise ItemLedgerEntryError,
              "Unable to find given item SKU: #{item.sku} / ID: #{item.id} in destination store's primary catalog NAME: #{new_store.primary_catalog.name} / ID: #{new_store.primary_catalog_id}."
      end

      so.line_items.build(catalog_item: cat_item,
                          quantity:,
                          price: store_item.unit_cogs.round(2),
                          discounted_price: store_item.unit_cogs.round(2))
    end
    so.save!
    # Retrieve shipping costs to create deliveries for the store transfer
    so.retrieve_shipping_costs
    d = so.reload.deliveries.first

    # Safety check: ensure delivery was created
    if d.nil?
      raise ItemLedgerEntryError,
            "Failed to create delivery for store transfer #{so.reference_number}. Please check shipping address and line items."
    end

    PurchaseOrder.new_or_update_st_po_from_delivery(d)
    so.payment_complete!
    # Ramie Blatt 5/19/22, I do not agree with the below, we need to enter the FBA ID and PRO numbers for the record and that must be done manually
    # if store.owner == 'amazon'
    #   # push the order through as no shipping info needs to be put in
    #   d.update(state: 'pending_ship_confirm')
    #   d.shipped
    #   d.trigger_invoiced
    # end
    so
  end
end

.total_cost_adjustment(store, line_items, gl_date, description = nil, allow_kits = false) ⇒ void

This method returns an undefined value.

Bulk total-cost write-up/down without touching unit COGS or
quantity (used to true up landed-cost entries that come in
weeks after the receipt). Posts to the inventory-revaluation
account via #total_cost_adjustment_action.

Parameters:

  • store (Store)
  • line_items (Hash{Integer=>Hash})
  • gl_date (Date)
  • description (String, nil) (defaults to: nil)
  • allow_kits (Boolean) (defaults to: false)


492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'app/models/item_ledger_entry.rb', line 492

def self.total_cost_adjustment(store, line_items, gl_date, description = nil, allow_kits = false)
  ItemLedgerEntry.transaction do
    line_items.each do |_index, attrs|
      store_item = StoreItem.find(attrs[:store_item_id])
      item = store_item.item
      total_cost = BigDecimal(attrs[:total_cost])
      ItemLedgerEntry.create!(store_id: store.id,
                              item_id: item.id,
                              category: 'TOTAL_COST_ADJUSTMENT',
                              gl_date:,
                              quantity: 0,
                              location: store_item.location,
                              currency: store.company.currency,
                              unit_cost: 0,
                              total_cost:,
                              description:,
                              allow_kits:)
    end
  end
end

Instance Method Details

#business_unitBusinessUnit



76
# File 'app/models/item_ledger_entry.rb', line 76

belongs_to :business_unit, optional: true

#consolidate_kits(async: true) ⇒ void

This method returns an undefined value.

Re-roll the active kit parents that contain this item in this store so
kit-level qty / COGS reflect the latest component change.

Coalesced + async in every environment. A single component can belong to
hundreds of kits (the SS-01 circuit check is in ~900), so a kit-RMA
receive/unreturn — one ledger entry per component — used to fan out into
~900 separate KitConsolidationWorker jobs, and ran them INLINE in
development (async: !Rails.env.development?), freezing the request for
15–35s with thousands of queries. We now enqueue ONE batch job carrying
every active kit-parent id. Discontinued kits are skipped — their
availability isn't sold, so there's nothing to reconsolidate. Dev offloads
to Sidekiq like prod; run the workers: process from Procfile.dev to drain
the +kit_consolidation+ queue.

Parameters:

  • async (Boolean) (defaults to: true)

    enqueue the batch job vs. reconsolidate inline
    (callers needing a synchronous pass pass async: false)



736
737
738
739
740
741
742
743
744
745
746
747
# File 'app/models/item_ledger_entry.rb', line 736

def consolidate_kits(async: true)
  return if skip_consolidate_kit_parents.to_b

  active_parents = item.kit_parents.active.to_a
  return if active_parents.empty?

  if async
    KitConsolidationWorker.perform_in(5.seconds, active_parents.map(&:id).sort, store_id)
  else
    active_parents.each { |kit_item| self.class.consolidate_kits(kit_item:, store_id:, async: false, async_delay: 0) }
  end
end

#creatorParty

Returns:

See Also:



78
# File 'app/models/item_ledger_entry.rb', line 78

belongs_to :creator, class_name: 'Party', optional: true

#currency_symbolString

ISO currency symbol for the entry currency.

Returns:

  • (String)


113
114
115
# File 'app/models/item_ledger_entry.rb', line 113

def currency_symbol
  Money::Currency.new(currency).symbol
end

#cycle_count_itemCycleCountItem



77
# File 'app/models/item_ledger_entry.rb', line 77

belongs_to :cycle_count_item, optional: true

#deliveryDelivery

Returns:

See Also:



74
# File 'app/models/item_ledger_entry.rb', line 74

belongs_to :delivery, optional: true

#entry_reversalItemLedgerEntry



81
# File 'app/models/item_ledger_entry.rb', line 81

has_one :entry_reversal, class_name: 'ItemLedgerEntry', foreign_key: 'reversed_entry_id'

#invoiceInvoice

Returns:

See Also:



73
# File 'app/models/item_ledger_entry.rb', line 73

belongs_to :invoice, optional: true

#itemItem

Returns:

See Also:



65
# File 'app/models/item_ledger_entry.rb', line 65

belongs_to :item

#item_kitItem

Returns:

See Also:



66
# File 'app/models/item_ledger_entry.rb', line 66

belongs_to :item_kit, class_name: 'Item', optional: true

#landed_costLandedCost

Returns:

See Also:



69
# File 'app/models/item_ledger_entry.rb', line 69

belongs_to :landed_cost, optional: true

#ledger_company_accountLedgerCompanyAccount



70
# File 'app/models/item_ledger_entry.rb', line 70

belongs_to :ledger_company_account, optional: true

#ledger_detail_projectLedgerDetailProject



72
# File 'app/models/item_ledger_entry.rb', line 72

belongs_to :ledger_detail_project, optional: true

#ledger_transactionLedgerTransaction



71
# File 'app/models/item_ledger_entry.rb', line 71

belongs_to :ledger_transaction, optional: true

#reverse(reason = nil) ⇒ ItemLedgerEntry

Build, save, and return a REVERSAL (or REVERSAL_KIT) ILE
that nets this entry to zero — same store / item / location,
negated quantity and totals, no audit-stamp inheritance.

Parameters:

  • reason (String, nil) (defaults to: nil)

    description of why the reversal exists

Returns:



696
697
698
699
700
701
702
703
704
705
706
707
708
709
# File 'app/models/item_ledger_entry.rb', line 696

def reverse(reason = nil)
  rev = dup
  rev.category = item.is_kit? ? 'REVERSAL_KIT' : 'REVERSAL'
  rev.quantity = -quantity
  rev.quantity_eval = -quantity_eval if quantity_eval
  rev.total_cost = -total_cost
  rev.description = reason
  rev.creator_id = rev.updater_id = rev.created_at = rev.updated_at = nil
  rev.old_qty_on_hand = rev.new_qty_on_hand = nil
  rev.old_unit_cogs = rev.new_unit_cogs = nil
  rev.ledger_transaction_id = nil
  rev.reversed_entry_id = id
  rev.save!
end

#reversed_entryItemLedgerEntry



80
# File 'app/models/item_ledger_entry.rb', line 80

belongs_to :reversed_entry, class_name: 'ItemLedgerEntry', optional: true

#rma_itemRmaItem

Returns:

See Also:



75
# File 'app/models/item_ledger_entry.rb', line 75

belongs_to :rma_item, optional: true

#serial_numbersActiveRecord::Relation<SerialNumber>

Returns:

See Also:



83
# File 'app/models/item_ledger_entry.rb', line 83

has_and_belongs_to_many :serial_numbers

#shipment_receipt_itemShipmentReceiptItem



68
# File 'app/models/item_ledger_entry.rb', line 68

belongs_to :shipment_receipt_item, optional: true

#storeStore

Returns:

See Also:



67
# File 'app/models/item_ledger_entry.rb', line 67

belongs_to :store

#store_itemStoreItem?

Memoized lookup of the StoreItem this ILE refers to (the
(store, item, location) triple). Returns nil if the row no
longer exists.

Returns:



715
716
717
# File 'app/models/item_ledger_entry.rb', line 715

def store_item
  @store_item ||= StoreItem.where(store_id:, item_id:, location:).first
end

#store_item_auditStoreItemAudit



82
# File 'app/models/item_ledger_entry.rb', line 82

has_one :store_item_audit

#updaterParty

Returns:

See Also:



79
# File 'app/models/item_ledger_entry.rb', line 79

belongs_to :updater, class_name: 'Party', optional: true