blob: 35f4f12235d197fb4fbc3f7f907e716ed0b6d112 [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 Menat12c01e22022-04-04 15:22:15 +053063 __slots__ = ["queue"]
Ankush Menat745caf92021-12-19 19:08:09 +053064
Ankush Menatb8550302022-01-15 12:36:56 +053065 def __init__(self, state: Optional[List[StockBin]]):
66 self.queue: List[StockBin] = state if state is not None else []
Ankush Menat4b29fb62021-12-18 18:40:22 +053067
Ankush Menatb8550302022-01-15 12:36:56 +053068 @property
69 def state(self) -> List[StockBin]:
Ankush Menat4b29fb62021-12-18 18:40:22 +053070 """Get current state of queue."""
71 return self.queue
72
Ankush Menatdb1c0882021-12-19 18:37:12 +053073 def add_stock(self, qty: float, rate: float) -> None:
74 """Update fifo queue with new stock.
Ankush Menat4b29fb62021-12-18 18:40:22 +053075
Ankush Menat494bd9e2022-03-28 18:52:46 +053076 args:
77 qty: new quantity to add
78 rate: incoming rate of new quantity"""
Ankush Menat4b29fb62021-12-18 18:40:22 +053079
80 if not len(self.queue):
81 self.queue.append([0, 0])
82
83 # last row has the same rate, merge new bin.
84 if self.queue[-1][RATE] == rate:
85 self.queue[-1][QTY] += qty
86 else:
87 # Item has a positive balance qty, add new entry
88 if self.queue[-1][QTY] > 0:
89 self.queue.append([qty, rate])
90 else: # negative balance qty
91 qty = self.queue[-1][QTY] + qty
92 if qty > 0: # new balance qty is positive
93 self.queue[-1] = [qty, rate]
94 else: # new balance qty is still negative, maintain same rate
95 self.queue[-1][QTY] = qty
Ankush Menat4b29fb62021-12-18 18:40:22 +053096
97 def remove_stock(
Ankush Menata00d8d02021-12-19 18:45:04 +053098 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
Ankush Menatb8550302022-01-15 12:36:56 +053099 ) -> List[StockBin]:
Ankush Menatdb1c0882021-12-19 18:37:12 +0530100 """Remove stock from the queue and return popped bins.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530101
102 args:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530103 qty: quantity to remove
104 rate: outgoing rate
105 rate_generator: function to be called if queue is not found and rate is required.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530106 """
Ankush Menata00d8d02021-12-19 18:45:04 +0530107 if not rate_generator:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530108 rate_generator = lambda: 0.0 # noqa
Ankush Menat4b29fb62021-12-18 18:40:22 +0530109
Ankush Menatdb1c0882021-12-19 18:37:12 +0530110 consumed_bins = []
Ankush Menat4b29fb62021-12-18 18:40:22 +0530111 while qty:
112 if not len(self.queue):
113 # rely on rate generator.
114 self.queue.append([0, rate_generator()])
115
116 index = None
Ankush Menata00d8d02021-12-19 18:45:04 +0530117 if outgoing_rate > 0:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530118 # Find the entry where rate matched with outgoing rate
119 for idx, fifo_bin in enumerate(self.queue):
Ankush Menata00d8d02021-12-19 18:45:04 +0530120 if fifo_bin[RATE] == outgoing_rate:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530121 index = idx
122 break
123
Ankush Menat12c01e22022-04-04 15:22:15 +0530124 # If no entry found with outgoing rate, consume as per FIFO
Ankush Menat4b29fb62021-12-18 18:40:22 +0530125 if index is None: # nosemgrep
Ankush Menat12c01e22022-04-04 15:22:15 +0530126 index = 0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530127 else:
128 index = 0
129
130 # select first bin or the bin with same rate
131 fifo_bin = self.queue[index]
132 if qty >= fifo_bin[QTY]:
133 # consume current bin
Ankush Menatb534fee2022-02-19 20:58:36 +0530134 qty = round_off_if_near_zero(qty - fifo_bin[QTY])
Ankush Menatdb1c0882021-12-19 18:37:12 +0530135 to_consume = self.queue.pop(index)
136 consumed_bins.append(list(to_consume))
137
Ankush Menat4b29fb62021-12-18 18:40:22 +0530138 if not self.queue and qty:
139 # stock finished, qty still remains to be withdrawn
140 # negative stock, keep in as a negative bin
Ankush Menata00d8d02021-12-19 18:45:04 +0530141 self.queue.append([-qty, outgoing_rate or fifo_bin[RATE]])
142 consumed_bins.append([qty, outgoing_rate or fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530143 break
Ankush Menat4b29fb62021-12-18 18:40:22 +0530144 else:
145 # qty found in current bin consume it and exit
Ankush Menatb534fee2022-02-19 20:58:36 +0530146 fifo_bin[QTY] = round_off_if_near_zero(fifo_bin[QTY] - qty)
Ankush Menatdb1c0882021-12-19 18:37:12 +0530147 consumed_bins.append([qty, fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530148 qty = 0
149
Ankush Menatdb1c0882021-12-19 18:37:12 +0530150 return consumed_bins
Ankush Menat4b29fb62021-12-18 18:40:22 +0530151
152
Ankush Menatb8550302022-01-15 12:36:56 +0530153class LIFOValuation(BinWiseValuation):
154 """Valuation method where a *stack* of all the incoming stock is maintained.
155
156 New stock is added at top of the stack.
157 Qty consumption happens on Last In First Out basis.
158
159 Stack is implemented using "bins" of [qty, rate].
160
161 ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530162 Implementation detail: appends and pops both at end of list.
Ankush Menatb8550302022-01-15 12:36:56 +0530163 """
164
165 # specifying the attributes to save resources
166 # ref: https://docs.python.org/3/reference/datamodel.html#slots
Ankush Menat12c01e22022-04-04 15:22:15 +0530167 __slots__ = ["stack"]
Ankush Menatb8550302022-01-15 12:36:56 +0530168
169 def __init__(self, state: Optional[List[StockBin]]):
170 self.stack: List[StockBin] = state if state is not None else []
171
172 @property
173 def state(self) -> List[StockBin]:
174 """Get current state of stack."""
175 return self.stack
176
177 def add_stock(self, qty: float, rate: float) -> None:
178 """Update lifo stack with new stock.
179
Ankush Menat494bd9e2022-03-28 18:52:46 +0530180 args:
181 qty: new quantity to add
182 rate: incoming rate of new quantity.
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530183
Ankush Menat494bd9e2022-03-28 18:52:46 +0530184 Behaviour of this is same as FIFO valuation.
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530185 """
186 if not len(self.stack):
187 self.stack.append([0, 0])
188
189 # last row has the same rate, merge new bin.
190 if self.stack[-1][RATE] == rate:
191 self.stack[-1][QTY] += qty
192 else:
193 # Item has a positive balance qty, add new entry
194 if self.stack[-1][QTY] > 0:
195 self.stack.append([qty, rate])
196 else: # negative balance qty
197 qty = self.stack[-1][QTY] + qty
198 if qty > 0: # new balance qty is positive
199 self.stack[-1] = [qty, rate]
200 else: # new balance qty is still negative, maintain same rate
201 self.stack[-1][QTY] = qty
Ankush Menatb8550302022-01-15 12:36:56 +0530202
Ankush Menatb8550302022-01-15 12:36:56 +0530203 def remove_stock(
204 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
205 ) -> List[StockBin]:
206 """Remove stock from the stack and return popped bins.
207
208 args:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530209 qty: quantity to remove
210 rate: outgoing rate - ignored. Kept for backwards compatibility.
211 rate_generator: function to be called if stack is not found and rate is required.
Ankush Menatb8550302022-01-15 12:36:56 +0530212 """
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530213 if not rate_generator:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530214 rate_generator = lambda: 0.0 # noqa
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530215
216 consumed_bins = []
217 while qty:
218 if not len(self.stack):
219 # rely on rate generator.
220 self.stack.append([0, rate_generator()])
221
222 # start at the end.
223 index = -1
224
225 stock_bin = self.stack[index]
226 if qty >= stock_bin[QTY]:
227 # consume current bin
Ankush Menatb534fee2022-02-19 20:58:36 +0530228 qty = round_off_if_near_zero(qty - stock_bin[QTY])
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530229 to_consume = self.stack.pop(index)
230 consumed_bins.append(list(to_consume))
231
232 if not self.stack and qty:
233 # stock finished, qty still remains to be withdrawn
234 # negative stock, keep in as a negative bin
235 self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
236 consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
237 break
238 else:
239 # qty found in current bin consume it and exit
Ankush Menatb534fee2022-02-19 20:58:36 +0530240 stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530241 consumed_bins.append([qty, stock_bin[RATE]])
242 qty = 0
243
244 return consumed_bins
Ankush Menatb8550302022-01-15 12:36:56 +0530245
246
Ankush Menatb534fee2022-02-19 20:58:36 +0530247def round_off_if_near_zero(number: float, precision: int = 7) -> float:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530248 """Rounds off the number to zero only if number is close to zero for decimal
Ankush Menat1833f7a2021-12-18 19:37:41 +0530249 specified in precision. Precision defaults to 7.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530250 """
Ankush Menat494bd9e2022-03-28 18:52:46 +0530251 if abs(0.0 - flt(number)) < (1.0 / (10**precision)):
Ankush Menate6e679c2021-12-18 20:36:47 +0530252 return 0.0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530253
254 return flt(number)