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