Files
autoficher/app/executor.py
omar f2ed694f98 feat: comando /grafico con cinco visualizaciones
Cinco graficos generados con matplotlib y enviados como foto a Telegram, con
botones para elegir. La paleta es la de referencia del proyecto, validada con el
comprobador de daltonismo en claro y oscuro (peor par CVD delta-E 24.7 / 26.8,
umbral 8).

- saldo        acumulado (linea) + saldo de cada dia (barras), dos paneles
- puntualidad  desviacion de cada entrada en el tiempo. Es el que explica el
               problema de un vistazo: en 6 meses no hay ni un solo punto por
               debajo de cero, o sea que nunca se ha llegado antes de la hora
- horas        histograma de la hora de entrada, historico frente a planificado.
               Las dos distribuciones no se solapan en absoluto
- regalado     minutos al mes presente sin que corra el reloj
- prevision    linea de tiempo de los proximos dias con la franja que cuenta

Un PNG no tiene hover, asi que las cifras que en una pagina resolveria el
tooltip van en el pie de foto y en etiquetas directas sobre las marcas.

Al mirarlos renderizados aparecieron cuatro defectos que el codigo no delata:
titulo y subtitulo pisandose, la etiqueta del peor dia chocando con el eje de
fechas, un dia de +64 min aplastando el resto del eje contra el cero, y un bug
de formato que pintaba "09:64" en vez de "10:04". Corregidos: ejes acotados con
los valores extremos anotados aparte, y las horas formateadas con un helper.

Executor.historico() extrae los dias de varios meses ya parseados; el servicio
lo cachea media hora porque son seis llamadas a la API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 20:05:20 +02:00

171 lines
6.8 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 calendar
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 historico(self, meses: int = 6) -> list[dict]:
"""Un registro por dia laborable de los ultimos `meses`, ordenado.
Cada registro: fecha, previsto (P), contado (W), saldo (X), presencia real
(A, el tiempo entre fichajes sin recortar) y los fichajes con su hora real
(HR) y la que cuenta (H).
"""
hoy = date.today()
y, m = hoy.year, hoy.month
periodos = []
for _ in range(meses):
periodos.append((y, m))
m -= 1
if m == 0:
m, y = 12, y - 1
out: list[dict] = []
for y, m in reversed(periodos):
ultimo = calendar.monthrange(y, m)[1]
try:
raw = self._with_login(self.cli.raw_checks,
"01/%02d/%d" % (m, y), "%02d/%02d/%d" % (ultimo, m, y))
except TimenetError as e:
logger.warning("no se pudo leer %d-%02d: %s", y, m, e)
continue
for dia in raw.get("C") or []:
cs = dia.get("C") or []
if not cs or not (dia.get("P") or 0):
continue
try:
f = datetime.strptime(dia["D"][:10], "%d/%m/%Y").date()
except (KeyError, ValueError):
continue
out.append({
"fecha": f,
"P": dia.get("P") or 0,
"W": dia.get("W") or 0,
"X": dia.get("X") or 0,
"A": (dia.get("A") or [{}])[0].get("M") or 0,
"tipo": (dia.get("T") or "").strip(),
"checks": [{"typ": c.get("T"), "H": c.get("H"), "HR": c.get("HR")}
for c in cs],
})
out.sort(key=lambda r: r["fecha"])
return out
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,
}