blob: e2bd1ad4dfe1a884def8f968ce866a5ed49fe363 [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):
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
Ankush Menatb534fee2022-02-19 20:58:36 +053037 return round_off_if_near_zero(total_qty), round_off_if_near_zero(total_value)
Ankush Menatb8550302022-01-15 12:36:56 +053038
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
Ankush Menatb534fee2022-02-19 20:58:36 +0530139 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 Menatb534fee2022-02-19 20:58:36 +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
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530167 Implementation detail: appends and pops both at end of list.
Ankush Menatb8550302022-01-15 12:36:56 +0530168 """
169
170 # specifying the attributes to save resources
171 # ref: https://docs.python.org/3/reference/datamodel.html#slots
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530172 __slots__ = ["stack",]
Ankush Menatb8550302022-01-15 12:36:56 +0530173
174 def __init__(self, state: Optional[List[StockBin]]):
175 self.stack: List[StockBin] = state if state is not None else []
176
177 @property
178 def state(self) -> List[StockBin]:
179 """Get current state of stack."""
180 return self.stack
181
182 def add_stock(self, qty: float, rate: float) -> None:
183 """Update lifo stack with new stock.
184
185 args:
186 qty: new quantity to add
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530187 rate: incoming rate of new quantity.
188
189 Behaviour of this is same as FIFO valuation.
190 """
191 if not len(self.stack):
192 self.stack.append([0, 0])
193
194 # last row has the same rate, merge new bin.
195 if self.stack[-1][RATE] == rate:
196 self.stack[-1][QTY] += qty
197 else:
198 # Item has a positive balance qty, add new entry
199 if self.stack[-1][QTY] > 0:
200 self.stack.append([qty, rate])
201 else: # negative balance qty
202 qty = self.stack[-1][QTY] + qty
203 if qty > 0: # new balance qty is positive
204 self.stack[-1] = [qty, rate]
205 else: # new balance qty is still negative, maintain same rate
206 self.stack[-1][QTY] = qty
Ankush Menatb8550302022-01-15 12:36:56 +0530207
208
209 def remove_stock(
210 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
211 ) -> List[StockBin]:
212 """Remove stock from the stack and return popped bins.
213
214 args:
215 qty: quantity to remove
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530216 rate: outgoing rate - ignored. Kept for backwards compatibility.
Ankush Menatb8550302022-01-15 12:36:56 +0530217 rate_generator: function to be called if stack is not found and rate is required.
218 """
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530219 if not rate_generator:
220 rate_generator = lambda : 0.0 # noqa
221
222 consumed_bins = []
223 while qty:
224 if not len(self.stack):
225 # rely on rate generator.
226 self.stack.append([0, rate_generator()])
227
228 # start at the end.
229 index = -1
230
231 stock_bin = self.stack[index]
232 if qty >= stock_bin[QTY]:
233 # consume current bin
Ankush Menatb534fee2022-02-19 20:58:36 +0530234 qty = round_off_if_near_zero(qty - stock_bin[QTY])
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530235 to_consume = self.stack.pop(index)
236 consumed_bins.append(list(to_consume))
237
238 if not self.stack and qty:
239 # stock finished, qty still remains to be withdrawn
240 # negative stock, keep in as a negative bin
241 self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
242 consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
243 break
244 else:
245 # qty found in current bin consume it and exit
Ankush Menatb534fee2022-02-19 20:58:36 +0530246 stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530247 consumed_bins.append([qty, stock_bin[RATE]])
248 qty = 0
249
250 return consumed_bins
Ankush Menatb8550302022-01-15 12:36:56 +0530251
252
Ankush Menatb534fee2022-02-19 20:58:36 +0530253def round_off_if_near_zero(number: float, precision: int = 7) -> float:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530254 """Rounds off the number to zero only if number is close to zero for decimal
Ankush Menat1833f7a2021-12-18 19:37:41 +0530255 specified in precision. Precision defaults to 7.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530256 """
Ankush Menate6e679c2021-12-18 20:36:47 +0530257 if abs(0.0 - flt(number)) < (1.0 / (10 ** precision)):
258 return 0.0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530259
260 return flt(number)