dodil/erp-brick-contracts

src/brick_contracts/domain/contract_type.py

"""ContractType — an aggregate of one row (ct_type): what may be signed online, with whom, and kept how long.

INVARIANTS (enforced here, on every save):
  * a DEED type has esign_allowed = false — it can never be sent for online signing (Law Com No 386: a witness
    to a deed must be physically present); it is signed elsewhere and the executed copy uploaded;
  * every type names a retention rule and an approval policy that exist, and the policy's rules each carry a
    fallback group (policies.ApprovalRule refuses one without);
  * the counterparty is ALWAYS a business partner: a type lists the bp roles it may be with (employee, worker,
    supplier, customer). `allow_roleless_person` lets a PERSON partner with no role yet be the counterparty — hr
    gives a starter their employee/worker role on the start date, and the contract is signed before it.
"""
from dataclasses import dataclass, field

from brick_contracts.domain.errors import Invalid, RuleBroken
from brick_contracts.domain.values import COMMERCIAL, DEED, EMPLOYMENT, SIMPLE

BP_ROLE_WORDS = ("employee", "worker", "supplier", "customer", "carrier", "partner", "contractor")
PACK_CHECKS = ("era_s1", "ir35", "ip_assignment")


@dataclass
class ContractType:
    type_code: str
    label: str
    allowed_bp_roles: tuple
    execution: str = SIMPLE
    esign_allowed: bool = True
    sensitivity: str = COMMERCIAL
    retention_rule: str = "simple-6y"
    approval_policy: str = "standard"
    pack: str = ""
    pack_checks: tuple = ()
    allow_roleless_person: bool = False
    status: str = "active"
    notes: dict = field(default_factory=dict)

    @classmethod
    def define(cls, row: dict, config) -> "ContractType":
        """Build (and check) a type from what an admin keeps. `config` is the current Configuration."""
        code = str(row.get("type_code") or "").strip().lower()
        if not code or not code.replace("_", "").replace("-", "").isalnum() or len(code) > 64:
            raise Invalid("type_code is a short code of letters, digits, - and _ (employment_contract)")
        roles = tuple(sorted({str(r).strip().lower() for r in (row.get("allowed_bp_roles") or []) if str(r).strip()}))
        if not roles:
            raise Invalid(f"{code}: name the business-partner roles this type may be with (employee, worker, "
                          "supplier, customer)")
        execution = str(row.get("execution") or SIMPLE).upper()
        if execution not in (SIMPLE, DEED):
            raise Invalid(f"{code}: execution is SIMPLE or DEED")
        esign = bool(row.get("esign_allowed", True))
        if execution == DEED:
            esign = False                       # the invariant: a deed is never signed online here
        sensitivity = str(row.get("sensitivity") or COMMERCIAL).lower()
        if sensitivity not in (COMMERCIAL, EMPLOYMENT):
            raise Invalid(f"{code}: sensitivity is commercial or employment")
        retention = str(row.get("retention_rule") or "").strip()
        policy = str(row.get("approval_policy") or "").strip()
        if not retention or retention not in config.retention:
            raise RuleBroken(f"{code}: every type names a retention rule that exists (have: "
                             f"{', '.join(sorted(config.retention)) or 'none — keep one first'})")
        if not policy or policy not in config.policies:
            raise RuleBroken(f"{code}: every type names an approval policy with a fallback group (have: "
                             f"{', '.join(sorted(config.policies)) or 'none — keep one first'})")
        checks = tuple(sorted({str(c).strip().lower() for c in (row.get("pack_checks") or []) if str(c).strip()}))
        unknown = set(checks) - set(PACK_CHECKS)
        if unknown:
            raise Invalid(f"{code}: unknown pack checks {sorted(unknown)} (known: {', '.join(PACK_CHECKS)})")
        status = str(row.get("status") or "active").lower()
        if status not in ("active", "retired"):
            raise Invalid(f"{code}: status is active or retired")
        return cls(code, str(row.get("label") or code), roles, execution, esign, sensitivity, retention, policy,
                   str(row.get("pack") or ""), checks, bool(row.get("allow_roleless_person", False)), status)

    @property
    def is_deed(self) -> bool:
        return self.execution == DEED

    @property
    def employment_sensitive(self) -> bool:
        return self.sensitivity == EMPLOYMENT

    def check_counterparty(self, partner: dict) -> None:
        """The counterparty holds a role this type allows (or is a role-less person partner, where allowed)."""
        roles = {str(r).lower() for r in partner.get("roles") or []}
        if roles & set(self.allowed_bp_roles):
            return
        if not roles and partner.get("kind") == "person" and self.allow_roleless_person:
            return
        article = "an" if self.label[:1].lower() in "aeiou" else "a"
        wanted = " or ".join(self.allowed_bp_roles)
        has = f"it holds {', '.join(sorted(roles))}" if roles else "it holds no role"
        raise RuleBroken(f"{article} {self.label} needs a partner with the {wanted} role — {has}")

    def can_esign(self) -> bool:
        return self.esign_allowed and not self.is_deed and self.status == "active"

    def as_row(self) -> dict:
        import json
        return {"type_code": self.type_code, "label": self.label, "allowed_bp_roles": json.dumps(list(self.allowed_bp_roles)),
                "execution": self.execution, "esign_allowed": self.esign_allowed, "sensitivity": self.sensitivity,
                "retention_rule": self.retention_rule, "approval_policy": self.approval_policy, "pack": self.pack,
                "pack_checks": json.dumps(list(self.pack_checks)), "allow_roleless_person": self.allow_roleless_person,
                "status": self.status}

    def as_json(self) -> dict:
        return {"type_code": self.type_code, "label": self.label, "allowed_bp_roles": list(self.allowed_bp_roles),
                "execution": self.execution, "esign_allowed": self.esign_allowed, "sensitivity": self.sensitivity,
                "retention_rule": self.retention_rule, "approval_policy": self.approval_policy, "pack": self.pack,
                "pack_checks": list(self.pack_checks), "allow_roleless_person": self.allow_roleless_person,
                "status": self.status}


def type_from_row(r: dict) -> ContractType:
    """A stored row back into a type, without re-checking references (they were checked when it was kept)."""
    import json

    def lst(v):
        if not v:
            return ()
        return tuple(json.loads(v)) if isinstance(v, str) else tuple(v)
    return ContractType(r["type_code"], r.get("label") or r["type_code"], lst(r.get("allowed_bp_roles")),
                        r.get("execution") or SIMPLE, bool(r.get("esign_allowed")), r.get("sensitivity") or COMMERCIAL,
                        r.get("retention_rule") or "", r.get("approval_policy") or "", r.get("pack") or "",
                        lst(r.get("pack_checks")), bool(r.get("allow_roleless_person")), r.get("status") or "active")


__all__ = ["ContractType", "type_from_row", "BP_ROLE_WORDS", "PACK_CHECKS"]