Files
autoficher/app/timenet_api.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

142 lines
5.7 KiB
Python

"""Cliente HTTP de la API de Timenet.
Notas sobre la API (descubiertas el 2026-09-24 contra el servidor 9.1.0):
* Las cabeceras ``Tn-D`` y ``dStr`` (timestamp UTC de la peticion) son OBLIGATORIAS:
sin ellas el servidor responde 403 incluso en endpoints publicos como ``version``.
Hay que refrescarlas en cada peticion.
* Todos los endpoints cuelgan de ``v1/`` menos ``version``.
* El login devuelve el token en el cuerpo, entrecomillado.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import requests
from app import strings
logger = logging.getLogger(__name__)
WCP_VERSION = "wcp_8.0.0.2"
TIMEOUT = 30
class TimenetError(RuntimeError):
"""Fallo de la API de Timenet."""
class TimenetClient:
def __init__(self, api_url: str, user_guid: str, wcp_version: str = WCP_VERSION):
self.api_url = api_url if api_url.endswith("/") else api_url + "/"
self.user_guid = user_guid
self.wcp_version = wcp_version
self.session = requests.Session()
self.token: str | None = None
# ---------------------------------------------------------------- headers
def _headers(self) -> dict[str, str]:
now = datetime.now(timezone.utc)
h = {
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"Referer": "https://timenet-wcp.gpisoftware.com/",
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Linux"',
"Sec-Ch-Ua": '"Chromium";v="138", "Not=A?Brand";v="8", "Google Chrome";v="138"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": strings.user_agent,
"Tn-U": strings.tn_u,
strings.tnv_string: self.wcp_version,
# obligatorias: sin ellas -> 403
strings.tnd_string: '"' + now.strftime("%Y-%m-%dT%H:%M:%SZ") + '"',
"dStr": '"' + now.strftime("%d/%m/%Y %H:%M:%S") + '"',
}
if self.token:
h["Token"] = self.token
return h
# ------------------------------------------------------------------ verbs
def _request(self, method: str, path: str, data: dict | None = None) -> requests.Response:
url = self.api_url + ("" if path == "version" else "v1/") + path
try:
r = self.session.request(method, url, data=data or {},
headers=self._headers(), timeout=TIMEOUT)
except requests.exceptions.RequestException as e:
raise TimenetError(f"{method} {path}: {type(e).__name__}: {e}") from e
logger.debug("%s %s -> %s", method, path, r.status_code)
return r
def _json(self, path: str) -> Any:
r = self._request("GET", path)
if r.status_code != 200:
raise TimenetError(f"GET {path}: HTTP {r.status_code} {r.text[:200]!r}")
try:
return r.json()
except ValueError as e:
raise TimenetError(f"GET {path}: respuesta no es JSON: {r.text[:200]!r}") from e
# -------------------------------------------------------------- endpoints
def version(self) -> str:
r = self._request("GET", "version")
if r.status_code != 200:
raise TimenetError(f"version: HTTP {r.status_code}")
return r.text.strip().strip('"')
def login(self, pin: int | str) -> str:
self.token = None
r = self._request("POST", f"cp/login/{self.user_guid}", {"p": pin})
if r.status_code != 200:
raise TimenetError(f"login rechazado: HTTP {r.status_code} {r.text[:200]!r}")
self.token = r.text.replace('"', "").strip()
if not self.token:
raise TimenetError("login devolvio un token vacio")
return self.token
def info(self) -> dict:
return self._json(f"check/info?guid={self.user_guid}")
def config(self) -> dict:
return self._json("cp/config")
def dashboard(self) -> dict:
return self._json("cp/dashboard")
def raw_calendar(self, start: str | None = None, end: str | None = None) -> dict:
"""cp/calendar: tipos de jornada + asignacion por dia. Sin fechas -> año en curso."""
q = f"?start={start}&end={end}" if start and end else ""
return self._json("cp/calendar" + q)
def raw_checks(self, start: str, end: str) -> dict:
"""cp/checks con fechas dd/mm/yyyy. Devuelve dias, fichajes y saldos."""
return self._json(f"cp/checks?start={start}&end={end}")
# ----------------------------------------------------------------- fichar
def post_check(self, typ: int, latitude, longitude, when: datetime | None = None) -> dict:
"""Registra un fichaje. typ: 0 = Entrada, 1 = Salida.
El unico metodo de esta clase que escribe. Devuelve el JSON de respuesta.
"""
now = (when or datetime.now(timezone.utc)).astimezone(timezone.utc)
data = {
"typ": typ,
"date": '"' + now.strftime("%Y-%m-%dT%H:%M:%SZ") + '"',
"geoLatitude": latitude,
"geoLongitude": longitude,
"geoError": "",
"dStr": now.strftime("%d/%m/%Y %H:%M:%S"),
}
r = self._request("POST", "cp/checks", data)
if r.text.strip() == "no valid worker":
raise TimenetError("el trabajador no ha podido ser identificado")
if r.status_code != 200:
raise TimenetError(f"fichaje rechazado: HTTP {r.status_code} {r.text[:200]!r}")
try:
return r.json()
except ValueError as e:
raise TimenetError(f"respuesta del fichaje no es JSON: {r.text[:200]!r}") from e