Files
autoficher/app/store.py
omar 9294474066 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>
2026-09-24 18:51:33 +02:00

159 lines
6.7 KiB
Python

"""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)))