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:
141
app/timenet_api.py
Normal file
141
app/timenet_api.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user