blob: b1df982c906621d109dcae9025c8f575098e9e90 [file] [log] [blame]
Ankush Menatb8550302022-01-15 12:36:56 +05301from abc import ABC, abstractmethod, abstractproperty
Akhil Narang3effaf22024-03-27 11:37:26 +05302from collections.abc import Callable
3from typing import NewType
Ankush Menat4b29fb62021-12-18 18:40:22 +05304
5from frappe.utils import flt
6
Akhil Narang3effaf22024-03-27 11:37:26 +05307StockBin = NewType("StockBin", list[float]) # [[qty, rate], ...]
Ankush Menat4b29fb62021-12-18 18:40:22 +05308
9# Indexes of values inside FIFO bin 2-tuple
10QTY = 0
11RATE = 1
12
13
Ankush Menatb8550302022-01-15 12:36:56 +053014class BinWiseValuation(ABC):
Ankush Menatb8550302022-01-15 12:36:56 +053015 @abstractmethod
16 def add_stock(self, qty: float, rate: float) -> None:
17 pass
18
19 @abstractmethod
20 def remove_stock(
Akhil Narang3effaf22024-03-27 11:37:26 +053021 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] | None = None
22 ) -> list[StockBin]:
Ankush Menatb8550302022-01-15 12:36:56 +053023 pass
24
25 @abstractproperty
Akhil Narang3effaf22024-03-27 11:37:26 +053026 def state(self) -> list[StockBin]:
Ankush Menatb8550302022-01-15 12:36:56 +053027 pass
28
Akhil Narang3effaf22024-03-27 11:37:26 +053029 def get_total_stock_and_value(self) -> tuple[float, float]:
Ankush Menatb8550302022-01-15 12:36:56 +053030 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
Ankush Menat12c01e22022-04-04 15:22:15 +053064 __slots__ = ["queue"]
Ankush Menat745caf92021-12-19 19:08:09 +053065
Akhil Narang3effaf22024-03-27 11:37:26 +053066 def __init__(self, state: list[StockBin] | None):
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
Akhil Narang3effaf22024-03-27 11:37:26 +053070 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
Ankush Menat494bd9e2022-03-28 18:52:46 +053077 args:
78 qty: new quantity to add
79 rate: incoming rate of new quantity"""
Ankush Menat4b29fb62021-12-18 18:40:22 +053080
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(
Akhil Narang3effaf22024-03-27 11:37:26 +053099 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] | None = None
100 ) -> 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:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530104 qty: quantity to remove
105 rate: outgoing rate
106 rate_generator: function to be called if queue is not found and rate is required.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530107 """
Ankush Menata00d8d02021-12-19 18:45:04 +0530108 if not rate_generator:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530109 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 Menat12c01e22022-04-04 15:22:15 +0530125 # If no entry found with outgoing rate, consume as per FIFO
Ankush Menat4b29fb62021-12-18 18:40:22 +0530126 if index is None: # nosemgrep
Ankush Menat12c01e22022-04-04 15:22:15 +0530127 index = 0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530128 else:
129 index = 0
130
131 # select first bin or the bin with same rate
132 fifo_bin = self.queue[index]
133 if qty >= fifo_bin[QTY]:
134 # consume current bin
Ankush Menatb534fee2022-02-19 20:58:36 +0530135 qty = round_off_if_near_zero(qty - fifo_bin[QTY])
Ankush Menatdb1c0882021-12-19 18:37:12 +0530136 to_consume = self.queue.pop(index)
137 consumed_bins.append(list(to_consume))
138
Ankush Menat4b29fb62021-12-18 18:40:22 +0530139 if not self.queue and qty:
140 # stock finished, qty still remains to be withdrawn
141 # negative stock, keep in as a negative bin
Ankush Menata00d8d02021-12-19 18:45:04 +0530142 self.queue.append([-qty, outgoing_rate or fifo_bin[RATE]])
143 consumed_bins.append([qty, outgoing_rate or fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530144 break
Ankush Menat4b29fb62021-12-18 18:40:22 +0530145 else:
146 # qty found in current bin consume it and exit
Ankush Menatb534fee2022-02-19 20:58:36 +0530147 fifo_bin[QTY] = round_off_if_near_zero(fifo_bin[QTY] - qty)
Ankush Menatdb1c0882021-12-19 18:37:12 +0530148 consumed_bins.append([qty, fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530149 qty = 0
150
Ankush Menatdb1c0882021-12-19 18:37:12 +0530151 return consumed_bins
Ankush Menat4b29fb62021-12-18 18:40:22 +0530152
153
Ankush Menatb8550302022-01-15 12:36:56 +0530154class LIFOValuation(BinWiseValuation):
155 """Valuation method where a *stack* of all the incoming stock is maintained.
156
157 New stock is added at top of the stack.
158 Qty consumption happens on Last In First Out basis.
159
160 Stack is implemented using "bins" of [qty, rate].
161
162 ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530163 Implementation detail: appends and pops both at end of list.
Ankush Menatb8550302022-01-15 12:36:56 +0530164 """
165
166 # specifying the attributes to save resources
167 # ref: https://docs.python.org/3/reference/datamodel.html#slots
Ankush Menat12c01e22022-04-04 15:22:15 +0530168 __slots__ = ["stack"]
Ankush Menatb8550302022-01-15 12:36:56 +0530169
Akhil Narang3effaf22024-03-27 11:37:26 +0530170 def __init__(self, state: list[StockBin] | None):
171 self.stack: list[StockBin] = state if state is not None else []
Ankush Menatb8550302022-01-15 12:36:56 +0530172
173 @property
Akhil Narang3effaf22024-03-27 11:37:26 +0530174 def state(self) -> list[StockBin]:
Ankush Menatb8550302022-01-15 12:36:56 +0530175 """Get current state of stack."""
176 return self.stack
177
178 def add_stock(self, qty: float, rate: float) -> None:
179 """Update lifo stack with new stock.
180
Ankush Menat494bd9e2022-03-28 18:52:46 +0530181 args:
182 qty: new quantity to add
183 rate: incoming rate of new quantity.
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530184
Ankush Menat494bd9e2022-03-28 18:52:46 +0530185 Behaviour of this is same as FIFO valuation.
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530186 """
187 if not len(self.stack):
188 self.stack.append([0, 0])
189
190 # last row has the same rate, merge new bin.
191 if self.stack[-1][RATE] == rate:
192 self.stack[-1][QTY] += qty
193 else:
194 # Item has a positive balance qty, add new entry
195 if self.stack[-1][QTY] > 0:
196 self.stack.append([qty, rate])
197 else: # negative balance qty
198 qty = self.stack[-1][QTY] + qty
199 if qty > 0: # new balance qty is positive
200 self.stack[-1] = [qty, rate]
201 else: # new balance qty is still negative, maintain same rate
202 self.stack[-1][QTY] = qty
Ankush Menatb8550302022-01-15 12:36:56 +0530203
Ankush Menatb8550302022-01-15 12:36:56 +0530204 def remove_stock(
Akhil Narang3effaf22024-03-27 11:37:26 +0530205 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] | None = None
206 ) -> list[StockBin]:
Ankush Menatb8550302022-01-15 12:36:56 +0530207 """Remove stock from the stack and return popped bins.
208
209 args:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530210 qty: quantity to remove
211 rate: outgoing rate - ignored. Kept for backwards compatibility.
212 rate_generator: function to be called if stack is not found and rate is required.
Ankush Menatb8550302022-01-15 12:36:56 +0530213 """
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530214 if not rate_generator:
Ankush Menat494bd9e2022-03-28 18:52:46 +0530215 rate_generator = lambda: 0.0 # noqa
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530216
217 consumed_bins = []
218 while qty:
219 if not len(self.stack):
220 # rely on rate generator.
221 self.stack.append([0, rate_generator()])
222
223 # start at the end.
224 index = -1
225
226 stock_bin = self.stack[index]
227 if qty >= stock_bin[QTY]:
228 # consume current bin
Ankush Menatb534fee2022-02-19 20:58:36 +0530229 qty = round_off_if_near_zero(qty - stock_bin[QTY])
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530230 to_consume = self.stack.pop(index)
231 consumed_bins.append(list(to_consume))
232
233 if not self.stack and qty:
234 # stock finished, qty still remains to be withdrawn
235 # negative stock, keep in as a negative bin
236 self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
237 consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
238 break
239 else:
240 # qty found in current bin consume it and exit
Ankush Menatb534fee2022-02-19 20:58:36 +0530241 stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty)
Ankush Menat9c49d2d2022-01-15 12:52:10 +0530242 consumed_bins.append([qty, stock_bin[RATE]])
243 qty = 0
244
245 return consumed_bins
Ankush Menatb8550302022-01-15 12:36:56 +0530246
247
Ankush Menatb534fee2022-02-19 20:58:36 +0530248def round_off_if_near_zero(number: float, precision: int = 7) -> float:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530249 """Rounds off the number to zero only if number is close to zero for decimal
Ankush Menat1833f7a2021-12-18 19:37:41 +0530250 specified in precision. Precision defaults to 7.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530251 """
Ankush Menat494bd9e2022-03-28 18:52:46 +0530252 if abs(0.0 - flt(number)) < (1.0 / (10**precision)):
Ankush Menate6e679c2021-12-18 20:36:47 +0530253 return 0.0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530254
255 return flt(number)