feat: comando /grafico con cinco visualizaciones

Cinco graficos generados con matplotlib y enviados como foto a Telegram, con
botones para elegir. La paleta es la de referencia del proyecto, validada con el
comprobador de daltonismo en claro y oscuro (peor par CVD delta-E 24.7 / 26.8,
umbral 8).

- saldo        acumulado (linea) + saldo de cada dia (barras), dos paneles
- puntualidad  desviacion de cada entrada en el tiempo. Es el que explica el
               problema de un vistazo: en 6 meses no hay ni un solo punto por
               debajo de cero, o sea que nunca se ha llegado antes de la hora
- horas        histograma de la hora de entrada, historico frente a planificado.
               Las dos distribuciones no se solapan en absoluto
- regalado     minutos al mes presente sin que corra el reloj
- prevision    linea de tiempo de los proximos dias con la franja que cuenta

Un PNG no tiene hover, asi que las cifras que en una pagina resolveria el
tooltip van en el pie de foto y en etiquetas directas sobre las marcas.

Al mirarlos renderizados aparecieron cuatro defectos que el codigo no delata:
titulo y subtitulo pisandose, la etiqueta del peor dia chocando con el eje de
fechas, un dia de +64 min aplastando el resto del eje contra el cero, y un bug
de formato que pintaba "09:64" en vez de "10:04". Corregidos: ejes acotados con
los valores extremos anotados aparte, y las horas formateadas con un helper.

Executor.historico() extrae los dias de varios meses ya parseados; el servicio
lo cachea media hora porque son seis llamadas a la API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 20:05:20 +02:00
parent ba0d6b5f08
commit f2ed694f98
5 changed files with 444 additions and 1 deletions

View File

