dodil/erp-brick-time-off

DODIL ERP brick: time off

src/brick_time_off/domain/odoo.py

"""Odoo 17's hr_holidays as our requests and opening entries (TOF-23): the arithmetic of the mapping, no I/O.

Plain Python. History is carried read-only and never re-priced: a validated leave's cost is Odoo's own number (days; hours for a person who books in hours),
and the opening entry brings each person's AVAILABLE to what Odoo says (allocated - taken), whatever this module derives.
  hr.leave state validate        -> an approved request  odoo:<db>:hr.leave:<id> (revision 1, take entry req:<id>:r1)
  hr.leave state confirm/validate1 (waiting) -> not carried; draft/refuse -> not carried
  hr.leave.allocation validate   -> one opening entry per person, type and leave year: open:<person>:<type>:<year> = allocated - what this module's derivation
                                    (entitlement - bank holidays - closures + entries already there) gives, in the person's unit"""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Optional

from brick_time_off.domain.calendar import ZERO, q4
from brick_time_off.domain.errors import RuleBroken

MODELS = ("hr.leave", "hr.leave.allocation")


def ref_id(v) -> Optional[int]:
    """An Odoo many2one: [id, name] or an id (or False)."""
    if isinstance(v, (list, tuple)):
        return int(v[0]) if v else None
    return int(v) if v else None


def request_id(db: str, leave_id) -> str:
    return f"odoo:{db}:hr.leave:{int(leave_id)}"


def number(v, what: str) -> Decimal:
    try:
        d = Decimal(str(v if v not in (None, False) else 0))
    except InvalidOperation:
        raise RuleBroken(f"{what} {str(v)[:20]!r} is not a number") from None
    if not d.is_finite() or d < 0 or d > Decimal("100000"):
        raise RuleBroken(f"{what} {d} is out of range")
    return q4(d)


def iso_date(v) -> Optional[date]:
    """A date from Odoo's 'YYYY-MM-DD' (or a datetime string): None when it holds none."""
    try:
        return date.fromisoformat(str(v)[:10]) if v else None
    except ValueError:
        return None


def parts_of(x: dict) -> tuple:
    """(from_part, to_part) of an Odoo leave: a whole-day request is full/full; a half day is that half (am|pm) on its one day. An hours request has
    no half to claim here and is refused by name (RuleBroken)."""
    if x.get("request_unit_hours"):
        raise RuleBroken(f"leave {x.get('id')} is booked in hours of the day: this module books halves of a day")
    if x.get("request_unit_half"):
        period = x.get("request_date_from_period") or "am"
        if period not in ("am", "pm"):
            raise RuleBroken(f"leave {x.get('id')} has the half-day period {period!r}")
        return period, period
    return "full", "full"


def cost_of(x: dict, unit: str, day_minutes: int) -> Decimal:
    """Odoo's own number: days for a person who books in days; hours for one who books in hours (Odoo's hours when it gives them, else its days times the
    contracted day)."""
    days = number(x.get("number_of_days"), "number_of_days")
    if unit == "days":
        return days
    hours = number(x.get("number_of_hours"), "number_of_hours")
    return hours if hours > 0 else q4(days * Decimal(day_minutes) / 60)


def in_days(quantity: Decimal, unit: str, day_minutes: int) -> Decimal:
    """What a quantity of ours is in Odoo's days: itself for days, hours over the contracted day for hours."""
    if unit == "days":
        return quantity
    return q4(quantity * 60 / Decimal(day_minutes)) if day_minutes else ZERO


def in_unit(days: Decimal, unit: str, day_minutes: int) -> Decimal:
    return days if unit == "days" else q4(days * Decimal(day_minutes) / 60)


def month_of(d: date) -> str:
    return f"{d.year:04d}-{d.month:02d}"