dodil/erp-brick-projects

src/brick_projects/domain/values.py

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

Stored as their plain strings; naming them here makes a typo a NameError, not a new state.
"""
import json
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from zoneinfo import ZoneInfo

from brick_projects.domain.errors import RuleBroken


class ProjectState(str, Enum):
    """project: its lifecycle states (module.yaml workflows)"""
    PLANNED = "planned"
    ACTIVE = "active"
    CLOSED = "closed"
    CANCELLED = "cancelled"


class ItemState(str, Enum):
    """item: its lifecycle states (module.yaml workflows)"""
    PLANNED = "planned"
    IN_PROGRESS = "in_progress"
    BLOCKED = "blocked"
    IN_REVIEW = "in_review"
    DONE = "done"
    CANCELLED = "cancelled"


OPEN_ITEM_STATES = (ItemState.PLANNED.value, ItemState.IN_PROGRESS.value, ItemState.BLOCKED.value,
                    ItemState.IN_REVIEW.value)


class ItemSource(str, Enum):
    """item.source: the values the spec allows"""
    SOLD = "sold"
    MAKEGOOD = "makegood"
    CHANGE_ORDER = "change_order"
    GOODWILL = "goodwill"


class ItemApprovedHow(str, Enum):
    """item.approved_how: the values the spec allows"""
    RECORDED = "recorded"
    DEEMED = "deemed"


class ItemBillingStatus(str, Enum):
    """item.billing_status. not_billed: nothing to bill here, or not accepted yet; pending: accepted, the
    draft not yet asked for (invoicing was down, or is not composed); requested: invoicing has the request,
    keyed acc:<item_id>."""
    NOT_BILLED = "not_billed"
    PENDING = "pending"
    REQUESTED = "requested"


class ItemDeliveryStatus(str, Enum):
    """item.delivery_status — telling a sales order that one unit of its line was delivered (SO-23). not_reported:
    nothing to report (not from an order, a makegood, or not done yet); pending: done, the order not told yet (it
    was down, or is not composed); reported: the order has it, keyed by the item."""
    NOT_REPORTED = "not_reported"
    PENDING = "pending"
    REPORTED = "reported"


# How a sold line becomes items (setting plan.expand): per_unit = one item per unit sold, one = a single
# item for the line, none = no item (it elapses, and invoicing's schedule bills it).
EXPANSIONS = ("per_unit", "one", "none")
AI_MODES = ("off", "suggest")            # module.yaml ai_steps modes_allowed: auto is not offered
AI_STEP_KEYS = ("ai_draft_plan", "ai_chase_brief", "ai_flag_shortfall")


def line_kind(billing_kind: str, billing_plan: str) -> str:
    """"<billing_kind>/<billing_plan>", lower-case: the key the settings are written in."""
    return f"{(billing_kind or '').strip().lower()}/{(billing_plan or '').strip().lower()}"


@dataclass(frozen=True)
class Settings:
    """How this business delivers. Every field's default is the reference behaviour; a setting row with no
    value means the default. The rules read this object, never the rows."""
    expand: dict = field(default_factory=lambda: {"one_off/single": "per_unit", "one_off/*": "one"})
    review_window_days: int = 10             # working days a client has to answer, from submission
    warning_notice_days: int = 5             # working days after the warning before deemed acceptance
    deemed_acceptance: bool = True
    chase_days: int = 3                      # days blocked on the client before the reminder is due
    block_reasons: tuple = ("waiting for brief", "waiting for assets", "waiting for access",
                            "waiting for a client decision")
    evidence_kinds: tuple = ("link", "file", "sign_off")
    bill_on_acceptance: tuple = ("one_off/single",)   # lines whose accepted item asks invoicing (AR-29)
    working_weekdays: tuple = (0, 1, 2, 3, 4)          # Monday = 0
    holidays: tuple = ()                              # ISO dates the business does not work
    timezone: str = "UTC"
    ai_modes: dict = field(default_factory=lambda: {k: "suggest" for k in AI_STEP_KEYS})

    def expansion(self, billing_kind: str, billing_plan: str) -> str:
        key = line_kind(billing_kind, billing_plan)
        return self.expand.get(key) or self.expand.get(f"{key.split('/', 1)[0]}/*") or "none"

    def bills_on_acceptance(self, billing_kind: str, billing_plan: str) -> bool:
        key = line_kind(billing_kind, billing_plan)
        return key in self.bill_on_acceptance or f"{key.split('/', 1)[0]}/*" in self.bill_on_acceptance

    def ai_mode(self, step: str) -> str:
        return self.ai_modes.get(step, "suggest")


# The setting rows: key -> what the customer changes, in their words. The stored value is JSON; null is the
# reference behaviour above.
SETTING_ROWS = {
    "plan.expand": 'which kinds of sold line become work, and how: per_unit, one or none '
                   '(e.g. {"one_off/single": "per_unit", "one_off/*": "one"}); a kind not named makes no item',
    "review.window_days": "working days a client has to review delivered work",
    "review.warning_notice_days": "working days after the warning before acceptance may be deemed",
    "review.deemed_acceptance": "whether silence after a warning may be recorded as acceptance (true or false)",
    "blocked.chase_days": "days blocked on the client before the reminder is due",
    "blocked.reasons": "the reasons offered when work is blocked on the client",
    "evidence.kinds": "what counts as proof of delivery",
    "billing.on_acceptance": "which kinds of sold line are invoiced when their item is accepted",
    "calendar.working_weekdays": "the working days, Monday = 0 to Sunday = 6",
    "calendar.holidays": "dates the business does not work (YYYY-MM-DD)",
    "calendar.timezone": "the time zone the board's 'today' is read in (e.g. Europe/Stockholm)",
    "ai.ai_draft_plan.mode": "AI suggests dates and owners for a new plan: off or suggest",
    "ai.ai_chase_brief.mode": "AI drafts the reminder for a late brief: off or suggest",
    "ai.ai_flag_shortfall.mode": "AI flags a likely shortfall before the window closes: off or suggest",
}


def _whole(key, v, lo, hi):
    if not isinstance(v, int) or isinstance(v, bool) or not lo <= v <= hi:
        raise RuleBroken(f"{key} is a whole number from {lo} to {hi}")
    return v


def _words(key, v, at_least=1):
    if not isinstance(v, list) or len(v) < at_least or not all(isinstance(s, str) and s.strip() for s in v):
        raise RuleBroken(f"{key} is a list of words" + (f", at least {at_least}" if at_least else ""))
    return tuple(s.strip() for s in v)


def _kinds(key, v):
    out = tuple(s.lower() for s in _words(key, v, 0))
    if any(s.count("/") != 1 for s in out):
        raise RuleBroken(f'{key} lists "<billing kind>/<billing plan>" (or "<kind>/*")')
    return out


def check_setting(key: str, value):
    """One setting's value, checked; None puts the reference behaviour back. Refuses in words."""
    if key not in SETTING_ROWS:
        raise RuleBroken(f"there is no setting {key}")
    if value is None:
        return None
    if key == "plan.expand":
        if not isinstance(value, dict) or not all(
                isinstance(k, str) and k.count("/") == 1 and v in EXPANSIONS for k, v in value.items()):
            raise RuleBroken('plan.expand maps "<billing kind>/<billing plan>" (or "<kind>/*") to per_unit, '
                             "one or none")
        return {k.strip().lower(): v for k, v in value.items()}
    if key in ("review.window_days", "review.warning_notice_days"):
        return _whole(key, value, 1, 90)
    if key == "blocked.chase_days":
        return _whole(key, value, 1, 60)
    if key == "review.deemed_acceptance":
        if not isinstance(value, bool):
            raise RuleBroken("review.deemed_acceptance is true or false")
        return value
    if key in ("blocked.reasons", "evidence.kinds"):
        return _words(key, value)
    if key == "billing.on_acceptance":
        return _kinds(key, value)
    if key == "calendar.working_weekdays":
        if not isinstance(value, list) or not value or not all(
                isinstance(d, int) and not isinstance(d, bool) and 0 <= d <= 6 for d in value):
            raise RuleBroken("calendar.working_weekdays lists days 0 (Monday) to 6 (Sunday), at least one")
        return tuple(sorted(set(value)))
    if key == "calendar.holidays":
        try:
            return tuple(date.fromisoformat(d).isoformat() for d in value)
        except (TypeError, ValueError):
            raise RuleBroken("calendar.holidays lists dates as YYYY-MM-DD") from None
    if key == "calendar.timezone":
        try:
            ZoneInfo(str(value))
        except Exception:                    # noqa: BLE001 — every unknown zone is the same refusal
            raise RuleBroken(f"{value!r} is not a time zone (e.g. Europe/Stockholm)") from None
        return str(value)
    if value not in AI_MODES:                # the ai.<step>.mode rows
        raise RuleBroken(f"{key} is off or suggest; auto is not offered for this step")
    return value


