dodil/erp-brick-purchasing
DODIL ERP brick: purchasing
src/brick_purchasing/application/subcontract.py
"""Providing components to a subcontractor (PUR-57): the receiver sends what the subcontractor needs for the finished goods on a subcontract line;
inventory moves it to stock held at the subcontractor, still ours (POST /subcontract-provisions, move_ref pur:<order>:<line>:prov:<seq>). The
provision is recorded on the order's component rows; providing more than the line needs is refused unless a reason is given."""
import json
from typing import Optional
from erp_platform.bricks import Refused
from erp_platform.types import IsoDate
from brick_purchasing.application import base, orders
from brick_purchasing.application.base import PURCHASING, SUBCONTRACT, _decide, _today, unit
from brick_purchasing.application.inputs import Code, Qty, Reason, Ref, Version, _In
from brick_purchasing.domain.errors import RuleBroken
from brick_purchasing.domain.values import as_dec, fr
from brick_purchasing.infrastructure.seams import Missing, ports
repository = lambda: base.repository() # noqa: E731
class ProvideIn(_In):
product_id: Code
quantity: Qty
@PURCHASING.use_case(kind="command", route="POST /purchase-orders/{purchase_order_id}/subcontract-provisions", permission=SUBCONTRACT)
def provide_components(caller: dict, purchase_order_id: Ref, line_no: int, from_location_id: Code, provided_on: Optional[IsoDate] = None,
components: Optional[list[ProvideIn]] = None, reason: Optional[Reason] = None, version: Optional[Version] = None) -> dict:
"""Provide the subcontractor with the components of a subcontract line: by default what is still missing for the whole line (quantity per
finished unit x ordered quantity, less what was provided), or the components named. Inventory moves them to stock held at the subcontractor,
still ours; a component short on hand is refused by inventory (409, naming it) and nothing is recorded. More than the line needs is refused
unless a reason is given. Returns what each component needs, has been provided and what the subcontractor holds of ours."""
def run():
with repository() as repo, _decide():
o, v = orders._load(repo, caller, purchase_order_id)
x = o.line(line_no)
if x is None or x.item_category != "subcontract":
raise RuleBroken(f"line {line_no} is not a subcontract line")
if o.status != "confirmed":
raise RuleBroken(f"an order that is {o.status} takes no components: confirm it first")
comps = [c for c in o.components if c.line_no == x.line_no]
asked = {c.product_id: None for c in comps} if components is None else {p.product_id: p.quantity for p in components}
day = base._day(provided_on, "provided_on") if provided_on else _today(repo)
lines = []
for c in comps:
if c.product_id not in asked:
continue
need = as_dec(fr(c.quantity_per) * x.exact_base, 6)
qty = need - c.provided if asked[c.product_id] is None else asked[c.product_id]
if qty <= 0:
continue
if c.provided + qty > need and not (reason or "").strip():
raise RuleBroken(f"over_provided: {c.product_id}: {c.provided + qty} would exceed the {need} the line needs; fix: give a reason, or provide less")
lines.append((c, qty))
unknown = set(asked) - {c.product_id for c in comps}
if unknown:
raise Refused(422, f"the line has no component {', '.join(sorted(unknown))}")
if not lines:
return {**orders.order_view(repo, caller, o, v, gates=False), "provisions": _held(comps, x)}
seq = 1 + max([len(json.loads(c.provision_move_refs)) for c in comps] or [0])
ref = f"pur:{purchase_order_id}:{line_no}:prov:{seq}"
if ports.installed("inventory"):
try:
ports.inventory.subcontract_provision({"move_ref": ref, "purchase_order_id": purchase_order_id, "line_no": line_no, "bp_id": str(o.bp_id),
"from_location_id": from_location_id, "provided_on": day.isoformat(),
"components": [{"product_id": c.product_id, "quantity": str(q), "unit_code": c.unit_code} for c, q in lines]})
except Missing as m:
raise Refused(409, "inventory: " + ("; ".join(f"{g.get('gate')}: {g.get('reason')}" for g in m.gates) or m.reason)) from None
for c, q in lines:
c.provided = c.provided + q
c.provision_move_refs = json.dumps(json.loads(c.provision_move_refs) + [ref])
if c.provided > as_dec(fr(c.quantity_per) * x.exact_base, 6):
c.allowance_reason = (reason or "").strip()
nv = repo.orders.save(o, v)
return {**orders.order_view(repo, caller, o, nv, gates=False), "move_ref": ref, "provisions": _held(comps, x)}
return unit(version, run)
def _held(comps: list, x) -> list:
return [{"line_no": c.line_no, "product_id": c.product_id, "needed": as_dec(fr(c.quantity_per) * x.exact_base, 6), "provided": c.provided,
"held_at_subcontractor": c.provided, "unit_code": c.unit_code} for c in comps]