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)