def settings_from_rows(rows: dict) -> Settings:
    """Settings from {key: JSON text or None}. A row that no longer parses falls back to its default: the
    value was checked when it was set, so this only guards a hand-edited bucket."""
    v = {}
    for key, raw in rows.items():
        if raw is None or key not in SETTING_ROWS:
            continue
        try:
            checked = check_setting(key, json.loads(raw))
        except (ValueError, RuleBroken):
            continue
        if checked is not None:
            v[key] = checked
    d = Settings()
    return Settings(
        expand=v.get("plan.expand", d.expand),
        review_window_days=v.get("review.window_days", d.review_window_days),
        warning_notice_days=v.get("review.warning_notice_days", d.warning_notice_days),
        deemed_acceptance=v.get("review.deemed_acceptance", d.deemed_acceptance),
        chase_days=v.get("blocked.chase_days", d.chase_days),
        block_reasons=v.get("blocked.reasons", d.block_reasons),
        evidence_kinds=v.get("evidence.kinds", d.evidence_kinds),
        bill_on_acceptance=v.get("billing.on_acceptance", d.bill_on_acceptance),
        working_weekdays=v.get("calendar.working_weekdays", d.working_weekdays),
        holidays=v.get("calendar.holidays", d.holidays),
        timezone=v.get("calendar.timezone", d.timezone),
        ai_modes={k: v.get(f"ai.{k}.mode", d.ai_modes[k]) for k in AI_STEP_KEYS},
    )


