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")