blob: 45c5083099536a05f4699ffd5aa96890fb9be238 [file] [log] [blame]
Ankush Menat4b29fb62021-12-18 18:40:22 +05301from typing import Callable, List, NewType, Optional, Tuple
2
3from frappe.utils import flt
4
5FifoBin = NewType("FifoBin", List[float])
6
7# Indexes of values inside FIFO bin 2-tuple
8QTY = 0
9RATE = 1
10
11
Ankush Menat107b4042021-12-19 20:47:08 +053012class FIFOValuation:
Ankush Menat4b29fb62021-12-18 18:40:22 +053013 """Valuation method where a queue of all the incoming stock is maintained.
14
15 New stock is added at end of the queue.
16 Qty consumption happens on First In First Out basis.
17
18 Queue is implemented using "bins" of [qty, rate].
19
20 ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
21 """
22
Ankush Menat745caf92021-12-19 19:08:09 +053023 # specifying the attributes to save resources
24 # ref: https://docs.python.org/3/reference/datamodel.html#slots
25 __slots__ = ["queue",]
26
Ankush Menat4b29fb62021-12-18 18:40:22 +053027 def __init__(self, state: Optional[List[FifoBin]]):
28 self.queue: List[FifoBin] = state if state is not None else []
29
Ankush Menata71b4762021-12-18 19:33:58 +053030 def __repr__(self):
31 return str(self.queue)
32
33 def __iter__(self):
34 return iter(self.queue)
35
36 def __eq__(self, other):
37 if isinstance(other, list):
38 return self.queue == other
39 return self.queue == other.queue
40
Ankush Menat4b29fb62021-12-18 18:40:22 +053041 def get_state(self) -> List[FifoBin]:
42 """Get current state of queue."""
43 return self.queue
44
45 def get_total_stock_and_value(self) -> Tuple[float, float]:
46 total_qty = 0.0
47 total_value = 0.0
48
49 for qty, rate in self.queue:
50 total_qty += flt(qty)
51 total_value += flt(qty) * flt(rate)
52
53 return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
54
Ankush Menatdb1c0882021-12-19 18:37:12 +053055 def add_stock(self, qty: float, rate: float) -> None:
56 """Update fifo queue with new stock.
Ankush Menat4b29fb62021-12-18 18:40:22 +053057
58 args:
59 qty: new quantity to add
60 rate: incoming rate of new quantity"""
61
62 if not len(self.queue):
63 self.queue.append([0, 0])
64
65 # last row has the same rate, merge new bin.
66 if self.queue[-1][RATE] == rate:
67 self.queue[-1][QTY] += qty
68 else:
69 # Item has a positive balance qty, add new entry
70 if self.queue[-1][QTY] > 0:
71 self.queue.append([qty, rate])
72 else: # negative balance qty
73 qty = self.queue[-1][QTY] + qty
74 if qty > 0: # new balance qty is positive
75 self.queue[-1] = [qty, rate]
76 else: # new balance qty is still negative, maintain same rate
77 self.queue[-1][QTY] = qty
Ankush Menat4b29fb62021-12-18 18:40:22 +053078
79 def remove_stock(
Ankush Menata00d8d02021-12-19 18:45:04 +053080 self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
Ankush Menat4b29fb62021-12-18 18:40:22 +053081 ) -> List[FifoBin]:
Ankush Menatdb1c0882021-12-19 18:37:12 +053082 """Remove stock from the queue and return popped bins.
Ankush Menat4b29fb62021-12-18 18:40:22 +053083
84 args:
85 qty: quantity to remove
86 rate: outgoing rate
87 rate_generator: function to be called if queue is not found and rate is required.
88 """
Ankush Menata00d8d02021-12-19 18:45:04 +053089 if not rate_generator:
90 rate_generator = lambda : 0.0 # noqa
Ankush Menat4b29fb62021-12-18 18:40:22 +053091
Ankush Menatdb1c0882021-12-19 18:37:12 +053092 consumed_bins = []
Ankush Menat4b29fb62021-12-18 18:40:22 +053093 while qty:
94 if not len(self.queue):
95 # rely on rate generator.
96 self.queue.append([0, rate_generator()])
97
98 index = None
Ankush Menata00d8d02021-12-19 18:45:04 +053099 if outgoing_rate > 0:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530100 # Find the entry where rate matched with outgoing rate
101 for idx, fifo_bin in enumerate(self.queue):
Ankush Menata00d8d02021-12-19 18:45:04 +0530102 if fifo_bin[RATE] == outgoing_rate:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530103 index = idx
104 break
105
Ankush Menat9d177432021-12-19 18:51:55 +0530106 # If no entry found with outgoing rate, collapse queue
Ankush Menat4b29fb62021-12-18 18:40:22 +0530107 if index is None: # nosemgrep
Ankush Menata00d8d02021-12-19 18:45:04 +0530108 new_stock_value = sum(d[QTY] * d[RATE] for d in self.queue) - qty * outgoing_rate
Ankush Menat4b29fb62021-12-18 18:40:22 +0530109 new_stock_qty = sum(d[QTY] for d in self.queue) - qty
Ankush Menata00d8d02021-12-19 18:45:04 +0530110 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 +0530111 consumed_bins.append([qty, outgoing_rate])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530112 break
113 else:
114 index = 0
115
116 # select first bin or the bin with same rate
117 fifo_bin = self.queue[index]
118 if qty >= fifo_bin[QTY]:
119 # consume current bin
120 qty = _round_off_if_near_zero(qty - fifo_bin[QTY])
Ankush Menatdb1c0882021-12-19 18:37:12 +0530121 to_consume = self.queue.pop(index)
122 consumed_bins.append(list(to_consume))
123
Ankush Menat4b29fb62021-12-18 18:40:22 +0530124 if not self.queue and qty:
125 # stock finished, qty still remains to be withdrawn
126 # negative stock, keep in as a negative bin
Ankush Menata00d8d02021-12-19 18:45:04 +0530127 self.queue.append([-qty, outgoing_rate or fifo_bin[RATE]])
128 consumed_bins.append([qty, outgoing_rate or fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530129 break
Ankush Menat4b29fb62021-12-18 18:40:22 +0530130 else:
131 # qty found in current bin consume it and exit
Ankush Menate6e679c2021-12-18 20:36:47 +0530132 fifo_bin[QTY] = _round_off_if_near_zero(fifo_bin[QTY] - qty)
Ankush Menatdb1c0882021-12-19 18:37:12 +0530133 consumed_bins.append([qty, fifo_bin[RATE]])
Ankush Menat4b29fb62021-12-18 18:40:22 +0530134 qty = 0
135
Ankush Menatdb1c0882021-12-19 18:37:12 +0530136 return consumed_bins
Ankush Menat4b29fb62021-12-18 18:40:22 +0530137
138
Ankush Menate6e679c2021-12-18 20:36:47 +0530139def _round_off_if_near_zero(number: float, precision: int = 7) -> float:
Ankush Menat4b29fb62021-12-18 18:40:22 +0530140 """Rounds off the number to zero only if number is close to zero for decimal
Ankush Menat1833f7a2021-12-18 19:37:41 +0530141 specified in precision. Precision defaults to 7.
Ankush Menat4b29fb62021-12-18 18:40:22 +0530142 """
Ankush Menate6e679c2021-12-18 20:36:47 +0530143 if abs(0.0 - flt(number)) < (1.0 / (10 ** precision)):
144 return 0.0
Ankush Menat4b29fb62021-12-18 18:40:22 +0530145
146 return flt(number)