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:
123
app/executor.py
Normal file
123
app/executor.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user