Files
autoficher/app/schedule.py
omar 9294474066 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>
2026-09-24 18:51:33 +02:00

150 lines
5.0 KiB
Python

"""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