@@ -13,7 +13,7 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
from telegram.constants import ParseMode from telegram.constants import ParseMode
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
from app import jitter, profiles from app import charts, jitter, profiles
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -103,6 +103,7 @@ MENU: list[tuple[str, str]] = [
("plan", "Ver el plan y re-tirar los dados"), ("plan", "Ver el plan y re-tirar los dados"),
("perfil", "Ver y cambiar el perfil de jitter"), ("perfil", "Ver y cambiar el perfil de jitter"),
("proximos", "Previsión de los próximos días [nº de días]"), ("proximos", "Previsión de los próximos días [nº de días]"),
("grafico", "Gráficos: saldo, puntualidad, previsión…"),
("fichar", "Fichar ENTRADA ahora"), ("fichar", "Fichar ENTRADA ahora"),
("desfichar", "Fichar SALIDA ahora"), ("desfichar", "Fichar SALIDA ahora"),
("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"), ("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"),
@@ -133,6 +134,7 @@ class BotUI:
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help, "start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan, "hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil, "proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
"grafico": self.cmd_grafico, "graficos": self.cmd_grafico,
"off": self.cmd_off, "on": self.cmd_on, "off": self.cmd_off, "on": self.cmd_on,
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar, "fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar, "pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
@@ -349,6 +351,80 @@ class BotUI:
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>", self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado()) parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
GRAFICOS = {
"saldo": "📉 Saldo acumulado",
"puntualidad": "🎯 Puntualidad",
"horas": "🕘 Hora de entrada",
"regalado": "🎁 Tiempo regalado",
"prevision": "📅 Previsión",
}
async def cmd_grafico(self, update: Update, ctx) -> None:
if ctx.args and ctx.args[0].lower() in self.GRAFICOS:
await update.effective_message.reply_text("⏳ Generando…")
await self._enviar_grafico(ctx.args[0].lower())
return
botones = [InlineKeyboardButton(txt, callback_data=f"{CB}:graf:{k}")
for k, txt in self.GRAFICOS.items()]
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
await update.effective_message.reply_text(
"<b>Gráficos</b>\nElige uno:", parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(filas))
async def _enviar_grafico(self, cual: str) -> None:
try:
png, pie = await self._generar(cual)
except Exception as e:
logger.exception("fallo generando el gráfico %s", cual)
await self.send(f"🟥 No se pudo generar el gráfico: {e}")
return
await self.svc.app.bot.send_photo(
chat_id=self.chat_id, photo=png, caption=pie, parse_mode=ParseMode.HTML)
async def _generar(self, cual: str) -> tuple[bytes, str]:
"""Devuelve (png, pie de foto). El pie lleva las cifras, porque una imagen
no tiene tooltip donde consultarlas."""
tema = self.svc.cfg.get("chart_theme", "dark")
if cual == "prevision":
dias = self.svc.datos_prevision()
if not dias:
raise ValueError("no hay nada planificado todavía")
png = await asyncio.to_thread(charts.prevision, dias, tema)
n = sum(len(d["checks"]) for d in dias)
return png, f"<b>{len(dias)} días</b> planificados · {n} fichajes"
hist = await self.svc.historico()
if not hist:
raise ValueError("no hay histórico que dibujar")
b = await asyncio.to_thread(self.svc.executor.balance)
if cual == "saldo":
xant = b["total"] - sum(r["X"] for r in hist)
png = await asyncio.to_thread(charts.saldo, hist, xant, tema)
peor = min(hist, key=lambda r: r["X"])
return png, (f"Acumulado <b>{hm(b['total'])}</b> · "
f"{len(hist)} días · peor día {peor['fecha']:%d/%m} ({peor['X']} min)")
if cual == "puntualidad":
png = await asyncio.to_thread(charts.puntualidad, hist, tema)
ents = [c for r in hist for c in r["checks"] if c["typ"] == ENTRADA]
return png, (f"{len(ents)} entradas · ninguna por debajo de cero: "
f"nunca has llegado antes de la hora")
if cual == "horas":
png = await asyncio.to_thread(charts.horas, hist,
self.svc.entradas_planificadas(), tema)
return png, "Naranja pierde minutos, azul no. Las dos nubes no se tocan."
if cual == "regalado":
png = await asyncio.to_thread(charts.regalado, hist, tema)
tot = sum(max(0, r["A"] - r["W"]) for r in hist)
return png, (f"<b>{tot} min</b> ({tot / 60:.1f} h) presente sin que "
f"corra el reloj, en {len(hist)} días")
raise ValueError(f"gráfico desconocido: {cual}")
async def cmd_off(self, update: Update, ctx) -> None: async def cmd_off(self, update: Update, ctx) -> None:
days = parse_range(ctx.args, self.svc.today()) days = parse_range(ctx.args, self.svc.today())
if not days: if not days:
@@ -428,6 +504,11 @@ class BotUI:
await self._cambiar_perfil(q, partes[2]) await self._cambiar_perfil(q, partes[2])
return return
if action == "graf":
await q.answer("Generando…")
await self._enviar_grafico(partes[2])
return
try: try:
day, idx = date.fromisoformat(partes[2]), int(partes[3]) day, idx = date.fromisoformat(partes[2]), int(partes[3])
except (IndexError, ValueError): except (IndexError, ValueError):

269
app/charts.py Normal file
View File

@@ -0,0 +1,269 @@
"""Graficos para Telegram. Devuelven PNG en bytes.
Paleta: la de referencia del proyecto, validada con el comprobador de daltonismo
en modo claro y oscuro (2 series azul/naranja, peor par CVD delta-E 24.7 claro /
26.8 oscuro, muy por encima del umbral de 8).
Un PNG no tiene hover, asi que lo que en una pagina web resolveria el tooltip
aqui va en etiquetas directas sobre las marcas que importan.
"""
from __future__ import annotations
import io
import logging
from datetime import date, datetime, timedelta
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.ticker import MaxNLocator # noqa: E402
logger = logging.getLogger(__name__)
TEMAS = {
"dark": {
"surface": "#1a1a19", "ink": "#ffffff", "ink2": "#c3c2b7", "muted": "#6b6a63",
"s1": "#3987e5", "s2": "#d95926", "pos": "#3987e5", "neg": "#e66767",
"zero": "#383835", "grid": "#2b2b29",
},
"light": {
"surface": "#fcfcfb", "ink": "#0b0b0b", "ink2": "#52514e", "muted": "#8a8880",
"s1": "#2a78d6", "s2": "#eb6834", "pos": "#2a78d6", "neg": "#e34948",
"zero": "#f0efec", "grid": "#e8e7e3",
},
}
DIAS_CORTO = ("L", "M", "X", "J", "V", "S", "D")
def _hhmm(minutos_del_dia: int) -> str:
return "%02d:%02d" % (minutos_del_dia // 60, minutos_del_dia % 60)
def _fig(t, w=10.0, h=5.0, filas=1, **kw):
fig, ax = plt.subplots(filas, 1, figsize=(w, h), dpi=150,
facecolor=t["surface"], **kw)
ejes = ax if filas > 1 else [ax]
for a in ejes:
a.set_facecolor(t["surface"])
for lado in ("top", "right"):
a.spines[lado].set_visible(False)
for lado in ("left", "bottom"):
a.spines[lado].set_color(t["grid"])
a.spines[lado].set_linewidth(1)
a.tick_params(colors=t["ink2"], labelsize=9, length=0)
a.grid(axis="y", color=t["grid"], linewidth=0.8, alpha=0.7)
a.set_axisbelow(True)
return fig, (ax if filas > 1 else ax)
def _salida(fig) -> bytes:
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0.35,
facecolor=fig.get_facecolor())
plt.close(fig)
return buf.getvalue()
def _titulo(ax, t, texto, sub=""):
ax.set_title(texto, color=t["ink"], fontsize=13, fontweight="bold",
loc="left", pad=30 if sub else 12)
if sub:
ax.text(0, 1.035, sub, transform=ax.transAxes, color=t["ink2"],
fontsize=9.5, va="bottom")
# --------------------------------------------------------------------- saldo
def saldo(hist: list[dict], xant: int, tema="dark") -> bytes:
"""Saldo acumulado (linea) y saldo de cada dia (barras divergentes)."""
t = TEMAS[tema]
fechas = [r["fecha"] for r in hist]
diario = [r["X"] for r in hist]
acum, s = [], xant
for x in diario:
s += x
acum.append(s)
fig, (a1, a2) = _fig(t, h=6.4, filas=2, sharex=True,
gridspec_kw={"height_ratios": [2, 1], "hspace": 0.28})
a1.axhline(0, color=t["zero"], linewidth=1.5)
a1.plot(fechas, acum, color=t["s1"], linewidth=2, solid_capstyle="round")
a1.fill_between(fechas, acum, 0, color=t["s1"], alpha=0.10)
a1.scatter([fechas[-1]], [acum[-1]], s=42, color=t["s1"],
edgecolor=t["surface"], linewidth=2, zorder=5)
a1.annotate(f"{acum[-1]:+d} min ({acum[-1] / 60:+.1f} h)",
(fechas[-1], acum[-1]), textcoords="offset points",
xytext=(-8, 14), ha="right", color=t["ink"],
fontsize=11, fontweight="600")
_titulo(a1, t, "Saldo acumulado",
"minutos que Timenet te debe (negativo = trabajas de más sin que cuente)")
a1.set_ylabel("minutos", color=t["ink2"], fontsize=9)
a2.axhline(0, color=t["zero"], linewidth=1.5)
a2.bar(fechas, diario, width=1.0,
color=[t["pos"] if v >= 0 else t["neg"] for v in diario])
a2.set_ylabel("por día", color=t["ink2"], fontsize=9)
a2.yaxis.set_major_locator(MaxNLocator(5, integer=True))
peor = min(hist, key=lambda r: r["X"])
a2.annotate(f"{peor['X']} min ({peor['fecha']:%d/%m})", (peor["fecha"], peor["X"]),
textcoords="offset points", xytext=(10, 6), ha="left",
color=t["neg"], fontsize=9)
fig.autofmt_xdate(rotation=0, ha="center")
return _salida(fig)
# ---------------------------------------------------------------- puntualidad
def puntualidad(hist: list[dict], tema="dark") -> bytes:
"""Desviacion de cada ENTRADA respecto de su hora teorica, en el tiempo.
Es el grafico que explica el problema: por encima de cero = llegas tarde y te
lo debitan; por debajo = llegas antes y no te cuesta nada.
"""
t = TEMAS[tema]
fx, fy = [], []
for r in hist:
for i, c in enumerate(r["checks"]):
if c["typ"] != 0 or not c["HR"]:
continue
teo = 9 * 60 if i == 0 else 15 * 60
hh, mm = c["HR"][:5].split(":")
fx.append(r["fecha"])
fy.append(int(hh) * 60 + int(mm) - teo)
fig, ax = _fig(t, h=4.6)
# un dia suelto de +64 min aplastaria el resto contra el cero: se acota el eje
# y los que quedan fuera se anotan uno a uno
tope = max(8, sorted(fy)[int(0.98 * len(fy))] + 2) if fy else 8
suelo = min(-6, min(fy) - 2) if fy else -6
ax.axhspan(0, tope, color=t["neg"], alpha=0.05)
ax.axhline(0, color=t["zero"], linewidth=1.5)
dentro = [(x, y) for x, y in zip(fx, fy) if y <= tope]
ax.scatter([x for x, _ in dentro], [y for _, y in dentro], s=26, alpha=0.8,
color=[t["neg"] if y > 0 else t["pos"] for _, y in dentro],
edgecolor=t["surface"], linewidth=0.6)
for x, y in zip(fx, fy):
if y > tope:
ax.annotate(f"▲ {y:+d} min ({x:%d/%m})", (x, tope), ha="center",
textcoords="offset points", xytext=(0, -12),
color=t["neg"], fontsize=8.5)
ax.set_ylim(suelo, tope)
ax.text(0.995, 0.96, "tarde · te lo descuentan", transform=ax.transAxes,
color=t["neg"], fontsize=9.5, va="top", ha="right")
ax.text(0.995, 0.04, "pronto · sale gratis", transform=ax.transAxes,
color=t["pos"], fontsize=9.5, va="bottom", ha="right")
_titulo(ax, t, "Puntualidad de las entradas",
"minutos de desviación respecto de las 09:00 y las 15:00")
ax.set_ylabel("minutos", color=t["ink2"], fontsize=9)
ax.yaxis.set_major_locator(MaxNLocator(7, integer=True))
fig.autofmt_xdate(rotation=0, ha="center")
return _salida(fig)
# ---------------------------------------------------------------- regalado
def regalado(hist: list[dict], tema="dark") -> bytes:
"""Minutos por mes en los que estas presente pero Timenet no cuenta."""
t = TEMAS[tema]
meses: dict[str, int] = {}
for r in hist:
k = r["fecha"].strftime("%Y-%m")
meses[k] = meses.get(k, 0) + max(0, r["A"] - r["W"])
ks = sorted(meses)
vals = [meses[k] for k in ks]
fig, ax = _fig(t, h=4.4)
barras = ax.bar(range(len(ks)), vals, color=t["s2"], width=0.62)
for i, (b, v) in enumerate(zip(barras, vals)):
ax.annotate(f"{v}", (b.get_x() + b.get_width() / 2, v),
textcoords="offset points", xytext=(0, 5), ha="center",
color=t["ink"], fontsize=10, fontweight="600")
ax.set_xticks(range(len(ks)))
ax.set_xticklabels([datetime.strptime(k, "%Y-%m").strftime("%b %y") for k in ks])
_titulo(ax, t, "Tiempo regalado",
"minutos al mes en los que estás pero el reloj no corre")
ax.set_ylabel("minutos", color=t["ink2"], fontsize=9)
return _salida(fig)
# ------------------------------------------------------------------- horas
def horas(hist: list[dict], previstos: list[int], tema="dark") -> bytes:
"""A que minuto llega la entrada de la mañana: historico frente a lo planificado."""
t = TEMAS[tema]
hh: dict[int, int] = {}
for r in hist:
cs = r["checks"]
if cs and cs[0]["typ"] == 0 and cs[0]["HR"]:
a, b = cs[0]["HR"][:5].split(":")
hh[int(a) * 60 + int(b) - 540] = hh.get(int(a) * 60 + int(b) - 540, 0) + 1
pp: dict[int, int] = {}
for v in previstos:
pp[v] = pp.get(v, 0) + 1
def pct(d):
tot = sum(d.values()) or 1
return {k: 100 * v / tot for k, v in d.items()}
ph, pv = pct(hh), pct(pp)
todos = sorted(set(ph) | set(pv))
# se acota a la zona con datos; lo de fuera (algun dia muy tarde) va en el pie
xs = [x for x in todos if -14 <= x <= 8]
fuera = [x for x in todos if x not in xs]
fig, ax = _fig(t, h=4.6)
ancho = 0.4
idx = list(range(len(xs)))
ax.bar([i - ancho / 2 - 0.01 for i in idx], [ph.get(x, 0) for x in xs],
width=ancho, color=t["s2"], label="antes (histórico)")
ax.bar([i + ancho / 2 + 0.01 for i in idx], [pv.get(x, 0) for x in xs],
width=ancho, color=t["s1"], label="ahora (planificado)")
if 0 in xs:
ax.axvline(xs.index(0) - 0.5, color=t["zero"], linewidth=1.5)
ax.set_xticks(idx)
ax.set_xticklabels([_hhmm(540 + x) for x in xs], fontsize=8.5, rotation=45,
ha="right")
pie = ("a la derecha de la línea roja se pierden minutos"
+ (f" · {len(fuera)} día(s) fuera del rango: "
+ ", ".join(_hhmm(540 + x) for x in fuera) if fuera else ""))
_titulo(ax, t, "Hora de la entrada de la mañana", "% de días · " + pie)
ax.set_ylabel("% de días", color=t["ink2"], fontsize=9)
leg = ax.legend(frameon=False, loc="upper left", fontsize=9.5)
for txt in leg.get_texts():
txt.set_color(t["ink2"])
return _salida(fig)
# ---------------------------------------------------------------- prevision
def prevision(dias: list[dict], tema="dark") -> bytes:
"""Proximos dias: la ventana teorica y donde cae cada fichaje."""
t = TEMAS[tema]
dias = [d for d in dias if d["windows"]][:14]
fig, ax = _fig(t, h=0.42 * max(6, len(dias)) + 2.0)
for i, d in enumerate(dias):
y = len(dias) - i
for w in d["windows"]:
ax.barh(y, w[1] - w[0], left=w[0], height=0.5,
color=t["grid"], zorder=1)
for p in d["checks"]:
ax.scatter([p["min"]], [y], s=54, zorder=3,
color=t["s1"] if p["typ"] == 0 else t["s2"],
edgecolor=t["surface"], linewidth=1.5)
ax.set_yticks(range(1, len(dias) + 1))
ax.set_yticklabels([f"{DIAS_CORTO[d['fecha'].weekday()]} {d['fecha']:%d/%m}"
for d in reversed(dias)], fontsize=9)
horas_x = list(range(8 * 60, 20 * 60 + 1, 60))
ax.set_xticks(horas_x)
ax.set_xticklabels(["%d:00" % (h // 60) for h in horas_x], fontsize=8.5)
ax.set_xlim(8 * 60 + 30, 19 * 60 + 30)
ax.grid(axis="y", visible=False)
ax.grid(axis="x", color=t["grid"], linewidth=0.8, alpha=0.6)
ax.set_ylim(0.3, len(dias) + 0.7)
_titulo(ax, t, "Previsión de fichajes",
"la barra es la franja que Timenet cuenta")
ax.scatter([], [], s=54, color=t["s1"], label="entrada")
ax.scatter([], [], s=54, color=t["s2"], label="salida")
leg = ax.legend(frameon=False, fontsize=9.5, ncol=2,
loc="lower right", bbox_to_anchor=(1.0, 1.005))
for txt in leg.get_texts():
txt.set_color(t["ink2"])
return _salida(fig)

View File

@@ -43,6 +43,8 @@ class Service:
self._stop = asyncio.Event() self._stop = asyncio.Event()
self._planned_for: date | None = None self._planned_for: date | None = None
self._gcal = None self._gcal = None
self._hist: list | None = None
self._hist_at: datetime | None = None
# ------------------------------------------------------------------ basico # ------------------------------------------------------------------ basico
def now(self) -> datetime: def now(self) -> datetime:
@@ -72,6 +74,49 @@ class Service:
logger.exception("no se pudo enviar el mensaje por Telegram") logger.exception("no se pudo enviar el mensaje por Telegram")
return None return None
async def historico(self, meses: int = 6, force: bool = False) -> list:
"""Historico de fichajes, cacheado media hora (son 6 llamadas a la API)."""
fresco = self._hist_at and datetime.now(self.tz) - self._hist_at < timedelta(minutes=30)
if self._hist is not None and fresco and not force:
return self._hist
self._hist = await asyncio.to_thread(self.executor.historico, meses)
self._hist_at = datetime.now(self.tz)
return self._hist
def datos_prevision(self, dias: int = 21) -> list[dict]:
"""Lo planificado por delante, en el formato que espera charts.prevision."""
cal = self.executor._cal
if cal is None:
return []
out = []
hoy = self.today()
for i in range(dias):
d = hoy + timedelta(days=i)
filas = self.store.get_plan(d)
if not filas:
continue
out.append({
"fecha": d,
"windows": [(w.start, w.end) for w in cal.windows(d)],
"checks": [{"typ": r["typ"],
"min": datetime.fromisoformat(r["planned_at"]).hour * 60
+ datetime.fromisoformat(r["planned_at"]).minute}
for r in filas],
})
return out
def entradas_planificadas(self) -> list[int]:
"""Minutos de desviacion de las entradas de la mañana ya sorteadas."""
out = []
hoy = self.today()
for i in range(60):
for r in self.store.get_plan(hoy + timedelta(days=i)):
t = datetime.fromisoformat(r["target"])
if r["typ"] == ENTRADA and (t.hour, t.minute) == (9, 0):
p = datetime.fromisoformat(r["planned_at"])
out.append(p.hour * 60 + p.minute - 540)
return out
# --------------------------------------------------------------- calendario # --------------------------------------------------------------- calendario
async def _personal_absence(self, day: date) -> str | None: async def _personal_absence(self, day: date) -> str | None:
"""Ausencias personales desde Google Calendar. Solo eso: festivos y """Ausencias personales desde Google Calendar. Solo eso: festivos y

View File

@@ -5,6 +5,7 @@ Todo lo de este modulo es sincrono (usa ``requests``). El daemon lo invoca con
""" """
from __future__ import annotations from __future__ import annotations
import calendar
import logging import logging
import random import random
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -108,6 +109,52 @@ class Executor:
return {"P": m.get("P") or 0, "W": m.get("W") or 0, return {"P": m.get("P") or 0, "W": m.get("W") or 0,
"X": x, "XAnt": xant, "total": x + xant} "X": x, "XAnt": xant, "total": x + xant}
def historico(self, meses: int = 6) -> list[dict]:
"""Un registro por dia laborable de los ultimos `meses`, ordenado.
Cada registro: fecha, previsto (P), contado (W), saldo (X), presencia real
(A, el tiempo entre fichajes sin recortar) y los fichajes con su hora real
(HR) y la que cuenta (H).
"""
hoy = date.today()
y, m = hoy.year, hoy.month
periodos = []
for _ in range(meses):
periodos.append((y, m))
m -= 1
if m == 0:
m, y = 12, y - 1
out: list[dict] = []
for y, m in reversed(periodos):
ultimo = calendar.monthrange(y, m)[1]
try:
raw = self._with_login(self.cli.raw_checks,
"01/%02d/%d" % (m, y), "%02d/%02d/%d" % (ultimo, m, y))
except TimenetError as e:
logger.warning("no se pudo leer %d-%02d: %s", y, m, e)
continue
for dia in raw.get("C") or []:
cs = dia.get("C") or []
if not cs or not (dia.get("P") or 0):
continue
try:
f = datetime.strptime(dia["D"][:10], "%d/%m/%Y").date()
except (KeyError, ValueError):
continue
out.append({
"fecha": f,
"P": dia.get("P") or 0,
"W": dia.get("W") or 0,
"X": dia.get("X") or 0,
"A": (dia.get("A") or [{}])[0].get("M") or 0,
"tipo": (dia.get("T") or "").strip(),
"checks": [{"typ": c.get("T"), "H": c.get("H"), "HR": c.get("HR")}
for c in cs],
})
out.sort(key=lambda r: r["fecha"])
return out
def today_summary(self, day: date | None = None) -> dict: def today_summary(self, day: date | None = None) -> dict:
"""Fichajes de hoy segun Timenet, con hora real y hora contada.""" """Fichajes de hoy segun Timenet, con hora real y hora contada."""
raw = self.today_raw(day) raw = self.today_raw(day)

View File

@@ -68,3 +68,4 @@ urllib3==1.26.5
wcwidth==0.2.8 wcwidth==0.2.8
websocket-client==0.57.0 websocket-client==0.57.0
zipp==1.0.0 zipp==1.0.0
matplotlib>=3.8