blob: 648d4406ca91c8bc20289aaae062789c77e61834 [file] [log] [blame]
Ankush Menat3e5f9402022-01-15 18:49:46 +05301import json
Ankush Menat1833f7a2021-12-18 19:37:41 +05302import unittest
3
Ankush Menat3e5f9402022-01-15 18:49:46 +05304import frappe
Ankush Menataa0e1632021-12-19 17:02:04 +05305from hypothesis import given
6from hypothesis import strategies as st
7
Ankush Menat3e5f9402022-01-15 18:49:46 +05308from erpnext.stock.doctype.item.test_item import make_item
9from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
Ankush Menat9c49d2d2022-01-15 12:52:10 +053010from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero
Ankush Menat3e5f9402022-01-15 18:49:46 +053011from erpnext.tests.utils import ERPNextTestCase
Ankush Menat1833f7a2021-12-18 19:37:41 +053012
Ankush Menataa0e1632021-12-19 17:02:04 +053013qty_gen = st.floats(min_value=-1e6, max_value=1e6)
14value_gen = st.floats(min_value=1, max_value=1e6)
15stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10)
Ankush Menat1833f7a2021-12-18 19:37:41 +053016
Ankush Menat1833f7a2021-12-18 19:37:41 +053017
Ankush Menat9c49d2d2022-01-15 12:52:10 +053018class TestFIFOValuation(unittest.TestCase):
Ankush Menat1833f7a2021-12-18 19:37:41 +053019
20 def setUp(self):
Ankush Menat107b4042021-12-19 20:47:08 +053021 self.queue = FIFOValuation([])
Ankush Menat1833f7a2021-12-18 19:37:41 +053022
23 def tearDown(self):
24 qty, value = self.queue.get_total_stock_and_value()
25 self.assertTotalQty(qty)
26 self.assertTotalValue(value)
27
28 def assertTotalQty(self, qty):
Ankush Menataa0e1632021-12-19 17:02:04 +053029 self.assertAlmostEqual(sum(q for q, _ in self.queue), qty, msg=f"queue: {self.queue}", places=4)
Ankush Menat1833f7a2021-12-18 19:37:41 +053030
31 def assertTotalValue(self, value):
Ankush Menataa0e1632021-12-19 17:02:04 +053032 self.assertAlmostEqual(sum(q * r for q, r in self.queue), value, msg=f"queue: {self.queue}", places=2)
Ankush Menat1833f7a2021-12-18 19:37:41 +053033
34 def test_simple_addition(self):
35 self.queue.add_stock(1, 10)
36 self.assertTotalQty(1)
37
38 def test_simple_removal(self):
39 self.queue.add_stock(1, 10)
Ankush Menataa0e1632021-12-19 17:02:04 +053040 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +053041 self.assertTotalQty(0)
42
43 def test_merge_new_stock(self):
44 self.queue.add_stock(1, 10)
45 self.queue.add_stock(1, 10)
46 self.assertEqual(self.queue, [[2, 10]])
47
48 def test_adding_negative_stock_keeps_rate(self):
Ankush Menat107b4042021-12-19 20:47:08 +053049 self.queue = FIFOValuation([[-5.0, 100]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053050 self.queue.add_stock(1, 10)
51 self.assertEqual(self.queue, [[-4, 100]])
52
53 def test_adding_negative_stock_updates_rate(self):
Ankush Menat107b4042021-12-19 20:47:08 +053054 self.queue = FIFOValuation([[-5.0, 100]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053055 self.queue.add_stock(6, 10)
56 self.assertEqual(self.queue, [[1, 10]])
57
58
59 def test_negative_stock(self):
Ankush Menataa0e1632021-12-19 17:02:04 +053060 self.queue.remove_stock(1, 5)
Ankush Menat1833f7a2021-12-18 19:37:41 +053061 self.assertEqual(self.queue, [[-1, 5]])
62
63 # XXX
Ankush Menataa0e1632021-12-19 17:02:04 +053064 self.queue.remove_stock(1, 10)
Ankush Menat1833f7a2021-12-18 19:37:41 +053065 self.assertTotalQty(-2)
66
67 self.queue.add_stock(2, 10)
68 self.assertTotalQty(0)
69 self.assertTotalValue(0)
70
71 def test_removing_specified_rate(self):
72 self.queue.add_stock(1, 10)
73 self.queue.add_stock(1, 20)
74
Ankush Menataa0e1632021-12-19 17:02:04 +053075 self.queue.remove_stock(1, 20)
Ankush Menat1833f7a2021-12-18 19:37:41 +053076 self.assertEqual(self.queue, [[1, 10]])
77
78
79 def test_remove_multiple_bins(self):
80 self.queue.add_stock(1, 10)
81 self.queue.add_stock(2, 20)
82 self.queue.add_stock(1, 20)
83 self.queue.add_stock(5, 20)
84
Ankush Menataa0e1632021-12-19 17:02:04 +053085 self.queue.remove_stock(4)
Ankush Menat1833f7a2021-12-18 19:37:41 +053086 self.assertEqual(self.queue, [[5, 20]])
87
88
89 def test_remove_multiple_bins_with_rate(self):
90 self.queue.add_stock(1, 10)
91 self.queue.add_stock(2, 20)
92 self.queue.add_stock(1, 20)
93 self.queue.add_stock(5, 20)
94
Ankush Menataa0e1632021-12-19 17:02:04 +053095 self.queue.remove_stock(3, 20)
Ankush Menat1833f7a2021-12-18 19:37:41 +053096 self.assertEqual(self.queue, [[1, 10], [5, 20]])
97
98 def test_collapsing_of_queue(self):
99 self.queue.add_stock(1, 1)
100 self.queue.add_stock(1, 2)
101 self.queue.add_stock(1, 3)
102 self.queue.add_stock(1, 4)
103
104 self.assertTotalValue(10)
105
Ankush Menataa0e1632021-12-19 17:02:04 +0530106 self.queue.remove_stock(3, 1)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530107 # XXX
108 self.assertEqual(self.queue, [[1, 7]])
109
110 def test_rounding_off(self):
111 self.queue.add_stock(1.0, 1.0)
Ankush Menataa0e1632021-12-19 17:02:04 +0530112 self.queue.remove_stock(1.0 - 1e-9)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530113 self.assertTotalQty(0)
114
115 def test_rounding_off_near_zero(self):
116 self.assertEqual(_round_off_if_near_zero(0), 0)
117 self.assertEqual(_round_off_if_near_zero(1), 1)
118 self.assertEqual(_round_off_if_near_zero(-1), -1)
119 self.assertEqual(_round_off_if_near_zero(-1e-8), 0)
120 self.assertEqual(_round_off_if_near_zero(1e-8), 0)
121
122 def test_totals(self):
123 self.queue.add_stock(1, 10)
124 self.queue.add_stock(2, 13)
125 self.queue.add_stock(1, 17)
Ankush Menataa0e1632021-12-19 17:02:04 +0530126 self.queue.remove_stock(1)
127 self.queue.remove_stock(1)
128 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530129 self.queue.add_stock(5, 17)
130 self.queue.add_stock(8, 11)
Ankush Menataa0e1632021-12-19 17:02:04 +0530131
132 @given(stock_queue_generator)
133 def test_fifo_qty_hypothesis(self, stock_queue):
Ankush Menat107b4042021-12-19 20:47:08 +0530134 self.queue = FIFOValuation([])
Ankush Menataa0e1632021-12-19 17:02:04 +0530135 total_qty = 0
136
137 for qty, rate in stock_queue:
138 if qty == 0:
139 continue
140 if qty > 0:
141 self.queue.add_stock(qty, rate)
142 total_qty += qty
143 else:
144 qty = abs(qty)
145 consumed = self.queue.remove_stock(qty)
146 self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
147 total_qty -= qty
148 self.assertTotalQty(total_qty)
149
150 @given(stock_queue_generator)
151 def test_fifo_qty_value_nonneg_hypothesis(self, stock_queue):
Ankush Menat107b4042021-12-19 20:47:08 +0530152 self.queue = FIFOValuation([])
Ankush Menataa0e1632021-12-19 17:02:04 +0530153 total_qty = 0.0
154 total_value = 0.0
155
156 for qty, rate in stock_queue:
157 # don't allow negative stock
158 if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
159 continue
160 if qty > 0:
161 self.queue.add_stock(qty, rate)
162 total_qty += qty
163 total_value += qty * rate
164 else:
165 qty = abs(qty)
166 consumed = self.queue.remove_stock(qty)
167 self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
168 total_qty -= qty
169 total_value -= sum(q * r for q, r in consumed)
170 self.assertTotalQty(total_qty)
171 self.assertTotalValue(total_value)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530172
173
174class TestLIFOValuation(unittest.TestCase):
175
176 def setUp(self):
177 self.stack = LIFOValuation([])
178
179 def tearDown(self):
180 qty, value = self.stack.get_total_stock_and_value()
181 self.assertTotalQty(qty)
182 self.assertTotalValue(value)
183
184 def assertTotalQty(self, qty):
185 self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
186
187 def assertTotalValue(self, value):
188 self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2)
189
190 def test_simple_addition(self):
191 self.stack.add_stock(1, 10)
192 self.assertTotalQty(1)
193
194 def test_merge_new_stock(self):
195 self.stack.add_stock(1, 10)
196 self.stack.add_stock(1, 10)
197 self.assertEqual(self.stack, [[2, 10]])
198
199 def test_simple_removal(self):
200 self.stack.add_stock(1, 10)
201 self.stack.remove_stock(1)
202 self.assertTotalQty(0)
203
204 def test_adding_negative_stock_keeps_rate(self):
205 self.stack = LIFOValuation([[-5.0, 100]])
206 self.stack.add_stock(1, 10)
207 self.assertEqual(self.stack, [[-4, 100]])
208
209 def test_adding_negative_stock_updates_rate(self):
210 self.stack = LIFOValuation([[-5.0, 100]])
211 self.stack.add_stock(6, 10)
212 self.assertEqual(self.stack, [[1, 10]])
213
214 def test_rounding_off(self):
215 self.stack.add_stock(1.0, 1.0)
216 self.stack.remove_stock(1.0 - 1e-9)
217 self.assertTotalQty(0)
218
219 def test_lifo_consumption(self):
220 self.stack.add_stock(10, 10)
221 self.stack.add_stock(10, 20)
222 consumed = self.stack.remove_stock(15)
223 self.assertEqual(consumed, [[10, 20], [5, 10]])
224 self.assertTotalQty(5)
225
226 def test_lifo_consumption_going_negative(self):
227 self.stack.add_stock(10, 10)
228 self.stack.add_stock(10, 20)
229 consumed = self.stack.remove_stock(25)
230 self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
231 self.assertTotalQty(-5)
232
233 def test_lifo_consumption_multiple(self):
234 self.stack.add_stock(1, 1)
235 self.stack.add_stock(2, 2)
236 consumed = self.stack.remove_stock(1)
237 self.assertEqual(consumed, [[1, 2]])
238
239 self.stack.add_stock(3, 3)
240 consumed = self.stack.remove_stock(4)
241 self.assertEqual(consumed, [[3, 3], [1, 2]])
242
243 self.stack.add_stock(4, 4)
244 consumed = self.stack.remove_stock(5)
245 self.assertEqual(consumed, [[4, 4], [1, 1]])
246
247 self.stack.add_stock(5, 5)
248 consumed = self.stack.remove_stock(5)
249 self.assertEqual(consumed, [[5, 5]])
250
251
252 @given(stock_queue_generator)
253 def test_lifo_qty_hypothesis(self, stock_stack):
254 self.stack = LIFOValuation([])
255 total_qty = 0
256
257 for qty, rate in stock_stack:
258 if qty == 0:
259 continue
260 if qty > 0:
261 self.stack.add_stock(qty, rate)
262 total_qty += qty
263 else:
264 qty = abs(qty)
265 consumed = self.stack.remove_stock(qty)
266 self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
267 total_qty -= qty
268 self.assertTotalQty(total_qty)
269
270 @given(stock_queue_generator)
271 def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
272 self.stack = LIFOValuation([])
273 total_qty = 0.0
274 total_value = 0.0
275
276 for qty, rate in stock_stack:
277 # don't allow negative stock
278 if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
279 continue
280 if qty > 0:
281 self.stack.add_stock(qty, rate)
282 total_qty += qty
283 total_value += qty * rate
284 else:
285 qty = abs(qty)
286 consumed = self.stack.remove_stock(qty)
287 self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
288 total_qty -= qty
289 total_value -= sum(q * r for q, r in consumed)
290 self.assertTotalQty(total_qty)
291 self.assertTotalValue(total_value)
Ankush Menat3e5f9402022-01-15 18:49:46 +0530292
293class TestLIFOValuationSLE(ERPNextTestCase):
294 ITEM_CODE = "_Test LIFO item"
295 WAREHOUSE = "_Test Warehouse - _TC"
296
297 @classmethod
298 def setUpClass(cls) -> None:
299 super().setUpClass()
300 make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
301
302 def _make_stock_entry(self, qty, rate=None):
303 kwargs = {
304 "item_code": self.ITEM_CODE,
305 "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
306 "rate": rate,
307 "qty": abs(qty),
308 }
309 return make_stock_entry(**kwargs)
310
311 def assertStockQueue(self, se, expected_queue):
312 sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"})
313 sle = frappe.get_doc("Stock Ledger Entry", sle_name)
314
315 stock_queue = json.loads(sle.stock_queue)
316
317 total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
318 self.assertEqual(sle.qty_after_transaction, total_qty)
319 self.assertEqual(sle.stock_value, total_value)
320
321 if total_qty > 0:
322 self.assertEqual(stock_queue, expected_queue)
323
324
325 def test_lifo_values(self):
326
327 in1 = self._make_stock_entry(1, 1)
328 self.assertStockQueue(in1, [[1, 1]])
329
330 in2 = self._make_stock_entry(2, 2)
331 self.assertStockQueue(in2, [[1, 1], [2, 2]])
332
333 out1 = self._make_stock_entry(-1)
334 self.assertStockQueue(out1, [[1, 1], [1, 2]])
335
336 in3 = self._make_stock_entry(3, 3)
337 self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
338
339 out2 = self._make_stock_entry(-4)
340 self.assertStockQueue(out2, [[1, 1]])
341
342 in4 = self._make_stock_entry(4, 4)
343 self.assertStockQueue(in4, [[1, 1], [4,4]])
344
345 out3 = self._make_stock_entry(-5)
346 self.assertStockQueue(out3, [])
347
348 in5 = self._make_stock_entry(5, 5)
349 self.assertStockQueue(in5, [[5, 5]])
350
351 out5 = self._make_stock_entry(-5)
352 self.assertStockQueue(out5, [])