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 Models::Schedulable

Models::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 Models::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.



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

def allow_kits
  @allow_kits
end

#categoryObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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

#currencyObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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

#gl_dateObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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

#item_idObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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

#locationObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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.



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

def previous_quantity
  @previous_quantity
end

#quantityObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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

#quantity_evalObject (readonly)

Validates quantity_eval is present for landed-cost and COGS adjustment entries.

Validations:

  • Presence ({ if: proc { |ile| %w[PO_LANDED_COST COGS_ADJUSTMENT].include?(ile.category) } })


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

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

#reversed_entry_idObject (readonly)

Validates the reversed entry is set for REVERSAL entries.

Validations:

  • Presence ({ if: proc { |ile| ile.category == 'REVERSAL' } })


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

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

#skip_consolidate_kit_parentsObject

Returns the value of attribute skip_consolidate_kit_parents.



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

def skip_consolidate_kit_parents
  @skip_consolidate_kit_parents
end

#store_idObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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

#total_costObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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

#unit_costObject (readonly)

Validates presence of the core ledger fields.

Validations:



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

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)>)


142
143
144
# File 'app/models/item_ledger_entry.rb', line 142

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)


466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# File 'app/models/item_ledger_entry.rb', line 466

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



781
782
783
784
785
786
787
788
789
790
791
792
793
# File 'app/models/item_ledger_entry.rb', line 781

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:



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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'app/models/item_ledger_entry.rb', line 359

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:



450
451
452
453
454
# File 'app/models/item_ledger_entry.rb', line 450

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)


323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'app/models/item_ledger_entry.rb', line 323

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:



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
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
# File 'app/models/item_ledger_entry.rb', line 549

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:



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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'app/models/item_ledger_entry.rb', line 157

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:



115
116
117
# File 'app/models/item_ledger_entry.rb', line 115

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:



690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# File 'app/models/item_ledger_entry.rb', line 690

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:



655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
# File 'app/models/item_ledger_entry.rb', line 655

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:



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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'app/models/item_ledger_entry.rb', line 239

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)


514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
# File 'app/models/item_ledger_entry.rb', line 514

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

Business unit this entry is attributed to.



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

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)



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

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

Party that created the entry.

Returns:

See Also:



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

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

#currency_symbolString

ISO currency symbol for the entry currency.

Returns:

  • (String)


135
136
137
# File 'app/models/item_ledger_entry.rb', line 135

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

#cycle_count_itemCycleCountItem

Cycle count line that caused this entry.



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

belongs_to :cycle_count_item, optional: true

#deliveryDelivery

Delivery that caused this entry (shipments/transfers).

Returns:

See Also:



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

belongs_to :delivery, optional: true

#entry_reversalItemLedgerEntry

Reversal entry pointing back at this entry.



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

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

#invoiceInvoice

Invoice that caused this entry (sales).

Returns:

See Also:



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

belongs_to :invoice, optional: true

#itemItem

Item whose inventory moved.

Returns:

See Also:



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

belongs_to :item

#item_kitItem

Kit item, when the movement was for a kit rather than a component.

Returns:

See Also:



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

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

#landed_costLandedCost

Landed cost record that caused this entry.

Returns:

See Also:



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

belongs_to :landed_cost, optional: true

#ledger_company_accountLedgerCompanyAccount

GL company account this entry posted against.



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

belongs_to :ledger_company_account, optional: true

#ledger_detail_projectLedgerDetailProject

Project this entry is attributed to.



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

belongs_to :ledger_detail_project, optional: true

#ledger_transactionLedgerTransaction

GL transaction this entry belongs to.



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

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:



718
719
720
721
722
723
724
725
726
727
728
729
730
731
# File 'app/models/item_ledger_entry.rb', line 718

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

Entry this entry reverses (for REVERSAL entries).



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

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

#rma_itemRmaItem

RMA line that caused this entry (returns).

Returns:

See Also:



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

belongs_to :rma_item, optional: true

#serial_numbersActiveRecord::Relation<SerialNumber>

Serial numbers moved by this entry.

Returns:

See Also:



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

has_and_belongs_to_many :serial_numbers

#shipment_receipt_itemShipmentReceiptItem

Shipment receipt line that caused this entry (PO receipts).



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

belongs_to :shipment_receipt_item, optional: true

#storeStore

Store (warehouse) where the movement happened.

Returns:

See Also:



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

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:



737
738
739
# File 'app/models/item_ledger_entry.rb', line 737

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

#store_item_auditStoreItemAudit

Store item audit row linked to this entry.



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

has_one :store_item_audit

#updaterParty

Party that last updated the entry.

Returns:

See Also:



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

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