# What changing a setting means, said when it is changed (module.yaml variation_points). A setting that switches a
# step of the signed process on or off changes the process the customer signed: that goes through
# `erp-compose change propose` and a new signature, and the brick says so rather than drifting silently.
_PROCESS_CHANGE = ("this changes the signed process ({node}): propose it with `erp-compose change propose` and have "
                   "the customer sign the new version")
SETTING_WARNINGS = {
    "plan.expand": "an item per month of a recurring service turns billing into busywork: invoicing's schedules "
                   "bill those lines",
    "review.window_days": "a window shorter than the contract's acceptance clause is unenforceable",
    "review.warning_notice_days": "a notice shorter than the contract's acceptance clause is unenforceable",
    "ai.ai_draft_plan.mode": "off: new items are dated at the project's end and owned by the lead",
    "ai.ai_chase_brief.mode": "off: the board shows blocked work past the chase days, and no reminder is drafted",
    "ai.ai_flag_shortfall.mode": "off: a shortfall is found at reconciliation, after the window has closed",
}


def setting_warning(key: str, value):
    """The warning for setting `key` to `value`; None for the reference behaviour or a harmless change."""
    if value is None:
        return None
    if key == "review.deemed_acceptance" and value is False:
        return (_PROCESS_CHANGE.format(node="Task_accept_deemed off") + "; without it a silent client holds the "
                "invoice for one-off work indefinitely")
    if key == "review.deemed_acceptance":
        return "deemed acceptance must be written into the contract the client signed, or it bills work they can dispute"
    if key.startswith("ai.") and value != "off":
        return None
    return SETTING_WARNINGS.get(key)