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]