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>
142 lines
5.7 KiB
Python
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
|