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