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:
158
app/store.py
Normal file
158
app/store.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Persistencia en SQLite: plan del dia, overrides manuales y auditoria."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import date, datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "autoficher.db")
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS day_plan (
|
||||
day TEXT NOT NULL, -- YYYY-MM-DD
|
||||
idx INTEGER NOT NULL, -- orden del fichaje dentro del dia
|
||||
typ INTEGER NOT NULL, -- 0 entrada, 1 salida
|
||||
window_idx INTEGER NOT NULL, -- tramo al que pertenece
|
||||
target TEXT NOT NULL, -- hora teorica ISO local
|
||||
planned_at TEXT NOT NULL, -- instante planificado ISO local (con jitter)
|
||||
state TEXT NOT NULL DEFAULT 'pending', -- pending|done|cancelled|skipped|failed
|
||||
fired_at TEXT,
|
||||
check_id INTEGER,
|
||||
note TEXT,
|
||||
announced_at TEXT,
|
||||
message_id INTEGER,
|
||||
PRIMARY KEY (day, idx)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS overrides (
|
||||
day TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL, -- off|on
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
msg TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
|
||||
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);
|
||||
"""
|
||||
|
||||
|
||||
def _d(x) -> str:
|
||||
return x.isoformat() if isinstance(x, (date, datetime)) else str(x)
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str = DEFAULT_PATH):
|
||||
self.path = os.path.abspath(path)
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
self.db = sqlite3.connect(self.path, detect_types=0)
|
||||
self.db.row_factory = sqlite3.Row
|
||||
self.db.execute("PRAGMA journal_mode=WAL")
|
||||
self.db.executescript(SCHEMA)
|
||||
self._migrate()
|
||||
self.db.commit()
|
||||
|
||||
def _migrate(self) -> None:
|
||||
cols = {r["name"] for r in self.db.execute("PRAGMA table_info(day_plan)")}
|
||||
for col, decl in (("announced_at", "TEXT"), ("message_id", "INTEGER")):
|
||||
if col not in cols:
|
||||
self.db.execute(f"ALTER TABLE day_plan ADD COLUMN {col} {decl}")
|
||||
logger.info("migracion: day_plan.%s añadida", col)
|
||||
|
||||
def close(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
# ------------------------------------------------------------- plan diario
|
||||
def save_plan(self, day: date, rows: list[dict]) -> None:
|
||||
"""Reemplaza el plan del dia. No toca fichajes ya realizados."""
|
||||
with self.db:
|
||||
done = {r["idx"] for r in self.db.execute(
|
||||
"SELECT idx FROM day_plan WHERE day=? AND state IN ('done','cancelled')", (_d(day),))}
|
||||
for r in rows:
|
||||
if r["idx"] in done:
|
||||
continue
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO day_plan(day,idx,typ,window_idx,target,planned_at,state)"
|
||||
" VALUES(?,?,?,?,?,?,COALESCE((SELECT state FROM day_plan WHERE day=? AND idx=?),'pending'))",
|
||||
(_d(day), r["idx"], r["typ"], r["window_idx"], _d(r["target"]),
|
||||
_d(r["planned_at"]), _d(day), r["idx"]))
|
||||
|
||||
def get_plan(self, day: date) -> list[sqlite3.Row]:
|
||||
return list(self.db.execute(
|
||||
"SELECT * FROM day_plan WHERE day=? ORDER BY idx", (_d(day),)))
|
||||
|
||||
def pending(self, day: date) -> list[sqlite3.Row]:
|
||||
return list(self.db.execute(
|
||||
"SELECT * FROM day_plan WHERE day=? AND state='pending' ORDER BY idx", (_d(day),)))
|
||||
|
||||
def set_state(self, day: date, idx: int, state: str,
|
||||
fired_at: datetime | None = None, check_id: int | None = None,
|
||||
note: str | None = None) -> None:
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
"UPDATE day_plan SET state=?, fired_at=COALESCE(?,fired_at),"
|
||||
" check_id=COALESCE(?,check_id), note=COALESCE(?,note) WHERE day=? AND idx=?",
|
||||
(state, _d(fired_at) if fired_at else None, check_id, note, _d(day), idx))
|
||||
|
||||
def mark_announced(self, day: date, idx: int, message_id: int | None) -> None:
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
"UPDATE day_plan SET announced_at=?, message_id=? WHERE day=? AND idx=?",
|
||||
(datetime.now().isoformat(timespec="seconds"), message_id, _d(day), idx))
|
||||
|
||||
def reschedule(self, day: date, idx: int, planned_at: datetime) -> None:
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
"UPDATE day_plan SET planned_at=?, announced_at=NULL, message_id=NULL"
|
||||
" WHERE day=? AND idx=? AND state='pending'",
|
||||
(_d(planned_at), _d(day), idx))
|
||||
|
||||
def get_check(self, day: date, idx: int):
|
||||
return self.db.execute(
|
||||
"SELECT * FROM day_plan WHERE day=? AND idx=?", (_d(day), idx)).fetchone()
|
||||
|
||||
# --------------------------------------------------------------- overrides
|
||||
def set_override(self, day: date, action: str, reason: str = "") -> None:
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO overrides(day,action,reason,created_at) VALUES(?,?,?,?)",
|
||||
(_d(day), action, reason, datetime.now().isoformat(timespec="seconds")))
|
||||
|
||||
def get_override(self, day: date) -> sqlite3.Row | None:
|
||||
return self.db.execute("SELECT * FROM overrides WHERE day=?", (_d(day),)).fetchone()
|
||||
|
||||
def clear_override(self, day: date) -> None:
|
||||
with self.db:
|
||||
self.db.execute("DELETE FROM overrides WHERE day=?", (_d(day),))
|
||||
|
||||
def list_overrides(self, since: date | None = None) -> list[sqlite3.Row]:
|
||||
if since:
|
||||
return list(self.db.execute(
|
||||
"SELECT * FROM overrides WHERE day>=? ORDER BY day", (_d(since),)))
|
||||
return list(self.db.execute("SELECT * FROM overrides ORDER BY day"))
|
||||
|
||||
# ------------------------------------------------------------------ varios
|
||||
def log(self, kind: str, msg: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute("INSERT INTO events(ts,kind,msg) VALUES(?,?,?)",
|
||||
(datetime.now().isoformat(timespec="seconds"), kind, msg))
|
||||
|
||||
def recent_events(self, limit: int = 30) -> list[sqlite3.Row]:
|
||||
return list(self.db.execute(
|
||||
"SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,)))
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
row = self.db.execute("SELECT v FROM kv WHERE k=?", (key,)).fetchone()
|
||||
return json.loads(row["v"]) if row else default
|
||||
|
||||
def put(self, key: str, value) -> None:
|
||||
with self.db:
|
||||
self.db.execute("INSERT OR REPLACE INTO kv(k,v) VALUES(?,?)",
|
||||
(key, json.dumps(value)))
|
||||
Reference in New Issue
Block a user