from typing import List, Optional, Sequence from .dates_add_days import epoch_day_from_iso from .inventory_valuation_fifo_types import FifoValuation, StockLayer, StockMovement from .money_amount import Money, assert_same_currency, money def fifo_valuation(movements: Sequence[StockMovement], currency: str) -> FifoValuation: """Value stock first in, first out, and cost each issue from the oldest layers. Every cost is a whole quantity times a unit cost, so nothing is rounded and closing value plus cost of sales always equals what was received. """ zero = money(0, currency) # [date, quantity, unit cost]: mutable while issues consume them. layers: List[list] = [] issue_costs: List[Money] = [] cost_of_sales = 0 previous_day: Optional[int] = None previous_date = "" for line in movements: day = epoch_day_from_iso(line.date) if previous_day is not None and day < previous_day: raise ValueError("movements must be in date order: %s comes after %s" % (line.date, previous_date)) previous_day = day previous_date = line.date if isinstance(line.quantity, bool) or not isinstance(line.quantity, int) or line.quantity == 0: raise ValueError( "quantity must be a non-zero whole number, received %r on %s" % (line.quantity, line.date) ) if line.quantity > 0: if line.unit_cost is None: raise ValueError("a receipt needs a unitCost: %d on %s" % (line.quantity, line.date)) assert_same_currency(zero, line.unit_cost) if line.unit_cost.minor < 0: raise ValueError( "unitCost must not be negative, received %d on %s" % (line.unit_cost.minor, line.date) ) layers.append([line.date, line.quantity, line.unit_cost]) continue if line.unit_cost is not None: raise ValueError( "an issue takes its cost from stock, so its unitCost must be null: %d on %s" % (line.quantity, line.date) ) wanted = -line.quantity on_hand = sum(layer[1] for layer in layers) if wanted > on_hand: raise ValueError( "insufficient stock: an issue of %d on %s exceeds the %d on hand" % (wanted, line.date, on_hand) ) cost = 0 while wanted > 0: oldest = layers[0] used = min(wanted, oldest[1]) cost += used * oldest[2].minor oldest[1] -= used wanted -= used if oldest[1] == 0: layers.pop(0) issue_costs.append(money(cost, currency)) cost_of_sales += cost closing_quantity = sum(layer[1] for layer in layers) closing_value = sum(layer[1] * layer[2].minor for layer in layers) return FifoValuation( closing_quantity=closing_quantity, closing_value=money(closing_value, currency), cost_of_sales=money(cost_of_sales, currency), issue_costs=issue_costs, layers=[StockLayer(date=d, quantity=q, unit_cost=c) for d, q, c in layers], )