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:
97
tools/dryrun.py
Normal file
97
tools/dryrun.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simula el plan de todo el año y comprueba que Timenet contaria 0 de saldo. No ficha nada."""
|
||||
import json, os, random, sys
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from app import settings, schedule, jitter
|
||||
from app.planner import Planner, ENTRADA
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CACHE = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "data", "calendar_cache.json")
|
||||
|
||||
|
||||
def load_calendar(cfg):
|
||||
if CACHE and os.path.exists(CACHE):
|
||||
print("calendario desde cache: %s" % CACHE)
|
||||
return schedule.Calendar(json.load(open(CACHE)))
|
||||
from app.timenet_api import TimenetClient
|
||||
cli = TimenetClient(cfg["api_url"], cfg["user"])
|
||||
cli.login(settings.pin(cfg))
|
||||
return schedule.Calendar(cli.raw_calendar())
|
||||
|
||||
|
||||
def main():
|
||||
cfg = settings.load()
|
||||
cal = load_calendar(cfg)
|
||||
rng = random.Random(int(os.environ.get("SEED", "1234")))
|
||||
pl = Planner(cfg, store=None)
|
||||
|
||||
lo, hi = cal.span
|
||||
dias = 0
|
||||
tot_P = tot_W = tot_pres = 0
|
||||
offs = {"ENTRADA": [], "SALIDA": []}
|
||||
muestra = []
|
||||
minutos = {"ENTRADA": Counter(), "SALIDA": Counter()}
|
||||
|
||||
d = lo
|
||||
while d <= hi:
|
||||
if cal.is_workday(d):
|
||||
plan = pl.plan_day(cal, d, rng)
|
||||
dias += 1
|
||||
for wi, w in enumerate(cal.windows(d)):
|
||||
ent = [p for p in plan if p.window_idx == wi and p.typ == ENTRADA][0]
|
||||
sal = [p for p in plan if p.window_idx == wi and p.typ != ENTRADA][0]
|
||||
# Timenet trabaja a granularidad de MINUTO: trunca los segundos
|
||||
em = ent.planned_at.hour * 60 + ent.planned_at.minute
|
||||
sm = sal.planned_at.hour * 60 + sal.planned_at.minute
|
||||
tot_W += min(sm, w.end) - max(em, w.start)
|
||||
tot_P += w.minutes
|
||||
tot_pres += sm - em
|
||||
for p in plan:
|
||||
offs[p.label].append(p.offset_seconds)
|
||||
minutos[p.label][p.planned_at.strftime("%H:%M")] += 1
|
||||
if len(muestra) < 5:
|
||||
muestra.append((d, plan))
|
||||
d += timedelta(days=1)
|
||||
|
||||
saldo = tot_W - tot_P
|
||||
print("\ndias laborables simulados: %d (%s -> %s)" % (dias, lo, hi))
|
||||
print(" previsto P = %6d min" % tot_P)
|
||||
print(" contado W = %6d min" % tot_W)
|
||||
print(" SALDO = %+d min <-- objetivo: 0" % saldo)
|
||||
print(" presencia = %6d min (%+d vs previsto = %.2f min/dia de mas)"
|
||||
% (tot_pres, tot_pres - tot_P, (tot_pres - tot_P) / dias))
|
||||
|
||||
print("\n=== margen respecto de la hora teorica (segundos, lado seguro) ===")
|
||||
for k, v in offs.items():
|
||||
v = sorted(v)
|
||||
print(" %-7s n=%-4d min %3d p25 %3d mediana %3d p75 %3d max %3d media %.0f"
|
||||
% (k, len(v), v[0], v[len(v) // 4], v[len(v) // 2], v[3 * len(v) // 4], v[-1], sum(v) / len(v)))
|
||||
|
||||
print("\n=== minuto en que lo registra Timenet ===")
|
||||
for k in ("ENTRADA", "SALIDA"):
|
||||
top = sorted(minutos[k].items())[:10]
|
||||
print(" %-7s %s" % (k, " ".join("%s x%d" % t for t in top)))
|
||||
|
||||
print("\n=== muestra de dias ===")
|
||||
for d, plan in muestra:
|
||||
print(" %s %s (%s)" % (d, d.strftime("%a"), cal.day_type(d).name.strip()))
|
||||
for p in plan:
|
||||
print(" %s" % p)
|
||||
|
||||
print("\n=== jitter de posicion (8 muestras, radio %s m) ===" % cfg["geo_jitter_meters"])
|
||||
g = cfg["geo"]
|
||||
for _ in range(8):
|
||||
la, ln = jitter.jitter_geo(g["latitude"], g["longitude"], cfg["geo_jitter_meters"], rng)
|
||||
print(" %.6f,%.6f" % (la, ln))
|
||||
|
||||
if saldo < 0:
|
||||
print("\n!! SALDO NEGATIVO: el plan pierde minutos")
|
||||
sys.exit(1)
|
||||
print("\nOK: ningun minuto perdido.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
151
tools/test_daemon.py
Normal file
151
tools/test_daemon.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ejercita el servicio con reloj simulado, sin red ni Telegram.
|
||||
|
||||
Comprueba el ciclo completo: planificar -> avisar -> fichar, el killswitch,
|
||||
el retraso de 15 min, la pausa y la recuperacion de fichajes perdidos.
|
||||
"""
|
||||
import asyncio, json, os, sys, tempfile
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from app import settings, schedule
|
||||
from app.daemon import ANNOUNCE_LEAD, Service
|
||||
from app.planner import ENTRADA
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CAL = json.load(open(os.path.join(ROOT, "data", "calendar_cache.json")))
|
||||
OK = FAIL = 0
|
||||
|
||||
|
||||
def check(cond, label):
|
||||
global OK, FAIL
|
||||
print((" ✅ " if cond else " ❌ ") + label)
|
||||
OK, FAIL = OK + bool(cond), FAIL + (not cond)
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
"""Sustituye la red por respuestas deterministas."""
|
||||
def __init__(self):
|
||||
self.cal = schedule.Calendar(CAL)
|
||||
self.checks = []
|
||||
self.fail_next = False
|
||||
|
||||
def ensure_login(self, force=False): pass
|
||||
def calendar(self, force=False): return self.cal
|
||||
def balance(self, day=None): return {"P": 9750, "W": 7810, "X": -50, "XAnt": -726, "total": -776}
|
||||
def today_summary(self, day=None): return {"checks": [], "P": 510, "W": 0, "X": 0, "tipo": "H 8"}
|
||||
|
||||
def do_check(self, typ):
|
||||
if self.fail_next:
|
||||
self.fail_next = False
|
||||
raise RuntimeError("timeout simulado")
|
||||
self.checks.append(typ)
|
||||
return {"typ": typ, "repeated": False, "check_id": 1000 + len(self.checks),
|
||||
"recorded": "09:00", "real": "08:58"}
|
||||
|
||||
|
||||
class FakeService(Service):
|
||||
def __init__(self, cfg, db):
|
||||
super().__init__(cfg, pin="0000", db_path=db)
|
||||
self.executor = FakeExecutor()
|
||||
self.clock = datetime(2026, 9, 28, 3, 0, tzinfo=self.tz) # lunes laborable
|
||||
self.sent = []
|
||||
|
||||
def now(self): return self.clock
|
||||
async def notify(self, text, keyboard=None):
|
||||
self.sent.append((text, keyboard))
|
||||
class M: message_id = len(self.sent)
|
||||
return M()
|
||||
|
||||
async def advance_to(self, when):
|
||||
"""Avanza el reloj disparando tick() en cada evento intermedio."""
|
||||
while self.clock < when:
|
||||
nxt = self._next_event()
|
||||
self.clock = min(when, max(nxt, self.clock + timedelta(seconds=1)))
|
||||
await self.tick()
|
||||
|
||||
|
||||
async def main():
|
||||
cfg = settings.load()
|
||||
cfg["use_google_calendar"] = False # el test no debe salir a la red
|
||||
db = os.path.join(tempfile.mkdtemp(), "t.db")
|
||||
svc = FakeService(cfg, db)
|
||||
day = date(2026, 9, 28)
|
||||
|
||||
print("\n--- plan del dia ---")
|
||||
await svc.ensure_plan()
|
||||
plan = svc.store.get_plan(day)
|
||||
check(len(plan) == 4, "se planifican 4 fichajes (lunes, 2 tramos)")
|
||||
for r in plan:
|
||||
print(" idx=%s %-7s %s" % (r["idx"], "ENTRADA" if r["typ"] == ENTRADA else "SALIDA",
|
||||
datetime.fromisoformat(r["planned_at"]).strftime("%H:%M:%S")))
|
||||
p0 = datetime.fromisoformat(plan[0]["planned_at"])
|
||||
check(p0.hour == 8 and p0.minute >= 55, "la primera entrada cae antes de las 09:00")
|
||||
|
||||
print("\n--- aviso con antelacion (killswitch) ---")
|
||||
await svc.advance_to(p0 - ANNOUNCE_LEAD + timedelta(seconds=5))
|
||||
check(any("ENTRADA" in t and "programada" in t for t, _ in svc.sent), "llega el aviso previo")
|
||||
check(any(k is not None for _, k in svc.sent), "el aviso trae teclado inline")
|
||||
check(svc.store.get_check(day, 0)["announced_at"] is not None, "queda marcado como anunciado")
|
||||
check(not svc.executor.checks, "todavia NO se ha fichado")
|
||||
|
||||
print("\n--- el fichaje se ejecuta a su hora ---")
|
||||
await svc.advance_to(p0 + timedelta(seconds=2))
|
||||
check(svc.executor.checks == [ENTRADA], "se ficha la entrada y solo esa")
|
||||
check(svc.store.get_check(day, 0)["state"] == "done", "queda en estado done")
|
||||
check(svc.store.get_check(day, 0)["check_id"] == 1001, "guarda el id devuelto por Timenet")
|
||||
|
||||
print("\n--- killswitch: cancelar el siguiente ---")
|
||||
svc.store.set_state(day, 1, "cancelled", note="prueba")
|
||||
p2 = datetime.fromisoformat(svc.store.get_check(day, 2)["planned_at"])
|
||||
await svc.advance_to(p2 + timedelta(seconds=2))
|
||||
check(svc.executor.checks == [ENTRADA, ENTRADA], "el cancelado NO se ficha, el siguiente si")
|
||||
|
||||
print("\n--- retraso de 15 min ---")
|
||||
svc2 = FakeService(cfg, os.path.join(tempfile.mkdtemp(), "t2.db"))
|
||||
await svc2.ensure_plan()
|
||||
orig = datetime.fromisoformat(svc2.store.get_check(day, 0)["planned_at"])
|
||||
svc2.store.reschedule(day, 0, orig + timedelta(minutes=15))
|
||||
await svc2.advance_to(orig + timedelta(seconds=30))
|
||||
check(not svc2.executor.checks, "tras retrasarlo no ficha a la hora vieja")
|
||||
check(svc2.store.get_check(day, 0)["announced_at"] is None, "se vuelve a anunciar")
|
||||
await svc2.advance_to(orig + timedelta(minutes=15, seconds=5))
|
||||
check(svc2.executor.checks == [ENTRADA], "ficha a la hora nueva")
|
||||
|
||||
print("\n--- pausa global ---")
|
||||
svc3 = FakeService(cfg, os.path.join(tempfile.mkdtemp(), "t3.db"))
|
||||
await svc3.ensure_plan()
|
||||
svc3.set_paused(True)
|
||||
last = datetime.fromisoformat(svc3.store.get_plan(day)[-1]["planned_at"])
|
||||
await svc3.advance_to(last + timedelta(minutes=1))
|
||||
check(not svc3.executor.checks, "en pausa no ficha nada")
|
||||
|
||||
print("\n--- recuperacion: el equipo estaba apagado ---")
|
||||
svc4 = FakeService(cfg, os.path.join(tempfile.mkdtemp(), "t4.db"))
|
||||
await svc4.ensure_plan()
|
||||
svc4.clock = datetime(2026, 9, 28, 16, 0, tzinfo=svc4.tz) # arranca tardisimo
|
||||
await svc4.tick()
|
||||
check(len(svc4.executor.checks) == 3, "ficha los 3 que se habian perdido, en orden")
|
||||
check(any("tarde respecto del plan" in t for t, _ in svc4.sent), "avisa de que van tarde")
|
||||
|
||||
print("\n--- fallo de red al fichar ---")
|
||||
svc5 = FakeService(cfg, os.path.join(tempfile.mkdtemp(), "t5.db"))
|
||||
await svc5.ensure_plan()
|
||||
svc5.executor.fail_next = True
|
||||
f0 = datetime.fromisoformat(svc5.store.get_check(day, 0)["planned_at"])
|
||||
await svc5.advance_to(f0 + timedelta(seconds=2))
|
||||
check(svc5.store.get_check(day, 0)["state"] == "failed", "el fichaje queda marcado failed")
|
||||
check(any("No se pudo fichar" in t for t, _ in svc5.sent), "avisa del fallo por Telegram")
|
||||
|
||||
print("\n--- dia no laborable ---")
|
||||
svc6 = FakeService(cfg, os.path.join(tempfile.mkdtemp(), "t6.db"))
|
||||
svc6.clock = datetime(2026, 9, 24, 3, 0, tzinfo=svc6.tz) # Festiu Local
|
||||
await svc6.ensure_plan()
|
||||
check(not svc6.store.get_plan(date(2026, 9, 24)), "no planifica en festivo local")
|
||||
|
||||
print("\n%d OK, %d fallos" % (OK, FAIL))
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
53
tools/tn_inspect.py
Normal file
53
tools/tn_inspect.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspecciona el calendario laboral y el saldo tal y como los ve Timenet. Solo lectura."""
|
||||
import os, sys
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from app import settings, schedule
|
||||
from app.timenet_api import TimenetClient
|
||||
|
||||
|
||||
def main():
|
||||
cfg = settings.load()
|
||||
cli = TimenetClient(cfg["api_url"], cfg["user"])
|
||||
print("servidor Timenet: %s (cliente manda %s)" % (cli.version(), cli.wcp_version))
|
||||
cli.login(settings.pin(cfg))
|
||||
print("login OK\n")
|
||||
|
||||
cal = schedule.Calendar(cli.raw_calendar())
|
||||
lo, hi = cal.span
|
||||
print("=== TIPOS DE JORNADA (%d) ===" % len(cal.day_types))
|
||||
for dt in sorted(cal.day_types.values(), key=lambda d: d.id):
|
||||
flag = " <- laborable" if dt.is_workday else ""
|
||||
warn = "" if (not dt.windows or dt.consistent) else " !! tramos no cuadran con minutos"
|
||||
print(" %s%s%s" % (dt, flag, warn))
|
||||
print("\ncalendario cargado: %s -> %s (%d dias)" % (lo, hi, len(cal.days)))
|
||||
|
||||
print("\n=== PROXIMOS 14 DIAS ===")
|
||||
hoy = date.today()
|
||||
for i in range(14):
|
||||
d = hoy + timedelta(days=i)
|
||||
dt = cal.day_type(d)
|
||||
if cal.is_workday(d):
|
||||
tramos = " ".join(str(w) for w in cal.windows(d))
|
||||
margenes = ", ".join("ent-%d/sal+%d" % (w.entry_margin, w.exit_margin) for w in cal.windows(d))
|
||||
print(" %s %-3s LABORABLE %-24s %4dmin margenes: %s" % (
|
||||
d, d.strftime("%a"), tramos, dt.minutes, margenes))
|
||||
else:
|
||||
print(" %s %-3s --- %s" % (d, d.strftime("%a"), cal.why_not_workday(d)))
|
||||
|
||||
print("\n=== SALDO (cp/checks del mes en curso) ===")
|
||||
first = hoy.replace(day=1)
|
||||
nxt = (first + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
ck = cli.raw_checks(first.strftime("%d/%m/%Y"), nxt.strftime("%d/%m/%Y"))
|
||||
print(" previsto mes P = %s min" % ck.get("P"))
|
||||
print(" trabajado W = %s min" % ck.get("W"))
|
||||
print(" saldo del mes X = %+s min" % ck.get("X"))
|
||||
print(" saldo anterior XAnt= %+s min" % ck.get("XAnt"))
|
||||
tot = (ck.get("XAnt") or 0) + (ck.get("X") or 0)
|
||||
print(" SALDO ACUMULADO = %+d min (%+.2f h)" % (tot, tot / 60))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user