dodil/erp-brick-inventory

DODIL ERP brick: inventory

src/brick_inventory/domain/valuation.py

"""Stock value: average cost or standard cost, appended per move, never a stored number somebody edits.

A valuation row is the state of ONE product in ONE company after ONE move: its quantity, its value, its average. The
value in the books is the sum of the moves' values, so a product at zero quantity is at zero value EXACTLY: the move that
empties the product takes the remaining value, never quantity x average, so no residue is left.

Under standard cost, value_after = round_half_up(quantity_after x standard, 2) and each move's value is the change in
value_after; the difference between what a receipt cost and the value it added is a price difference.
"""
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional

from brick_inventory.domain.values import ZERO, money, q4, round_half_up


@dataclass(frozen=True)
class Position:
    """A product's stock in a company after the last move: quantity, value, and the average it implies."""
    quantity: Decimal = ZERO
    value: Decimal = ZERO
    average: Decimal = ZERO
    seq: int = 0

    def as_row_fields(self) -> dict:
        return {"quantity_after": self.quantity, "value_after": self.value, "average_cost_after": self.average}


@dataclass(frozen=True)
class Step:
    """What one move did: its value (signed), the position after, a price difference under standard cost."""
    value_change: Decimal
    after: Position
    price_difference: Decimal = ZERO


def average_step(prev: Position, quantity_change: Decimal, *, value_in: Optional[Decimal] = None,
                 value_out: Optional[Decimal] = None) -> Step:
    """One move at average cost.

    In (quantity_change > 0): worth `value_in` when the move brings its own cost (a receipt, a return at the cost it
    left, an opening balance), else at the average (a found item). Out: worth `value_out` when the move names the cost it
    reverses (a return to the supplier), else quantity x the average. A move that empties the product takes the remaining
    value; a move out of a product at or below zero is worth quantity x the last average (negative stock)."""
    after_qty = prev.quantity + quantity_change
    if after_qty == 0:
        change = -prev.value                    # the value is zero at zero quantity, whatever brought it there
    elif quantity_change > 0:
        change = value_in if value_in is not None else money(quantity_change * prev.average)
    elif value_out is not None:
        change = -money(value_out)
    elif prev.quantity <= 0:
        change = -money(-quantity_change * prev.average)
    else:
        change = -money(-quantity_change * prev.average)
    after_value = prev.value + change
    if after_qty > 0:
        average = q4(after_value / after_qty)
    else:
        average = prev.average
    return Step(change, Position(after_qty, after_value, average, prev.seq + 1))


def standard_step(prev: Position, quantity_change: Decimal, standard: Decimal, *,
                  purchase_value: Optional[Decimal] = None, reversal_value: Optional[Decimal] = None) -> Step:
    """One move at standard cost: value_after = round_half_up(quantity_after x standard, 2); the move's value is the change.
    A receipt brought `purchase_value` (what purchasing paid): the difference to the value it added is a price difference."""
    after_qty = prev.quantity + quantity_change
    if reversal_value is not None and quantity_change > 0 and after_qty != 0:
        # a reversal puts back what the move it reverses took out: the POSTED value, not today's standard
        return Step(reversal_value, Position(after_qty, prev.value + reversal_value, standard, prev.seq + 1))
    after_value = round_half_up(after_qty * standard, 2) if after_qty != 0 else ZERO
    change = after_value - prev.value
    diff = ZERO
    if purchase_value is not None and quantity_change > 0:
        diff = purchase_value - change
    return Step(change, Position(after_qty, after_value, standard, prev.seq + 1), diff)


def revalue(prev: Position, standard: Decimal) -> Step:
    """The quantity on hand at a new standard price: a revaluation row, quantity unchanged."""
    after_value = round_half_up(prev.quantity * standard, 2) if prev.quantity != 0 else ZERO
    return Step(after_value - prev.value, Position(prev.quantity, after_value, standard, prev.seq + 1))


def switch_to_standard(prev: Position, standard: Decimal) -> Step:
    return revalue(prev, standard)


def switch_to_average(prev: Position) -> Step:
    """From standard back to average: the value stands; the average is what it implies."""
    avg = q4(prev.value / prev.quantity) if prev.quantity > 0 else prev.average
    return Step(ZERO, Position(prev.quantity, prev.value, avg, prev.seq + 1))