dodil/erp-brick-purchasing
DODIL ERP brick: purchasing
src/brick_purchasing/application/consignment.py
"""Consignment withdrawals (PUR-54): inventory tells purchasing what supplier-owned stock we took into our own, at the supplier's consignment price
on the day. A row keyed on the move (a retry records nothing new), answered {withdrawal_id, unit_price, amount}; a reversal is a negative row; the
supplier's invoice line (withdrawal_id) is matched two-way against it and settles it at approval."""
from decimal import Decimal
from typing import Optional
from erp_platform.bricks import Refused
from erp_platform.types import BigId, IsoDate
from brick_purchasing.application import base
from brick_purchasing.application.base import BILL_READ, CONSIGN_INTAKE, PURCHASING, _decide, _today, unit
from brick_purchasing.application.inputs import Code, Qty, Ref
from brick_purchasing.application.master import price_in_force
from brick_purchasing.domain.errors import RuleBroken
from brick_purchasing.domain.values import money_round
repository = lambda: base.repository() # noqa: E731
def _view(w: dict) -> dict:
return {**w, "bp_id": str(w["bp_id"])}
@PURCHASING.use_case(kind="command", route="POST /consignment-withdrawals", permission=CONSIGN_INTAKE, idempotent=True)
def record_consignment_withdrawal(caller: dict, move_ref: Ref, bp_id: BigId, product_id: Code, quantity: Qty, unit_code: Code, withdrawn_on: IsoDate,
reverses_move_ref: Optional[Ref] = None) -> dict:
"""Record stock inventory moved from the supplier's consignment into our own: priced at the consignment price in force on the day it was taken
(a product with none is refused 409 and the stock stays the supplier's). The date is inventory's fact, today or earlier. The same move_ref
answers the same row. A reversal (reverses_move_ref) is a negative row of the original's amount."""
def run():
with repository() as repo, _decide():
wid = f"wd:{move_ref}"
have = repo.withdrawals.get(wid)
if have:
return {**_view(have), "replay": True}
day = base._day(withdrawn_on, "withdrawn_on")
if day > _today(repo):
raise RuleBroken(f"not_future: stock cannot be taken on {day}, after today")
if reverses_move_ref:
orig = repo.withdrawals.get(f"wd:{reverses_move_ref}")
if orig is None or orig["amount"] < 0:
raise Refused(404, f"no withdrawal for move {reverses_move_ref} to reverse")
if repo.withdrawals.get(f"wd:rev:{reverses_move_ref}") is not None and repo.withdrawals.get(f"wd:rev:{reverses_move_ref}")["move_ref"] != move_ref:
raise RuleBroken("that withdrawal is already reversed")
row = {**orig, "withdrawal_id": wid, "quantity": -orig["quantity"], "amount": -orig["amount"], "status": "open", "settled_bill_id": "",
"reverses_withdrawal_id": orig["withdrawal_id"], "move_ref": move_ref, "withdrawn_on": day}
repo.withdrawals.put(row)
repo.withdrawals.put({**row, "withdrawal_id": f"wd:rev:{reverses_move_ref}"})
return {**_view(row), "replay": False}
p = price_in_force(repo, int(bp_id), product_id, unit_code, quantity, day, consignment=True)
if p is None:
raise RuleBroken(f"consignment_price: {product_id} has no consignment price from supplier {bp_id} in force on {day}; the stock stays the supplier's; fix: record the price")
amount = money_round(Decimal(quantity) * Decimal(p["unit_price"]) / Decimal(p.get("pricing_quantity") or 1), p.get("currency") or "GBP")
row = {"withdrawal_id": wid, "bp_id": int(bp_id), "product_id": product_id, "quantity": quantity, "unit_code": unit_code, "withdrawn_on": day,
"unit_price": p["unit_price"], "pricing_unit_code": p.get("pricing_unit_code") or unit_code, "pricing_quantity": p.get("pricing_quantity") or 1,
"amount": amount, "currency": p.get("currency") or "GBP", "status": "open", "settled_bill_id": "", "reverses_withdrawal_id": "", "move_ref": move_ref}
repo.withdrawals.put(row)
return {**_view(row), "replay": False}
return unit(None, run)
@PURCHASING.use_case(kind="query", route="GET /consignment-withdrawals", permission=BILL_READ)
def list_consignment_withdrawals(caller: dict, bp_id: Optional[BigId] = None, open_only: bool = True) -> dict:
"""The supplier's withdrawals waiting to be billed (or settled ones with open_only false)."""
with repository() as repo:
rows = [r for r in (repo.withdrawals.all(bp_id=int(bp_id)) if bp_id else repo.withdrawals.all()) if not r["withdrawal_id"].startswith("wd:rev:")]
return {"items": [_view(r) for r in rows if r["status"] == "open" or not open_only]}