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:
2026-09-24 18:51:33 +02:00
parent 51976fbe22
commit 9294474066
10 changed files with 1123 additions and 0 deletions

123
app/executor.py Normal file
View File

@@ -0,0 +1,123 @@
"""Ejecuta los fichajes contra Timenet y consulta el estado real.
Todo lo de este modulo es sincrono (usa ``requests``). El daemon lo invoca con
``asyncio.to_thread`` para no bloquear el bucle de eventos.
"""
from __future__ import annotations
import logging
import random
from datetime import date, datetime, timedelta
from app import jitter, schedule
from app.timenet_api import TimenetClient, TimenetError
logger = logging.getLogger(__name__)
ENTRADA, SALIDA = 0, 1
CALENDAR_TTL = timedelta(hours=12)
class Executor:
def __init__(self, cfg: dict, store, pin: str):
self.cfg = cfg
self.store = store
self.pin = pin
self.cli = TimenetClient(cfg["api_url"], cfg["user"])
self._cal: schedule.Calendar | None = None
self._cal_at: datetime | None = None
# ------------------------------------------------------------------ login
def ensure_login(self, force: bool = False) -> None:
if self.cli.token and not force:
return
self.cli.login(self.pin)
logger.info("login en Timenet OK")
def _with_login(self, fn, *a, **kw):
"""Ejecuta y reintenta una vez con login nuevo si el token ha caducado."""
self.ensure_login()
try:
return fn(*a, **kw)
except TimenetError as e:
logger.warning("fallo en %s (%s); reintentando con login nuevo", fn.__name__, e)
self.ensure_login(force=True)
return fn(*a, **kw)
# -------------------------------------------------------------- calendario
def calendar(self, force: bool = False) -> schedule.Calendar:
fresh = self._cal_at and datetime.now() - self._cal_at < CALENDAR_TTL
if self._cal and fresh and not force:
return self._cal
raw = self._with_login(self.cli.raw_calendar)
self._cal = schedule.Calendar(raw)
self._cal_at = datetime.now()
logger.info("calendario refrescado: %d dias, %d tipos de jornada",
len(self._cal.days), len(self._cal.day_types))
return self._cal
# ------------------------------------------------------------------ fichar
def do_check(self, typ: int) -> dict:
"""Ficha de verdad. Devuelve un resumen con lo que Timenet ha registrado."""
g = self.cfg.get("geo") or {}
lat, lon = jitter.jitter_geo(
g.get("latitude", 0), g.get("longitude", 0),
float(self.cfg.get("geo_jitter_meters") or 0), random.Random())
logger.info("fichando typ=%s en %.6f,%.6f", typ, lat, lon)
rj = self._with_login(self.cli.post_check, typ, lat, lon)
out = {"typ": typ, "lat": lat, "lon": lon, "raw": rj,
"repeated": bool(rj.get("Repeated")), "check_id": None,
"recorded": None, "real": None}
try:
out.update(self._confirm(typ))
except Exception as e: # la confirmacion nunca debe tumbar el fichaje
logger.warning("no se pudo confirmar el fichaje: %s", e)
return out
def _confirm(self, typ: int) -> dict:
"""Relee los fichajes de hoy y devuelve el ultimo, tal y como lo ve Timenet."""
today = self.today_raw()
dias = today.get("C") or []
if not dias:
return {}
checks = dias[-1].get("C") or []
if not checks:
return {}
last = checks[-1]
return {"check_id": last.get("I"),
"recorded": last.get("H"), # hora que CUENTA
"real": last.get("HR")} # hora realmente fichada
# ------------------------------------------------------------------ estado
def today_raw(self, day: date | None = None) -> dict:
d = (day or date.today()).strftime("%d/%m/%Y")
return self._with_login(self.cli.raw_checks, d, d)
def month_raw(self, day: date | None = None) -> dict:
d = day or date.today()
first = d.replace(day=1)
last = (first + timedelta(days=32)).replace(day=1) - timedelta(days=1)
return self._with_login(self.cli.raw_checks,
first.strftime("%d/%m/%Y"), last.strftime("%d/%m/%Y"))
def balance(self, day: date | None = None) -> dict:
m = self.month_raw(day)
x, xant = m.get("X") or 0, m.get("XAnt") or 0
return {"P": m.get("P") or 0, "W": m.get("W") or 0,
"X": x, "XAnt": xant, "total": x + xant}
def today_summary(self, day: date | None = None) -> dict:
"""Fichajes de hoy segun Timenet, con hora real y hora contada."""
raw = self.today_raw(day)
dias = raw.get("C") or []
if not dias:
return {"checks": [], "P": 0, "W": 0, "X": 0, "tipo": None}
d0 = dias[-1]
return {
"checks": [{"typ": c.get("T"), "recorded": c.get("H"), "real": c.get("HR"),
"id": c.get("I")} for c in (d0.get("C") or [])],
"P": d0.get("P") or 0, "W": d0.get("W") or 0, "X": d0.get("X") or 0,
"tipo": (d0.get("T") or "").strip() or None,
}