acts_as_fifo_lifo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +82 -0
- data/Rakefile +3 -0
- data/lib/acts_as_fifo_lifo/railtie.rb +4 -0
- data/lib/acts_as_fifo_lifo/version.rb +3 -0
- data/lib/acts_as_fifo_lifo.rb +387 -0
- data/lib/tasks/acts_as_fifo_lifo_tasks.rake +4 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 82b246066063db6e8bba14109b13951be0b077e767661e60d580143a1083e12e
|
|
4
|
+
data.tar.gz: da151bdb155f6b43125aae53f3ad31d6834d0c63e08487db8aaa6ad549321178
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ea09a13a508720420749418a33c1d6bf925f966588fd02fc9a9392fe97858a99395ce0d34eee823d02f29f7d33c83039ab302193058f509ebed693abccf1b11d
|
|
7
|
+
data.tar.gz: 3306b8b63055604ffeb81852a686d1e4db673a7f6a43c64bfd5c59c8095199dbf2e660fc68be1a061ac255f4ad08e1362bb201169b93a00fbabf92080ca3ce2b
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Rem
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ActsAsFifoLifo
|
|
2
|
+
|
|
3
|
+
A Rails gem providing FIFO (First In, First Out) and LIFO (Last In, First Out) inventory calculation methods for ActiveRecord models.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "acts_as_fifo_lifo"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
```bash
|
|
15
|
+
$ bundle
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or install it yourself as:
|
|
19
|
+
```bash
|
|
20
|
+
$ gem install acts_as_fifo_lifo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Include the module in your ActiveRecord model and configure the field mappings:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class StockTransaction < ApplicationRecord
|
|
29
|
+
acts_as_fifo_lifo item_field: :item_id,
|
|
30
|
+
qty_field: :quantity,
|
|
31
|
+
cost_field: :unit_cost,
|
|
32
|
+
time_field: :created_at,
|
|
33
|
+
batch_field: :batch_number,
|
|
34
|
+
storage_field: :storage_id,
|
|
35
|
+
operation_field: :operation_id,
|
|
36
|
+
operation_type_field: :operation_type
|
|
37
|
+
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Demo application:
|
|
42
|
+
|
|
43
|
+
[FIFO LIFO Warehouse Application](https://github.com/fatshinobi/fifo_lifo_warehouse)
|
|
44
|
+
|
|
45
|
+
### Available Methods
|
|
46
|
+
|
|
47
|
+
#### `get_batches_for(item_id, store_id, qty, time_at, method: "fifo")`
|
|
48
|
+
|
|
49
|
+
Returns an ordered list of batches needed to satisfy a quantity request.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
StockTransaction.get_batches_for(1, 1, 100, Time.current, method: "fifo")
|
|
53
|
+
# => [{ batch_number: "B001", qty: 50, cost: 10.5, batch_time: 2024-01-01 10:00:00 }, ...]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### `stock_balance_by_batches_calculation(storage_id: nil, item_id: nil, to_time: nil, fields_info: {})`
|
|
57
|
+
|
|
58
|
+
Returns stock balance grouped by storage, item, and batch in a nested structure.
|
|
59
|
+
|
|
60
|
+
#### `stock_balance_by_items_calculation(storage_id: nil, item_id: nil, to_time: nil, fields_info: {})`
|
|
61
|
+
|
|
62
|
+
Returns stock balance grouped by storage and item with mean cost calculation.
|
|
63
|
+
|
|
64
|
+
#### `stock_movement_calculation(storage_id: nil, item_id: nil, start_time: nil, end_time: nil, fields_info: {})`
|
|
65
|
+
|
|
66
|
+
Returns stock movement with running balance, grouped by storage, item, and transaction.
|
|
67
|
+
|
|
68
|
+
#### `stock_balance_for_items(item_id: nil, to_time: nil, limit: nil, fields_info: {})`
|
|
69
|
+
|
|
70
|
+
Returns an ActiveRecord::Relation with aggregate stock data per item (total_qty and mean_cost).
|
|
71
|
+
|
|
72
|
+
#### `stock_balance_for_items_calculation(item_id: nil, to_time: nil, fields_info: {})`
|
|
73
|
+
|
|
74
|
+
Transforms item stock balance records into a structured array format.
|
|
75
|
+
|
|
76
|
+
## Contributing
|
|
77
|
+
|
|
78
|
+
Contribution directions go here.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
require "acts_as_fifo_lifo/version"
|
|
2
|
+
require "acts_as_fifo_lifo/railtie"
|
|
3
|
+
|
|
4
|
+
module ActsAsFifoLifo
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
# Configures FIFO/LIFO behavior field mappings for the model.
|
|
9
|
+
#
|
|
10
|
+
# Stores field name mappings as class-level instance variables for use in
|
|
11
|
+
# FIFO/LIFO calculation methods. All parameters are required keyword arguments.
|
|
12
|
+
#
|
|
13
|
+
# @param item_field [Symbol,String] Field name for the item identifier
|
|
14
|
+
# @param qty_field [Symbol,String] Field name for the quantity
|
|
15
|
+
# @param cost_field [Symbol,String] Field name for the cost
|
|
16
|
+
# @param time_field [Symbol,String] Field name for the timestamp
|
|
17
|
+
# @param batch_field [Symbol,String] Field name for the batch identifier
|
|
18
|
+
# @param storage_field [Symbol,String] Field name for the storage identifier
|
|
19
|
+
# @param operation_field [Symbol,String] Field name for the operation identifier
|
|
20
|
+
# @param operation_type_field [Symbol,String] Field name for the operation type
|
|
21
|
+
def acts_as_fifo_lifo(item_field:, qty_field:, cost_field:, time_field:, batch_field:, storage_field:, operation_field:, operation_type_field:)
|
|
22
|
+
@fifo_item_field = item_field
|
|
23
|
+
@fifo_qty_field = qty_field
|
|
24
|
+
@fifo_cost_field = cost_field
|
|
25
|
+
@fifo_time_field = time_field
|
|
26
|
+
@fifo_batch_field = batch_field
|
|
27
|
+
@fifo_storage_field = storage_field
|
|
28
|
+
@fifo_operation_field = operation_field
|
|
29
|
+
@fifo_operation_type_field = operation_type_field
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns an ordered list of batches needed to satisfy a quantity request.
|
|
33
|
+
# Each element is a hash with :batch_number, :qty, :cost, and :batch_time keys.
|
|
34
|
+
#
|
|
35
|
+
# @param item_id [Integer] the identifier of the item
|
|
36
|
+
# @param store_id [Integer] the identifier of the storage location
|
|
37
|
+
# @param qty [Integer] the required quantity
|
|
38
|
+
# @param time_at [Time,DateTime,String] the reference timestamp for filtering transactions
|
|
39
|
+
# @param method [String] "fifo" for ascending order or "lifo" for descending order
|
|
40
|
+
# @return [Array<Hash{batch_number: String, qty: Integer, cost: Float, batch_time: Time}>]
|
|
41
|
+
def get_batches_for(item_id, store_id, qty, time_at, method: "fifo")
|
|
42
|
+
base_scope = where(
|
|
43
|
+
@fifo_item_field => item_id,
|
|
44
|
+
@fifo_storage_field => store_id
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
order_direction = method == "fifo" ? "ASC" : "DESC"
|
|
48
|
+
|
|
49
|
+
batch_records = base_scope
|
|
50
|
+
.group(@fifo_batch_field)
|
|
51
|
+
.select(
|
|
52
|
+
@fifo_batch_field,
|
|
53
|
+
"SUM(#{@fifo_qty_field}) AS total_qty",
|
|
54
|
+
"MIN(#{@fifo_cost_field}) AS total_cost",
|
|
55
|
+
"MIN(#{@fifo_time_field}) AS first_time"
|
|
56
|
+
)
|
|
57
|
+
.where("#{@fifo_time_field} <= ?", time_at)
|
|
58
|
+
.having("SUM(#{@fifo_qty_field}) > 0")
|
|
59
|
+
.order("first_time #{order_direction}")
|
|
60
|
+
|
|
61
|
+
result = []
|
|
62
|
+
remaining = qty
|
|
63
|
+
|
|
64
|
+
batch_records.each do |rec|
|
|
65
|
+
batch_number = rec.send(@fifo_batch_field)
|
|
66
|
+
batch_qty = rec.total_qty.to_i
|
|
67
|
+
batch_cost = rec.total_cost.to_f
|
|
68
|
+
batch_time = rec.first_time
|
|
69
|
+
|
|
70
|
+
take = [ batch_qty, remaining ].min
|
|
71
|
+
result << { batch_number: batch_number, qty: take, cost: batch_cost.round(2), batch_time: batch_time }
|
|
72
|
+
remaining -= take
|
|
73
|
+
break if remaining <= 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Calculates stock balance grouped by storage, item and batch.
|
|
80
|
+
# Returns a nested array of hashes with :details and :children keys.
|
|
81
|
+
# Each element represents a storage location (Level 1), containing items (Level 2),
|
|
82
|
+
# which in turn contain batches (Level 3) with their qty and cost.
|
|
83
|
+
#
|
|
84
|
+
# @param storage_id [Integer, nil] optional storage location filter
|
|
85
|
+
# @param item_id [Integer, nil] optional item filter
|
|
86
|
+
# @param to_time [Time, nil] optional upper bound timestamp for transactions
|
|
87
|
+
# @param fields_info [Hash] association field configuration for :storages and :items
|
|
88
|
+
# @return [Array<Hash{details: Hash, children: Array>]: nested structure with storage->items->batches
|
|
89
|
+
def stock_balance_by_batches_calculation(storage_id: nil, item_id: nil, to_time: nil, fields_info: {})
|
|
90
|
+
storage_include = fields_info.dig(:storages, :include) || :storage
|
|
91
|
+
item_include = fields_info.dig(:items, :include) || :item
|
|
92
|
+
storage_field = fields_info.dig(:storages, :field) || :name
|
|
93
|
+
item_field = fields_info.dig(:items, :field) || :name
|
|
94
|
+
|
|
95
|
+
base_scope = all
|
|
96
|
+
|
|
97
|
+
base_scope = base_scope.where(@fifo_storage_field => storage_id) if storage_id.present?
|
|
98
|
+
base_scope = base_scope.where(@fifo_item_field => item_id) if item_id.present?
|
|
99
|
+
base_scope = base_scope.where(@fifo_time_field => ...to_time) if to_time.present?
|
|
100
|
+
|
|
101
|
+
records = base_scope.group(@fifo_storage_field, @fifo_item_field, @fifo_batch_field)
|
|
102
|
+
.select(
|
|
103
|
+
@fifo_storage_field,
|
|
104
|
+
@fifo_item_field,
|
|
105
|
+
@fifo_batch_field,
|
|
106
|
+
"SUM(#{@fifo_qty_field}) AS total_qty",
|
|
107
|
+
"MIN(#{@fifo_cost_field}) AS batch_cost"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
records = records.includes(storage_include, item_include)
|
|
111
|
+
|
|
112
|
+
nested_records = records.group_by(&@fifo_storage_field.to_sym).transform_values do |storage_group|
|
|
113
|
+
storage_group.group_by(&@fifo_item_field.to_sym)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
results = []
|
|
117
|
+
nested_records.each do |storage_id, items_hash|
|
|
118
|
+
storage_name = items_hash.first.dig(1, 0)&.send(storage_include)&.send(storage_field) || "Storage #{storage_id}"
|
|
119
|
+
# Level 1: Storage level
|
|
120
|
+
storage_hash = { details: { item: storage_name, qty: 0, batch_cost: "", cost: 0 }, children: [] }
|
|
121
|
+
|
|
122
|
+
items_hash.each do |item_id, records|
|
|
123
|
+
item_name = records.first&.send(item_include)&.send(item_field) || "Item #{item_id}"
|
|
124
|
+
|
|
125
|
+
# Level 2: Item level
|
|
126
|
+
item_hash = { details: { item: item_name, qty: 0, batch_cost: "", cost: 0 }, children: [] }
|
|
127
|
+
item_hash[:details][:cost] = item_hash[:details][:cost].round(2)
|
|
128
|
+
item_hash[:details][:mean_cost] = item_hash[:details][:mean_cost].round(2) if item_hash[:details][:mean_cost].is_a?(Numeric)
|
|
129
|
+
storage_hash[:children] << item_hash
|
|
130
|
+
|
|
131
|
+
# Level 3: Batch records level (each record contains your select aliases)
|
|
132
|
+
records.each do |record|
|
|
133
|
+
batch_cost = record.batch_cost.to_f.round(2)
|
|
134
|
+
item_hash[:children] << { details: { item: record.batch_number, qty: record.total_qty.to_i, batch_cost: batch_cost, cost: (batch_cost * record.total_qty.to_i).round(2) }, children: [] }
|
|
135
|
+
item_hash[:details][:qty] += record.total_qty.to_i
|
|
136
|
+
|
|
137
|
+
batch_cost = record.batch_cost.to_f.round(2)
|
|
138
|
+
total_batch_cost = (batch_cost * record.total_qty.to_i).round(2)
|
|
139
|
+
item_hash[:details][:cost] = (item_hash[:details][:cost] + total_batch_cost).round(2)
|
|
140
|
+
storage_hash[:details][:qty] += record.total_qty.to_i
|
|
141
|
+
storage_hash[:details][:cost] = (storage_hash[:details][:cost] + total_batch_cost).round(2)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
storage_hash[:details][:cost] = storage_hash[:details][:cost].round(2)
|
|
145
|
+
storage_hash[:details][:mean_cost] = storage_hash[:details][:mean_cost].round(2) if storage_hash[:details][:mean_cost].is_a?(Numeric)
|
|
146
|
+
results << storage_hash
|
|
147
|
+
end
|
|
148
|
+
results
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Calculates stock balance grouped by storage and item.
|
|
152
|
+
# Returns a nested array of hashes with :details and :children keys.
|
|
153
|
+
# Each element represents a storage location containing items with their mean cost.
|
|
154
|
+
#
|
|
155
|
+
# @param storage_id [Integer, nil] optional storage location filter
|
|
156
|
+
# @param item_id [Integer, nil] optional item filter
|
|
157
|
+
# @param to_time [Time, nil] optional upper bound timestamp for transactions
|
|
158
|
+
# @param fields_info [Hash] association field configuration for :storages and :items
|
|
159
|
+
# @return [Array<Hash{details: Hash, children: Array>]: nested structure with storage->items
|
|
160
|
+
def stock_balance_by_items_calculation(storage_id: nil, item_id: nil, to_time: nil, fields_info: {})
|
|
161
|
+
storage_include = fields_info.dig(:storages, :include) || :storage
|
|
162
|
+
item_include = fields_info.dig(:items, :include) || :item
|
|
163
|
+
storage_field = fields_info.dig(:storages, :field) || :name
|
|
164
|
+
item_field = fields_info.dig(:items, :field) || :name
|
|
165
|
+
|
|
166
|
+
base_scope = all
|
|
167
|
+
|
|
168
|
+
base_scope = base_scope.where(@fifo_storage_field => storage_id) if storage_id.present?
|
|
169
|
+
base_scope = base_scope.where(@fifo_item_field => item_id) if item_id.present?
|
|
170
|
+
base_scope = base_scope.where(@fifo_time_field => ...to_time) if to_time.present?
|
|
171
|
+
|
|
172
|
+
records = base_scope.group(@fifo_storage_field, @fifo_item_field)
|
|
173
|
+
.select(
|
|
174
|
+
@fifo_storage_field,
|
|
175
|
+
@fifo_item_field,
|
|
176
|
+
"SUM(#{@fifo_qty_field}) AS total_qty",
|
|
177
|
+
"SUM(#{@fifo_cost_field} * #{@fifo_qty_field}) / SUM(#{@fifo_qty_field}) AS item_cost"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
records = records.includes(storage_include, item_include)
|
|
181
|
+
results = []
|
|
182
|
+
|
|
183
|
+
nested = records.group_by(&@fifo_storage_field.to_sym).transform_values do |storage_group|
|
|
184
|
+
storage_group.group_by(&@fifo_item_field.to_sym)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
nested.each do |storage_id, items_hash|
|
|
188
|
+
first_record = items_hash.values.first.first
|
|
189
|
+
storage_name = first_record&.send(storage_include)&.send(storage_field) || "Storage #{storage_id}"
|
|
190
|
+
|
|
191
|
+
storage_hash = { details: { item: storage_name, qty: 0, mean_cost: "", cost: 0.0 }, children: [] }
|
|
192
|
+
|
|
193
|
+
items_hash.each do |item_id, recs|
|
|
194
|
+
first_item = recs.first
|
|
195
|
+
item_name = first_item&.send(item_include)&.send(item_field) || "Item #{item_id}"
|
|
196
|
+
item_hash = { details: { item: item_name, qty: 0, mean_cost: 0.0, cost: 0.0 }, children: [] }
|
|
197
|
+
|
|
198
|
+
recs.each do |record|
|
|
199
|
+
qty = record.total_qty.to_i
|
|
200
|
+
cost_per = record.item_cost.to_f
|
|
201
|
+
total_cost = (qty * cost_per).round(2)
|
|
202
|
+
item_hash[:details][:qty] += qty
|
|
203
|
+
item_hash[:details][:cost] += total_cost
|
|
204
|
+
if item_hash[:details][:qty] > 0
|
|
205
|
+
mean = item_hash[:details][:cost] / item_hash[:details][:qty]
|
|
206
|
+
item_hash[:details][:mean_cost] = mean.round(2)
|
|
207
|
+
end
|
|
208
|
+
storage_hash[:details][:qty] += qty
|
|
209
|
+
storage_hash[:details][:cost] += total_cost
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
storage_hash[:children] << item_hash
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
results << storage_hash
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
results
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Calculates stock movement returning a three-level nested structure.
|
|
222
|
+
# Groups by storage (Level 1), then by item (Level 2), then by batch/transaction (Level 3).
|
|
223
|
+
# Each transaction record includes running balance computed from the initial balance plus
|
|
224
|
+
# all preceding transactions in chronological order.
|
|
225
|
+
#
|
|
226
|
+
# @param storage_id [Integer, nil] optional storage location filter
|
|
227
|
+
# @param item_id [Integer, nil] optional item filter
|
|
228
|
+
# @param start_time [Time, nil] lower bound timestamp for transactions
|
|
229
|
+
# @param end_time [Time, nil] upper bound timestamp for transactions
|
|
230
|
+
# @param fields_info [Hash] association field configuration for :storages and :items
|
|
231
|
+
# @return [Array<Hash{details: Hash, children: Array>]: nested structure with storage->items->transactions
|
|
232
|
+
def stock_movement_calculation(storage_id: nil, item_id: nil, start_time: nil, end_time: nil, fields_info: {})
|
|
233
|
+
storage_include = fields_info.dig(:storages, :include) || :storage
|
|
234
|
+
item_include = fields_info.dig(:items, :include) || :item
|
|
235
|
+
storage_field = fields_info.dig(:storages, :field) || :name
|
|
236
|
+
item_field = fields_info.dig(:items, :field) || :name
|
|
237
|
+
|
|
238
|
+
balances = balance_for(start_time, item_id = item_id, store_id = storage_id)
|
|
239
|
+
|
|
240
|
+
base_scope = all
|
|
241
|
+
base_scope = base_scope.where(@fifo_storage_field => storage_id) if storage_id.present?
|
|
242
|
+
base_scope = base_scope.where(@fifo_item_field => item_id) if item_id.present?
|
|
243
|
+
base_scope = base_scope.where(@fifo_time_field => start_time..end_time) if start_time.present? && end_time.present?
|
|
244
|
+
|
|
245
|
+
records = base_scope
|
|
246
|
+
.select(
|
|
247
|
+
@fifo_storage_field,
|
|
248
|
+
@fifo_item_field,
|
|
249
|
+
@fifo_batch_field,
|
|
250
|
+
@fifo_time_field,
|
|
251
|
+
@fifo_qty_field,
|
|
252
|
+
@fifo_cost_field,
|
|
253
|
+
@fifo_operation_field,
|
|
254
|
+
@fifo_operation_type_field
|
|
255
|
+
)
|
|
256
|
+
.order(@fifo_time_field => :asc)
|
|
257
|
+
|
|
258
|
+
records = records.includes(storage_include, item_include)
|
|
259
|
+
|
|
260
|
+
nested = records.group_by(&@fifo_storage_field.to_sym).transform_values do |storage_group|
|
|
261
|
+
storage_group.group_by(&@fifo_item_field.to_sym)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
results = []
|
|
265
|
+
nested.each do |storage_id_key, items_hash|
|
|
266
|
+
first_record = items_hash.values.first.first
|
|
267
|
+
storage_name = first_record&.send(storage_include)&.send(storage_field) || "Storage #{storage_id_key}"
|
|
268
|
+
storage_hash = { details: { item: storage_name, time: "", operation: "", qty: 0, cost: "", balance: 0 }, children: [] }
|
|
269
|
+
|
|
270
|
+
items_hash.each do |item_id_key, recs|
|
|
271
|
+
first_item = recs.first
|
|
272
|
+
item_name = first_item&.send(item_include)&.send(item_field) || "Item #{item_id_key}"
|
|
273
|
+
item_balance = balances[[ item_id_key, storage_id_key ]] || 0
|
|
274
|
+
item_hash = { details: { item: item_name, time: "", operation: "", qty: 0, cost: "", balance: item_balance }, children: [] }
|
|
275
|
+
|
|
276
|
+
running_balance = item_balance
|
|
277
|
+
recs.each do |record|
|
|
278
|
+
qty = record.send(@fifo_qty_field).to_i
|
|
279
|
+
cost = record.send(@fifo_cost_field).to_f
|
|
280
|
+
running_balance += qty
|
|
281
|
+
|
|
282
|
+
item_hash[:children] << {
|
|
283
|
+
details: {
|
|
284
|
+
item: record.send(@fifo_batch_field),
|
|
285
|
+
time: record.send(@fifo_time_field).strftime("%F %T"),
|
|
286
|
+
operation: "#{record.send(@fifo_operation_type_field)} ##{record.send(@fifo_operation_field)}",
|
|
287
|
+
qty: qty,
|
|
288
|
+
cost: cost.round(2),
|
|
289
|
+
balance: running_balance
|
|
290
|
+
},
|
|
291
|
+
children: []
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
item_hash[:details][:qty] += qty
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
storage_hash[:children] << item_hash
|
|
298
|
+
|
|
299
|
+
storage_hash[:details][:qty] += item_hash[:details][:qty]
|
|
300
|
+
storage_hash[:details][:balance] += item_hash[:details][:balance]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
results << storage_hash
|
|
304
|
+
end
|
|
305
|
+
results
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Computes initial stock balances for items at a given point in time.
|
|
309
|
+
# Returns a hash mapping [item_id, storage_id] pairs to their balance quantities.
|
|
310
|
+
#
|
|
311
|
+
# @param to_time [Time] the reference timestamp for the balance calculation
|
|
312
|
+
# @param item_id [Integer, nil] optional item filter
|
|
313
|
+
# @param store_id [Integer, nil] optional storage location filter
|
|
314
|
+
# @return [Hash{Array<item_id, storage_id> => Integer] mapping of item-storage pairs to quantities
|
|
315
|
+
def balance_for(to_time, item_id = nil, store_id = nil)
|
|
316
|
+
item_scope = item_id.present? ? where(@fifo_item_field => item_id) : all
|
|
317
|
+
store_scope = store_id.present? ? item_scope.where(@fifo_storage_field => store_id) : item_scope
|
|
318
|
+
|
|
319
|
+
result = store_scope
|
|
320
|
+
.where("#{@fifo_time_field} <= ?", to_time)
|
|
321
|
+
.group(@fifo_storage_field, @fifo_item_field)
|
|
322
|
+
.select(
|
|
323
|
+
@fifo_storage_field,
|
|
324
|
+
@fifo_item_field,
|
|
325
|
+
"SUM(#{@fifo_qty_field}) AS total_qty"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
result.each_with_object({}) do |record, hash|
|
|
329
|
+
item_id_key = record.send(@fifo_item_field)
|
|
330
|
+
store_id_key = record.send(@fifo_storage_field)
|
|
331
|
+
hash[[ item_id_key, store_id_key ]] = record.total_qty.to_i
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Queries aggregate stock data for items using mean cost calculation.
|
|
336
|
+
# Returns an ActiveRecord::Relation with total_qty and mean_cost selected per item.
|
|
337
|
+
#
|
|
338
|
+
# @param item_id [Integer, nil] optional item filter
|
|
339
|
+
# @param to_time [Time, nil] optional upper bound timestamp for transactions
|
|
340
|
+
# @param limit [Integer, nil] optional limit on number of results
|
|
341
|
+
# @param fields_info [Hash] association field configuration for :items
|
|
342
|
+
# @return [ActiveRecord::Relation] query scope for further chaining or execution
|
|
343
|
+
def stock_balance_for_items(item_id: nil, to_time: nil, limit: nil, fields_info: {})
|
|
344
|
+
scope = all
|
|
345
|
+
scope = scope.where(@fifo_item_field => item_id) if item_id.present?
|
|
346
|
+
scope = scope.where("#{@fifo_time_field} <= ?", to_time) if to_time.present?
|
|
347
|
+
scope = scope.group(@fifo_item_field)
|
|
348
|
+
.select(
|
|
349
|
+
@fifo_item_field,
|
|
350
|
+
"SUM(#{@fifo_qty_field}) AS total_qty",
|
|
351
|
+
"SUM(#{@fifo_cost_field} * #{@fifo_qty_field}) / SUM(#{@fifo_qty_field}) AS mean_cost"
|
|
352
|
+
)
|
|
353
|
+
scope = scope.order("total_qty DESC")
|
|
354
|
+
item_include = fields_info.dig(:items, :include) || :item
|
|
355
|
+
|
|
356
|
+
scope = scope.includes(item_include)
|
|
357
|
+
scope = scope.limit(limit) if limit.present?
|
|
358
|
+
scope
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Transforms item stock balance records into a structured format.
|
|
362
|
+
# Wraps each item's data in a :details/:children hash structure.
|
|
363
|
+
#
|
|
364
|
+
# @param item_id [Integer, nil] optional item filter
|
|
365
|
+
# @param to_time [Time, nil] optional upper bound timestamp for transactions
|
|
366
|
+
# @param fields_info [Hash] association field configuration for :items
|
|
367
|
+
# @return [Array<Hash{details: Hash, children: Array>]: array of item summaries with qty and mean_cost
|
|
368
|
+
def stock_balance_for_items_calculation(item_id: nil, to_time: nil, fields_info: {})
|
|
369
|
+
records = stock_balance_for_items(item_id: item_id, to_time: to_time, fields_info: fields_info)
|
|
370
|
+
item_include = fields_info.dig(:items, :include) || :item
|
|
371
|
+
item_field = fields_info.dig(:items, :field) || :name
|
|
372
|
+
|
|
373
|
+
records.map do |record|
|
|
374
|
+
{
|
|
375
|
+
details: {
|
|
376
|
+
item: record.send(item_include)&.send(item_field),
|
|
377
|
+
qty: record.total_qty,
|
|
378
|
+
cost: record.mean_cost.round(2)
|
|
379
|
+
},
|
|
380
|
+
children: []
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
ActiveRecord::Base.send :include, ActsAsFifoLifo
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: acts_as_fifo_lifo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rem
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-05-31 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 8.1.3
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 8.1.3
|
|
26
|
+
description: A Rails gem for handling FIFO (First-In, First-Out) and LIFO (Last-In,
|
|
27
|
+
First-Out) inventory management operations.
|
|
28
|
+
email:
|
|
29
|
+
- r3mnik@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- MIT-LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- Rakefile
|
|
37
|
+
- lib/acts_as_fifo_lifo.rb
|
|
38
|
+
- lib/acts_as_fifo_lifo/railtie.rb
|
|
39
|
+
- lib/acts_as_fifo_lifo/version.rb
|
|
40
|
+
- lib/tasks/acts_as_fifo_lifo_tasks.rake
|
|
41
|
+
homepage: https://github.com/fatshinobi/acts_as_fifo_lifo/tree/main
|
|
42
|
+
licenses:
|
|
43
|
+
- MIT
|
|
44
|
+
metadata:
|
|
45
|
+
homepage_uri: https://github.com/fatshinobi/acts_as_fifo_lifo/tree/main
|
|
46
|
+
source_code_uri: https://github.com/fatshinobi/acts_as_fifo_lifo/tree/main
|
|
47
|
+
changelog_uri: https://github.com/fatshinobi/acts_as_fifo_lifo/blob/main/CHANGELOG.md
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.6.2
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: Gem to process FIFO & LIFO operations in inventory management.
|
|
65
|
+
test_files: []
|