dodil/erp-brick-product

src/brick_product/domain/values.py

"""Value objects: the states each lifecycle may be in, the attributes with a fixed set of values, and the
settings the customer edits (module.yaml config_rows).

Stored as their plain strings; naming them here makes a typo a NameError, not a new state.
"""
import re
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError


class ProductState(str, Enum):
    """product: its lifecycle states (module.yaml workflows)"""
    DRAFT = "draft"
    ACTIVE = "active"
    ARCHIVED = "archived"


class ProductKind(str, Enum):
    """product.kind: the values the spec allows"""
    GOODS = "goods"
    SERVICE = "service"


class ProductBillingKind(str, Enum):
    """product.billing_kind: the values the spec allows"""
    ONE_OFF = "one_off"
    RECURRING = "recurring"
    METERED = "metered"


class ProductBillingPlan(str, Enum):
    """product.billing_plan: the values the spec allows"""
    SINGLE = "single"
    PERIODIC = "periodic"
    ARREARS = "arrears"


class ProductInvoicingPolicy(str, Enum):
    """product.invoicing_policy: the values the spec allows"""
    ORDERED = "ordered"
    DELIVERED = "delivered"


class PriceListState(str, Enum):
    """price_list: its lifecycle states (module.yaml workflows)"""
    DRAFT = "draft"
    PUBLISHED = "published"
    RETIRED = "retired"


class RowState(str, Enum):
    """A price row or a rule is DRAFT until its list (or its change) is published; only LIVE rows price anything."""
    DRAFT = "draft"
    LIVE = "live"


class RuleMethod(str, Enum):
    """price_rule.method (module.yaml): how a rule produces a price."""
    FIXED = "fixed"                  # the rule's fixed_price
    DISCOUNT_PCT = "discount_pct"    # the base price less discount_pct percent
    FORMULA = "formula"              # the base price less discount_pct percent, plus surcharge_amount per unit


class RuleScope(str, Enum):
    """price_rule.applies_to (module.yaml)"""
    PRODUCT = "product"
    TEMPLATE = "template"           # every variant of a product template
    CATEGORY = "category"
    ALL = "all"


# ── settings (module.yaml config_rows → prod_settings) ──────────────────────────────────────────────────────

# The controls on pricing authors: changed only with product:settings:controls, never by whoever they control.
CONTROL_KEYS = ("publish_needs_second_person", "unusual_price_pct", "business_timezone")
AI_STEPS = ("ai_check_duplicate", "ai_suggest_details", "ai_price_check")
CODE_PATTERN = re.compile(r"^(?P<pre>[A-Za-z0-9._/-]*)\{seq:(?P<width>[1-9])\}(?P<post>[A-Za-z0-9._/-]*)$")


@dataclass(frozen=True)
class Settings:
    """How this business keeps products and prices. A stored null is the reference behaviour (the default)."""
    product_code_format: str = "typed"
    publish_needs_second_person: bool = False
    unusual_price_pct: int = 50
    business_timezone: str = "Europe/London"      # the IANA zone in which "today" is counted
    ai_check_duplicate: str = "suggest"
    ai_suggest_details: str = "suggest"
    ai_price_check: str = "suggest"

    @classmethod
    def from_values(cls, values: dict) -> "Settings":
        """`values`: setting_key → stored value (None for the reference behaviour)."""
        def pick(key: str, default):
            v = values.get(key)
            return default if v is None else v
        d = cls()
        return cls(
            product_code_format=pick("product_code_format", d.product_code_format),
            publish_needs_second_person=pick("publish_needs_second_person", d.publish_needs_second_person),
            unusual_price_pct=pick("unusual_price_pct", d.unusual_price_pct),
            business_timezone=pick("business_timezone", d.business_timezone),
            **{s: pick(f"ai.{s}.mode", getattr(d, s)) for s in AI_STEPS})

    def mode(self, step: str) -> str:
        return getattr(self, step)


def check_setting(key: str, value: Any) -> Optional[str]:
    """Why `value` is not valid for this setting beyond its kind (the kind itself is the repository's check); None if fine."""
    if value is None:
        return None
    if key == "product_code_format" and isinstance(value, str):
        if value != "typed" and not CODE_PATTERN.match(value):
            return "'typed', or a pattern with one counter such as PRD-{seq:5}"
    if key == "business_timezone" and isinstance(value, str):
        try:
            ZoneInfo(value)
        except (ZoneInfoNotFoundError, ValueError, OSError):
            return "an IANA time zone such as Europe/London or America/New_York"
    if key == "unusual_price_pct" and isinstance(value, int) and not isinstance(value, bool) and not (1 <= value <= 10000):
        return "a percentage between 1 and 10000"
    return None