dodil/erp-brick-purchasing

DODIL ERP brick: purchasing

src/brick_purchasing/application/assets.py

"""After a bill is approved: hand asset lines to the asset register (PUR-56) and delivery-cost differences to inventory (PUR-63).

An asset line is handed to fixed-assets' POST /acquisitions keyed acquisition_ref pur:<bill_id>:<line_no> (a credit is the same call with a negative
amount): the amount is the net plus any VAT that is not reclaimed, the value date is the bill's invoice date. A refusal keeps the bill approved and
lists the hand-over (pur_handover) with fixed-assets' reason for the clock worker's retry; the transaction id is kept on the bill line."""
import json
from decimal import Decimal
from fractions import Fraction

from erp_platform.bricks import Refused

from brick_purchasing.application import base
from brick_purchasing.application.base import ASSET_HAND, PURCHASING, _decide, unit
from brick_purchasing.application.inputs import Ref
from brick_purchasing.domain.values import ZERO
from brick_purchasing.infrastructure.seams import Missing, ports

repository = lambda: base.repository()          # noqa: E731


def _body(repo, b, ln) -> dict:
    credit = b.kind == "credit"
    amount = ln.net_amount + (ln.tax_amount - ln.vat_reclaimable)
    body = {"acquisition_ref": f"pur:{b.bill_id}:{ln.line_no}", "source_kind": "purchase_credit" if credit else "purchase_bill", "company_code": b.company_code or "",
            "bp_id": str(b.bp_id), "bill_id": b.bill_id, "line_no": ln.line_no, "purchase_order_id": b.purchase_order_id, "value_date": b.invoice_date.isoformat(),
            "amount": str(-abs(amount) if credit else amount), "currency": b.currency, "quantity": str(ln.quantity), "cost_centre": ln.cost_centre}
    if ln.asset_id:
        body["asset_id"] = ln.asset_id
    else:
        got = repo.orders.get(b.purchase_order_id) if b.purchase_order_id else None
        ol = got[0].line(ln.order_line_no) if got and ln.order_line_no else None
        body["class_code"], body["description"] = (ol.asset_class_code if ol else ""), ln.description
    return body


def _hand(repo, b, ln) -> None:
    hid = f"asset:{b.bill_id}:{ln.line_no}"
    body = _body(repo, b, ln)
    row = repo.handovers.get(hid) or {"handover_id": hid, "kind": "asset", "subject_ref": b.bill_id, "attempts": 0}
    row.update(payload=json.dumps(body), attempts=int(row.get("attempts") or 0) + 1)
    try:
        got = ports.fa.acquisition(body)
        ln.fa_transaction_id = str(got.get("transaction_id") or "")
        row.update(status="done", reason="")
    except Missing as m:
        row.update(status="pending", reason="fixed-assets refused: " + ("; ".join(f"{g.get('gate')}: {g.get('reason')}" for g in m.gates) or m.reason))
    repo.handovers.put(row)


def _planned(o, kind: str) -> Decimal:
    from brick_purchasing.application.receipts import planned_costs
    return sum((a for xl in o.lines for k, a in planned_costs(o, xl, Fraction(1)) if k == kind), ZERO)


def _adjust(repo, b, ln) -> None:
    """PUR-63: a delivery-cost line that differs from its planned cost: inventory spreads the difference over what is on hand and posts the rest to
    price difference (inventory decides the split from its own moves). Keyed pur:<bill_id>:<line_no>, so a retry changes nothing."""
    got = repo.orders.get(b.purchase_order_id) if b.purchase_order_id else None
    if not got:
        return
    o = got[0]
    diff = ln.net_amount - _planned(o, ln.delivery_cost_ref)
    if diff == 0:
        return
    hid = f"valuation:{b.bill_id}:{ln.line_no}"
    row = repo.handovers.get(hid) or {"handover_id": hid, "kind": "valuation", "subject_ref": b.bill_id, "attempts": 0}
    if row.get("status") == "done":
        return
    rcpts = repo.receipts.of_order(b.purchase_order_id)
    body = {"adjustment_ref": f"pur:{b.bill_id}:{ln.line_no}", "source_kind": "purchase_delivery_cost", "purchase_order_id": b.purchase_order_id,
            "receipt_id": rcpts[0].receipt_id if rcpts else "", "product_id": o.lines[0].product_id, "amount": str(-diff if b.kind == "credit" else diff),
            "currency": b.currency, "on": b.invoice_date.isoformat()}
    row.update(payload=json.dumps(body), attempts=int(row.get("attempts") or 0) + 1)
    try:
        ports.inventory.valuation_adjustment(body)
        row.update(status="done", reason="")
    except Missing as m:
        row.update(status="pending", reason="inventory refused: " + ("; ".join(f"{g.get('gate')}: {g.get('reason')}" for g in m.gates) or m.reason))
    repo.handovers.put(row)


def _run(bill_id: str) -> int:
    with repository() as repo, _decide():
        got = repo.bills.get(bill_id)
        if not got or got[0].status != "approved":
            return 0
        b, v = got
        if ports.installed("inventory"):
            for ln in b.lines:
                if ln.delivery_cost_ref:
                    _adjust(repo, b, ln)
        todo = [ln for ln in b.lines if ln.posting_kind == "asset" and not ln.fa_transaction_id] if ports.installed("fa") else []
        for ln in todo:
            _hand(repo, b, ln)
        if todo:
            repo.bills.save(b, v)
        return len(todo)


def after_approve(caller: dict, bill_id: str) -> None:
    unit(None, lambda: _run(bill_id))


@PURCHASING.use_case(kind="command", route="POST /bills/{bill_id}/hand-over-assets", permission=ASSET_HAND, idempotent=True)
def hand_assets_to_register(caller: dict, bill_id: Ref) -> dict:
    """Retry handing an approved bill's asset lines to fixed-assets (the composition's clock worker calls this). Idempotent: fixed-assets keeps one
    transaction per acquisition_ref. Answers each line's hand-over with its status and reason."""
    with repository() as repo:
        if repo.bills.get(bill_id) is None:
            raise Refused(404, f"no bill {bill_id}")
    unit(None, lambda: _run(bill_id))
    with repository() as repo:
        return {"bill_id": bill_id, "handovers": [{k: r[k] for k in ("handover_id", "kind", "status", "reason", "attempts")} for r in repo.handovers.all(subject_ref=bill_id)]}