dodil/erp-brick-inventory

DODIL ERP brick: inventory

src/brick_inventory/domain/settings.py

"""How this business keeps stock: dated rows, never back-dated (module.yaml config_rows).

A row is in force on a date when it is the latest with valid_from on or before it. A scoped setting carries its scope in the
key ('valuation_method:UK1', 'default_warehouse:UK1', 'pick_pack_days:WH1'). The controls on warehouse staff are changed only
with inv:settings:controls; the day-to-day settings with inv:settings:write.
"""
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_inventory.domain.errors import BadInput

DEFAULTS = {
    "allow_negative_stock": "none",
    "count_approval_threshold": "500.00",
    "lots_enabled": "false",
    "ledger_mode": "",
    "over_receipt_tolerance_pct": "0",
    "delivery_needs_weights": "false",
    "part_shipments": "true",
    "default_warehouse": "",
    "number_format": "{warehouse}/{kind}/{seq:5}",
    "valuation_method": "average",
    "issue_approval_threshold": "500.00",
    "replenishment_mode": "suggest",
    "allow_expired_pick": "false",
    "receipt_steps": "one",
    "delivery_steps": "one_step",
    "pick_pack_days": "1",
    "abc_thresholds": "80,95",
    "count_frequency": "A:12,B:4,C:1",
    "gs1_company_prefix": "",
    "ai_explain_variance": "off",
}
CONTROLS = frozenset({"allow_negative_stock", "count_approval_threshold", "lots_enabled", "ledger_mode", "valuation_method",
                      "issue_approval_threshold", "replenishment_mode", "allow_expired_pick", "ai_explain_variance"})
SCOPED = frozenset({"valuation_method", "default_warehouse", "pick_pack_days"})
CHOICES = {
    "ledger_mode": {"", "post", "export", "off"},
    "valuation_method": {"average", "standard"},
    "replenishment_mode": {"suggest", "auto"},
    "receipt_steps": {"one", "two"},
    "delivery_steps": {"one_step", "pick_ship", "pick_pack_ship"},
    "ai_explain_variance": {"off", "suggest"},
}
BOOLS = {"lots_enabled", "delivery_needs_weights", "part_shipments", "allow_expired_pick"}
INTS = {"over_receipt_tolerance_pct": (0, 100), "pick_pack_days": (0, 365)}
AMOUNTS = {"count_approval_threshold", "issue_approval_threshold"}


def base_key(key: str) -> str:
    return key.split(":", 1)[0]


def check_value(key: str, value: str) -> str:
    """The value as stored, or a BadInput that says what this setting takes."""
    base = base_key(key)
    if base not in DEFAULTS:
        raise BadInput(f"there is no setting {base}")
    if (":" in key) != (base in SCOPED):
        raise BadInput(f"{base} is " + ("kept per company or warehouse: name it as " + base + ":<code>" if base in SCOPED
                                        else "not scoped: no ':' in its key"))
    v = str(value).strip()
    if base in BOOLS:
        if v.lower() not in ("true", "false"):
            raise BadInput(f"{base} is true or false")
        return v.lower()
    if base in CHOICES and v not in CHOICES[base]:
        raise BadInput(f"{base} is one of {', '.join(sorted(c for c in CHOICES[base] if c)) or 'none'}")
    if base in INTS:
        try:
            n = int(v)
        except ValueError:
            raise BadInput(f"{base} is a whole number") from None
        lo, hi = INTS[base]
        if not lo <= n <= hi:
            raise BadInput(f"{base} is between {lo} and {hi}")
        return str(n)
    if base in AMOUNTS:
        try:
            d = Decimal(v)
        except Exception:                                            # noqa: BLE001
            raise BadInput(f"{base} is an amount such as 500.00") from None
        if not d.is_finite() or d < 0 or d >= Decimal(10) ** 15 or -d.as_tuple().exponent > 2:
            raise BadInput(f"{base} is an amount of zero or more with at most two decimals")
        return f"{d:.2f}"
    if base == "abc_thresholds":
        try:
            a, b = (int(x) for x in v.split(","))
        except ValueError:
            raise BadInput("abc_thresholds is two whole numbers such as 80,95") from None
        if not 0 < a < b <= 100:
            raise BadInput("abc_thresholds: A ends before B, and both are at most 100")
        return f"{a},{b}"
    if base == "count_frequency":
        try:
            parts = dict(p.split(":") for p in v.split(","))
            parsed = {k: int(x) for k, x in parts.items()}
        except ValueError:
            raise BadInput("count_frequency looks like A:12,B:4,C:1") from None
        if set(parsed) != {"A", "B", "C"} or any(not 1 <= x <= 366 for x in parsed.values()):
            raise BadInput("count_frequency names A, B and C, each 1 to 366 counts a year")
        return v
    if base == "allow_negative_stock":
        return "none" if v in ("", "none") else ",".join(sorted({x.strip() for x in v.split(",") if x.strip()}))
    return v[:200]


class Settings:
    def __init__(self, rows: list):
        self._rows: dict = {}
        for r in sorted(rows, key=lambda r: (r["key"], r["valid_from"])):
            self._rows.setdefault(r["key"], []).append(r)

    def rows(self, key: str) -> list:
        return self._rows.get(key, [])

    def value(self, key: str, on: date) -> str:
        """The value in force on `on` for a (possibly scoped) key; an unscoped default when none is set."""
        found = None
        for r in self._rows.get(key, []):
            if r["valid_from"] <= on:
                found = r["value"]
            else:
                break
        if found is not None:
            return found
        return DEFAULTS.get(base_key(key), "")

    def flag(self, key: str, on: date) -> bool:
        return self.value(key, on) == "true"

    def number(self, key: str, on: date) -> Decimal:
        return Decimal(self.value(key, on) or "0")

    def negative_allowed(self, warehouse_id: str, on: date) -> bool:
        v = self.value("allow_negative_stock", on)
        return v != "none" and warehouse_id in v.split(",")

    def method(self, company_code: str, on: date) -> str:
        return self.value(f"valuation_method:{company_code}", on) or "average"

    def method_since(self, company_code: str, on: date) -> Optional[date]:
        rows = [r for r in self.rows(f"valuation_method:{company_code}") if r["valid_from"] <= on]
        return rows[-1]["valid_from"] if rows else None

    def ledger_mode(self, on: date, ledger_installed: bool) -> str:
        v = self.value("ledger_mode", on)
        return v or ("post" if ledger_installed else "export")