"""Modelo de horarios de Timenet, construido desde ``cp/calendar``. ``cp/calendar`` devuelve dos cosas que juntas describen el calendario laboral entero: * ``DayTypes`` -- catalogo de tipos de jornada. Cada uno lleva su ``schedule`` (tramos entrada/salida), sus minutos previstos y si permite fichar. * ``Calendar`` -- que tipo de jornada toca cada dia del año. Con esto no hace falta adivinar festivos ni vacaciones: los da el propio Timenet. Google Calendar queda solo para ausencias personales puntuales. """ from __future__ import annotations import logging from dataclasses import dataclass, field from datetime import date, datetime logger = logging.getLogger(__name__) # dayMode observados MODE_WITH_SCHEDULE = "WITH_SCHEDULE" MODE_NO_WORK = "NO_WORK" MODE_FREE_HOURS = "FREE_HOURS" MODE_FLEXIBLE = "FLEXIBLE_SCHEDULE" @dataclass(frozen=True) class Window: """Un tramo de trabajo, en minutos desde medianoche.""" start: int end: int entry_margin: int = 0 # minutos antes de `start` en que ya se admite el fichaje exit_margin: int = 0 @property def minutes(self) -> int: return self.end - self.start def hhmm(self, m: int) -> str: return "%02d:%02d" % (m // 60, m % 60) def __str__(self) -> str: return f"{self.hhmm(self.start)}-{self.hhmm(self.end)}" @dataclass class DayType: id: int name: str mode: str minutes: int can_check: bool windows: list[Window] = field(default_factory=list) colour: str | None = None @property def is_workday(self) -> bool: return self.mode == MODE_WITH_SCHEDULE and self.minutes > 0 and bool(self.windows) @property def consistent(self) -> bool: """Los tramos declarados suman los minutos previstos.""" return sum(w.minutes for w in self.windows) == self.minutes def __str__(self) -> str: w = ", ".join(str(x) for x in self.windows) or "-" return f"[{self.id}] {self.name.strip()!r} {self.mode} {self.minutes}min ({w})" def _windows_from_schedule(sched: list[dict]) -> list[Window]: out: list[Window] = [] for s in sched or []: for suf in ("", "2"): eh, em = s.get(f"EntryHour{suf}"), s.get(f"EntryMinute{suf}") xh, xm = s.get(f"ExitHour{suf}"), s.get(f"ExitMinute{suf}") if eh is None or xh is None: continue out.append(Window( start=int(eh) * 60 + int(em or 0), end=int(xh) * 60 + int(xm or 0), entry_margin=int(s.get("EntryMargin") or 0), exit_margin=int(s.get("ExitMargin") or 0), )) return sorted(out, key=lambda w: w.start) def _parse_day(s: str) -> date | None: for fmt in ("%d/%m/%Y", "%Y-%m-%d"): try: return datetime.strptime(s[:10], fmt).date() except (ValueError, TypeError): continue return None class Calendar: """Calendario laboral resuelto: dia -> tipo de jornada -> tramos.""" def __init__(self, raw: dict): self.day_types: dict[int, DayType] = {} for d in raw.get("DayTypes") or []: dt = DayType( id=int(d["idDayType"]), name=(d.get("name") or "").strip(), mode=d.get("dayMode") or "", minutes=int(d.get("minutes") or 0), can_check=bool(d.get("canCheck")), windows=_windows_from_schedule(d.get("schedule")), colour=d.get("colour"), ) if dt.windows and not dt.consistent: logger.warning("Tipo de jornada incoherente: %s (tramos suman %d, previstos %d)", dt, sum(w.minutes for w in dt.windows), dt.minutes) self.day_types[dt.id] = dt self.days: dict[date, int] = {} for bloque in raw.get("Calendar") or []: for item in bloque or []: d = _parse_day(item.get("D", "")) if d is not None: self.days[d] = int(item.get("T")) # ------------------------------------------------------------------ query def day_type(self, d: date) -> DayType | None: tid = self.days.get(d) return self.day_types.get(tid) if tid is not None else None def windows(self, d: date) -> list[Window]: dt = self.day_type(d) return list(dt.windows) if dt else [] def is_workday(self, d: date) -> bool: dt = self.day_type(d) return bool(dt and dt.is_workday and dt.can_check) def why_not_workday(self, d: date) -> str | None: """Motivo legible por el que hoy no toca fichar, o None si si toca.""" dt = self.day_type(d) if dt is None: return f"sin tipo de jornada asignado para {d:%d/%m/%Y}" if not dt.is_workday: return f"{dt.name or dt.mode}" if not dt.can_check: return f"{dt.name}: el tipo de jornada no permite fichar" return None @property def span(self) -> tuple[date, date] | None: return (min(self.days), max(self.days)) if self.days else None