blob: 506a666c28475c5241658df0782eadc1efef537f [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
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
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
96 def test_collapsing_of_queue(self):
97 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 Menat1833f7a2021-12-18 19:37:41 +0530105 # XXX
106 self.assertEqual(self.queue, [[1, 7]])
107
108 def test_rounding_off(self):
109 self.queue.add_stock(1.0, 1.0)
Ankush Menataa0e1632021-12-19 17:02:04 +0530110 self.queue.remove_stock(1.0 - 1e-9)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530111 self.assertTotalQty(0)
112
113 def test_rounding_off_near_zero(self):
Ankush Menatb534fee2022-02-19 20:58:36 +0530114 self.assertEqual(round_off_if_near_zero(0), 0)
115 self.assertEqual(round_off_if_near_zero(1), 1)
116 self.assertEqual(round_off_if_near_zero(-1), -1)
117 self.assertEqual(round_off_if_near_zero(-1e-8), 0)
118 self.assertEqual(round_off_if_near_zero(1e-8), 0)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530119
120 def test_totals(self):
121 self.queue.add_stock(1, 10)
122 self.queue.add_stock(2, 13)
123 self.queue.add_stock(1, 17)
Ankush Menataa0e1632021-12-19 17:02:04 +0530124 self.queue.remove_stock(1)
125 self.queue.remove_stock(1)
126 self.queue.remove_stock(1)
Ankush Menat1833f7a2021-12-18 19:37:41 +0530127 self.queue.add_stock(5, 17)
128 self.queue.add_stock(8, 11)
Ankush Menataa0e1632021-12-19 17:02:04 +0530129
130 @given(stock_queue_generator)
131 def test_fifo_qty_hypothesis(self, stock_queue):
Ankush Menat107b4042021-12-19 20:47:08 +0530132 self.queue = FIFOValuation([])
Ankush Menataa0e1632021-12-19 17:02:04 +0530133 total_qty = 0
134
135 for qty, rate in stock_queue:
136 if qty == 0:
137 continue
138 if qty > 0:
139 self.queue.add_stock(qty, rate)
140 total_qty += qty
141 else:
142 qty = abs(qty)
143 consumed = self.queue.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530144 self.assertAlmostEqual(
145 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
146 )
Ankush Menataa0e1632021-12-19 17:02:04 +0530147 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)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530167 self.assertAlmostEqual(
168 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
169 )
Ankush Menataa0e1632021-12-19 17:02:04 +0530170 total_qty -= qty
171 total_value -= sum(q * r for q, r in consumed)
172 self.assertTotalQty(total_qty)
173 self.assertTotalValue(total_value)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530174
175
176class TestLIFOValuation(unittest.TestCase):
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530177 def setUp(self):
178 self.stack = LIFOValuation([])
179
180 def tearDown(self):
181 qty, value = self.stack.get_total_stock_and_value()
182 self.assertTotalQty(qty)
183 self.assertTotalValue(value)
184
185 def assertTotalQty(self, qty):
186 self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
187
188 def assertTotalValue(self, value):
Ankush Menat494bd9e2022-03-28 18:52:46 +0530189 self.assertAlmostEqual(
190 sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2
191 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530192
193 def test_simple_addition(self):
194 self.stack.add_stock(1, 10)
195 self.assertTotalQty(1)
196
197 def test_merge_new_stock(self):
198 self.stack.add_stock(1, 10)
199 self.stack.add_stock(1, 10)
200 self.assertEqual(self.stack, [[2, 10]])
201
202 def test_simple_removal(self):
203 self.stack.add_stock(1, 10)
204 self.stack.remove_stock(1)
205 self.assertTotalQty(0)
206
207 def test_adding_negative_stock_keeps_rate(self):
208 self.stack = LIFOValuation([[-5.0, 100]])
209 self.stack.add_stock(1, 10)
210 self.assertEqual(self.stack, [[-4, 100]])
211
212 def test_adding_negative_stock_updates_rate(self):
213 self.stack = LIFOValuation([[-5.0, 100]])
214 self.stack.add_stock(6, 10)
215 self.assertEqual(self.stack, [[1, 10]])
216
217 def test_rounding_off(self):
218 self.stack.add_stock(1.0, 1.0)
219 self.stack.remove_stock(1.0 - 1e-9)
220 self.assertTotalQty(0)
221
222 def test_lifo_consumption(self):
223 self.stack.add_stock(10, 10)
224 self.stack.add_stock(10, 20)
225 consumed = self.stack.remove_stock(15)
226 self.assertEqual(consumed, [[10, 20], [5, 10]])
227 self.assertTotalQty(5)
228
229 def test_lifo_consumption_going_negative(self):
230 self.stack.add_stock(10, 10)
231 self.stack.add_stock(10, 20)
232 consumed = self.stack.remove_stock(25)
233 self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
234 self.assertTotalQty(-5)
235
236 def test_lifo_consumption_multiple(self):
237 self.stack.add_stock(1, 1)
238 self.stack.add_stock(2, 2)
239 consumed = self.stack.remove_stock(1)
240 self.assertEqual(consumed, [[1, 2]])
241
242 self.stack.add_stock(3, 3)
243 consumed = self.stack.remove_stock(4)
244 self.assertEqual(consumed, [[3, 3], [1, 2]])
245
246 self.stack.add_stock(4, 4)
247 consumed = self.stack.remove_stock(5)
248 self.assertEqual(consumed, [[4, 4], [1, 1]])
249
250 self.stack.add_stock(5, 5)
251 consumed = self.stack.remove_stock(5)
252 self.assertEqual(consumed, [[5, 5]])
253
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530254 @given(stock_queue_generator)
255 def test_lifo_qty_hypothesis(self, stock_stack):
256 self.stack = LIFOValuation([])
257 total_qty = 0
258
259 for qty, rate in stock_stack:
260 if qty == 0:
261 continue
262 if qty > 0:
263 self.stack.add_stock(qty, rate)
264 total_qty += qty
265 else:
266 qty = abs(qty)
267 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530268 self.assertAlmostEqual(
269 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
270 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530271 total_qty -= qty
272 self.assertTotalQty(total_qty)
273
274 @given(stock_queue_generator)
275 def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
276 self.stack = LIFOValuation([])
277 total_qty = 0.0
278 total_value = 0.0
279
280 for qty, rate in stock_stack:
281 # don't allow negative stock
282 if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
283 continue
284 if qty > 0:
285 self.stack.add_stock(qty, rate)
286 total_qty += qty
287 total_value += qty * rate
288 else:
289 qty = abs(qty)
290 consumed = self.stack.remove_stock(qty)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530291 self.assertAlmostEqual(
292 qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
293 )
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530294 total_qty -= qty
295 total_value -= sum(q * r for q, r in consumed)
296 self.assertTotalQty(total_qty)
297 self.assertTotalValue(total_value)
Ankush Menat3e5f9402022-01-15 18:49:46 +0530298
Ankush Menat494bd9e2022-03-28 18:52:46 +0530299
Ankush Menatb0d1e6d2022-02-28 16:55:46 +0530300class TestLIFOValuationSLE(FrappeTestCase):
Ankush Menat3e5f9402022-01-15 18:49:46 +0530301 ITEM_CODE = "_Test LIFO item"
302 WAREHOUSE = "_Test Warehouse - _TC"
303
304 @classmethod
305 def setUpClass(cls) -> None:
306 super().setUpClass()
307 make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
308
309 def _make_stock_entry(self, qty, rate=None):
310 kwargs = {
311 "item_code": self.ITEM_CODE,
312 "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
313 "rate": rate,
314 "qty": abs(qty),
315 }
316 return make_stock_entry(**kwargs)
317
318 def assertStockQueue(self, se, expected_queue):
Ankush Menat494bd9e2022-03-28 18:52:46 +0530319 sle_name = frappe.db.get_value(
320 "Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}
321 )
Ankush Menat3e5f9402022-01-15 18:49:46 +0530322 sle = frappe.get_doc("Stock Ledger Entry", sle_name)
323
324 stock_queue = json.loads(sle.stock_queue)
325
326 total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
327 self.assertEqual(sle.qty_after_transaction, total_qty)
328 self.assertEqual(sle.stock_value, total_value)
329
330 if total_qty > 0:
331 self.assertEqual(stock_queue, expected_queue)
332
Ankush Menat3e5f9402022-01-15 18:49:46 +0530333 def test_lifo_values(self):
334
335 in1 = self._make_stock_entry(1, 1)
336 self.assertStockQueue(in1, [[1, 1]])
337
338 in2 = self._make_stock_entry(2, 2)
339 self.assertStockQueue(in2, [[1, 1], [2, 2]])
340
341 out1 = self._make_stock_entry(-1)
342 self.assertStockQueue(out1, [[1, 1], [1, 2]])
343
344 in3 = self._make_stock_entry(3, 3)
345 self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
346
347 out2 = self._make_stock_entry(-4)
348 self.assertStockQueue(out2, [[1, 1]])
349
350 in4 = self._make_stock_entry(4, 4)
Ankush Menat494bd9e2022-03-28 18:52:46 +0530351 self.assertStockQueue(in4, [[1, 1], [4, 4]])
Ankush Menat3e5f9402022-01-15 18:49:46 +0530352
353 out3 = self._make_stock_entry(-5)
354 self.assertStockQueue(out3, [])
355
356 in5 = self._make_stock_entry(5, 5)
357 self.assertStockQueue(in5, [[5, 5]])
358
359 out5 = self._make_stock_entry(-5)
360 self.assertStockQueue(out5, [])