dodil/erp-brick-time-off

DODIL ERP brick: time off

src/brick_time_off/domain/adjustment.py

"""A manual adjustment of a balance (TOF-12): proposed by one HR user, decided by ANOTHER, and only then a ledger entry.

Plain Python. The ledger is append-only and entries are never edited, so a correction is a new entry; what makes it safe is that nobody adjusts alone:
  - the proposal carries a reason and a signed quantity (in the unit of the person's policy), and moves FORWARD only: proposed -> approved | rejected
  - the decider is neither the proposer nor the person whose balance it is, under either login (email OR sub: the same person under either is the same)
  - the entry is keyed by the proposal's id (`adj:<person>:<type>:<year>:<n>`), so one proposal is one entry"""
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Optional

from brick_time_off.domain.errors import RuleBroken
from brick_time_off.domain.request import Identity

STATES = ("proposed", "approved", "rejected")
TERMINAL = ("approved", "rejected")
MAX_QUANTITY = Decimal("400")
MOVES = {"approve_adjustment": (("proposed",), "approved"), "reject_adjustment": (("proposed",), "rejected")}


def parse_quantity(text: str) -> Decimal:
    """A signed, non-zero amount of at most four decimals and 400 either way, or ValueError in words the caller can act on."""
    try:
        q = Decimal(str(text).strip())
    except InvalidOperation:
        raise ValueError(f"quantity {str(text)[:24]!r} is not a number like 1.00 or -0.5") from None
    if not q.is_finite() or q == 0:
        raise ValueError("quantity is a number other than zero, like 1.00 or -0.5")
    if abs(q) > MAX_QUANTITY:
        raise ValueError(f"quantity is at most {MAX_QUANTITY} either way")
    if q != q.quantize(Decimal("0.0001")):
        raise ValueError("quantity has at most 4 decimals")
    return q


@dataclass
class Adjustment:
    adjustment_id: str
    person_id: int
    company_code: str
    leave_type_code: str
    leave_year: int
    quantity: Decimal
    unit: str
    on_date: date
    reason: str
    proposed_by: Identity
    status: str = "proposed"
    decided_by: Identity = Identity()
    decided_at: Optional[datetime] = None
    decision_reason: str = ""
    created_at: Optional[datetime] = None

    def move(self, key: str) -> None:
        starts, end = MOVES[key]
        if self.status not in starts:
            raise RuleBroken(f"Adjustment {self.adjustment_id} is {self.status}: {key.replace('_', ' ')} needs it to be {' or '.join(starts)}")
        self.status = end