dodil/erp-brick-purchasing

DODIL ERP brick: purchasing

src/brick_purchasing/domain/receipt.py

"""A goods receipt: what actually turned up, or went back, and when (PUR-07, PUR-25, PUR-26).

A receipt is a FACT and is never edited: a wrong receipt is corrected by a return row, a missing one by another receipt. It is dated by
the delivery (today or earlier, never later). Received-ness is derived by summing receipt lines; nothing is a stored flag."""
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from fractions import Fraction
from typing import Optional

from brick_purchasing.domain.errors import Invalid, RuleBroken
from brick_purchasing.domain.values import ZERO, as_dec, fr


@dataclass
class ReceiptLine:
    receipt_id: str
    line_no: int
    order_line_no: int = 0
    product_id: str = ""
    quantity: Decimal = ZERO                 # negative on a return
    unit_code: str = ""
    quantity_base: Decimal = ZERO
    location_id: str = ""
    move_ref: str = ""
    stock_move_id: str = ""
    handed_over_at: Optional[datetime] = None
    handover_note: str = ""                  # why the hand-over is pending or refused, in inventory's words
    reverses_line_no: Optional[int] = None         # a return line: the line of the receipt it reverses
    catch_quantity: Optional[Decimal] = None       # RESERVED until M7 (catch weight): NULL
    catch_unit_code: Optional[str] = None


@dataclass
class GoodsReceipt:
    receipt_id: str
    kind: str = "receipt"                    # receipt | return
    purchase_order_id: str = ""
    delivery_note: str = ""
    received_on: Optional[date] = None
    received_by_email: str = ""
    received_by_sub: str = ""
    location_id: str = ""
    reason: str = ""
    corrects_receipt_id: str = ""
    status: str = "recorded"                 # recorded | handed_over
    bp_id: int = 0
    lines: list = field(default_factory=list, repr=False)
    events: list = field(default_factory=list, repr=False)

    def line(self, n: int) -> Optional[ReceiptLine]:
        return next((x for x in self.lines if x.line_no == int(n)), None)

    def settle_status(self) -> None:
        """handed_over once every line has been handed to inventory (a move id) or deliberately kept without stock."""
        if self.lines and all(x.handed_over_at is not None for x in self.lines):
            self.status = "handed_over"


def receipt_id(purchase_order_id: str, delivery_note: str) -> str:
    return f"rcpt:{purchase_order_id}:{delivery_note}"


def return_id(rid: str, seq: int) -> str:
    return f"ret:{rid}:{seq}"


def check_catch(line: dict) -> None:
    """Catch weight arrives in M7 (inventory deferred.catch_weight): a receipt carrying it is a 422 until then."""
    if line.get("catch_quantity") not in (None, "") or line.get("catch_unit_code") not in (None, ""):
        raise Invalid("catch weight arrives in M7: catch_quantity and catch_unit_code are not accepted yet")


def received_base(rows: list) -> dict:
    """{order line_no: Fraction received in the base unit}: receipts less returns, derived from the receipt lines."""
    out = {}
    for r in rows:
        out[r["order_line_no"]] = out.get(r["order_line_no"], Fraction(0)) + fr(r["quantity_base"])
    return out


def check_return(received_on_line: Fraction, already_returned: Fraction, asked: Fraction, line_no: int) -> None:
    if asked <= 0:
        raise RuleBroken(f"within_received: a return of line {line_no} is above zero")
    if asked > received_on_line - already_returned:
        raise RuleBroken(f"within_received: line {line_no}: {as_dec(asked)} is more than was received on that delivery "
                         f"({as_dec(received_on_line - already_returned)} left to return)")