blob: f056439bccae17b0b98f6aaf7d92ef49c0b3af81 [file] [log] [blame]
Ankush Menatb8550302022-01-15 12:36:56 +05301from abc import ABC, abstractmethod, abstractproperty
Ankush Menat4b29fb62021-12-18 18:40:22 +05302from typing import Callable, List, NewType, Optional, Tuple
3
4from frappe.utils import flt
5
Ankush Menatb8550302022-01-15 12:36:56 +05306StockBin = NewType("FifoBin", List[float])
Ankush Menat4b29fb62021-12-18 18:40:22 +05307
8# Indexes of values inside FIFO bin 2-tuple
9QTY = 0
10RATE = 1
11
12
Ankush Menatb8550302022-01-15 12:36:56 +053013class BinWiseValuation(ABC):
14
15 @abstractmethod
16 def add_stock(self, qty: float, rate: float) -> None:
17 pass
18
19 @abstractmethod
20 def remove_stock(
21 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
22 ) -> List[StockBin]:
23 pass
24
25 @abstractproperty
26 def state(self) -> List[StockBin]:
27 pass
28
29 def get_total_stock_and_value(self) -> Tuple[float, float]:
30 total_qty = 0.0
31 total_value = 0.0
32
33 for qty, rate in self.state:
34 total_qty += flt(qty)
35 total_value += flt(qty) * flt(rate)
36
37 return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
38
39 def __repr__(self):
40 return str(self.state)
41
42 def __iter__(self):
43 return iter(self.state)
44
45 def __eq__(self, other):
46 if isinstance(other, list):
47 return self.state == other
48 return type(self) == type(other) and self.state == other.state
49
50
51class FIFOValuation(BinWiseValuation):
Ankush Menat4b29fb62021-12-18 18:40:22 +053052 """Valuation method where a queue of all the incoming stock is maintained.
53
54 New stock is added at end of the queue.
55 Qty consumption happens on First In First Out basis.
56
57 Queue is implemented using "bins" of [qty, rate].
58
59 ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
60 """
61
Ankush Menat745caf92021-12-19 19:08:09 +053062 # specifying the attributes to save resources
63 # ref: https://docs.python.org/3/reference/datamodel.html#slots
64 __slots__ = ["queue",]
65
Ankush Menatb8550302022-01-15 12:36:56 +053066 def __init__(self, state: Optional[List[StockBin]]):
67 self.queue: List[StockBin] = state if state is not None else []
Ankush Menat4b29fb62021-12-18 18:40:22 +053068
Ankush Menatb8550302022-01-15 12:36:56 +053069 @property
70 def state(self) -> List[StockBin]:
Ankush Menat4b29fb62021-12-18 18:40:22 +053071 """Get current state of queue."""
72 return self.queue
73
Ankush Menatdb1c0882021-12-19 18:37:12 +053074 def add_stock(self, qty: float, rate: float) -> None:
75 """Update fifo queue with new stock.
Ankush Menat4b29fb62021-12-18 18:40:22 +053076
77 args:
78 qty: new quantity to add
79 rate: incoming rate of new quantity"""
80
81 if not len(self.queue):
82 self.queue.append([0, 0])
83
84 # last row has the same rate, merge new bin.
85 if self.queue[-1][RATE] == rate:
86 self.queue[-1][QTY] += qty
87 else:
88 # Item has a positive balance qty, add new entry
89 if self.queue[-1][QTY] > 0:
90 self.queue.append([qty, rate])
91 else: # negative balance qty
92 qty = self.queue[-1][QTY] + qty
93 if qty > 0: # new balance qty is positive
94 self.queue[-1] = [qty, rate]
95 else: # new balance qty is still negative, maintain same rate
96 self.queue[-1][QTY] = qty
Ankush Menat4b29fb62021-12-18 18:40:22 +053097
98 def remove_stock(
Ankush Menata00d8d02021-12-19 18:45:04 +053099 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
Ankush Menatb8550302022-01-15 12:36:56 +0530100 ) -> List[StockBin]:
Ankush Menatdb1c0882021-12-19 18:37:12 +0530101 """Remove stock from the queue and return popped bins.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530102
103 args:
104 qty: quantity to remove
105 rate: outgoing rate
106 rate_generator: function to be called if queue is not found and rate is required.
107 """
Ankush Menata00d8d02021-12-19 18:45:04 +0530108 if not rate_generator:
109 rate_generator = lambda : 0.0 # noqa
Ankush Menat4b29fb62021-12-18 18:40:22 +0530110
Ankush Menatdb1c0882021-12-19 18:37:12 +0530111 consumed_bins = []
Ankush Menat4b29fb62021-12-18 18:40:22 +0530112 while qty:
113 if not len(self.queue):
114 # rely on rate generator.
115 self.queue.append([0, rate_generator()])
116
117 index = None
Ankush Menata00d8d02021-12-19 18:45:04 +0530118 if outgoing_rate > 0:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530119 # Find the entry where rate matched with outgoing rate
120 for idx, fifo_bin in enumerate(self.queue):
Ankush Menata00d8d02021-12-19 18:45:04 +0530121 if fifo_bin[RATE] == outgoing_rate:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530122 index = idx
123 break
124
Ankush Menat9d177432021-12-19 18:51:55 +0530125 # If no entry found with outgoing rate, collapse queue
Ankush Menat4b29fb62021-12-18 18:40:22 +0530126 if index is None: # nosemgrep
Ankush Menata00d8d02021-12-19 18:45:04 +0530127 new_stock_value = sum(d[QTY] * d[RATE] for d in self.queue) - qty * outgoing_rate
Ankush Menat4b29fb62021-12-18 18:40:22 +0530128 new_stock_qty = sum(d[QTY] for d in self.queue) - qty
Ankush Menata00d8d02021-12-19 18:45:04 +0530129 self.queue = [[new_stock_qty, new_stock_value / new_stock_qty if new_stock_qty > 0 else outgoing_rate]]
Ankush Menat9d177432021-12-19 18:51:55 +0530130 consumed_bins.append([qty, outgoing_rate])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530131 break
132 else:
133 index = 0
134
135 # select first bin or the bin with same rate
136 fifo_bin = self.queue[index]
137 if qty >= fifo_bin[QTY]:
138 # consume current bin
139 qty = _round_off_if_near_zero(qty - fifo_bin[QTY])
Ankush Menatdb1c0882021-12-19 18:37:12 +0530140 to_consume = self.queue.pop(index)
141 consumed_bins.append(list(to_consume))
142
Ankush Menat4b29fb62021-12-18 18:40:22 +0530143 if not self.queue and qty:
144 # stock finished, qty still remains to be withdrawn
145 # negative stock, keep in as a negative bin
Ankush Menata00d8d02021-12-19 18:45:04 +0530146 self.queue.append([-qty, outgoing_rate or fifo_bin[RATE]])
147 consumed_bins.append([qty, outgoing_rate or fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530148 break
Ankush Menat4b29fb62021-12-18 18:40:22 +0530149 else:
150 # qty found in current bin consume it and exit
Ankush Menate6e679c2021-12-18 20:36:47 +0530151 fifo_bin[QTY] = _round_off_if_near_zero(fifo_bin[QTY] - qty)
Ankush Menatdb1c0882021-12-19 18:37:12 +0530152 consumed_bins.append([qty, fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530153 qty = 0
154
Ankush Menatdb1c0882021-12-19 18:37:12 +0530155 return consumed_bins
Ankush Menat4b29fb62021-12-18 18:40:22 +0530156
157
Ankush Menatb8550302022-01-15 12:36:56 +0530158class LIFOValuation(BinWiseValuation):
159 """Valuation method where a *stack* of all the incoming stock is maintained.
160
161 New stock is added at top of the stack.
162 Qty consumption happens on Last In First Out basis.
163
164 Stack is implemented using "bins" of [qty, rate].
165
166 ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
167 """
168
169 # specifying the attributes to save resources
170 # ref: https://docs.python.org/3/reference/datamodel.html#slots
171 __slots__ = ["queue",]
172
173 def __init__(self, state: Optional[List[StockBin]]):
174 self.stack: List[StockBin] = state if state is not None else []
175
176 @property
177 def state(self) -> List[StockBin]:
178 """Get current state of stack."""
179 return self.stack
180
181 def add_stock(self, qty: float, rate: float) -> None:
182 """Update lifo stack with new stock.
183
184 args:
185 qty: new quantity to add
186 rate: incoming rate of new quantity"""
187 pass
188
189
190 def remove_stock(
191 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
192 ) -> List[StockBin]:
193 """Remove stock from the stack and return popped bins.
194
195 args:
196 qty: quantity to remove
197 rate: outgoing rate
198 rate_generator: function to be called if stack is not found and rate is required.
199 """
200 pass
201
202
Ankush Menate6e679c2021-12-18 20:36:47 +0530203def _round_off_if_near_zero(number: float, precision: int = 7) -> float:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530204 """Rounds off the number to zero only if number is close to zero for decimal
Ankush Menat1833f7a2021-12-18 19:37:41 +0530205 specified in precision. Precision defaults to 7.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530206 """
Ankush Menate6e679c2021-12-18 20:36:47 +0530207 if abs(0.0 - flt(number)) < (1.0 / (10 ** precision)):
208 return 0.0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530209
210 return flt(number)