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