dodil/erp-brick-purchasing
DODIL ERP brick: purchasing
src/brick_purchasing/application/bill_drafts.py
"""Reading a supplier's PDF into a DRAFT bill (PUR-32, AI step ai_read_bill, suggest only): the PDF is kept as raw/purchasing/bills/<sha256>.pdf, the
step suggests each field (supplier, number, date, lines, VAT) with its evidence, and the clerk accepts, corrects or rejects each one; only a draft with
every field decided is ready to be recorded (then the clerk records it like any bill). Every suggestion and decision is a pur_ai_finding row."""
import base64
import binascii
import hashlib
import json
from typing import Optional
from erp_platform.bricks import Refused
from erp_platform.types import BigId, text
from brick_purchasing.application import base, payments
from brick_purchasing.application.base import BILL_WRITE, PURCHASING, _decide, _stamp, _today, _who, unit
from brick_purchasing.application.inputs import Ref
from brick_purchasing.infrastructure import ai_steps
repository = lambda: base.repository() # noqa: E731
def _subject(sha: str) -> str:
return f"pur_bill_draft:{sha}"
def _view(repo, sha: str) -> dict:
rows = sorted(repo.findings.all(subject_ref=_subject(sha)), key=lambda r: r["finding_no"])
fields = [{"finding_no": r["finding_no"], **json.loads(r["finding"]), "evidence": r["evidence"], "decision": r.get("decision") or "", "decided_by_email": r.get("decided_by_email") or ""} for r in rows]
return {"draft": sha, "object_key": f"raw/purchasing/bills/{sha}.pdf", "fields": fields, "ready_to_record": bool(fields) and all(f["decision"] for f in fields) and
not any(f["decision"] == "pending" for f in fields)}
@PURCHASING.use_case(kind="command", route="POST /bill-drafts", permission=BILL_WRITE, idempotent=True)
def read_bill_pdf(caller: dict, bp_id: BigId, pdf_base64: text(14_000_000, min_length=8)) -> dict:
"""Keep a supplier's PDF invoice and ask the AI step to read it into suggestions (supplier, number, date, lines, VAT), each with its evidence. Nothing
is recorded: the clerk decides each field. With the step switched off, or no model answering, the draft is empty and the clerk keys the bill."""
try:
data = base64.b64decode(pdf_base64, validate=True)
except (binascii.Error, ValueError):
raise Refused(422, "pdf_base64 is not base64") from None
if not data.startswith(b"%PDF"):
raise Refused(422, "the invoice is a PDF")
sha = hashlib.sha256(data).hexdigest()
def run():
with repository() as repo, _decide():
key = f"raw/purchasing/bills/{sha}.pdf"
payments.objects().put(key, data, "application/pdf")
mode = base.Settings(repo, _today(repo)).mode("ai_read_bill")
if repo.findings.all(subject_ref=_subject(sha)):
return {**_view(repo, sha), "mode": mode, "replay": True}
ans = ai_steps.ask("ai_read_bill", mode, _subject(sha), 1, {"object_key": key, "bp_id": str(bp_id)})
for n, (field, value) in enumerate(sorted((ans.suggestion or {}).items()), 1):
v = value if isinstance(value, dict) else {"value": value}
repo.findings.put({"subject_ref": _subject(sha), "step": "ai_read_bill", "run_no": 1, "finding_no": n, "model": ans.model or "model", "inputs_digest": sha,
"finding": json.dumps({"field": field, "value": v.get("value")}), "evidence": str(v.get("evidence") or ans.rationale or "")})
return {**_view(repo, sha), "mode": mode, "answer": ans.as_dict(), "replay": False}
return unit(None, run)
@PURCHASING.use_case(kind="command", route="POST /bill-drafts/{draft}/fields/{finding_no}/decide", permission=BILL_WRITE)
def decide_draft_field(caller: dict, draft: Ref, finding_no: int, decision: str, corrected_value: Optional[text(500)] = None) -> dict:
"""Accept, correct (with the value) or reject one suggested field of a draft bill. Recorded with who and when; a corrected field keeps the value
the clerk typed."""
if decision not in ("accepted", "corrected", "rejected"):
raise Refused(422, "decision is accepted, corrected or rejected")
if decision == "corrected" and not (corrected_value or "").strip():
raise Refused(422, "a corrected field carries the corrected value")
def run():
with repository() as repo, _decide():
rows = [r for r in repo.findings.all(subject_ref=_subject(draft)) if r["finding_no"] == finding_no]
if not rows:
raise Refused(404, f"no suggested field {finding_no} on draft {draft}")
r = rows[0]
f = json.loads(r["finding"])
if decision == "corrected":
f["corrected_value"] = corrected_value
r.update(finding=json.dumps(f), decision=decision, decided_by_email=_who(caller), decided_at=_stamp())
repo.findings.put(r)
return _view(repo, draft)
return unit(None, run)
@PURCHASING.use_case(kind="query", route="GET /bill-drafts/{draft}", permission=BILL_WRITE)
def get_bill_draft(caller: dict, draft: Ref) -> dict:
"""The draft's suggested fields with their evidence and decisions; ready_to_record once every field is decided."""
with repository() as repo:
if not repo.findings.all(subject_ref=_subject(draft)):
raise Refused(404, f"no draft {draft}")
return _view(repo, draft)