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>
124 lines
5.0 KiB
Python
124 lines
5.0 KiB
Python
"""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,
|
|
}
|