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)")