dodil/erp-brick-pos

DODIL ERP brick: point of sale

src/brick_pos/domain/outbox.py

"""The outbox: a side effect waiting to go to a neighbour. It is written in the SAME transaction as the receipt and sent
only after that commits, so a crash between the two sends it on the next run and a rolled-back receipt sends nothing. The
row's ref is the neighbour's idempotency key: sending twice is the neighbour's replay."""
import json
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

from brick_pos.domain.errors import RuleBroken
from brick_pos.domain.values import OutboxStatus

STOCK = "inventory.goods_issue"
RESTOCK = "inventory.restock"                         # a restocked refund line: inventory receives it back (INV-59)
GL = "gl.journal"                                    # a session's summary journal (POS-20, POS-29)
DEPOSIT = "payments.deposit_batch"                   # a sealed cash bag, expected at the bank (POS-19, POS-26)


def _q(d) -> str:
    """A quantity as a plain decimal string with no trailing zeros ("0.036", "10", "0.25")."""
    return format(d.normalize(), "f")


def goods_issue(receipt, *, company_code: str, location_id: str) -> dict:
    """inventory POST /goods-issues/from-source (INV-58): one request per receipt, a line per COMPONENT of each product rung (the product
    itself when it has no recipe; its recipe's or manufacturing's components when it has: beans, milk, a cup). A till sale is never
    refused for stock: inventory books the move and lists a negative location for the stock controller. Each move_ref is
    `pos:<receipt_id>:<line_no>:<component_no>`, frozen with the line, so a later recipe change never changes a past sale."""
    return {"source_kind": "pos_sale", "source_id": receipt.receipt_id, "company_code": company_code,
            "location_id": location_id, "issued_on": receipt.business_date.isoformat(),
            "lines": [{"move_ref": f"pos:{receipt.receipt_id}:{c.line_no}:{c.component_no}", "product_id": c.product_id,
                       "quantity": _q(c.quantity), "unit_code": c.unit_code}
                      for ln in receipt.lines if ln.kind == "product" for c in receipt.components_of(ln)]}   # a voucher sold takes no stock


def restock(refund, line, *, original_receipt_id: str, company_code: str, location_id: str, component_no: int = 1) -> dict:
    """inventory POST /receipts (INV-59): one request per restocked move, valued by inventory at the cost the reversed move left at (no cost
    is sent). move_ref pos:<refund_receipt_id>:<line_no>:<component_no> reverses pos:<original receipt_id>:<original line_no>:<component_no>."""
    return {"move_ref": f"pos:{refund.receipt_id}:{line.line_no}:{component_no}", "source_kind": "pos_refund_restock",
            "source_id": refund.receipt_id, "company_code": company_code, "product_id": line.product_id,
            "quantity": format(line.quantity, "f"), "unit_code": line.unit_code, "location_id": location_id,
            "received_on": refund.business_date.isoformat(),
            "reverses_move_ref": f"pos:{original_receipt_id}:{line.original_line_no}:{component_no}"}


def deposit_batch(bag) -> dict:
    """payments POST /deposit-batches/expected (PAY-49): the bag, expected at the bank; batch_ref is the bag's key, so a resend is
    payments' replay."""
    return {"batch_ref": bag.deposit_ref, "kind": "cash", "bank_account_id": bag.bank_account_id, "company_code": bag.company_code,
            "currency": bag.currency, "amount": format(bag.amount, "f"), "expected_on": bag.expected_on.isoformat(),
            "clearing_purpose": "cash_in_transit", "source_module": "pos"}


@dataclass
class OutboxRow:
    target: str
    ref: str
    payload: dict
    status: str = OutboxStatus.PENDING.value
    attempts: int = 0
    last_error: str = ""
    sent_at: Optional[datetime] = None

    @classmethod
    def pending(cls, target: str, ref: str, payload: dict) -> "OutboxRow":
        return cls(target=target, ref=ref, payload=payload)

    def dump(self) -> str:
        return json.dumps(self.payload, sort_keys=True)

    def sent(self, at: datetime) -> None:
        """pending -> sent. A sent row is never sent again and never goes back."""
        if self.status == OutboxStatus.FAILED.value:
            raise RuleBroken(f"{self.target} {self.ref} failed for good: a person re-queues it", gate="outbox_failed",
                             fix="read last_error and fix the cause")
        self.status, self.sent_at, self.attempts = OutboxStatus.SENT.value, at, self.attempts + 1
        self.last_error = ""

    def requeue(self) -> None:
        """failed -> pending, by a person who fixed the cause (a closed gl period reopened, a bank account created). A sent row
        is never sent again."""
        if self.status == OutboxStatus.SENT.value:
            raise RuleBroken(f"{self.target} {self.ref} was sent: it never goes back to pending", gate="outbox_sent",
                             fix="nothing to do")
        self.status = OutboxStatus.PENDING.value

    def tried(self, error: str, *, permanent: bool) -> None:
        """A neighbour that was down stays pending (tried again); one that refused the request for good is failed."""
        if self.status == OutboxStatus.SENT.value:
            raise RuleBroken(f"{self.target} {self.ref} was sent: it never goes back to pending", gate="outbox_sent",
                             fix="nothing to do")
        self.attempts += 1
        self.last_error = error[:500]
        if permanent:
            self.status = OutboxStatus.FAILED.value