feat: nucleo nuevo: cliente API, calendario laboral, planificador y jitter
Timenet recorta el tiempo a la ventana teorica del horario:
trabajado = min(salida, fin_teorico) - max(entrada, inicio_teorico)
Verificado sobre 6 meses (104 dias laborables, 376 fichajes). La API guarda dos
horas por fichaje: HR (la que fichas) y H (la que cuenta). De 376 fichajes, 136
llevaban ajuste: todos salidas, todos recortados hacia atras. Ninguna entrada
ajustada jamas. Resultado: una entrada tardia se debita al minuto y una salida
tardia no acredita nada, asi que el jitter aleatorio positivo que habia perdia
unos 3,2 min/dia (-776 min acumulados, casi 13 horas).
Y acabar el mes en positivo es imposible: 0 dias con saldo positivo en 108, y
timeExtraHours=false en todos los tipos de jornada. El objetivo es 0 clavado.
- timenet_api: cliente limpio. Documenta que Tn-D y dStr son obligatorias (sin
ellas el servidor responde 403 hasta en endpoints publicos).
- schedule: calendario laboral desde cp/calendar. Festivos, vacaciones y tramos
horarios los da Timenet, no hace falta adivinarlos.
- planner + jitter: entradas siempre ANTES de la hora, salidas siempre DESPUES,
con costumbre que deriva lenta y cola larga hacia el lado seguro. Invariantes
que fallan en duro si una entrada se planifica tarde.
- jitter_geo: las coordenadas del historico son identicas al sexto decimal en
los 376 fichajes; ahora se desplazan dentro de un disco.
- store: SQLite con plan diario, overrides, auditoria y estado del drift.
- settings: config sin el acoplamiento a argparse del config_parser original.
tools/dryrun.py simula el año entero: saldo +0 en 226 dias laborables, con
~3,5 min/dia de presencia extra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
149
app/schedule.py
Normal file
149
app/schedule.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user