blob: 4d8990ae40bfc37e3856aa2ad975c1ba3549bdb6 [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 Menatb0d1e6d2022-02-28 16:55:46 +05305from frappe.tests.utils import FrappeTestCase
Ankush Menataa0e1632021-12-19 17:02:04 +05306from hypothesis import given
7from hypothesis import strategies as st
8
Ankush Menat3e5f9402022-01-15 18:49:46 +05309from erpnext.stock.doctype.item.test_item import make_item
10from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
Ankush Menatb534fee2022-02-19 20:58:36 +053011from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
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 def setUp(self):
Ankush Menat107b4042021-12-19 20:47:08 +053020 self.queue = FIFOValuation([])
Ankush Menat1833f7a2021-12-18 19:37:41 +053021
22 def tearDown(self):
23 qty, value = self.queue.get_total_stock_and_value()
24 self.assertTotalQty(qty)
25 self.assertTotalValue(value)
26
27 def assertTotalQty(self, qty):
Ankush Menataa0e1632021-12-19 17:02:04 +053028 self.assertAlmostEqual(sum(q for q, _ in self.queue), qty, msg=f"queue: {self.queue}", places=4)
Ankush Menat1833f7a2021-12-18 19:37:41 +053029
30 def assertTotalValue(self, value):
Ankush Menat494bd9e2022-03-28 18:52:46 +053031 self.assertAlmostEqual(
32 sum(q * r for q, r in self.queue), value, msg=f"queue: {self.queue}", places=2
33 )
Ankush Menat1833f7a2021-12-18 19:37:41 +053034
35 def test_simple_addition(self):
36 self.queue.add_stock(1, 10)
37 self.assertTotalQty(1)
38
39 def test_simple_removal(self):
40 self.queue.add_stock(1, 10)
Ankush Menataa0e1632021-12-19 17:02:04 +053041 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +053042 self.assertTotalQty(0)
43
44 def test_merge_new_stock(self):
45 self.queue.add_stock(1, 10)
46 self.queue.add_stock(1, 10)
47 self.assertEqual(self.queue, [[2, 10]])
48
49 def test_adding_negative_stock_keeps_rate(self):
Ankush Menat107b4042021-12-19 20:47:08 +053050 self.queue = FIFOValuation([[-5.0, 100]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053051 self.queue.add_stock(1, 10)
52 self.assertEqual(self.queue, [[-4, 100]])
53
54 def test_adding_negative_stock_updates_rate(self):
Ankush Menat107b4042021-12-19 20:47:08 +053055 self.queue = FIFOValuation([[-5.0, 100]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053056 self.queue.add_stock(6, 10)
57 self.assertEqual(self.queue, [[1, 10]])
58
Ankush Menat1833f7a2021-12-18 19:37:41 +053059 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
Ankush Menat12c01e22022-04-04 15:22:15 +053063 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +053064 self.assertTotalQty(-2)
Ankush Menat12c01e22022-04-04 15:22:15 +053065 self.assertEqual(self.queue, [[-2, 5]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053066
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
Ankush Menat1833f7a2021-12-18 19:37:41 +053078 def test_remove_multiple_bins(self):
79 self.queue.add_stock(1, 10)
80 self.queue.add_stock(2, 20)
81 self.queue.add_stock(1, 20)
82 self.queue.add_stock(5, 20)
83
Ankush Menataa0e1632021-12-19 17:02:04 +053084 self.queue.remove_stock(4)
Ankush Menat1833f7a2021-12-18 19:37:41 +053085 self.assertEqual(self.queue, [[5, 20]])
86
Ankush Menat1833f7a2021-12-18 19:37:41 +053087 def test_remove_multiple_bins_with_rate(self):
88 self.queue.add_stock(1, 10)
89 self.queue.add_stock(2, 20)
90 self.queue.add_stock(1, 20)
91 self.queue.add_stock(5, 20)
92
Ankush Menataa0e1632021-12-19 17:02:04 +053093 self.queue.remove_stock(3, 20)
Ankush Menat1833f7a2021-12-18 19:37:41 +053094 self.assertEqual(self.queue, [[1, 10], [5, 20]])
95
Ankush Menat12c01e22022-04-04 15:22:15 +053096 def test_queue_with_unknown_rate(self):
Ankush Menat1833f7a2021-12-18 19:37:41 +053097 self.queue.add_stock(1, 1)
98 self.queue.add_stock(1, 2)
99 self.queue.add_stock(1, 3)
100 self.queue.add_stock(1, 4)
101
102 self.assertTotalValue(10)
103
Ankush Menataa0e1632021-12-19 17:02:04 +0530104 self.queue.remove_stock(3, 1)
Ankush Menat12c01e22022-04-04 15:22:15 +0530105 self.assertEqual(self.queue, [[1, 4]])
Ankush Menat1833f7a2021-12-18 19:37:41 +0530106
107 def test_rounding_off(self):
108 self.queue.add_stock(1.0, 1.0)
Ankush Menataa0e1632021-12-19 17:02:04 +0530109 self.queue.remove_stock(1.0 - 1e-9)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530110 self.assertTotalQty(0)
111
112 def test_rounding_off_near_zero(self):
Ankush Menatb534fee2022-02-19 20:58:36 +0530113 self.assertEqual(round_off_if_near_zero(0), 0)
114 self.assertEqual(round_off_if_near_zero(1), 1)
115 self.assertEqual(round_off_if_near_zero(-1), -1)
116 self.assertEqual(round_off_if_near_zero(-1e-8), 0)
117 self.assertEqual(round_off_if_near_zero(1e-8), 0)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530118
119 def test_totals(self):
120 self.queue.add_stock(1, 10)
121 self.queue.add_stock(2, 13)
122 self.queue.add_stock(1, 17)
Ankush Menataa0e1632021-12-19 17:02:04 +0530123 self.queue.remove_stock(1)
124 self.queue.remove_stock(1)
125 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530126 self.queue.add_stock(5, 17)
127 self.queue.add_stock(8, 11)
Ankush Menataa0e1632021-12-19 17:02:04 +0530128
129 @given(stock_queue_generator)
130 def test_fifo_qty_hypothesis(self, stock_queue):
Ankush Menat107b4042021-12-19 20:47:08 +0530131 self.queue = FIFOValuation([])
Ankush Menataa0e1632021-12-19 17:02:04 +0530132 total_qty = 0
133
134 for qty, rate in stock_queue:
Ankush Menatb8a61be2023-03-13 15:16:30 +0530135 if round_off_if_near_zero(qty) == 0:
Ankush Menataa0e1632021-12-19 17:02:04 +0530136 continue
137 if qty > 0:
138 self.queue.add_stock(qty, rate)
139 total_qty += qty
140 else:
141 qty = abs(qty)
142 consumed = self.queue.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530143 self.assertAlmostEqual(
144 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
145 )
Ankush Menataa0e1632021-12-19 17:02:04 +0530146 total_qty -= qty
147 self.assertTotalQty(total_qty)
148
149 @given(stock_queue_generator)
150 def test_fifo_qty_value_nonneg_hypothesis(self, stock_queue):
Ankush Menat107b4042021-12-19 20:47:08 +0530151 self.queue = FIFOValuation([])
Ankush Menataa0e1632021-12-19 17:02:04 +0530152 total_qty = 0.0
153 total_value = 0.0
154
155 for qty, rate in stock_queue:
156 # don't allow negative stock
Ankush Menatb8a61be2023-03-13 15:16:30 +0530157 if round_off_if_near_zero(qty) == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
Ankush Menataa0e1632021-12-19 17:02:04 +0530158 continue
159 if qty > 0:
160 self.queue.add_stock(qty, rate)
161 total_qty += qty
162 total_value += qty * rate
163 else:
164 qty = abs(qty)
165 consumed = self.queue.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530166 self.assertAlmostEqual(
167 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
168 )
Ankush Menataa0e1632021-12-19 17:02:04 +0530169 total_qty -= qty
170 total_value -= sum(q * r for q, r in consumed)
171 self.assertTotalQty(total_qty)
172 self.assertTotalValue(total_value)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530173
Ankush Menat12c01e22022-04-04 15:22:15 +0530174 @given(stock_queue_generator, st.floats(min_value=0.1, max_value=1e6))
175 def test_fifo_qty_value_nonneg_hypothesis_with_outgoing_rate(self, stock_queue, outgoing_rate):
176 self.queue = FIFOValuation([])
177 total_qty = 0.0
178 total_value = 0.0
179
180 for qty, rate in stock_queue:
181 # don't allow negative stock
Ankush Menatb8a61be2023-03-13 15:16:30 +0530182 if round_off_if_near_zero(qty) == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
Ankush Menat12c01e22022-04-04 15:22:15 +0530183 continue
184 if qty > 0:
185 self.queue.add_stock(qty, rate)
186 total_qty += qty
187 total_value += qty * rate
188 else:
189 qty = abs(qty)
190 consumed = self.queue.remove_stock(qty, outgoing_rate)
191 self.assertAlmostEqual(
192 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
193 )
194 total_qty -= qty
195 total_value -= sum(q * r for q, r in consumed)
196 self.assertTotalQty(total_qty)
197 self.assertTotalValue(total_value)
Ankush Menat12c01e22022-04-04 15:22:15 +0530198
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530199
200class TestLIFOValuation(unittest.TestCase):
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530201 def setUp(self):
202 self.stack = LIFOValuation([])
203
204 def tearDown(self):
205 qty, value = self.stack.get_total_stock_and_value()
206 self.assertTotalQty(qty)
207 self.assertTotalValue(value)
208
209 def assertTotalQty(self, qty):
210 self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
211
212 def assertTotalValue(self, value):
Ankush Menat494bd9e2022-03-28 18:52:46 +0530213 self.assertAlmostEqual(
214 sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2
215 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530216
217 def test_simple_addition(self):
218 self.stack.add_stock(1, 10)
219 self.assertTotalQty(1)
220
221 def test_merge_new_stock(self):
222 self.stack.add_stock(1, 10)
223 self.stack.add_stock(1, 10)
224 self.assertEqual(self.stack, [[2, 10]])
225
226 def test_simple_removal(self):
227 self.stack.add_stock(1, 10)
228 self.stack.remove_stock(1)
229 self.assertTotalQty(0)
230
231 def test_adding_negative_stock_keeps_rate(self):
232 self.stack = LIFOValuation([[-5.0, 100]])
233 self.stack.add_stock(1, 10)
234 self.assertEqual(self.stack, [[-4, 100]])
235
236 def test_adding_negative_stock_updates_rate(self):
237 self.stack = LIFOValuation([[-5.0, 100]])
238 self.stack.add_stock(6, 10)
239 self.assertEqual(self.stack, [[1, 10]])
240
241 def test_rounding_off(self):
242 self.stack.add_stock(1.0, 1.0)
243 self.stack.remove_stock(1.0 - 1e-9)
244 self.assertTotalQty(0)
245
246 def test_lifo_consumption(self):
247 self.stack.add_stock(10, 10)
248 self.stack.add_stock(10, 20)
249 consumed = self.stack.remove_stock(15)
250 self.assertEqual(consumed, [[10, 20], [5, 10]])
251 self.assertTotalQty(5)
252
253 def test_lifo_consumption_going_negative(self):
254 self.stack.add_stock(10, 10)
255 self.stack.add_stock(10, 20)
256 consumed = self.stack.remove_stock(25)
257 self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
258 self.assertTotalQty(-5)
259
260 def test_lifo_consumption_multiple(self):
261 self.stack.add_stock(1, 1)
262 self.stack.add_stock(2, 2)
263 consumed = self.stack.remove_stock(1)
264 self.assertEqual(consumed, [[1, 2]])
265
266 self.stack.add_stock(3, 3)
267 consumed = self.stack.remove_stock(4)
268 self.assertEqual(consumed, [[3, 3], [1, 2]])
269
270 self.stack.add_stock(4, 4)
271 consumed = self.stack.remove_stock(5)
272 self.assertEqual(consumed, [[4, 4], [1, 1]])
273
274 self.stack.add_stock(5, 5)
275 consumed = self.stack.remove_stock(5)
276 self.assertEqual(consumed, [[5, 5]])
277
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530278 @given(stock_queue_generator)
279 def test_lifo_qty_hypothesis(self, stock_stack):
280 self.stack = LIFOValuation([])
281 total_qty = 0
282
283 for qty, rate in stock_stack:
Ankush Menatb8a61be2023-03-13 15:16:30 +0530284 if round_off_if_near_zero(qty) == 0:
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530285 continue
286 if qty > 0:
287 self.stack.add_stock(qty, rate)
288 total_qty += qty
289 else:
290 qty = abs(qty)
291 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530292 self.assertAlmostEqual(
293 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
294 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530295 total_qty -= qty
296 self.assertTotalQty(total_qty)
297
298 @given(stock_queue_generator)
299 def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
300 self.stack = LIFOValuation([])
301 total_qty = 0.0
302 total_value = 0.0
303
304 for qty, rate in stock_stack:
305 # don't allow negative stock
Ankush Menatb8a61be2023-03-13 15:16:30 +0530306 if round_off_if_near_zero(qty) == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530307 continue
308 if qty > 0:
309 self.stack.add_stock(qty, rate)
310 total_qty += qty
311 total_value += qty * rate
312 else:
313 qty = abs(qty)
314 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530315 self.assertAlmostEqual(
316 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
317 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530318 total_qty -= qty
319 total_value -= sum(q * r for q, r in consumed)
320 self.assertTotalQty(total_qty)
321 self.assertTotalValue(total_value)
Ankush Menat3e5f9402022-01-15 18:49:46 +0530322
Ankush Menat494bd9e2022-03-28 18:52:46 +0530323
Ankush Menatb0d1e6d2022-02-28 16:55:46 +0530324class TestLIFOValuationSLE(FrappeTestCase):
Ankush Menat3e5f9402022-01-15 18:49:46 +0530325 ITEM_CODE = "_Test LIFO item"
326 WAREHOUSE = "_Test Warehouse - _TC"
327
328 @classmethod
329 def setUpClass(cls) -> None:
330 super().setUpClass()
331 make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
332
333 def _make_stock_entry(self, qty, rate=None):
334 kwargs = {
335 "item_code": self.ITEM_CODE,
336 "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
337 "rate": rate,
338 "qty": abs(qty),
339 }
340 return make_stock_entry(**kwargs)
341
342 def assertStockQueue(self, se, expected_queue):
Ankush Menat494bd9e2022-03-28 18:52:46 +0530343 sle_name = frappe.db.get_value(
344 "Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}
345 )
Ankush Menat3e5f9402022-01-15 18:49:46 +0530346 sle = frappe.get_doc("Stock Ledger Entry", sle_name)
347
348 stock_queue = json.loads(sle.stock_queue)
349
350 total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
351 self.assertEqual(sle.qty_after_transaction, total_qty)
352 self.assertEqual(sle.stock_value, total_value)
353
354 if total_qty > 0:
355 self.assertEqual(stock_queue, expected_queue)
356
Ankush Menat3e5f9402022-01-15 18:49:46 +0530357 def test_lifo_values(self):
358
359 in1 = self._make_stock_entry(1, 1)
360 self.assertStockQueue(in1, [[1, 1]])
361
362 in2 = self._make_stock_entry(2, 2)
363 self.assertStockQueue(in2, [[1, 1], [2, 2]])
364
365 out1 = self._make_stock_entry(-1)
366 self.assertStockQueue(out1, [[1, 1], [1, 2]])
367
368 in3 = self._make_stock_entry(3, 3)
369 self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
370
371 out2 = self._make_stock_entry(-4)
372 self.assertStockQueue(out2, [[1, 1]])
373
374 in4 = self._make_stock_entry(4, 4)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530375 self.assertStockQueue(in4, [[1, 1], [4, 4]])
Ankush Menat3e5f9402022-01-15 18:49:46 +0530376
377 out3 = self._make_stock_entry(-5)
378 self.assertStockQueue(out3, [])
379
380 in5 = self._make_stock_entry(5, 5)
381 self.assertStockQueue(in5, [[5, 5]])
382
383 out5 = self._make_stock_entry(-5)
384 self.assertStockQueue(out5, [])