"""Construye el plan de fichajes de un dia a partir del calendario de Timenet.""" from __future__ import annotations import logging import random from dataclasses import dataclass from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo from app import jitter from app.schedule import Calendar logger = logging.getLogger(__name__) ENTRADA, SALIDA = 0, 1 @dataclass class PlannedCheck: idx: int typ: int # 0 entrada, 1 salida window_idx: int target: datetime # hora teorica planned_at: datetime # instante real en que se fichara @property def label(self) -> str: return "ENTRADA" if self.typ == ENTRADA else "SALIDA" @property def offset_seconds(self) -> int: """Segundos de margen respecto de la teorica. Positivo = lado seguro.""" d = (self.planned_at - self.target).total_seconds() return int(-d if self.typ == ENTRADA else d) def __str__(self) -> str: return "%-7s teorica %s ficha %s (%+ds -> %s)" % ( self.label, self.target.strftime("%H:%M:%S"), self.planned_at.strftime("%H:%M:%S"), int((self.planned_at - self.target).total_seconds()), "antes" if self.typ == ENTRADA else "despues") class Planner: def __init__(self, cfg: dict, store=None): self.cfg = cfg self.store = store self.tz = ZoneInfo(cfg.get("timezone", "Europe/Madrid")) self.j = cfg["jitter"] # ------------------------------------------------------------------ drift def _drift_key(self, window_idx: int, typ: int) -> str: return f"drift.{window_idx}.{typ}" def _drift(self, window_idx: int, typ: int, rng: random.Random) -> float: key = self._drift_key(window_idx, typ) prev = float(self.store.get(key, 0.0)) if self.store else 0.0 nxt = jitter.next_drift(prev, self.j["drift_sigma"], self.j["drift_clamp"], rng) if self.store: self.store.put(key, nxt) return nxt # ------------------------------------------------------------------- plan def plan_day(self, cal: Calendar, day: date, rng: random.Random | None = None) -> list[PlannedCheck]: """Plan del dia. Lista vacia si no toca fichar.""" if not cal.is_workday(day): logger.info("%s no es laborable: %s", day, cal.why_not_workday(day)) return [] rng = rng or random.Random() out: list[PlannedCheck] = [] idx = 0 for wi, w in enumerate(cal.windows(day)): start = datetime.combine(day, datetime.min.time(), self.tz) + timedelta(minutes=w.start) end = datetime.combine(day, datetime.min.time(), self.tz) + timedelta(minutes=w.end) # ENTRADA: siempre antes de la hora teorica off = jitter.offset_seconds( self.j["entry_before_min"], self.j["entry_before_max"], self._drift(wi, ENTRADA, rng), rng, # el margen de entrada del propio Timenet acota cuanto se puede adelantar outlier_cap=min(self.j["entry_before_max"] * 3, max(w.entry_margin * 60 // 2, 60)), ) off = max(off, self.j["entry_guard"]) out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off))) idx += 1 # SALIDA: siempre despues off = jitter.offset_seconds( self.j["exit_after_min"], self.j["exit_after_max"], self._drift(wi, SALIDA, rng), rng, outlier_cap=self.j["exit_after_max"] * 4, ) out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off))) idx += 1 self._validate(out, cal, day) return out # --------------------------------------------------------------- garantias def _validate(self, plan: list[PlannedCheck], cal: Calendar, day: date) -> None: """Invariantes que, si se rompen, cuestan minutos de verdad.""" for p in plan: if p.typ == ENTRADA and p.planned_at >= p.target: raise AssertionError(f"entrada planificada tarde: {p}") if p.typ == SALIDA and p.planned_at <= p.target: raise AssertionError(f"salida planificada pronto: {p}") for a, b in zip(plan, plan[1:]): if b.planned_at <= a.planned_at: raise AssertionError(f"fichajes desordenados: {a} / {b}") def rows_for_store(self, plan: list[PlannedCheck]) -> list[dict]: return [{"idx": p.idx, "typ": p.typ, "window_idx": p.window_idx, "target": p.target.isoformat(), "planned_at": p.planned_at.isoformat()} for p in plan]