blob: 05f153b4a0cd9c10492a8f321b84b0a32c90ad04 [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)
198 self.assertGreaterEqual(total_value, 0)
199
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530200
201class TestLIFOValuation(unittest.TestCase):
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530202 def setUp(self):
203 self.stack = LIFOValuation([])
204
205 def tearDown(self):
206 qty, value = self.stack.get_total_stock_and_value()
207 self.assertTotalQty(qty)
208 self.assertTotalValue(value)
209
210 def assertTotalQty(self, qty):
211 self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
212
213 def assertTotalValue(self, value):
Ankush Menat494bd9e2022-03-28 18:52:46 +0530214 self.assertAlmostEqual(
215 sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2
216 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530217
218 def test_simple_addition(self):
219 self.stack.add_stock(1, 10)
220 self.assertTotalQty(1)
221
222 def test_merge_new_stock(self):
223 self.stack.add_stock(1, 10)
224 self.stack.add_stock(1, 10)
225 self.assertEqual(self.stack, [[2, 10]])
226
227 def test_simple_removal(self):
228 self.stack.add_stock(1, 10)
229 self.stack.remove_stock(1)
230 self.assertTotalQty(0)
231
232 def test_adding_negative_stock_keeps_rate(self):
233 self.stack = LIFOValuation([[-5.0, 100]])
234 self.stack.add_stock(1, 10)
235 self.assertEqual(self.stack, [[-4, 100]])
236
237 def test_adding_negative_stock_updates_rate(self):
238 self.stack = LIFOValuation([[-5.0, 100]])
239 self.stack.add_stock(6, 10)
240 self.assertEqual(self.stack, [[1, 10]])
241
242 def test_rounding_off(self):
243 self.stack.add_stock(1.0, 1.0)
244 self.stack.remove_stock(1.0 - 1e-9)
245 self.assertTotalQty(0)
246
247 def test_lifo_consumption(self):
248 self.stack.add_stock(10, 10)
249 self.stack.add_stock(10, 20)
250 consumed = self.stack.remove_stock(15)
251 self.assertEqual(consumed, [[10, 20], [5, 10]])
252 self.assertTotalQty(5)
253
254 def test_lifo_consumption_going_negative(self):
255 self.stack.add_stock(10, 10)
256 self.stack.add_stock(10, 20)
257 consumed = self.stack.remove_stock(25)
258 self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
259 self.assertTotalQty(-5)
260
261 def test_lifo_consumption_multiple(self):
262 self.stack.add_stock(1, 1)
263 self.stack.add_stock(2, 2)
264 consumed = self.stack.remove_stock(1)
265 self.assertEqual(consumed, [[1, 2]])
266
267 self.stack.add_stock(3, 3)
268 consumed = self.stack.remove_stock(4)
269 self.assertEqual(consumed, [[3, 3], [1, 2]])
270
271 self.stack.add_stock(4, 4)
272 consumed = self.stack.remove_stock(5)
273 self.assertEqual(consumed, [[4, 4], [1, 1]])
274
275 self.stack.add_stock(5, 5)
276 consumed = self.stack.remove_stock(5)
277 self.assertEqual(consumed, [[5, 5]])
278
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530279 @given(stock_queue_generator)
280 def test_lifo_qty_hypothesis(self, stock_stack):
281 self.stack = LIFOValuation([])
282 total_qty = 0
283
284 for qty, rate in stock_stack:
Ankush Menatb8a61be2023-03-13 15:16:30 +0530285 if round_off_if_near_zero(qty) == 0:
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530286 continue
287 if qty > 0:
288 self.stack.add_stock(qty, rate)
289 total_qty += qty
290 else:
291 qty = abs(qty)
292 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530293 self.assertAlmostEqual(
294 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
295 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530296 total_qty -= qty
297 self.assertTotalQty(total_qty)
298
299 @given(stock_queue_generator)
300 def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
301 self.stack = LIFOValuation([])
302 total_qty = 0.0
303 total_value = 0.0
304
305 for qty, rate in stock_stack:
306 # don't allow negative stock
Ankush Menatb8a61be2023-03-13 15:16:30 +0530307 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 +0530308 continue
309 if qty > 0:
310 self.stack.add_stock(qty, rate)
311 total_qty += qty
312 total_value += qty * rate
313 else:
314 qty = abs(qty)
315 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530316 self.assertAlmostEqual(
317 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
318 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530319 total_qty -= qty
320 total_value -= sum(q * r for q, r in consumed)
321 self.assertTotalQty(total_qty)
322 self.assertTotalValue(total_value)
Ankush Menat3e5f9402022-01-15 18:49:46 +0530323
Ankush Menat494bd9e2022-03-28 18:52:46 +0530324
Ankush Menatb0d1e6d2022-02-28 16:55:46 +0530325class TestLIFOValuationSLE(FrappeTestCase):
Ankush Menat3e5f9402022-01-15 18:49:46 +0530326 ITEM_CODE = "_Test LIFO item"
327 WAREHOUSE = "_Test Warehouse - _TC"
328
329 @classmethod
330 def setUpClass(cls) -> None:
331 super().setUpClass()
332 make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
333
334 def _make_stock_entry(self, qty, rate=None):
335 kwargs = {
336 "item_code": self.ITEM_CODE,
337 "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
338 "rate": rate,
339 "qty": abs(qty),
340 }
341 return make_stock_entry(**kwargs)
342
343 def assertStockQueue(self, se, expected_queue):
Ankush Menat494bd9e2022-03-28 18:52:46 +0530344 sle_name = frappe.db.get_value(
345 "Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}
346 )
Ankush Menat3e5f9402022-01-15 18:49:46 +0530347 sle = frappe.get_doc("Stock Ledger Entry", sle_name)
348
349 stock_queue = json.loads(sle.stock_queue)
350
351 total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
352 self.assertEqual(sle.qty_after_transaction, total_qty)
353 self.assertEqual(sle.stock_value, total_value)
354
355 if total_qty > 0:
356 self.assertEqual(stock_queue, expected_queue)
357
Ankush Menat3e5f9402022-01-15 18:49:46 +0530358 def test_lifo_values(self):
359
360 in1 = self._make_stock_entry(1, 1)
361 self.assertStockQueue(in1, [[1, 1]])
362
363 in2 = self._make_stock_entry(2, 2)
364 self.assertStockQueue(in2, [[1, 1], [2, 2]])
365
366 out1 = self._make_stock_entry(-1)
367 self.assertStockQueue(out1, [[1, 1], [1, 2]])
368
369 in3 = self._make_stock_entry(3, 3)
370 self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
371
372 out2 = self._make_stock_entry(-4)
373 self.assertStockQueue(out2, [[1, 1]])
374
375 in4 = self._make_stock_entry(4, 4)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530376 self.assertStockQueue(in4, [[1, 1], [4, 4]])
Ankush Menat3e5f9402022-01-15 18:49:46 +0530377
378 out3 = self._make_stock_entry(-5)
379 self.assertStockQueue(out3, [])
380
381 in5 = self._make_stock_entry(5, 5)
382 self.assertStockQueue(in5, [[5, 5]])
383
384 out5 = self._make_stock_entry(-5)
385 self.assertStockQueue(out5, [])