feat: LIFOValuation class for handling LIFO
This commit is contained in:
parent
b855030e7c
commit
9c49d2d3aa
@ -3,14 +3,14 @@ import unittest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero
|
||||
from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero
|
||||
|
||||
qty_gen = st.floats(min_value=-1e6, max_value=1e6)
|
||||
value_gen = st.floats(min_value=1, max_value=1e6)
|
||||
stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10)
|
||||
|
||||
|
||||
class TestFifoValuation(unittest.TestCase):
|
||||
class TestFIFOValuation(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.queue = FIFOValuation([])
|
||||
@ -164,3 +164,123 @@ class TestFifoValuation(unittest.TestCase):
|
||||
total_value -= sum(q * r for q, r in consumed)
|
||||
self.assertTotalQty(total_qty)
|
||||
self.assertTotalValue(total_value)
|
||||
|
||||
|
||||
class TestLIFOValuation(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.stack = LIFOValuation([])
|
||||
|
||||
def tearDown(self):
|
||||
qty, value = self.stack.get_total_stock_and_value()
|
||||
self.assertTotalQty(qty)
|
||||
self.assertTotalValue(value)
|
||||
|
||||
def assertTotalQty(self, qty):
|
||||
self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
|
||||
|
||||
def assertTotalValue(self, value):
|
||||
self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2)
|
||||
|
||||
def test_simple_addition(self):
|
||||
self.stack.add_stock(1, 10)
|
||||
self.assertTotalQty(1)
|
||||
|
||||
def test_merge_new_stock(self):
|
||||
self.stack.add_stock(1, 10)
|
||||
self.stack.add_stock(1, 10)
|
||||
self.assertEqual(self.stack, [[2, 10]])
|
||||
|
||||
def test_simple_removal(self):
|
||||
self.stack.add_stock(1, 10)
|
||||
self.stack.remove_stock(1)
|
||||
self.assertTotalQty(0)
|
||||
|
||||
def test_adding_negative_stock_keeps_rate(self):
|
||||
self.stack = LIFOValuation([[-5.0, 100]])
|
||||
self.stack.add_stock(1, 10)
|
||||
self.assertEqual(self.stack, [[-4, 100]])
|
||||
|
||||
def test_adding_negative_stock_updates_rate(self):
|
||||
self.stack = LIFOValuation([[-5.0, 100]])
|
||||
self.stack.add_stock(6, 10)
|
||||
self.assertEqual(self.stack, [[1, 10]])
|
||||
|
||||
def test_rounding_off(self):
|
||||
self.stack.add_stock(1.0, 1.0)
|
||||
self.stack.remove_stock(1.0 - 1e-9)
|
||||
self.assertTotalQty(0)
|
||||
|
||||
def test_lifo_consumption(self):
|
||||
self.stack.add_stock(10, 10)
|
||||
self.stack.add_stock(10, 20)
|
||||
consumed = self.stack.remove_stock(15)
|
||||
self.assertEqual(consumed, [[10, 20], [5, 10]])
|
||||
self.assertTotalQty(5)
|
||||
|
||||
def test_lifo_consumption_going_negative(self):
|
||||
self.stack.add_stock(10, 10)
|
||||
self.stack.add_stock(10, 20)
|
||||
consumed = self.stack.remove_stock(25)
|
||||
self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
|
||||
self.assertTotalQty(-5)
|
||||
|
||||
def test_lifo_consumption_multiple(self):
|
||||
self.stack.add_stock(1, 1)
|
||||
self.stack.add_stock(2, 2)
|
||||
consumed = self.stack.remove_stock(1)
|
||||
self.assertEqual(consumed, [[1, 2]])
|
||||
|
||||
self.stack.add_stock(3, 3)
|
||||
consumed = self.stack.remove_stock(4)
|
||||
self.assertEqual(consumed, [[3, 3], [1, 2]])
|
||||
|
||||
self.stack.add_stock(4, 4)
|
||||
consumed = self.stack.remove_stock(5)
|
||||
self.assertEqual(consumed, [[4, 4], [1, 1]])
|
||||
|
||||
self.stack.add_stock(5, 5)
|
||||
consumed = self.stack.remove_stock(5)
|
||||
self.assertEqual(consumed, [[5, 5]])
|
||||
|
||||
|
||||
@given(stock_queue_generator)
|
||||
def test_lifo_qty_hypothesis(self, stock_stack):
|
||||
self.stack = LIFOValuation([])
|
||||
total_qty = 0
|
||||
|
||||
for qty, rate in stock_stack:
|
||||
if qty == 0:
|
||||
continue
|
||||
if qty > 0:
|
||||
self.stack.add_stock(qty, rate)
|
||||
total_qty += qty
|
||||
else:
|
||||
qty = abs(qty)
|
||||
consumed = self.stack.remove_stock(qty)
|
||||
self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
|
||||
total_qty -= qty
|
||||
self.assertTotalQty(total_qty)
|
||||
|
||||
@given(stock_queue_generator)
|
||||
def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
|
||||
self.stack = LIFOValuation([])
|
||||
total_qty = 0.0
|
||||
total_value = 0.0
|
||||
|
||||
for qty, rate in stock_stack:
|
||||
# don't allow negative stock
|
||||
if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
|
||||
continue
|
||||
if qty > 0:
|
||||
self.stack.add_stock(qty, rate)
|
||||
total_qty += qty
|
||||
total_value += qty * rate
|
||||
else:
|
||||
qty = abs(qty)
|
||||
consumed = self.stack.remove_stock(qty)
|
||||
self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
|
||||
total_qty -= qty
|
||||
total_value -= sum(q * r for q, r in consumed)
|
||||
self.assertTotalQty(total_qty)
|
||||
self.assertTotalValue(total_value)
|
||||
|
@ -3,7 +3,7 @@ from typing import Callable, List, NewType, Optional, Tuple
|
||||
|
||||
from frappe.utils import flt
|
||||
|
||||
StockBin = NewType("FifoBin", List[float])
|
||||
StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...]
|
||||
|
||||
# Indexes of values inside FIFO bin 2-tuple
|
||||
QTY = 0
|
||||
@ -164,11 +164,12 @@ class LIFOValuation(BinWiseValuation):
|
||||
Stack is implemented using "bins" of [qty, rate].
|
||||
|
||||
ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
|
||||
Implementation detail: appends and pops both at end of list.
|
||||
"""
|
||||
|
||||
# specifying the attributes to save resources
|
||||
# ref: https://docs.python.org/3/reference/datamodel.html#slots
|
||||
__slots__ = ["queue",]
|
||||
__slots__ = ["stack",]
|
||||
|
||||
def __init__(self, state: Optional[List[StockBin]]):
|
||||
self.stack: List[StockBin] = state if state is not None else []
|
||||
@ -183,8 +184,26 @@ class LIFOValuation(BinWiseValuation):
|
||||
|
||||
args:
|
||||
qty: new quantity to add
|
||||
rate: incoming rate of new quantity"""
|
||||
pass
|
||||
rate: incoming rate of new quantity.
|
||||
|
||||
Behaviour of this is same as FIFO valuation.
|
||||
"""
|
||||
if not len(self.stack):
|
||||
self.stack.append([0, 0])
|
||||
|
||||
# last row has the same rate, merge new bin.
|
||||
if self.stack[-1][RATE] == rate:
|
||||
self.stack[-1][QTY] += qty
|
||||
else:
|
||||
# Item has a positive balance qty, add new entry
|
||||
if self.stack[-1][QTY] > 0:
|
||||
self.stack.append([qty, rate])
|
||||
else: # negative balance qty
|
||||
qty = self.stack[-1][QTY] + qty
|
||||
if qty > 0: # new balance qty is positive
|
||||
self.stack[-1] = [qty, rate]
|
||||
else: # new balance qty is still negative, maintain same rate
|
||||
self.stack[-1][QTY] = qty
|
||||
|
||||
|
||||
def remove_stock(
|
||||
@ -194,10 +213,41 @@ class LIFOValuation(BinWiseValuation):
|
||||
|
||||
args:
|
||||
qty: quantity to remove
|
||||
rate: outgoing rate
|
||||
rate: outgoing rate - ignored. Kept for backwards compatibility.
|
||||
rate_generator: function to be called if stack is not found and rate is required.
|
||||
"""
|
||||
pass
|
||||
if not rate_generator:
|
||||
rate_generator = lambda : 0.0 # noqa
|
||||
|
||||
consumed_bins = []
|
||||
while qty:
|
||||
if not len(self.stack):
|
||||
# rely on rate generator.
|
||||
self.stack.append([0, rate_generator()])
|
||||
|
||||
# start at the end.
|
||||
index = -1
|
||||
|
||||
stock_bin = self.stack[index]
|
||||
if qty >= stock_bin[QTY]:
|
||||
# consume current bin
|
||||
qty = _round_off_if_near_zero(qty - stock_bin[QTY])
|
||||
to_consume = self.stack.pop(index)
|
||||
consumed_bins.append(list(to_consume))
|
||||
|
||||
if not self.stack and qty:
|
||||
# stock finished, qty still remains to be withdrawn
|
||||
# negative stock, keep in as a negative bin
|
||||
self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
|
||||
consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
|
||||
break
|
||||
else:
|
||||
# qty found in current bin consume it and exit
|
||||
stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty)
|
||||
consumed_bins.append([qty, stock_bin[RATE]])
|
||||
qty = 0
|
||||
|
||||
return consumed_bins
|
||||
|
||||
|
||||
def _round_off_if_near_zero(number: float, precision: int = 7) -> float:
|
||||
|
Loading…
x
Reference in New Issue
Block a user