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)]}