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