blob: 47b8f0b5cf5d74434d9ed68adfc1e7e62d4e165a [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):
Akhil Narang3effaf22024-03-27 11:37:26 +053031 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 +053032
33 def test_simple_addition(self):
34 self.queue.add_stock(1, 10)
35 self.assertTotalQty(1)
36
37 def test_simple_removal(self):
38 self.queue.add_stock(1, 10)
Ankush Menataa0e1632021-12-19 17:02:04 +053039 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +053040 self.assertTotalQty(0)
41
42 def test_merge_new_stock(self):
43 self.queue.add_stock(1, 10)
44 self.queue.add_stock(1, 10)
45 self.assertEqual(self.queue, [[2, 10]])
46
47 def test_adding_negative_stock_keeps_rate(self):
Ankush Menat107b4042021-12-19 20:47:08 +053048 self.queue = FIFOValuation([[-5.0, 100]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053049 self.queue.add_stock(1, 10)
50 self.assertEqual(self.queue, [[-4, 100]])
51
52 def test_adding_negative_stock_updates_rate(self):
Ankush Menat107b4042021-12-19 20:47:08 +053053 self.queue = FIFOValuation([[-5.0, 100]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053054 self.queue.add_stock(6, 10)
55 self.assertEqual(self.queue, [[1, 10]])
56
Ankush Menat1833f7a2021-12-18 19:37:41 +053057 def test_negative_stock(self):
Ankush Menataa0e1632021-12-19 17:02:04 +053058 self.queue.remove_stock(1, 5)
Ankush Menat1833f7a2021-12-18 19:37:41 +053059 self.assertEqual(self.queue, [[-1, 5]])
60
Ankush Menat12c01e22022-04-04 15:22:15 +053061 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +053062 self.assertTotalQty(-2)
Ankush Menat12c01e22022-04-04 15:22:15 +053063 self.assertEqual(self.queue, [[-2, 5]])
Ankush Menat1833f7a2021-12-18 19:37:41 +053064
65 self.queue.add_stock(2, 10)
66 self.assertTotalQty(0)
67 self.assertTotalValue(0)
68
69 def test_removing_specified_rate(self):
70 self.queue.add_stock(1, 10)
71 self.queue.add_stock(1, 20)
72
Ankush Menataa0e1632021-12-19 17:02:04 +053073 self.queue.remove_stock(1, 20)
Ankush Menat1833f7a2021-12-18 19:37:41 +053074 self.assertEqual(self.queue, [[1, 10]])
75
Ankush Menat1833f7a2021-12-18 19:37:41 +053076 def test_remove_multiple_bins(self):
77 self.queue.add_stock(1, 10)
78 self.queue.add_stock(2, 20)
79 self.queue.add_stock(1, 20)
80 self.queue.add_stock(5, 20)
81
Ankush Menataa0e1632021-12-19 17:02:04 +053082 self.queue.remove_stock(4)
Ankush Menat1833f7a2021-12-18 19:37:41 +053083 self.assertEqual(self.queue, [[5, 20]])
84
Ankush Menat1833f7a2021-12-18 19:37:41 +053085 def test_remove_multiple_bins_with_rate(self):
86 self.queue.add_stock(1, 10)
87 self.queue.add_stock(2, 20)
88 self.queue.add_stock(1, 20)
89 self.queue.add_stock(5, 20)
90
Ankush Menataa0e1632021-12-19 17:02:04 +053091 self.queue.remove_stock(3, 20)
Ankush Menat1833f7a2021-12-18 19:37:41 +053092 self.assertEqual(self.queue, [[1, 10], [5, 20]])
93
Ankush Menat12c01e22022-04-04 15:22:15 +053094 def test_queue_with_unknown_rate(self):
Ankush Menat1833f7a2021-12-18 19:37:41 +053095 self.queue.add_stock(1, 1)
96 self.queue.add_stock(1, 2)
97 self.queue.add_stock(1, 3)
98 self.queue.add_stock(1, 4)
99
100 self.assertTotalValue(10)
101
Ankush Menataa0e1632021-12-19 17:02:04 +0530102 self.queue.remove_stock(3, 1)
Ankush Menat12c01e22022-04-04 15:22:15 +0530103 self.assertEqual(self.queue, [[1, 4]])
Ankush Menat1833f7a2021-12-18 19:37:41 +0530104
105 def test_rounding_off(self):
106 self.queue.add_stock(1.0, 1.0)
Ankush Menataa0e1632021-12-19 17:02:04 +0530107 self.queue.remove_stock(1.0 - 1e-9)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530108 self.assertTotalQty(0)
109
110 def test_rounding_off_near_zero(self):
Ankush Menatb534fee2022-02-19 20:58:36 +0530111 self.assertEqual(round_off_if_near_zero(0), 0)
112 self.assertEqual(round_off_if_near_zero(1), 1)
113 self.assertEqual(round_off_if_near_zero(-1), -1)
114 self.assertEqual(round_off_if_near_zero(-1e-8), 0)
115 self.assertEqual(round_off_if_near_zero(1e-8), 0)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530116
117 def test_totals(self):
118 self.queue.add_stock(1, 10)
119 self.queue.add_stock(2, 13)
120 self.queue.add_stock(1, 17)
Ankush Menataa0e1632021-12-19 17:02:04 +0530121 self.queue.remove_stock(1)
122 self.queue.remove_stock(1)
123 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530124 self.queue.add_stock(5, 17)
125 self.queue.add_stock(8, 11)
Ankush Menataa0e1632021-12-19 17:02:04 +0530126
127 @given(stock_queue_generator)
128 def test_fifo_qty_hypothesis(self, stock_queue):
Ankush Menat107b4042021-12-19 20:47:08 +0530129 self.queue = FIFOValuation([])
Ankush Menataa0e1632021-12-19 17:02:04 +0530130 total_qty = 0
131
132 for qty, rate in stock_queue:
Ankush Menatb8a61be2023-03-13 15:16:30 +0530133 if round_off_if_near_zero(qty) == 0:
Ankush Menataa0e1632021-12-19 17:02:04 +0530134 continue
135 if qty > 0:
136 self.queue.add_stock(qty, rate)
137 total_qty += qty
138 else:
139 qty = abs(qty)
140 consumed = self.queue.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530141 self.assertAlmostEqual(
142 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
143 )
Ankush Menataa0e1632021-12-19 17:02:04 +0530144 total_qty -= qty
145 self.assertTotalQty(total_qty)
146
147 @given(stock_queue_generator)
148 def test_fifo_qty_value_nonneg_hypothesis(self, stock_queue):
Ankush Menat107b4042021-12-19 20:47:08 +0530149 self.queue = FIFOValuation([])
Ankush Menataa0e1632021-12-19 17:02:04 +0530150 total_qty = 0.0
151 total_value = 0.0
152
153 for qty, rate in stock_queue:
154 # don't allow negative stock
Ankush Menatb8a61be2023-03-13 15:16:30 +0530155 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 +0530156 continue
157 if qty > 0:
158 self.queue.add_stock(qty, rate)
159 total_qty += qty
160 total_value += qty * rate
161 else:
162 qty = abs(qty)
163 consumed = self.queue.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530164 self.assertAlmostEqual(
165 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
166 )
Ankush Menataa0e1632021-12-19 17:02:04 +0530167 total_qty -= qty
168 total_value -= sum(q * r for q, r in consumed)
169 self.assertTotalQty(total_qty)
170 self.assertTotalValue(total_value)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530171
Ankush Menat12c01e22022-04-04 15:22:15 +0530172 @given(stock_queue_generator, st.floats(min_value=0.1, max_value=1e6))
173 def test_fifo_qty_value_nonneg_hypothesis_with_outgoing_rate(self, stock_queue, outgoing_rate):
174 self.queue = FIFOValuation([])
175 total_qty = 0.0
176 total_value = 0.0
177
178 for qty, rate in stock_queue:
179 # don't allow negative stock
Ankush Menatb8a61be2023-03-13 15:16:30 +0530180 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 +0530181 continue
182 if qty > 0:
183 self.queue.add_stock(qty, rate)
184 total_qty += qty
185 total_value += qty * rate
186 else:
187 qty = abs(qty)
188 consumed = self.queue.remove_stock(qty, outgoing_rate)
189 self.assertAlmostEqual(
190 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
191 )
192 total_qty -= qty
193 total_value -= sum(q * r for q, r in consumed)
194 self.assertTotalQty(total_qty)
195 self.assertTotalValue(total_value)
Ankush Menat12c01e22022-04-04 15:22:15 +0530196
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530197
198class TestLIFOValuation(unittest.TestCase):
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530199 def setUp(self):
200 self.stack = LIFOValuation([])
201
202 def tearDown(self):
203 qty, value = self.stack.get_total_stock_and_value()
204 self.assertTotalQty(qty)
205 self.assertTotalValue(value)
206
207 def assertTotalQty(self, qty):
208 self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
209
210 def assertTotalValue(self, value):
Akhil Narang3effaf22024-03-27 11:37:26 +0530211 self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530212
213 def test_simple_addition(self):
214 self.stack.add_stock(1, 10)
215 self.assertTotalQty(1)
216
217 def test_merge_new_stock(self):
218 self.stack.add_stock(1, 10)
219 self.stack.add_stock(1, 10)
220 self.assertEqual(self.stack, [[2, 10]])
221
222 def test_simple_removal(self):
223 self.stack.add_stock(1, 10)
224 self.stack.remove_stock(1)
225 self.assertTotalQty(0)
226
227 def test_adding_negative_stock_keeps_rate(self):
228 self.stack = LIFOValuation([[-5.0, 100]])
229 self.stack.add_stock(1, 10)
230 self.assertEqual(self.stack, [[-4, 100]])
231
232 def test_adding_negative_stock_updates_rate(self):
233 self.stack = LIFOValuation([[-5.0, 100]])
234 self.stack.add_stock(6, 10)
235 self.assertEqual(self.stack, [[1, 10]])
236
237 def test_rounding_off(self):
238 self.stack.add_stock(1.0, 1.0)
239 self.stack.remove_stock(1.0 - 1e-9)
240 self.assertTotalQty(0)
241
242 def test_lifo_consumption(self):
243 self.stack.add_stock(10, 10)
244 self.stack.add_stock(10, 20)
245 consumed = self.stack.remove_stock(15)
246 self.assertEqual(consumed, [[10, 20], [5, 10]])
247 self.assertTotalQty(5)
248
249 def test_lifo_consumption_going_negative(self):
250 self.stack.add_stock(10, 10)
251 self.stack.add_stock(10, 20)
252 consumed = self.stack.remove_stock(25)
253 self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
254 self.assertTotalQty(-5)
255
256 def test_lifo_consumption_multiple(self):
257 self.stack.add_stock(1, 1)
258 self.stack.add_stock(2, 2)
259 consumed = self.stack.remove_stock(1)
260 self.assertEqual(consumed, [[1, 2]])
261
262 self.stack.add_stock(3, 3)
263 consumed = self.stack.remove_stock(4)
264 self.assertEqual(consumed, [[3, 3], [1, 2]])
265
266 self.stack.add_stock(4, 4)
267 consumed = self.stack.remove_stock(5)
268 self.assertEqual(consumed, [[4, 4], [1, 1]])
269
270 self.stack.add_stock(5, 5)
271 consumed = self.stack.remove_stock(5)
272 self.assertEqual(consumed, [[5, 5]])
273
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530274 @given(stock_queue_generator)
275 def test_lifo_qty_hypothesis(self, stock_stack):
276 self.stack = LIFOValuation([])
277 total_qty = 0
278
279 for qty, rate in stock_stack:
Ankush Menatb8a61be2023-03-13 15:16:30 +0530280 if round_off_if_near_zero(qty) == 0:
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530281 continue
282 if qty > 0:
283 self.stack.add_stock(qty, rate)
284 total_qty += qty
285 else:
286 qty = abs(qty)
287 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530288 self.assertAlmostEqual(
289 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
290 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530291 total_qty -= qty
292 self.assertTotalQty(total_qty)
293
294 @given(stock_queue_generator)
295 def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
296 self.stack = LIFOValuation([])
297 total_qty = 0.0
298 total_value = 0.0
299
300 for qty, rate in stock_stack:
301 # don't allow negative stock
Ankush Menatb8a61be2023-03-13 15:16:30 +0530302 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 +0530303 continue
304 if qty > 0:
305 self.stack.add_stock(qty, rate)
306 total_qty += qty
307 total_value += qty * rate
308 else:
309 qty = abs(qty)
310 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530311 self.assertAlmostEqual(
312 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
313 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530314 total_qty -= qty
315 total_value -= sum(q * r for q, r in consumed)
316 self.assertTotalQty(total_qty)
317 self.assertTotalValue(total_value)
Ankush Menat3e5f9402022-01-15 18:49:46 +0530318
Ankush Menat494bd9e2022-03-28 18:52:46 +0530319
Ankush Menatb0d1e6d2022-02-28 16:55:46 +0530320class TestLIFOValuationSLE(FrappeTestCase):
Ankush Menat3e5f9402022-01-15 18:49:46 +0530321 ITEM_CODE = "_Test LIFO item"
322 WAREHOUSE = "_Test Warehouse - _TC"
323
324 @classmethod
325 def setUpClass(cls) -> None:
326 super().setUpClass()
327 make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
328
329 def _make_stock_entry(self, qty, rate=None):
330 kwargs = {
331 "item_code": self.ITEM_CODE,
332 "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
333 "rate": rate,
334 "qty": abs(qty),
335 }
336 return make_stock_entry(**kwargs)
337
338 def assertStockQueue(self, se, expected_queue):
Ankush Menat494bd9e2022-03-28 18:52:46 +0530339 sle_name = frappe.db.get_value(
340 "Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}
341 )
Ankush Menat3e5f9402022-01-15 18:49:46 +0530342 sle = frappe.get_doc("Stock Ledger Entry", sle_name)
343
344 stock_queue = json.loads(sle.stock_queue)
345
346 total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
347 self.assertEqual(sle.qty_after_transaction, total_qty)
348 self.assertEqual(sle.stock_value, total_value)
349
350 if total_qty > 0:
351 self.assertEqual(stock_queue, expected_queue)
352
Ankush Menat3e5f9402022-01-15 18:49:46 +0530353 def test_lifo_values(self):
Ankush Menat3e5f9402022-01-15 18:49:46 +0530354 in1 = self._make_stock_entry(1, 1)
355 self.assertStockQueue(in1, [[1, 1]])
356
357 in2 = self._make_stock_entry(2, 2)
358 self.assertStockQueue(in2, [[1, 1], [2, 2]])
359
360 out1 = self._make_stock_entry(-1)
361 self.assertStockQueue(out1, [[1, 1], [1, 2]])
362
363 in3 = self._make_stock_entry(3, 3)
364 self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
365
366 out2 = self._make_stock_entry(-4)
367 self.assertStockQueue(out2, [[1, 1]])
368
369 in4 = self._make_stock_entry(4, 4)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530370 self.assertStockQueue(in4, [[1, 1], [4, 4]])
Ankush Menat3e5f9402022-01-15 18:49:46 +0530371
372 out3 = self._make_stock_entry(-5)
373 self.assertStockQueue(out3, [])
374
375 in5 = self._make_stock_entry(5, 5)
376 self.assertStockQueue(in5, [[5, 5]])
377
378 out5 = self._make_stock_entry(-5)
379 self.assertStockQueue(out5, [])