blob: 648b21828774e55fc19c32d3d43b416b3ee21064 [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 Menat9c49d2d2022-01-15 12:52:10 +05306StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...]
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):
Ankush Menatb8550302022-01-15 12:36:56 +053014 @abstractmethod
15 def add_stock(self, qty: float, rate: float) -> None:
16 pass
17
18 @abstractmethod
19 def remove_stock(
20 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
21 ) -> List[StockBin]:
22 pass
23
24 @abstractproperty
25 def state(self) -> List[StockBin]:
26 pass
27
28 def get_total_stock_and_value(self) -> Tuple[float, float]:
29 total_qty = 0.0
30 total_value = 0.0
31
32 for qty, rate in self.state:
33 total_qty += flt(qty)
34 total_value += flt(qty) * flt(rate)
35
Ankush Menatb534fee2022-02-19 20:58:36 +053036 return round_off_if_near_zero(total_qty), round_off_if_near_zero(total_value)
Ankush Menatb8550302022-01-15 12:36:56 +053037
38 def __repr__(self):
39 return str(self.state)
40
41 def __iter__(self):
42 return iter(self.state)
43
44 def __eq__(self, other):
45 if isinstance(other, list):
46 return self.state == other
47 return type(self) == type(other) and self.state == other.state
48
49
50class FIFOValuation(BinWiseValuation):
Ankush Menat4b29fb62021-12-18 18:40:22 +053051 """Valuation method where a queue of all the incoming stock is maintained.
52
53 New stock is added at end of the queue.
54 Qty consumption happens on First In First Out basis.
55
56 Queue is implemented using "bins" of [qty, rate].
57
58 ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
59 """
60
Ankush Menat745caf92021-12-19 19:08:09 +053061 # specifying the attributes to save resources
62 # ref: https://docs.python.org/3/reference/datamodel.html#slots
Ankush Menat494bd9e2022-03-28 18:52:46 +053063 __slots__ = [
64 "queue",
65 ]
Ankush Menat745caf92021-12-19 19:08:09 +053066
Ankush Menatb8550302022-01-15 12:36:56 +053067 def __init__(self, state: Optional[List[StockBin]]):
68 self.queue: List[StockBin] = state if state is not None else []
Ankush Menat4b29fb62021-12-18 18:40:22 +053069
Ankush Menatb8550302022-01-15 12:36:56 +053070 @property
71 def state(self) -> List[StockBin]:
Ankush Menat4b29fb62021-12-18 18:40:22 +053072 """Get current state of queue."""
73 return self.queue
74
Ankush Menatdb1c0882021-12-19 18:37:12 +053075 def add_stock(self, qty: float, rate: float) -> None:
76 """Update fifo queue with new stock.
Ankush Menat4b29fb62021-12-18 18:40:22 +053077
Ankush Menat494bd9e2022-03-28 18:52:46 +053078 args:
79 qty: new quantity to add
80 rate: incoming rate of new quantity"""
Ankush Menat4b29fb62021-12-18 18:40:22 +053081
82 if not len(self.queue):
83 self.queue.append([0, 0])
84
85 # last row has the same rate, merge new bin.
86 if self.queue[-1][RATE] == rate:
87 self.queue[-1][QTY] += qty
88 else:
89 # Item has a positive balance qty, add new entry
90 if self.queue[-1][QTY] > 0:
91 self.queue.append([qty, rate])
92 else: # negative balance qty
93 qty = self.queue[-1][QTY] + qty
94 if qty > 0: # new balance qty is positive
95 self.queue[-1] = [qty, rate]
96 else: # new balance qty is still negative, maintain same rate
97 self.queue[-1][QTY] = qty
Ankush Menat4b29fb62021-12-18 18:40:22 +053098
99 def remove_stock(
Ankush Menata00d8d02021-12-19 18:45:04 +0530100 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
Ankush Menatb8550302022-01-15 12:36:56 +0530101 ) -> List[StockBin]:
Ankush Menatdb1c0882021-12-19 18:37:12 +0530102 """Remove stock from the queue and return popped bins.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530103
104 args:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530105 qty: quantity to remove
106 rate: outgoing rate
107 rate_generator: function to be called if queue is not found and rate is required.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530108 """
Ankush Menata00d8d02021-12-19 18:45:04 +0530109 if not rate_generator:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530110 rate_generator = lambda: 0.0 # noqa
Ankush Menat4b29fb62021-12-18 18:40:22 +0530111
Ankush Menatdb1c0882021-12-19 18:37:12 +0530112 consumed_bins = []
Ankush Menat4b29fb62021-12-18 18:40:22 +0530113 while qty:
114 if not len(self.queue):
115 # rely on rate generator.
116 self.queue.append([0, rate_generator()])
117
118 index = None
Ankush Menata00d8d02021-12-19 18:45:04 +0530119 if outgoing_rate > 0:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530120 # Find the entry where rate matched with outgoing rate
121 for idx, fifo_bin in enumerate(self.queue):
Ankush Menata00d8d02021-12-19 18:45:04 +0530122 if fifo_bin[RATE] == outgoing_rate:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530123 index = idx
124 break
125
Ankush Menat9d177432021-12-19 18:51:55 +0530126 # If no entry found with outgoing rate, collapse queue
Ankush Menat4b29fb62021-12-18 18:40:22 +0530127 if index is None: # nosemgrep
Ankush Menata00d8d02021-12-19 18:45:04 +0530128 new_stock_value = sum(d[QTY] * d[RATE] for d in self.queue) - qty * outgoing_rate
Ankush Menat4b29fb62021-12-18 18:40:22 +0530129 new_stock_qty = sum(d[QTY] for d in self.queue) - qty
Ankush Menat494bd9e2022-03-28 18:52:46 +0530130 self.queue = [
131 [new_stock_qty, new_stock_value / new_stock_qty if new_stock_qty > 0 else outgoing_rate]
132 ]
Ankush Menat9d177432021-12-19 18:51:55 +0530133 consumed_bins.append([qty, outgoing_rate])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530134 break
135 else:
136 index = 0
137
138 # select first bin or the bin with same rate
139 fifo_bin = self.queue[index]
140 if qty >= fifo_bin[QTY]:
141 # consume current bin
Ankush Menatb534fee2022-02-19 20:58:36 +0530142 qty = round_off_if_near_zero(qty - fifo_bin[QTY])
Ankush Menatdb1c0882021-12-19 18:37:12 +0530143 to_consume = self.queue.pop(index)
144 consumed_bins.append(list(to_consume))
145
Ankush Menat4b29fb62021-12-18 18:40:22 +0530146 if not self.queue and qty:
147 # stock finished, qty still remains to be withdrawn
148 # negative stock, keep in as a negative bin
Ankush Menata00d8d02021-12-19 18:45:04 +0530149 self.queue.append([-qty, outgoing_rate or fifo_bin[RATE]])
150 consumed_bins.append([qty, outgoing_rate or fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530151 break
Ankush Menat4b29fb62021-12-18 18:40:22 +0530152 else:
153 # qty found in current bin consume it and exit
Ankush Menatb534fee2022-02-19 20:58:36 +0530154 fifo_bin[QTY] = round_off_if_near_zero(fifo_bin[QTY] - qty)
Ankush Menatdb1c0882021-12-19 18:37:12 +0530155 consumed_bins.append([qty, fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530156 qty = 0
157
Ankush Menatdb1c0882021-12-19 18:37:12 +0530158 return consumed_bins
Ankush Menat4b29fb62021-12-18 18:40:22 +0530159
160
Ankush Menatb8550302022-01-15 12:36:56 +0530161class LIFOValuation(BinWiseValuation):
162 """Valuation method where a *stack* of all the incoming stock is maintained.
163
164 New stock is added at top of the stack.
165 Qty consumption happens on Last In First Out basis.
166
167 Stack is implemented using "bins" of [qty, rate].
168
169 ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530170 Implementation detail: appends and pops both at end of list.
Ankush Menatb8550302022-01-15 12:36:56 +0530171 """
172
173 # specifying the attributes to save resources
174 # ref: https://docs.python.org/3/reference/datamodel.html#slots
Ankush Menat494bd9e2022-03-28 18:52:46 +0530175 __slots__ = [
176 "stack",
177 ]
Ankush Menatb8550302022-01-15 12:36:56 +0530178
179 def __init__(self, state: Optional[List[StockBin]]):
180 self.stack: List[StockBin] = state if state is not None else []
181
182 @property
183 def state(self) -> List[StockBin]:
184 """Get current state of stack."""
185 return self.stack
186
187 def add_stock(self, qty: float, rate: float) -> None:
188 """Update lifo stack with new stock.
189
Ankush Menat494bd9e2022-03-28 18:52:46 +0530190 args:
191 qty: new quantity to add
192 rate: incoming rate of new quantity.
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530193
Ankush Menat494bd9e2022-03-28 18:52:46 +0530194 Behaviour of this is same as FIFO valuation.
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530195 """
196 if not len(self.stack):
197 self.stack.append([0, 0])
198
199 # last row has the same rate, merge new bin.
200 if self.stack[-1][RATE] == rate:
201 self.stack[-1][QTY] += qty
202 else:
203 # Item has a positive balance qty, add new entry
204 if self.stack[-1][QTY] > 0:
205 self.stack.append([qty, rate])
206 else: # negative balance qty
207 qty = self.stack[-1][QTY] + qty
208 if qty > 0: # new balance qty is positive
209 self.stack[-1] = [qty, rate]
210 else: # new balance qty is still negative, maintain same rate
211 self.stack[-1][QTY] = qty
Ankush Menatb8550302022-01-15 12:36:56 +0530212
Ankush Menatb8550302022-01-15 12:36:56 +0530213 def remove_stock(
214 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
215 ) -> List[StockBin]:
216 """Remove stock from the stack and return popped bins.
217
218 args:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530219 qty: quantity to remove
220 rate: outgoing rate - ignored. Kept for backwards compatibility.
221 rate_generator: function to be called if stack is not found and rate is required.
Ankush Menatb8550302022-01-15 12:36:56 +0530222 """
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530223 if not rate_generator:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530224 rate_generator = lambda: 0.0 # noqa
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530225
226 consumed_bins = []
227 while qty:
228 if not len(self.stack):
229 # rely on rate generator.
230 self.stack.append([0, rate_generator()])
231
232 # start at the end.
233 index = -1
234
235 stock_bin = self.stack[index]
236 if qty >= stock_bin[QTY]:
237 # consume current bin
Ankush Menatb534fee2022-02-19 20:58:36 +0530238 qty = round_off_if_near_zero(qty - stock_bin[QTY])
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530239 to_consume = self.stack.pop(index)
240 consumed_bins.append(list(to_consume))
241
242 if not self.stack and qty:
243 # stock finished, qty still remains to be withdrawn
244 # negative stock, keep in as a negative bin
245 self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
246 consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
247 break
248 else:
249 # qty found in current bin consume it and exit
Ankush Menatb534fee2022-02-19 20:58:36 +0530250 stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530251 consumed_bins.append([qty, stock_bin[RATE]])
252 qty = 0
253
254 return consumed_bins
Ankush Menatb8550302022-01-15 12:36:56 +0530255
256
Ankush Menatb534fee2022-02-19 20:58:36 +0530257def round_off_if_near_zero(number: float, precision: int = 7) -> float:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530258 """Rounds off the number to zero only if number is close to zero for decimal
Ankush Menat1833f7a2021-12-18 19:37:41 +0530259 specified in precision. Precision defaults to 7.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530260 """
Ankush Menat494bd9e2022-03-28 18:52:46 +0530261 if abs(0.0 - flt(number)) < (1.0 / (10**precision)):
Ankush Menate6e679c2021-12-18 20:36:47 +0530262 return 0.0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530263
264 return flt(number)