27-oct: página de entrada, incidencias, standup, vigía y agente diario; integración del cotizador

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
This commit is contained in:
Leopoldo Rodríguez 2026-10-27 14:24:06 -06:00
parent 23f98d3f5b
commit 742e534af2
71 changed files with 3719 additions and 65 deletions

View File

@ -5,7 +5,8 @@
> This file is the mechanics: where each piece of the site lives and how to change it.
This repo (submodule of Drogheda) is the internal site: MkDocs Material in Mexican Spanish + a Flask
backend (`backend/app.py`, gunicorn `actas-hbox` on `127.0.0.1:8081`, nginx basic auth in front). Leo,
backend (`backend/app.py`, gunicorn `actas-hbox` on `127.0.0.1:8081`; since 27-oct nginx asks the backend for
the session on every request — `auth_request` to `/api/_auth`, login page `/entrar.html`, `backend/acceso.py`). Leo,
Adriana, Michelle, Jesús and the field technicians work from it; Claude closes every task on it too.
## Read first (start of every task)
@ -30,6 +31,9 @@ Adriana, Michelle, Jesús and the field technicians work from it; Claude closes
| **Ruta a producción**, version cards, fase, pedido, próximas fechas, map pins | `docs/datos/proyecto.json` | Claude or Leo | edit by hand (keep the one-array-per-line format); step states `hecho · ahora · bloqueado · falta`, card tone `hecho · ahora · aviso · bloqueado`; bump `actualizado` |
| **Instalaciones** (per-site installation board, 22-oct) | `docs/tableros/instalaciones.md` + `.js`/`.css`; data: field actas via `GET /api/instalaciones` (`backend/instalaciones.py`, hooked at the end of `app.py`) | automatic | stages envío → recepción → instalación → **prueba en sitio** (acta `prueba-sitio`; only a passed test counts for the 1–2 per-site goal) → en operación (7 days after the test, no failure) + «con falla»; colour, nota and meta per site from `proyecto.json`; forms accept `?serie=&sitio=&<clave>=` prefill (`acta.js`) |
| **Inventario por sitio** (23-oct) | `docs/tableros/inventario.md` + `.js`/`.css`; data: server `/srv/projects/drogheda/inventario` via `/api/inventario` | everyone in the UI | tabs ICAVE · TIMSA · EIT · LCT · Querétaro + «Todo» (`?sitio=eit&n=2`); every movement carries `sitio` (none = qro); `envio` leaves a site (or `fuera`: purchase in transit / material shipped before counting) and stays «en camino» until the destination presses «Recibir» (`recepcion`); item `por_instalacion` = the installation kit; team rules in `docs/inventario/index.md`; tests `backend/tests-1023.sh` |
| **Entrada al sitio** (27-oct) | `docs/entrar.html` + `docs/publico/` (public); `backend/acceso.py`; nginx `backend/nginx/drogheda.helgen.online.conf` installed by `backend/nginx/cambiar-entrada.sh` (backup, tests, auto-rollback; `deshacer`) | — | session = signed cookie `hbox_sesion` (30 days; version = hash of the user's htpasswd line, so a password change ends old sessions); login by user or Equipo email, case-insensitive; **no HTTP Basic** (only from `ACCESO_BASIC_IPS`, empty by default — server scripts use 127.0.0.1:8081); unsafe methods only same-origin (Origin/Sec-Fetch-Site); lockout per user+IP (8) and per IP (20) in 15 min; attempts in `/srv/projects/drogheda/acceso/intentos-AAAA-MM.jsonl` (no passwords); public paths: entrada, `/publico/`, `/fonts/`, `helgen.css`, `tema.js`, icon, `/api/{entrar,salir,sesion,salud,incidencias/publica(s)}` |
| **Incidencias del sitio** (27-oct) | server `/srv/projects/drogheda/incidencias` (INC-0001.json…, `_errores/`, `_vigia/`); `backend/incidencias.py`; page `docs/tableros/incidencias.md` + `incidencias-tablero.js/.css`; footer «Problemas del sitio» = `docs/js/incidencias.js` (in `overrides/main.html` head and in every standalone page) | everyone reports; `INCIDENCIAS_EDITORES` (leo, gibranlp, adriana, claude) triage; the daily agent through `scripts/agente-api.sh` | rules: `docs/agentes.md`; the agent's runbook: `agentes/agente-diario.md`; vigía `backend/vigia.py` (leo's crontab, minute 7); never put passwords or project data in `publica` notices |
| **Standup** (27-oct) | server `/srv/projects/drogheda/standup/AAAA-MM-DD/<user>.json`; `backend/standup.py`; `docs/tableros/standup.md` + `standup-tablero.js/.css`; banner `docs/js/standup.js` | the bot (`claude`) prepares; each person answers; Leo/Adriana see «Equipo» | answers can move the person's own cards (as them); everything else from the answers goes in a lote |
| **Equipo y accesos** (14/23-oct) | server `/srv/projects/drogheda/equipo` (one JSON per member) + `/etc/nginx/hbox-docs.htpasswd` | Leo (admin) in Equipo and Mi cuenta; in bulk `backend/cuentas.py` run by Leo on the server | member fields `sitio`, `correo`, `correo_alt` (two email domains), `contrasena_inicial` (set when someone else sets the password, cleared when the person changes it; banner from `docs/js/sitio.js`, admin-only pill in Equipo); new people go in `backend/cuentas/AAAA-MM-DD_*.json` (never passwords) → `cuentas.py todo|alta`; task `responsable_ids` are recomputed from the text with today's team |
| **Últimas decisiones** (Ingeniería) | `docs/tableros/ingenieria.md` | Claude or Leo | new `<li><b>DD-mmm (hora)</b> — …</li>` at the top, with the decision number (D-xx-n) and links |
| **Bitácora → Documentos por fecha** | `docs/bitacora.md` | Claude or Leo | newest first under the month: `**DD oct**` + `: [Title](path.md#anchor): what it decides or changes` |

View File

@ -502,12 +502,70 @@ Pruebas en la Mac (opcional, necesita Flask): `bash backend/tests-cuentas.sh .`
| Hay que deshacer el restablecimiento | — | el archivo anterior quedó en `/srv/projects/drogheda/equipo/_respaldos/htpasswd-AAAAMMDD-hhmmss`: cópialo encima de `/etc/nginx/hbox-docs.htpasswd` como leo |
| No sale la franja de la contraseña ni la columna Sitio | servicio sin reiniciar | el `restart` de arriba |
## A11 · 27-oct: página de entrada, incidencias, standup, vigía y agente diario
**Por qué.** Adriana reportó el 26-oct que, después de restablecer contraseñas, la gente no podía volver a entrar. Con la
ventana gris de HTTP Basic el navegador y el teléfono guardan la contraseña anterior y la vuelven a mandar sola; Mi cuenta
no tenía campo de usuario (el gestor de contraseñas nunca se actualizaba); «Adriana» con mayúscula no existe para nginx; no
había cómo cerrar sesión ni registro de quién fallaba. Además, a las 9:15 del 27-oct se instaló a mano un
`actas-hbox.service` viejo (sin `prueba-ethernet-serie`) que el repo no tenía.
**Qué cambia.**
- **Página de entrada propia** (`docs/entrar.html`, `backend/acceso.py`): nginx pregunta a `/api/_auth` en cada petición
(`auth_request`); sesión = cookie firmada de 30 días; se entra con el usuario o con el correo de Equipo, sin importar
mayúsculas ni espacios; cambiar la propia contraseña no saca a nadie; restablecer cierra las sesiones de esa persona;
cada intento queda en `/srv/projects/drogheda/acceso/intentos-AAAA-MM.jsonl` (sin la contraseña) y se ve en
Incidencias (administradores). Las contraseñas siguen en el mismo htpasswd: Mi cuenta, Equipo y `cuentas.py` no cambian.
**`curl -u` ya no entra** (revisión de seguridad: por ahí se podían probar contraseñas sin límite). Los scripts del
servidor (vigía, `tablero.py`, el agente) hablan con `127.0.0.1:8081` directo; para probar desde tu terminal:
`read -s P; curl -c /tmp/j -H 'Content-Type: application/json' -d "{\"usuario\":\"leo\",\"password\":\"$P\"}" https://drogheda.helgen.online/api/entrar && curl -b /tmp/j https://drogheda.helgen.online/api/whoami`.
Revisión de seguridad independiente antes de publicar (27-oct): además, POST/PATCH/DELETE solo desde páginas del
sitio, bloqueo por usuario + IP (nadie deja fuera a otro), redirección después de entrar solo dentro del sitio,
avisos públicos solo con su texto público, y lo tecleado como usuario se guarda enmascarado.
- **nginx** (`backend/nginx/drogheda.helgen.online.conf`, lo instala `backend/nginx/cambiar-entrada.sh` con respaldo,
`nginx -t`, 14 pruebas —incluida una sesión de prueba firmada con el secreto del servicio, sin contraseña— y regreso automático; revisa antes que todas las contraseñas sean `$apr1$`): sin sesión → `/entrar.html`; las API → 401 JSON; públicos solo la entrada,
`/publico/`, las fuentes, `helgen.css`, `tema.js` y el ícono; `X-Remote-User` lo pone nginx desde la sesión.
- **Servicio** (`backend/actas-hbox.service`): `ACTAS_ADMINS=leo,gibranlp` (como lo dejó Gibran), la lista completa de
actas repetibles, gunicorn con hilos (`-k gthread -w 2 --threads 8`: cada página pasa por `/api/_auth`) y `Restart=always`.
- **Incidencias** (`backend/incidencias.py`, `docs/js/incidencias.js`, `docs/tableros/incidencias.md`): «Problemas del
sitio» al pie de cada página, reporte sin sesión desde la entrada, avisos públicos, errores del navegador y del servidor
en `/srv/projects/drogheda/incidencias/_errores/`.
- **Standup** (`backend/standup.py`, `docs/tableros/standup.md`, `docs/js/standup.js`) y **vigía** (`backend/vigia.py`,
crontab de leo, minuto 7 de cada hora). Reglas: `docs/agentes.md`; instrucciones del agente: `agentes/agente-diario.md`.
- **Correos de Equipo**: `backend/cuentas/2026-10-27_correos.json` (12 correos; solo llena vacíos).
**Un comando** (en la Mac; pide sudo una vez y dos «si»):
```bash
cd ~/Documents/Claude/Projects/Drogheda/hbox-docs && ./scripts/despliegue-1027.sh
```
Lo mismo a mano: `sync-docs.sh` → commit/push de hbox-docs y del submódulo → `./scripts/deploy.sh` →
`ssh -t leo@git.helgen.online 'sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh servicio && sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh aplicar'`
→ `cuentas.py alta cuentas/2026-10-27_correos.json` → crontab del vigía + `semilla_1027.py` → `tablero.sh revisar` / `aplicar`.
**Regresar a la ventana gris** (si algo no te gusta; el servicio nuevo puede quedarse):
`ssh -t leo@git.helgen.online 'sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh deshacer'`.
**Confirmar la causa del 26-oct** (opcional, lee el log de nginx de antes del cambio):
`ssh -t leo@git.helgen.online "sudo grep -hE 'password mismatch|was not found' /var/log/nginx/error.log /var/log/nginx/error.log.1 | tail -40"`
— «password mismatch» = contraseña vieja o mal escrita; «was not found» = usuario mal escrito (p. ej. con mayúscula).
| Síntoma | Causa | Arreglo |
|---|---|---|
| Sale «Un momento…» en todas las páginas | el backend no contesta (sin él no hay sesión) | `ssh -t leo@git.helgen.online 'sudo systemctl status actas-hbox'`; `journalctl -u actas-hbox -n 50` |
| La entrada dice «No encuentro ese usuario» | escribió otro usuario; el registro de intentos lo dice | Incidencias → «Intentos de entrada»; su usuario está en Equipo |
| Alguien entra pero las API dicen 401 | cookie de otro dominio o navegador sin cookies | que entre de nuevo en `/entrar.html`; si sigue, incidencia con datos técnicos |
| `cambiar-entrada.sh aplicar` dice que el backend no tiene la entrada | falta el paso `servicio` (reiniciar con los módulos nuevos) | correr `servicio` primero |
| Quiero cerrar la sesión de todos | — | `sudo rm /srv/projects/drogheda/.sesion-secreto && sudo systemctl restart actas-hbox` |
## B · Verificación después de desplegar
```bash
# 1. el backend responde y sabe quién eres
curl -s -u leo:PASS https://drogheda.helgen.online/api/whoami # {"user":"leo","admin":true}
curl -s -u alan:PASS https://drogheda.helgen.online/api/whoami # {"user":"alan","admin":false}
curl -s -H 'X-Remote-User: leo' http://127.0.0.1:8081/api/whoami # en el servidor (desde el 27-oct curl -u ya no entra por fuera)
curl -s -H 'X-Remote-User: alan' http://127.0.0.1:8081/api/whoami # {"user":"alan","admin":false}
```
Si `user` sale vacío, falta la línea `X-Remote-User` del paso 4.

110
agentes/agente-diario.md Normal file
View File

@ -0,0 +1,110 @@
# Daily agent — runbook (Claude-facing)
Created 27-oct-2026. The team-facing rules are `docs/agentes.md` (published at /agentes/). **When the two disagree,
the stricter one wins; change both in the same commit.** Everything the team reads is Mexican Spanish.
You run as a scheduled task on Leo's Mac (Monday–Friday, 18:37 Mexico City). You reach the Mac through the device
bridge: `device_bash` for files in the mounted `Drogheda` folder (read and write; **never git there**), and the
desktop-commander `start_process` tool for native macOS commands (ssh to the server, git in your own clone). If the
computer or the server cannot be reached, stop and say so in the summary — do not improvise another path.
Paths: `D=~/Documents/Claude/Projects/Drogheda`, `H=$D/hbox-docs` (Leo's working copy — read it, write lotes and
notes there, never git), `A=~/Documents/Claude/Projects/_agentes/hbox-docs` (your own clone for code fixes, outside the
Drogheda repo; create it with `git clone ssh://git@git.helgen.online:222/Helgen-Services/hbox-docs.git` if missing, and
`pip install mkdocs-material` in a venv there if `mkdocs` is not on the Mac).
API: `$H/scripts/agente-api.sh METHOD PATH [json-file]` (ssh + curl on 127.0.0.1:8081 as `claude`). Server files you
may read over ssh as `leo`: `/srv/projects/drogheda/{incidencias,acceso,standup,tareas,equipo}/…`. You never use sudo.
## 0. Preflight (5 minutes)
1. Read `$H/CLAUDE.md`, `$H/docs/agentes.md`, this file, and `$D/CLAUDE.md` §★ and §0.
2. Fresh board: `cd $H && ./scripts/tablero.sh foto` (read-only), then read `$H/_estado/tablero.md`.
3. Today's vigía report: `ssh leo@git.helgen.online cat /srv/projects/drogheda/incidencias/_vigia/$(date +%F).json`.
4. Note what another session is doing: `git -C $H status --short` **via start_process** (native, read-only) — if the
tree is dirty, someone is mid-task: do not touch the files they changed.
## 1. Incidents
Inputs: `GET /api/incidencias?estado=abiertas`, each one with `GET /api/incidencia?id=…`,
`GET /api/incidencias/errores?dias=2`, the vigía report, and the last 2 days of
`/srv/projects/drogheda/acceso/intentos-AAAA-MM.jsonl` (never print passwords — they are never stored anyway).
For every open incident, in this order (gravedad alta first):
1. **Triage** — `PATCH /api/incidencia?id=` with `tipo`, `gravedad`, `asignado` (`claude` for site software;
`gibranlp` for htracker/monitor/server; `leo` for decisions, accounts and anything physical), `estado: en-revision`,
and a comment in Mexican Spanish: what you understood, what you will do, and — if information is missing — the exact
question for the reporter. Duplicates: `duplicado_de` + `estado: descartada` + a comment linking the original.
2. **Reproduce** in a local copy (the cloud container is fine: build the site from `$A`, run the backend with temp data
like `backend/tests-1027.sh` does). Write the cause in a comment. If you cannot reproduce, say what you tried.
3. **Fix** following the levels below. Comment what changed and how to undo it.
4. **Close** — an incident fixed and published yesterday (`estado: esperando`) whose firma / page shows no new errors in
the vigía report and the error logs for 24 h → `estado: resuelta` with a one-line comment. Access incidents: only when
the person has an `ok` in the intentos log after the fix.
5. **Public notice** — if a failure keeps several people from logging in, set `publica: true` with a short
`resumen_publico` (no internal names, no data) and remove it when solved.
### Level A — do it yourself (no authorization)
Only changes inside `docs/` of hbox-docs that are pages, text, links, CSS or the JavaScript of the boards
(`docs/tableros/*.js|css|md`, `docs/*.md`, `docs/js/sitio.js`, `docs/js/standup.js`). **Not** level A: `docs/entrar.html`,
`docs/publico/`, `docs/cuenta.html`, `docs/salir.html`, `docs/js/incidencias.js`, `docs/formularios/`, `backend/`,
`overrides/`, `hooks/`, `mkdocs.yml` nav changes, anything on the server.
1. In `$A`: `git checkout main && git pull --ff-only`. Make the change. Commit message:
`agente: INC-xxxx <qué> (Claude, agente diario)` + the attribution lines.
2. `mkdocs build --strict` must pass; run the backend tests that touch what you changed
(`bash backend/tests-1027.sh .` at least); check the page in light, dark and 390-px width with Playwright.
3. **Deploy-safety check:** `rsync -n -c -i --delete -e ssh site/ leo@git.helgen.online:/srv/projects/drogheda/hbox-docs/site/`.
Every listed file must be explained by your change (the changed pages, `search/search_index.json`, `sitemap.xml*`, and —
if you changed a CSS/JS file — the pages whose `?v=` hash moved). **Anything else means the server has work that is not
in main (Leo deployed something uncommitted): stop, do not deploy, push a branch instead (level B).**
4. Deploy the site only: `rsync -az --delete -e ssh site/ leo@git.helgen.online:/srv/projects/drogheda/hbox-docs/site/`
(never `deploy.sh`: it also copies the backend). Then `git push` main.
5. Leo's working copy: if `git -C $H status --porcelain` is empty and it is on `main`, `git -C $H pull --ff-only`
(native, via start_process). If it is dirty, do not touch it; tell Leo in the summary to `git pull --rebase` before his
next push.
6. Incident → `estado: esperando`, comment: «Publicado: … Lo cierro mañana si no se repite.»
### Level B — prepare it, Leo approves
Backend, nginx, systemd, accounts, passwords, permissions, data (actas, inventario, pendientes), the entry page, the field
forms, deleting anything, `sudo`, restarts. Work in `$A` on a branch `agente/AAAA-MM-DD-INC-xxxx`, with tests, push the
branch, and comment on the incident with the exact commands Leo runs (merge, deploy, restart). Board changes always go in
a **lote** (`$H/backend/lotes/AAAA-MM-DD_agente.json`, format in `backend/lotes/README.md`); Leo applies it.
### Never
Delete anything · read, store, ask for or reset passwords · change permissions · send messages or email in anyone's name
· put project data on the public entry page · touch the 4G/NTRIP parameter file · edit an applied lote · push to main
anything that failed a check.
## 2. Standup bot
1. **Read today's answers:** `GET /api/standup?fecha=$(date +%F)`. For each answer:
- Board changes the person already made with the checkbox are done — do not repeat them.
- `bloqueos` that need someone else → in the lote, a comment on that pendiente (`nota`) naming who unblocks it, or a
new pendiente for that person if none exists (`si_no_hay`).
- Things they finished that are not on the board → comment on the closest pendiente; do not invent tasks.
Write the lote to `$H/backend/lotes/AAAA-MM-DD_standup.json` (validate with `tablero.validar_cambio` before saving).
2. **Prepare the next working day** (Friday → Monday): one entry per team member with open pendientes (from the fresh
snapshot; skip `claude` and `vigia`). For each person:
- `instrucciones`: 2–5 concrete steps for that day, in order, each tied to an existing pendiente (`tarea_id`) or a
decision, with a `liga` to the document, form or board view (relative URL). Field technicians get the acta form to
fill (`/formularios/…html?serie=…&sitio=…`). Use what they answered today and the latest juntas/decisions.
- `preguntas`: one per open pendiente (max 6; overdue, en-curso and bloqueado first), each a specific question
(«¿Ya llegó la cotización de gabinetes o sigue bloqueada por el pago?»), never generic.
- `nota_bot`: one line about yesterday (e.g. «Ayer cerraste 2; quedó bloqueado t-… esperando a Leo»).
Mexican Spanish, «tú», short sentences. Save as JSON and `POST /api/standup/preparar` with `fecha` = next working day.
3. People who did not answer for 2 working days in a row → mention them in the summary for Adriana (do not nag them).
## 3. Close the run
1. Summary to Leo (final message, Mexican Spanish, ≤ 25 lines): incidents opened/closed/fixed (with ids), what needs his
OK with the **exact commands**, standup (answered n of m, blockers and who unblocks them), the lote(s) to apply
(`./scripts/tablero.sh revisar` → `aplicar`).
2. Bitácora only if you published a fix: one line under today's date in `$H/docs/bitacora.md`… only through `$A` (level A
commit), never by editing `$H` directly.
3. If the run itself hit friction (a missing API, a permission), open an incident about it (`origen: agente`) so it is
fixed like any other.

403
backend/acceso.py Normal file
View File

@ -0,0 +1,403 @@
"""Entrada al sitio con página propia (27-oct-2026). Reemplaza la ventana gris de HTTP Basic de nginx.
Por qué (INC-0001, reporte de Adriana del 26-oct: «después de restablecer la contraseña ya no puedo entrar»):
con HTTP Basic el navegador guarda la contraseña anterior y la vuelve a mandar sola; el gestor de contraseñas del
teléfono llena la vieja en la ventana gris porque Mi cuenta no tenía campo de usuario; no hay forma real de cerrar
sesión; un usuario con mayúscula inicial («Adriana») no existe para nginx; y el servidor no sabía quién falló ni
por qué. Ahora la entrada es una página nuestra, con usuario (o correo) y contraseña, que dice qué pasó y deja
cada intento registrado (sin la contraseña) para el vigía y el agente diario.
Cómo funciona:
nginx pregunta a /api/_auth en cada petición (auth_request), también por páginas y archivos. Responde 204 con
X-Hbox-User si hay sesión válida (cookie firmada «hbox_sesion») y 401 si no. Sin sesión, nginx manda a
/entrar.html (las API reciben 401 en JSON). HTTP Basic ya no entra, salvo desde las IP de ACCESO_BASIC_IPS (vacío
por omisión): los scripts del servidor (vigía, tablero.py, el agente) hablan con 127.0.0.1:8081 directo.
Revisión de seguridad del 27-oct: sin Basic abierto (fuerza bruta sin límite), redirección solo a este sitio,
bloqueo por usuario + IP (nadie bloquea a otro), POST/PATCH/DELETE solo desde este sitio (Origin/Sec-Fetch-Site),
cualquier error al revisar la sesión = 401, secreto creado de forma atómica.
Las contraseñas siguen en el mismo htpasswd ($apr1$): Mi cuenta, Equipo y cuentas.py no cambian.
Rutas (las de «sin sesión» las deja pasar nginx sin auth_request y SIN X-Remote-User):
GET /api/_auth solo nginx (la ruta pública /api/_auth se bloquea en nginx)
POST /api/entrar {usuario, password, recordar} sin sesión. usuario = el del sitio o su correo de Equipo
POST /api/salir sin sesión: borra la cookie
GET /api/sesion sin sesión: {sesion, user} (la página de entrada salta si ya hay sesión)
GET /api/accesos/intentos?dias=7 administradores: intentos por usuario (sin contraseñas)
La cookie lleva usuario ~ vence ~ versión ~ firma (HMAC-SHA256). versión = huella del hash del htpasswd de esa
persona: si su contraseña cambia (restablecimiento, Mi cuenta o cuentas.py) sus sesiones viejas dejan de valer y
la próxima vez ve la página de entrada. Quien cambia la suya en Mi cuenta recibe una cookie nueva en ese momento.
Secreto: SESION_SECRETO (entorno) o DATOS_BASE/.sesion-secreto (se crea solo, modo 600). Cambiarlo cierra todas
las sesiones.
Intentos: DATOS_BASE/acceso/intentos-AAAA-MM.jsonl — cuándo, a quién corresponde (o, si el usuario no existe, solo
sus 3 primeras letras y su largo: a veces se teclea la contraseña en el campo del usuario), resultado (ok ·
usuario-no-existe · contrasena-incorrecta · sin-datos), pistas (bloq-mayus, mayuscula-inicial, espacios-alrededor: se
calculan al fallar, nunca se le dicen a la persona), IP y navegador. Los intentos bloqueados no se anotan.
Límite: 8 fallas de un mismo usuario desde una misma IP, o 20 fallas de una IP, en 15 minutos → espera (además del
limit_req de nginx). Así nadie puede dejar fuera a otra persona tecleando mal su usuario a propósito.
"""
import base64
import datetime
import hashlib
import hmac
import json
import os
import re
import time
import unicodedata
from flask import jsonify, request
COOKIE = "hbox_sesion"
DIAS_SESION = int(os.environ.get("SESION_DIAS", "30"))
DIAS_RENOVAR = 10 # si a la sesión le quedan menos días, /api/whoami la renueva
VENTANA_S = 15 * 60
MAX_FALLAS_USUARIO = int(os.environ.get("ENTRAR_MAX_FALLAS_USUARIO", "8")) # por usuario Y por IP
BASIC_IPS = {x.strip() for x in os.environ.get("ACCESO_BASIC_IPS", "").split(",") if x.strip()}
MAX_FALLAS_IP = int(os.environ.get("ENTRAR_MAX_FALLAS_IP", "20"))
COOKIE_INSEGURA = os.environ.get("SESION_COOKIE_INSEGURA", "") == "1" # solo pruebas locales por http
SEP = "~"
def _plano(s):
"""' Adriana ' → 'adriana'; 'Jesús' → 'jesus'. Sin acentos ni espacios."""
s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower()
return re.sub(r"\s+", "", s)
def registrar(app, g):
"""g = globals() de app.py (HTPASSWD, apr1, _leer_htpasswd, DATOS_BASE, EQUIPO_DIR, ADMINS…)."""
cache = {"clave": None, "usuarios": {}}
carpeta = os.path.join(g["DATOS_BASE"], "acceso")
# ------------------------------------------------------------ secreto de las cookies
def _secreto():
s = os.environ.get("SESION_SECRETO", "").strip()
if s:
return s.encode()
ruta = os.path.join(g["DATOS_BASE"], ".sesion-secreto")
def leer():
try:
with open(ruta, encoding="utf-8") as f:
return f.read().strip()
except OSError:
return ""
actual = leer()
if len(actual) < 32: # no existe, o quedó vacío/corto: se crea completo y se publica de un golpe
tmp = ruta + ".tmp-" + os.urandom(4).hex()
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as f:
f.write(base64.urlsafe_b64encode(os.urandom(32)).decode())
try:
if actual: # corto o vacío: se reemplaza
os.replace(tmp, ruta)
else:
os.link(tmp, ruta) # atómico; si otro proceso ganó, se usa el suyo
except FileExistsError:
pass
finally:
if os.path.exists(tmp):
os.unlink(tmp)
actual = leer()
if len(actual) < 32:
raise RuntimeError("No pude crear el secreto de sesión en " + ruta)
return actual.encode()
SECRETO = {"v": None}
def secreto():
if SECRETO["v"] is None:
SECRETO["v"] = _secreto()
return SECRETO["v"]
# ------------------------------------------------------------ usuarios del htpasswd
def usuarios():
"""{usuario en minúsculas: (usuario tal cual, hash)}; se relee solo si el archivo cambió."""
try:
st = os.stat(g["HTPASSWD"])
except OSError:
return {}
clave = (st.st_mtime_ns, st.st_size, st.st_ino)
if cache["clave"] != clave:
d = {}
for linea in g["_leer_htpasswd"]() or []:
if ":" in linea and not linea.lstrip().startswith("#"):
u, h = linea.split(":", 1)
d[u.strip().lower()] = (u.strip(), h.strip())
cache.update(clave=clave, usuarios=d)
return cache["usuarios"]
def verificar(h, password):
if not password:
return False
if h.startswith("$apr1$"):
partes = h.split("$")
return len(partes) >= 4 and hmac.compare_digest(g["apr1"](password, partes[2]), h)
if h.startswith("{SHA}"):
calc = "{SHA}" + base64.b64encode(hashlib.sha1(password.encode("utf-8")).digest()).decode()
return hmac.compare_digest(calc, h)
if h.startswith(("$2y$", "$2b$", "$2a$")): # htpasswd -B (bcrypt), si alguien lo usó a mano
try:
import bcrypt # noqa: PLC0415
return bcrypt.checkpw(password.encode("utf-8"), h.replace("$2y$", "$2b$", 1).encode())
except Exception: # noqa: BLE001
return False
return False
def version(h):
return hashlib.sha256(h.encode()).hexdigest()[:12]
def por_correo(texto):
"""Correo de Equipo (correo o correo_alt) → usuario. None si no hay o si dos personas lo comparten."""
t = str(texto or "").strip().lower()
if "@" not in t:
return None
hallados = {m["id"] for m in g["_listar_json"](g["EQUIPO_DIR"])
if m.get("id") and m.get("activo", True) is not False
and t in (str(m.get("correo") or "").strip().lower(), str(m.get("correo_alt") or "").strip().lower())}
return hallados.pop() if len(hallados) == 1 else None
# ------------------------------------------------------------ cookie firmada
def firmar(u, vence, ver):
return hmac.new(secreto(), f"{u}|{vence}|{ver}".encode(), hashlib.sha256).hexdigest()[:40]
def emitir(resp, u, dias=DIAS_SESION):
reg = usuarios().get(u.lower())
if not reg:
return resp
vence = int(time.time()) + dias * 86400
ver = version(reg[1])
valor = SEP.join((reg[0], str(vence), ver, firmar(reg[0], vence, ver)))
resp.set_cookie(COOKIE, valor, max_age=dias * 86400, path="/", httponly=True,
secure=not COOKIE_INSEGURA, samesite="Lax")
return resp
def leer_cookie():
"""(usuario, vence) si la cookie es válida hoy; (None, 0) si no."""
v = request.cookies.get(COOKIE) or ""
partes = v.split(SEP)
if len(partes) != 4:
return None, 0
u, vence, ver, firma = partes
try:
vence_i = int(vence)
except ValueError:
return None, 0
b = lambda x: str(x).encode("utf-8", "replace")
if vence_i < time.time() or not hmac.compare_digest(b(firma), b(firmar(u, vence_i, ver))):
return None, 0
reg = usuarios().get(u.lower())
if not reg or not hmac.compare_digest(b(version(reg[1])), b(ver)): # contraseña cambiada o acceso quitado
return None, 0
return reg[0], vence_i
def leer_basic():
"""Authorization: Basic solo desde las IP de ACCESO_BASIC_IPS (vacío por omisión = nunca). Sin esto, cualquiera
podría probar contraseñas sin límite por /api/_auth. Los scripts del servidor usan 127.0.0.1:8081 directo."""
if not BASIC_IPS or ip_cliente() not in BASIC_IPS:
return None
h = request.headers.get("Authorization", "")
if not h.lower().startswith("basic "):
return None
if (request.headers.get("User-Agent") or "").startswith("Mozilla/"):
return None
try:
crudo = base64.b64decode(h[6:].strip()).decode("utf-8")
except Exception: # noqa: BLE001
try:
crudo = base64.b64decode(h[6:].strip()).decode("latin-1")
except Exception: # noqa: BLE001
return None
u, _, pw = crudo.partition(":")
reg = usuarios().get(u.strip().lower())
return reg[0] if reg and verificar(reg[1], pw) else None
# ------------------------------------------------------------ registro de intentos
def ip_cliente():
return (request.headers.get("X-Real-IP") or (request.headers.get("X-Forwarded-For") or "").split(",")[0]
or request.remote_addr or "").strip()[:60]
def ruta_intentos(cuando=None):
cuando = cuando or datetime.datetime.now()
return os.path.join(carpeta, f"intentos-{cuando:%Y-%m}.jsonl")
def clave_de(usuario, tecleado):
"""Usuario real (minúsculas) o, si no existe, una huella de lo tecleado: cuenta los intentos sin guardar el texto."""
return (usuario or "").lower() or ("?" + hashlib.sha1(_plano(tecleado).encode()).hexdigest()[:10])
def enmascarar(tecleado):
t = _plano(tecleado)
return (t[:3] + "…(" + str(len(t)) + ")") if t else ""
def anotar(tecleado, usuario, resultado, pistas=()):
os.makedirs(carpeta, exist_ok=True)
r = {"cuando": g["ahora"](), "tecleado": "" if usuario else enmascarar(tecleado), "usuario": usuario or "",
"clave": clave_de(usuario, tecleado), "resultado": resultado, "pistas": list(pistas), "ip": ip_cliente(),
"navegador": (request.headers.get("User-Agent") or "")[:160]}
with open(ruta_intentos(), "a", encoding="utf-8") as f:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
def intentos_desde(desde_iso, max_bytes=400_000):
"""Intentos con cuando >= desde_iso, de este mes y del anterior (lee el final de cada archivo)."""
hoy = datetime.date.today()
mes_ant = (hoy.replace(day=1) - datetime.timedelta(days=1))
out = []
for d in (mes_ant, hoy):
ruta = ruta_intentos(datetime.datetime(d.year, d.month, 1))
if not os.path.isfile(ruta):
continue
with open(ruta, "rb") as f:
f.seek(0, 2)
tam = f.tell()
f.seek(max(0, tam - max_bytes))
texto = f.read().decode("utf-8", "ignore")
for linea in texto.splitlines():
try:
r = json.loads(linea)
except Exception: # noqa: BLE001
continue
if str(r.get("cuando", "")) >= desde_iso:
out.append(r)
return out
def bloqueado(clave, ip):
"""Por usuario Y por IP (8) y por IP (20) en 15 min. No hay bloqueo global por usuario: si lo hubiera, cualquiera
podría dejar fuera a una persona tecleando mal su usuario a propósito."""
desde = (datetime.datetime.now() - datetime.timedelta(seconds=VENTANA_S)).replace(microsecond=0).isoformat()
fallas = [r for r in intentos_desde(desde, 2_000_000) if r.get("resultado") not in ("ok", "bloqueado")]
n_ui = sum(1 for r in fallas if r.get("ip") == ip and r.get("clave") == clave)
n_ip = sum(1 for r in fallas if ip and r.get("ip") == ip)
return n_ui >= MAX_FALLAS_USUARIO or n_ip >= MAX_FALLAS_IP
# ------------------------------------------------------------ rutas
@app.get("/api/_auth")
def _auth():
try:
u, _ = leer_cookie()
u = u or leer_basic()
except Exception as e: # noqa: BLE001 (cookie rara, htpasswd ilegible…): mejor la página de entrada que «caído»
app.logger.warning("acceso: /api/_auth falló, respondo 401: %s", e)
u = None
if not u:
return ("", 401) # sin WWW-Authenticate: nginx manda a /entrar.html
r = app.response_class(status=204)
r.headers["X-Hbox-User"] = u
return r
@app.get("/api/sesion")
def sesion():
u, vence = leer_cookie()
return jsonify(ok=True, sesion=bool(u), user=u or "",
vence=datetime.datetime.fromtimestamp(vence).replace(microsecond=0).isoformat() if u else None)
@app.post("/api/entrar")
def entrar():
d = request.get_json(silent=True) or request.form or {}
tecleado = str(d.get("usuario") or "")
password = str(d.get("password") or "")
recordar = g["_bool"](d.get("recordar"), True)
u = _plano(tecleado)
if "@" in u:
u = por_correo(u) or u
ip = ip_cliente()
if not u or not password:
anotar(tecleado, "", "sin-datos")
return jsonify(ok=False, error="Escribe tu usuario y tu contraseña."), 400
reg0 = usuarios().get(u)
if bloqueado(clave_de(reg0[0] if reg0 else "", tecleado), ip):
return jsonify(ok=False, codigo="bloqueado",
error="Demasiados intentos seguidos. Espera 15 minutos y vuelve a intentar, "
"o repórtalo abajo para que te ayudemos."), 429
reg = usuarios().get(u)
if not reg:
anotar(tecleado, "", "usuario-no-existe")
return jsonify(ok=False, codigo="usuario",
error="No encuentro ese usuario. Es tu nombre en minúsculas y sin acentos, como te lo "
"dieron (por ejemplo «adriana» o «jesush»), o tu correo de Helgen."), 401
if not verificar(reg[1], password):
pistas = []
if password != password.strip() and verificar(reg[1], password.strip()):
pistas.append("espacios-alrededor")
if verificar(reg[1], password.swapcase()):
pistas.append("bloq-mayus")
if password[:1].isupper() and verificar(reg[1], password[:1].lower() + password[1:]):
pistas.append("mayuscula-inicial")
anotar(tecleado, reg[0], "contrasena-incorrecta", pistas)
return jsonify(ok=False, codigo="contrasena",
error="La contraseña no coincide. Si te la restablecieron, usa la nueva tal como te la "
"pasaron (respeta mayúsculas); si tu navegador la llena solo, bórrala y escríbela."), 401
anotar(tecleado, reg[0], "ok")
resp = jsonify(ok=True, user=reg[0])
return emitir(resp, reg[0], DIAS_SESION if recordar else 1)
@app.post("/api/salir")
def salir():
resp = jsonify(ok=True)
resp.set_cookie(COOKIE, "", max_age=0, expires=0, path="/", httponly=True,
secure=not COOKIE_INSEGURA, samesite="Lax")
return resp
@app.get("/api/accesos/intentos")
def accesos_intentos():
if not g["is_admin"]():
return jsonify(ok=False, error="Solo un administrador puede ver los intentos de entrada."), 403
dias = max(1, min(60, g["_entero"](request.args.get("dias"), 7)))
desde = (datetime.datetime.now() - datetime.timedelta(days=dias)).replace(microsecond=0).isoformat()
filas = intentos_desde(desde, 2_000_000)
por = {}
for r in filas:
clave = r.get("usuario") or ("? " + (r.get("tecleado") or r.get("clave") or "?"))
p = por.setdefault(clave, {"ok": 0, "fallas": 0, "ultimo_ok": None, "ultima_falla": None, "motivos": {},
"pistas": {}})
if r.get("resultado") == "ok":
p["ok"] += 1
p["ultimo_ok"] = r["cuando"]
else:
p["fallas"] += 1
p["ultima_falla"] = r["cuando"]
p["motivos"][r.get("resultado")] = p["motivos"].get(r.get("resultado"), 0) + 1
for x in r.get("pistas") or []:
p["pistas"][x] = p["pistas"].get(x, 0) + 1
return jsonify(ok=True, desde=desde, total=len(filas), por_usuario=por,
ultimos=list(reversed(filas[-50:])))
# ------------------------------------------------------------ solo desde este sitio (contra CSRF)
INSEGUROS = {"POST", "PUT", "PATCH", "DELETE"}
@app.before_request
def _mismo_sitio():
"""Un POST/PATCH/DELETE a la API tiene que venir de una página de este sitio. Los navegadores mandan
Sec-Fetch-Site y Origin; los scripts del servidor (curl a 127.0.0.1:8081) no mandan ninguno y pasan."""
if request.method not in INSEGUROS or not request.path.startswith("/api/"):
return None
sfs = (request.headers.get("Sec-Fetch-Site") or "").lower()
if sfs and sfs not in ("same-origin", "none"):
return jsonify(ok=False, error="Petición desde otro sitio: no se acepta."), 403
origen = request.headers.get("Origin")
if origen:
from urllib.parse import urlsplit # noqa: PLC0415
if (urlsplit(origen).hostname or "").lower() != (request.host or "").split(":")[0].lower():
return jsonify(ok=False, error="Petición desde otro sitio: no se acepta."), 403
return None
# ------------------------------------------------------------ después de cada respuesta
@app.after_request
def _despues(resp):
try:
if request.path == "/api/password" and request.method == "POST" and resp.status_code == 200:
j = resp.get_json(silent=True) or {}
yo = g["current_user"]()
if j.get("ok") and not j.get("reset") and yo and str(j.get("usuario", "")).lower() == yo.lower():
emitir(resp, yo) # cambió la suya: sesión nueva con la versión nueva, no lo saca
elif request.path == "/api/whoami" and resp.status_code == 200:
u, vence = leer_cookie()
if u and vence - time.time() < DIAS_RENOVAR * 86400:
emitir(resp, u)
except Exception as e: # noqa: BLE001
app.logger.warning("acceso: no se pudo renovar la sesión: %s", e)
return resp
g["_acceso"] = {"usuarios": usuarios, "verificar": verificar, "intentos_desde": intentos_desde, "plano": _plano}

View File

@ -11,7 +11,8 @@ Environment=ADJUNTOS_DIR=/srv/projects/drogheda/adjuntos
Environment=ADJUNTO_MAX_MB=8
Environment=ARCHIVO_MAX_MB=20
Environment=ACTAS_REPETIBLES=diagnostico-campo,diagnostico-banco,instalacion-campo,reflasheo,dano,envio,recepcion,prueba-bateria-modem,prueba-ethernet-serie
Environment=ACTAS_ADMINS=leo
# 27-oct: Gibran es administrador (así quedó el servicio que se instaló a mano ese día a las 9:15).
Environment=ACTAS_ADMINS=leo,gibranlp
# Colecciones del 13-oct (tableros, inventario, sitios). Si no se fijan, el backend las deduce
# de ACTAS_DIR: viven junto a /actas. Descomenta solo para moverlas de lugar.
#Environment=TAREAS_DIR=/srv/projects/drogheda/tareas
@ -28,8 +29,16 @@ Environment=ACTAS_ADMINS=leo
Environment=HTPASSWD_FILE=/etc/nginx/hbox-docs.htpasswd
Environment=PASS_MIN=8
Environment=PORT=8081
ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -w 2 -b 127.0.0.1:8081 app:app
Restart=on-failure
# Página de entrada (27-oct, backend/acceso.py): nginx pregunta a /api/_auth en CADA petición (páginas y archivos).
# Por eso hilos: una subida de fotos de 2 minutos no debe dejar esperando a los demás. Secreto de sesión: si no se fija
# aquí, el servicio crea /srv/projects/drogheda/.sesion-secreto (600) la primera vez. Cambiarlo cierra todas las sesiones.
#Environment=SESION_DIAS=30
#Environment=SESION_SECRETO=
# Quién atiende incidencias (cambia estado, gravedad, «mostrar en la entrada») y quién ve el standup del equipo.
#Environment=INCIDENCIAS_EDITORES=leo,gibranlp,adriana,claude
#Environment=STANDUP_LECTORES=leo,adriana,claude,gibranlp
ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -k gthread -w 2 --threads 8 -b 127.0.0.1:8081 app:app
Restart=always
RestartSec=3
[Install]

View File

@ -570,6 +570,8 @@ def cambiar_password():
return jsonify(ok=False, error=f"La nueva contraseña debe tener al menos {PASS_MIN} caracteres."), 400
if nueva != confirma:
return jsonify(ok=False, error="Las dos contraseñas nuevas no coinciden."), 400
if nueva != nueva.strip(): # 27-oct: el teclado del teléfono a veces agrega un espacio al final
return jsonify(ok=False, error="La contraseña no puede empezar ni terminar con un espacio."), 400
if not es_reset and nueva == actual:
return jsonify(ok=False, error="La nueva contraseña es igual a la actual."), 400
@ -790,7 +792,12 @@ SITIOS_TAREA = (("icave", "ICAVE · Veracruz", "ICAVE"), ("timsa", "TIMSA · Man
SITIOS_TAREA_IDS = tuple(s[0] for s in SITIOS_TAREA)
SITIO_OMISION = ["qro"] # pendientes sin sitio (los de antes del 22-oct) cuentan como Querétaro
ALIAS_SITIO = {"lct": "lzc", "lazarocardenas": "lzc", "lazaro-cardenas": "lzc", "veracruz": "icave",
"manzanillo": "timsa", "ensenada": "eit", "queretaro": "qro", "oficina": "qro"}
"manzanillo": "timsa", "ensenada": "eit", "queretaro": "qro", "oficina": "qro",
# (27-oct) código de 3 letras de cada ciudad, el de su aeropuerto y su puerto (UN/LOCODE MX___):
# lo usan las órdenes de compra del cotizador. VER Veracruz · ZLO Manzanillo · ESE Ensenada ·
# LZC Lázaro Cárdenas · QRO Querétaro.
"ver": "icave", "zlo": "timsa", "ese": "eit"}
CODIGO_SITIO = {"icave": "VER", "timsa": "ZLO", "eit": "ESE", "lzc": "LZC", "qro": "QRO"}
AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "pruebas", "fabricacion",
"software", "campo", "asistente")
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
@ -1162,6 +1169,8 @@ def usuarios_crear():
password = str(d.get("password_inicial") or "")
if len(password) < PASS_MIN:
return jsonify(ok=False, error=f"La contraseña inicial debe tener al menos {PASS_MIN} caracteres."), 400
if password != password.strip():
return jsonify(ok=False, error="La contraseña no puede empezar ni terminar con un espacio."), 400
m_in = {"nombre": d.get("nombre") or usuario, "rol": d.get("rol", ""), "area": d.get("area", "produccion"),
"sitio": d.get("sitio", ""), "correo": d.get("correo", ""), "correo_alt": d.get("correo_alt", ""),
"telefono": d.get("telefono", ""), "activo": True}
@ -2310,14 +2319,18 @@ def actividad():
# ------------------------------------------------------------------ tablero de Instalaciones (22-oct)
# backend/instalaciones.py: GET /api/instalaciones y el acta repetible «prueba-sitio». Si el archivo
# falta o falla, el resto del servicio arranca igual (el tablero avisa que no hay datos).
try:
# (27-oct) Igual se enganchan acceso.py (página de entrada y sesión: /api/_auth, /api/entrar, /api/salir),
# incidencias.py (problemas del sitio, errores del navegador y del servidor) y standup.py (el bot de scrum).
# Cada uno se carga aparte: si uno falla, los demás y el resto del servicio arrancan igual.
import importlib.util as _ilu
_spec = _ilu.spec_from_file_location("instalaciones", os.path.join(os.path.dirname(os.path.abspath(__file__)), "instalaciones.py"))
_inst = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_inst)
_inst.registrar(app, globals())
for _mod in ("instalaciones", "acceso", "incidencias", "standup"):
try:
_spec = _ilu.spec_from_file_location(_mod, os.path.join(os.path.dirname(os.path.abspath(__file__)), _mod + ".py"))
_m = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_m)
_m.registrar(app, globals())
except Exception as _e: # noqa: BLE001
app.logger.error("instalaciones.py no se cargó: %s", _e)
app.logger.error("%s.py no se cargó: %s", _mod, _e)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))

View File

@ -0,0 +1,54 @@
{
"_nota": "27-oct: correos de cada quien según la lista de cuentas activas que mandó Gibran (23-oct), confirmada por Leo: correo = @helgen.tech cuando existe; correo_alt = @helgen.services. daniel (Daniel Martínez) = daniel@helgen.tech, jesus (Jesús Romero) = jesusr@helgen.tech y jesush (Jesús Honorato) = jesus@helgen.tech ya estaban; danielf@ y jesusd@ son otras personas (sin acceso al sitio). Solo se llenan los vacíos (cuentas.py no pisa nada): el «Otro correo» de Leo (hoy ceo@helgen.tech, que es un alias) se cambia a mano en Equipo por leo@helgen.services. Correr: venv/bin/python3 cuentas.py alta cuentas/2026-10-27_correos.json",
"alta": [],
"actualizar": [
{
"usuario": "abraham",
"correo": "abraham@helgen.services"
},
{
"usuario": "adriana",
"correo_alt": "adriana@helgen.services"
},
{
"usuario": "alan",
"correo_alt": "alan@helgen.services"
},
{
"usuario": "antonio",
"correo": "antonio@helgen.tech"
},
{
"usuario": "danielg",
"correo": "danielg@helgen.services"
},
{
"usuario": "eduardo",
"correo": "eduardo@helgen.services"
},
{
"usuario": "gibranlp",
"correo_alt": "gibranlp@helgen.services"
},
{
"usuario": "joel",
"correo": "joel@helgen.tech"
},
{
"usuario": "jose",
"correo": "jose@helgen.services"
},
{
"usuario": "juan",
"correo": "juan@helgen.services"
},
{
"usuario": "michelle",
"correo": "luz@helgen.services"
},
{
"usuario": "miguel",
"correo": "miguel@helgen.tech"
}
]
}

404
backend/incidencias.py Normal file
View File

@ -0,0 +1,404 @@
"""Incidencias del sitio (27-oct-2026): lo que falla en drogheda.helgen.online, reportado desde cualquier página
(sección «Problemas del sitio» al pie), desde la página de entrada (sin sesión) o detectado solo (vigía).
Lo engancha app.py al final con registrar(app, globals()), como instalaciones.py.
Datos: DATOS_BASE/incidencias/INC-0001.json (un JSON por incidencia) + _indice.csv; folio con candado en _folio.
DATOS_BASE/incidencias/_errores/cliente-AAAA-MM-DD.jsonl errores de JavaScript y llamadas fallidas (navegador)
DATOS_BASE/incidencias/_errores/servidor-AAAA-MM-DD.jsonl excepciones y respuestas 5xx del backend
Nada se borra: una incidencia se resuelve o se descarta.
Rutas con sesión:
GET /api/incidencias?estado=abiertas|cerradas|todas&tipo=&origen=&q= lista + conteos
GET /api/incidencias/resumen lo que muestra el pie de cada página (abiertas, mías, últimas 3)
GET /api/incidencia?id=INC-0001
POST /api/incidencias {titulo?, descripcion, tipo, pagina, contexto, firma?}
(firma: quien detecta solo —vigia o claude— manda una huella; si ya hay una abierta con esa huella se suma
una ocurrencia en vez de abrir otra)
PATCH /api/incidencia?id= {comentario} cualquiera · {estado, gravedad, asignado, publica,
resumen_publico, duplicado_de, tarea_id, titulo, tipo} quien edita (INCIDENCIAS_EDITORES + administradores);
quien la reportó puede marcarla resuelta o reabrirla
POST /api/incidencias/error errores del navegador (los manda js/incidencias.js solo)
GET /api/incidencias/errores?dias=2 (editores) errores de cliente y servidor agrupados
Rutas sin sesión (nginx las deja pasar sin auth_request y SIN X-Remote-User):
POST /api/incidencias/publica {nombre, contacto, descripcion, tipo} desde /entrar.html
GET /api/incidencias/publicas avisos marcados «publica» (solo título público, estado y fecha)
"""
import datetime
import fcntl
import hashlib
import json
import os
import re
import traceback
from flask import got_request_exception, jsonify, request
TIPOS = ("acceso", "error", "datos", "lento", "sugerencia", "otro")
NOMBRE_TIPO = {"acceso": "No puedo entrar", "error": "Error o pantalla rota", "datos": "Datos incorrectos",
"lento": "Lento", "sugerencia": "Sugerencia", "otro": "Otro"}
ESTADOS = ("nueva", "en-revision", "en-curso", "esperando", "resuelta", "descartada")
ABIERTAS = ("nueva", "en-revision", "en-curso", "esperando")
GRAVEDADES = ("alta", "media", "baja")
ORIGENES = ("sitio", "entrada", "vigia", "agente")
SISTEMA = {"vigia", "claude"} # pueden abrir con origen vigia/agente y con firma
COLS = ["id", "titulo", "tipo", "estado", "gravedad", "origen", "reportado_por", "asignado", "pagina", "ocurrencias",
"publica", "tarea_id", "duplicado_de", "creado_en", "modificado_en", "resuelta_en"]
MAX_ERRORES_DIA = 3000
def registrar(app, g):
carpeta = os.environ.get("INCIDENCIAS_DIR", os.path.join(g["DATOS_BASE"], "incidencias"))
carpeta_err = os.path.join(carpeta, "_errores")
editores = {u.strip().lower() for u in os.environ.get("INCIDENCIAS_EDITORES", "leo,gibranlp,adriana,claude").split(",")
if u.strip()}
texto, ahora, hist = g["_texto"], g["ahora"], g["_hist"]
def yo():
return g["current_user"]()
def puede_editar(u):
return bool(u) and (u.lower() in editores or u.lower() in g["ADMINS"])
def ruta(iid):
return os.path.join(carpeta, iid + ".json")
def todas():
return [d for d in g["_listar_json"](carpeta) if str(d.get("id", "")).startswith("INC-")]
def guardar(d):
g["_escribir_json"](ruta(d["id"]), d)
g["_reescribir_indice"](carpeta, todas(), COLS)
def siguiente_folio():
os.makedirs(carpeta, exist_ok=True)
with open(os.path.join(carpeta, "_folio"), "a+", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
f.seek(0)
try:
n = int((f.read() or "0").strip() or 0)
except ValueError:
n = 0
existentes = [int(m.group(1)) for x in os.listdir(carpeta) if (m := re.match(r"INC-(\d+)\.json$", x))]
n = max([n] + existentes) + 1
f.seek(0)
f.truncate()
f.write(str(n))
f.flush()
fcntl.flock(f, fcntl.LOCK_UN)
return f"INC-{n:04d}"
def contexto_limpio(c):
"""El contexto técnico que manda la página: se guarda acotado (nada de cookies ni contraseñas)."""
if not isinstance(c, dict):
return {}
out = {}
for k in ("pantalla", "navegador", "idioma", "referer", "hora_local"):
if c.get(k):
out[k] = texto(c.get(k), 200)
for k in ("errores", "llamadas"):
v = c.get(k)
if isinstance(v, list):
out[k] = [{kk: texto(x.get(kk), 300) for kk in list(x)[:8]} for x in v[-10:] if isinstance(x, dict)]
return out
def nueva(d, u, origen):
tipo = d.get("tipo") if d.get("tipo") in TIPOS else "otro"
desc = texto(d.get("descripcion"), 4000)
titulo = texto(d.get("titulo"), 140) or (desc.split("\n")[0][:120] if desc else NOMBRE_TIPO[tipo])
r = {"id": siguiente_folio(), "titulo": titulo, "descripcion": desc, "tipo": tipo, "estado": "nueva",
"gravedad": d.get("gravedad") if d.get("gravedad") in GRAVEDADES else ("alta" if tipo == "acceso" else "media"),
"origen": origen, "reportado_por": u or "", "nombre": texto(d.get("nombre"), 80),
"contacto": texto(d.get("contacto"), 120), "asignado": "", "pagina": texto(d.get("pagina"), 300),
"navegador": texto(request.headers.get("User-Agent"), 200), "contexto": contexto_limpio(d.get("contexto")),
"firma": texto(d.get("firma"), 80), "ocurrencias": 1, "ultima_vez": ahora(), "evidencia": [],
"publica": False, "resumen_publico": "", "tarea_id": "", "duplicado_de": "", "comentarios": [],
"creado_por": u or "(sin sesión)", "creado_en": ahora(), "resuelta_en": None}
if isinstance(d.get("evidencia"), list):
r["evidencia"] = [texto(x, 600) for x in d["evidencia"][:20]]
hist(r, u or "(sin sesión)", f"reportada desde {origen}")
guardar(r)
return r
def para_lista(r, completo=False):
if completo:
return r
keys = ("id", "titulo", "tipo", "estado", "gravedad", "origen", "reportado_por", "nombre", "asignado", "pagina",
"ocurrencias", "ultima_vez", "publica", "tarea_id", "duplicado_de", "creado_en", "modificado_en",
"resuelta_en")
out = {k: r.get(k) for k in keys}
out["comentarios"] = len(r.get("comentarios") or [])
out["ultimo_comentario"] = (r.get("comentarios") or [{}])[-1] if r.get("comentarios") else None
return out
# ------------------------------------------------------------------ con sesión
@app.get("/api/incidencias")
def incidencias_lista():
u = yo()
if not u:
return g["_sin_usuario"]()
est = (request.args.get("estado") or "abiertas").strip()
tipo = (request.args.get("tipo") or "").strip()
origen = (request.args.get("origen") or "").strip()
q = (request.args.get("q") or "").strip().lower()
regs = todas()
conteo = {"abiertas": sum(1 for r in regs if r.get("estado") in ABIERTAS),
"por_estado": {e: sum(1 for r in regs if r.get("estado") == e) for e in ESTADOS},
"por_tipo": {t: sum(1 for r in regs if r.get("tipo") == t and r.get("estado") in ABIERTAS) for t in TIPOS}}
if est == "abiertas":
regs = [r for r in regs if r.get("estado") in ABIERTAS]
elif est == "cerradas":
regs = [r for r in regs if r.get("estado") not in ABIERTAS]
elif est in ESTADOS:
regs = [r for r in regs if r.get("estado") == est]
if tipo in TIPOS:
regs = [r for r in regs if r.get("tipo") == tipo]
if origen in ORIGENES:
regs = [r for r in regs if r.get("origen") == origen]
if q:
regs = [r for r in regs if q in (r.get("id", "") + " " + r.get("titulo", "") + " " + r.get("descripcion", "")).lower()]
abiertas = sorted([r for r in regs if r.get("estado") in ABIERTAS],
key=lambda r: r.get("modificado_en") or r.get("creado_en") or "", reverse=True)
abiertas.sort(key=lambda r: GRAVEDADES.index(r["gravedad"]) if r.get("gravedad") in GRAVEDADES else 1) # estable
cerradas = sorted([r for r in regs if r.get("estado") not in ABIERTAS],
key=lambda r: r.get("modificado_en") or "", reverse=True)
return jsonify(ok=True, incidencias=[para_lista(r) for r in abiertas + cerradas][:300], conteo=conteo,
tipos=[{"id": t, "nombre": NOMBRE_TIPO[t]} for t in TIPOS], estados=list(ESTADOS),
gravedades=list(GRAVEDADES), puede_editar=puede_editar(u), yo=u)
@app.get("/api/incidencias/resumen")
def incidencias_resumen():
u = yo()
if not u:
return g["_sin_usuario"]()
regs = [r for r in todas() if r.get("estado") in ABIERTAS]
regs.sort(key=lambda r: r.get("modificado_en") or r.get("creado_en") or "", reverse=True)
mias = [r for r in regs if (r.get("reportado_por") or "").lower() == u.lower()]
return jsonify(ok=True, abiertas=len(regs), mias=len(mias),
ultimas=[{k: r.get(k) for k in ("id", "titulo", "estado", "tipo")} for r in regs[:3]])
@app.get("/api/incidencia")
def incidencia_una():
if not yo():
return g["_sin_usuario"]()
r = g["_leer_json"](ruta(g["slug"](request.args.get("id"), 12)))
if not r:
return jsonify(ok=False, error="No existe esa incidencia."), 404
return jsonify(ok=True, incidencia=r, puede_editar=puede_editar(yo()))
@app.post("/api/incidencias")
def incidencias_crear():
u = yo()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
if not texto(d.get("descripcion")) and not texto(d.get("titulo")):
return jsonify(ok=False, error="Cuéntanos qué pasó (aunque sea una línea)."), 400
origen = d.get("origen") if d.get("origen") in ORIGENES else "sitio"
if origen in ("vigia", "agente") and u.lower() not in SISTEMA:
origen = "sitio"
firma = texto(d.get("firma"), 80)
if firma and u.lower() in SISTEMA:
for r in todas():
if r.get("firma") == firma and r.get("estado") in ABIERTAS:
r["ocurrencias"] = int(r.get("ocurrencias") or 1) + 1
r["ultima_vez"] = ahora()
ev = [texto(x, 600) for x in (d.get("evidencia") or [])][:5]
r["evidencia"] = (r.get("evidencia") or [])[-(20 - len(ev)):] + ev if ev else r.get("evidencia") or []
hist(r, u, f"se repitió (ocurrencia {r['ocurrencias']})")
guardar(r)
return jsonify(ok=True, id=r["id"], nueva=False, ocurrencias=r["ocurrencias"])
r = nueva(d, u, origen)
return jsonify(ok=True, id=r["id"], nueva=True, incidencia=r)
@app.patch("/api/incidencia")
def incidencia_cambiar():
u = yo()
if not u:
return g["_sin_usuario"]()
r = g["_leer_json"](ruta(g["slug"](request.args.get("id"), 12)))
if not r:
return jsonify(ok=False, error="No existe esa incidencia."), 404
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
editor = puede_editar(u)
reporto = (r.get("reportado_por") or "").lower() == u.lower()
cambios = []
if texto(d.get("comentario")):
r.setdefault("comentarios", []).append({"quien": u, "cuando": ahora(), "texto": texto(d.get("comentario"), 4000)})
cambios.append("comentó")
if "estado" in d:
e = d.get("estado")
if e not in ESTADOS:
return jsonify(ok=False, error="Estado inválido."), 400
if not editor and not (reporto and e in ("resuelta", "nueva")):
return jsonify(ok=False, error="Solo quien atiende las incidencias cambia su estado "
"(tú puedes marcar la tuya como resuelta o reabrirla)."), 403
if e != r.get("estado"):
cambios.append(f"estado: {r.get('estado')} → {e}")
r["estado"] = e
r["resuelta_en"] = ahora() if e in ("resuelta", "descartada") else None
for campo, validos, largo in (("gravedad", GRAVEDADES, 0), ("tipo", TIPOS, 0), ("asignado", None, 40),
("tarea_id", None, 60), ("duplicado_de", None, 12), ("titulo", None, 140),
("resumen_publico", None, 200)):
if campo not in d:
continue
if not editor:
return jsonify(ok=False, error=f"Solo quien atiende las incidencias cambia «{campo}»."), 403
v = d.get(campo) if validos else texto(d.get(campo), largo)
if validos and v not in validos:
return jsonify(ok=False, error=f"Valor inválido para {campo}."), 400
if v != r.get(campo):
cambios.append(f"{campo}: {g['_corto'](r.get(campo))} → {g['_corto'](v)}")
r[campo] = v
if "publica" in d:
if not editor:
return jsonify(ok=False, error="Solo quien atiende las incidencias decide qué se ve en la entrada."), 403
v = g["_bool"](d.get("publica"), False)
if v and not texto(r.get("resumen_publico")): # el título puede traer nombres o teléfonos: nunca se publica
return jsonify(ok=False, error="Para mostrarla en la entrada escribe primero el texto corto público "
"(sin nombres, teléfonos ni datos internos)."), 400
if v != bool(r.get("publica")):
cambios.append("se muestra en la página de entrada" if v else "ya no se muestra en la entrada")
r["publica"] = v
if not cambios:
return jsonify(ok=True, incidencia=r, sin_cambios=True)
hist(r, u, "; ".join(cambios))
guardar(r)
return jsonify(ok=True, incidencia=r)
# ------------------------------------------------------------------ errores (navegador y servidor)
def anotar_error(lado, reg):
os.makedirs(carpeta_err, exist_ok=True)
fp = os.path.join(carpeta_err, f"{lado}-{datetime.date.today():%Y-%m-%d}.jsonl")
try:
if os.path.exists(fp) and os.path.getsize(fp) > MAX_ERRORES_DIA * 1200:
return False
with open(fp, "a", encoding="utf-8") as f:
f.write(json.dumps(reg, ensure_ascii=False) + "\n")
return True
except OSError:
return False
@app.post("/api/incidencias/error")
def incidencias_error():
u = yo()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]() or {}
reg = {"cuando": ahora(), "usuario": u, "tipo": texto(d.get("tipo"), 20) or "js",
"mensaje": texto(d.get("mensaje"), 400), "fuente": texto(d.get("fuente"), 200),
"linea": g["_entero"](d.get("linea"), 0), "pagina": texto(d.get("pagina"), 200),
"url_api": texto(d.get("url_api"), 200), "estado_http": g["_entero"](d.get("estado_http"), 0),
"pila": texto(d.get("pila"), 1200), "navegador": texto(request.headers.get("User-Agent"), 160)}
return jsonify(ok=anotar_error("cliente", reg))
def leer_errores(dias):
out = {"cliente": [], "servidor": []}
for i in range(dias):
dia = datetime.date.today() - datetime.timedelta(days=i)
for lado in out:
fp = os.path.join(carpeta_err, f"{lado}-{dia:%Y-%m-%d}.jsonl")
if os.path.isfile(fp):
with open(fp, encoding="utf-8", errors="ignore") as f:
for linea in f:
try:
out[lado].append(json.loads(linea))
except Exception: # noqa: BLE001
pass
return out
def agrupar(regs, claves):
grupos = {}
for r in regs:
k = hashlib.sha1("|".join(str(r.get(c, "")) for c in claves).encode()).hexdigest()[:12]
gr = grupos.setdefault(k, {"firma": k, "veces": 0, "usuarios": set(), "primero": r.get("cuando"),
"ultimo": r.get("cuando"), "ejemplo": r})
gr["veces"] += 1
gr["usuarios"].add(r.get("usuario") or "")
gr["ultimo"] = max(gr["ultimo"] or "", r.get("cuando") or "")
out = sorted(grupos.values(), key=lambda x: -x["veces"])
for x in out:
x["usuarios"] = sorted(x["usuarios"])
return out
@app.get("/api/incidencias/errores")
def incidencias_errores():
u = yo()
if not puede_editar(u):
return jsonify(ok=False, error="Solo quien atiende las incidencias ve los errores."), 403
dias = max(1, min(14, g["_entero"](request.args.get("dias"), 2)))
e = leer_errores(dias)
return jsonify(ok=True, dias=dias,
cliente=agrupar(e["cliente"], ("tipo", "mensaje", "fuente", "linea", "url_api", "estado_http")),
servidor=agrupar(e["servidor"], ("tipo", "ruta", "error")))
def _excepcion(sender, exception, **extra): # noqa: ARG001
try:
anotar_error("servidor", {"cuando": ahora(), "tipo": "excepcion", "ruta": request.path,
"metodo": request.method, "usuario": yo(),
"error": f"{type(exception).__name__}: {exception}"[:400],
"traza": "".join(traceback.format_exception(exception))[-3000:]})
except Exception: # noqa: BLE001
pass
got_request_exception.connect(_excepcion, app, weak=False)
@app.after_request
def _respuestas_5xx(resp):
try:
if resp.status_code >= 500 and request.path.startswith("/api/"):
cuerpo = resp.get_json(silent=True) if resp.is_json else None
anotar_error("servidor", {"cuando": ahora(), "tipo": f"respuesta-{resp.status_code}", "ruta": request.path,
"metodo": request.method, "usuario": yo(),
"error": texto((cuerpo or {}).get("error") if isinstance(cuerpo, dict) else "", 400)})
except Exception: # noqa: BLE001
pass
return resp
# ------------------------------------------------------------------ sin sesión (página de entrada)
@app.post("/api/incidencias/publica")
def incidencias_publica():
d = g["_json_body"]() or request.form or {}
if texto(d.get("sitio_web")): # campo trampa: solo lo llenan los robots
return jsonify(ok=True, id="INC-0000")
desc = texto(d.get("descripcion"), 2000)
contacto = texto(d.get("contacto"), 120)
nombre = texto(d.get("nombre"), 80)
if len(desc) < 5:
return jsonify(ok=False, error="Cuéntanos qué pasa (aunque sea una línea)."), 400
if not (nombre or contacto):
return jsonify(ok=False, error="Dinos quién eres (tu usuario o tu nombre) para poder ayudarte."), 400
# límite: 5 por IP por hora (además del limit_req de nginx)
ip = (request.headers.get("X-Real-IP") or request.remote_addr or "")[:60]
hace_1h = (datetime.datetime.now() - datetime.timedelta(hours=1)).replace(microsecond=0).isoformat()
recientes = [r for r in todas() if r.get("origen") == "entrada" and (r.get("creado_en") or "") >= hace_1h
and (r.get("contexto") or {}).get("ip") == ip]
if len(recientes) >= 5:
return jsonify(ok=False, error="Ya recibimos varios reportes desde aquí. Te contactamos pronto."), 429
tipo = d.get("tipo") if d.get("tipo") in ("acceso", "error", "otro") else "acceso"
r = nueva({"descripcion": desc, "tipo": tipo, "nombre": nombre, "contacto": contacto,
"titulo": (NOMBRE_TIPO[tipo] + " — " + (nombre or contacto))[:140],
"pagina": "/entrar.html", "contexto": {"pantalla": texto(d.get("pantalla"), 40)}}, "", "entrada")
r["contexto"]["ip"] = ip
r["reportado_por"] = ""
guardar(r)
return jsonify(ok=True, id=r["id"])
@app.get("/api/incidencias/publicas")
def incidencias_publicas():
hace = (datetime.datetime.now() - datetime.timedelta(days=14)).replace(microsecond=0).isoformat()
regs = [r for r in todas() if r.get("publica") and texto(r.get("resumen_publico"))
and (r.get("estado") in ABIERTAS or (r.get("resuelta_en") or "") >= hace)]
regs.sort(key=lambda r: r.get("modificado_en") or "", reverse=True)
return jsonify(ok=True, avisos=[{"id": r["id"], "titulo": r.get("resumen_publico"),
"estado": r.get("estado"),
"resuelta": r.get("estado") in ("resuelta", "descartada"),
"actualizado": (r.get("modificado_en") or r.get("creado_en") or "")[:10]}
for r in regs[:8]])
g["_incidencias"] = {"todas": todas, "nueva": nueva, "guardar": guardar, "leer_errores": leer_errores,
"ABIERTAS": ABIERTAS}

View File

@ -0,0 +1,58 @@
{
"lote": "2026-10-27_entrada-incidencias-agentes",
"autor": "claude",
"motivo": "27-oct: página de entrada propia (el reporte de Adriana de no poder entrar después de restablecer), incidencias del sitio, bot de standup, vigía y agente diario; propuesta del cotizador con las decisiones de Leo (recibir en el sitio, SQLite)",
"fuente": "/agentes/",
"cambios": [
{ "id": "t-cambio-contrasenas",
"poner": { "fecha_limite": "2026-10-31" },
"nota": "27-oct: 15 de 19 siguen con la contraseña inicial. Con la página de entrada nueva cambiarla ya no saca a nadie y el teléfono guarda la nueva (antes llenaba la vieja). Equipo marca quién falta." },
{ "id": "t-correos-equipo",
"si_estado": ["por-hacer", "en-curso"],
"poner": { "estado": "en-curso" },
"nota": "Lista de Gibran confirmada por Leo (27-oct): cuentas.py alta llenó 12 correos (helgen.tech y helgen.services). Solo falta: Equipo → Leo → Otro correo = leo@helgen.services (ceo@ es alias)." },
{ "id": "t-login-correo",
"nota": "Parte 1 hecha (27-oct): página de entrada propia; se entra con el usuario o con el correo de Equipo (los dos dominios), y cerrar sesión ya funciona. Falta «Entrar con Google» y los avisos por correo: se decide después de una semana con la página nueva." },
{ "id": "t-cotizador-integracion",
"poner": { "estado": "hecho" },
"nota": "Propuesta lista (27-oct) y decidida por Leo: se recibe en el sitio (Recibir del inventario) y la base es SQLite. Siguen la fase 1 del centro de mando y los cambios del cotizador (pendientes nuevos)." },
{ "crear": { "id": "d-27-3-sitios-oc",
"titulo": "D-27-3/4/5: códigos de sitio VER·ZLO·ESE·LZC·QRO, autorización de OC en el cotizador (sin montos en el sitio) y DROGHEDA como proyecto ligado — confirma Leo",
"pista": "compras", "estado": "por-hacer", "responsable": "leo", "fecha_limite": "2026-10-29",
"sitios": ["qro"], "etiquetas": ["decisión", "cotizador"],
"descripcion": "Propuestas de Claude en la integración del cotizador. D-27-3: códigos = los del aeropuerto y del puerto (UN/LOCODE): VER Veracruz (ICAVE), ZLO Manzanillo (TIMSA), ESE Ensenada (EIT), LZC Lázaro Cárdenas (LCT), QRO Querétaro; el sitio ya los acepta. D-27-4: la autorización y los montos se quedan en el cotizador. D-27-5: solo las OC de PRY-2026-0002-DRO van al centro de mando.",
"enlaces": [{ "titulo": "Integración del cotizador", "url": "/inventario/integracion-cotizador/#decisiones" }] },
"si_no_hay": ["D-27-3"] },
{ "crear": { "id": "t-compras-fase1",
"titulo": "Compras fase 1 en el centro de mando: OC del cotizador en SQLite, envíos «en camino» por sitio y un pendiente por OC",
"pista": "compras", "estado": "por-hacer", "responsable": "claude, leo", "fecha_limite": "2026-11-04",
"sitios": ["qro"], "etiquetas": ["cotizador", "sitio"],
"descripcion": "backend/compras.py (SQLite /srv/projects/drogheda/drogheda.db en WAL), las 3 rutas firmadas /api/integracion/… (evento, ítems, recepciones), página Compras con renglones por ligar, liga desde el envío en camino del inventario, pendiente por OC que se cierra al recibir todo, y pruebas. Leo genera el secreto compartido y lo pone en el servidor (COTIZADOR_SECRETO) y en el cotizador (PORTAL_SECRETO). Empieza cuando Leo diga «va».",
"enlaces": [{ "titulo": "Diseño y contrato", "url": "/inventario/integracion-cotizador/" }] },
"si_no_hay": ["Compras fase 1"] },
{ "crear": { "id": "t-cotizador-oc-sitio",
"titulo": "Cotizador: sitio de entrega, ítem del inventario por renglón, estado «En camino», avisos al centro de mando y cierre automático",
"pista": "compras", "estado": "por-hacer", "responsable": "efrain, leo", "fecha_limite": "2026-11-06",
"sitios": ["qro"], "etiquetas": ["cotizador"],
"descripcion": "Lo hace la sesión de Claude del proyecto del cotizador con Efraín, siguiendo las instrucciones del 27-oct (pasos 1 a 7, con ronda en PRUEBAS). Puede empezar ya contra un servidor simulado; la prueba de punta a punta espera a t-compras-fase1.",
"enlaces": [{ "titulo": "Instrucciones para el cotizador", "url": "/inventario/integracion-cotizador-instrucciones/" }] },
"si_no_hay": ["sitio de entrega"] },
{ "crear": { "id": "t-standup-arranque",
"titulo": "Standup diario: primera semana — que cada quien conteste en Standup (el bot lo prepara cada tarde)",
"pista": "gestion", "estado": "por-hacer", "responsable": "adriana", "fecha_limite": "2026-11-03",
"sitios": ["qro", "icave", "timsa", "eit", "lzc"], "etiquetas": ["quick-win"],
"descripcion": "El bot deja en /tableros/standup/ las instrucciones de cada persona y tres preguntas sobre sus pendientes; el sitio avisa con una franja azul hasta que contesten. Adriana ve en la pestaña Equipo quién contestó y los bloqueos. Al final de la semana: ¿se queda, cambia la hora o las preguntas?",
"enlaces": [{ "titulo": "Standup", "url": "/tableros/standup/" }, { "titulo": "Agentes del sitio", "url": "/agentes/" }] },
"si_no_hay": ["Standup diario"] },
{ "id": "t-juntas",
"nota": "Desde el 28-oct el bot de standup pregunta a cada quien por escrito y mueve sus pendientes si lo deja marcado; la junta grabada se queda para lo que necesita conversación." }
]
}

View File

@ -0,0 +1,72 @@
{
"lote": "2026-10-27_t45-lista",
"autor": "claude",
"motivo": "T45 listo y probado en una copia de la v6: R80 abajo de Q7 (/VEH5V por F.Cu y tomas Kelvin por B.Cu), vías de los EP de U4 y U7 y limpieza. La RF LTE pasa a T45b",
"fuente": "/hardware/instrucciones-michelle-27oct/#2-despues-de-t45-martes-28-y-miercoles-29-bloque-b-y-r112",
"cambios": [
{
"id": "t-v6-t45",
"poner": {
"titulo": "T45 en la v6 por script: R80 abajo de Q7 con /VEH5V por F.Cu y tomas Kelvin, vías de los EP de U4 y U7 y limpieza",
"descripcion": "D-26-1 (a), Leo 27-oct. Script tools/t45_v6.py sobre el commit de Michelle del martes (después de Actualizar PCB con T48 + T49 + T41), con KiCad cerrado:\n1. R80 pasa a (174.6, 108.87), abajo de Q7: donde estaba, el anillo de /MAIN5V (Q7 → C77…C81 → U17.1/24) no dejaba llegar a R80.1 por F.Cu.\n2. /VEH5V de C100 a R80.1 por F.Cu (1.5 mm en el cuello entre C99.2 y R86.2; C100 → C101 a 2.0 mm): ≈ 7 mΩ desde L5.2. Fuera la vía de (180.1, 103.2) y los 8 mm de In2.\n3. /VIN_SW de R80.2 a los pines S de Q7 con 2.0 mm.\n4. Tomas VIN y CLN por el lado de adentro de R80, por B.Cu, hasta C86 (VIN) y la pista de U17.7 (CLN). El script hace la prueba de Kelvin.\n5. /MAIN5V de JP5.1 y R85.1 desde Q7.5, con 0.35 mm entre los pines 3 y 4.\n6. 6 vías en el EP de U4 (fuera el cuello de GND) y 6 en el de U7.\n7. Limpieza del 26-oct: 3 vías y 3 pistas sueltas.\nProbado en una copia de 9a6f250: DRC con 0 errores y 0 sin conectar. Si algo de la zona cambió, el script no escribe nada."
},
"nota": "27-oct, 15:45: script listo (tools/t45_v6.py), probado en una copia: DRC 0 errores, 0 sin conectar, Kelvin bien. Mueve R80 porque lo encerraba /MAIN5V. La RF LTE pasa a t-v6-t45b-rf."
},
{
"crear": {
"id": "t-v6-t45b-rf",
"titulo": "T45b en la v6 por script: RF LTE en F.Cu con cerca de vías y /VBAT_MODEM de C13 a los pines 55–57 con el cruce por B.Cu",
"pista": "ingenieria",
"estado": "por-hacer",
"responsable": "claude",
"version_tarjeta": "v6",
"fecha_limite": "2026-10-30",
"descripcion": "Sale de T45 (27-oct). Script sobre el commit de Michelle del miércoles 29 (bloque B), con KiCad cerrado:\n1. /RF_LTE y /RF_ANT_LTE solo en F.Cu: U6.60 → C19/R9 → C5 → D8 → J8, coplanar de 0.35/0.20 mm, sin esquinas de 90°. Se borran el tramo de In1 y sus 2 vías.\n2. /VBAT_MODEM de C13 a los pines 55–57, con C14–C18, cruzando la RF por B.Cu (polígono de ≥ 3 mm y ≥ 6 vías por lado). C14 o C15 se recorren y siguen a ≤ 3 mm de los pines.\n3. Cerca de vías de GND a los dos lados cada ≤ 3 mm; 2 vías por pad de tierra de J8 y una en D8.2.\n4. In1 sin pistas a 3 mm de la línea. Lo que no se pueda mover ahí (/KCOIL, /SW_IN) se lista para T29a y el bloque D.\nSe prueba en una copia con DRC y paridad antes de aplicar.",
"bloqueada_por": [
"t-v6-t45"
],
"enlaces": [
{
"titulo": "Plan de fines de semana, §4.1 (RF del módem)",
"url": "/hardware/v6-plan-fines-de-semana/#41-rf-del-modem-bloque-c"
},
{
"titulo": "Instrucciones para Michelle (27-oct), §3",
"url": "/hardware/instrucciones-michelle-27oct/#3-jueves-30-y-viernes-31-bloques-c-d-y-e"
}
],
"etiquetas": [
"v6",
"script",
"rf"
]
},
"si_no_hay": [
"T45b"
]
},
{
"id": "t-v6-kelvin-vin-r80",
"nota": "27-oct, 15:45: lo resuelve T45: R80 abajo de Q7 y las tomas VIN y CLN por el lado de adentro, por B.Cu; la prueba de Kelvin del script pasa. Se cierra cuando T45 quede aplicado (28-oct)."
},
{
"id": "t-v6-limpieza-26oct",
"nota": "27-oct, 15:45: las 3 vías y las 3 pistas sueltas van en T45. Si Michelle ya borró alguna, el script avisa y sigue."
},
{
"id": "t-v6-b-main5v-vsys",
"nota": "27-oct, 15:45: con T45, R80 pasa abajo de Q7 y su lugar viejo (entre Q7, C77 y C86) queda libre para el polígono de /MAIN5V y C77–C81. No mover R80, Q7, C86/C87, C100/C101 ni las tomas Kelvin."
},
{
"id": "t-v6-b-bstfb-igate",
"nota": "27-oct, 15:45: T45 da el 5 V a JP5.1 y R85.1 desde Q7.5 (0.35 mm entre los pines 3 y 4 de Q7). Al mover R85 a U17.18, su rama de arriba toma el 5 V de los capacitores de VSYS; JP5.1 se queda así."
},
{
"id": "t-v6-c-modem",
"poner": {
"descripcion": "Bloque C del plan de fines de semana (§4.2), sin la RF:\n1. C44/C34 a ≤ 3 mm y C52 junto a U4.2.\n2. R28/R27 a ≤ 2 mm de U4.5, con el nodo FB corto.\n3. /VBAT_MODEM como polígono de ≥ 3 mm de L1.2 a C13; C4 a ≤ 3 mm de L1.2.\n4. TP5 a 2.54 mm de TP6.\nDe C13 a los pines 55/56/57 (con C14–C18), la RF LTE y su cerca de vías los hace T45b (t-v6-t45b-rf) el jueves 30 temprano: no tocarlos. Las 6 vías del EP de U4 y el cuello de GND ya los resuelve T45."
},
"nota": "27-oct, 15:45: la RF LTE, su cerca de vías y /VBAT_MODEM de C13 a los pines 55–57 (con C14–C18) pasan a T45b, el 30-oct. A Michelle le queda el VBAT de L1.2 a C13 y el resto del bloque C."
}
]
}

114
backend/nginx/cambiar-entrada.sh Executable file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Página de entrada del sitio (27-oct-2026): instala el nginx nuevo de drogheda.helgen.online con respaldo, prueba y
# regreso automático. Se corre EN EL SERVIDOR con sudo (lo llama scripts/despliegue-1027.sh desde la Mac):
# sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh servicio # 1. servicio nuevo (hilos, admins)
# sudo bash …/cambiar-entrada.sh aplicar # 2. nginx con página de entrada (si una prueba falla, regresa solo)
# sudo bash …/cambiar-entrada.sh deshacer # regresar al nginx anterior (ventana gris) si algo no te gusta
# sudo bash …/cambiar-entrada.sh probar # solo las pruebas, sin cambiar nada
set -uo pipefail
AQUI="$(cd "$(dirname "$0")" && pwd)"
BACK="$(dirname "$AQUI")"
# El archivo real de nginx de este sitio (el que está habilitado), no una ruta supuesta
CONF="$(readlink -f "$(grep -l 'server_name drogheda.helgen.online' /etc/nginx/sites-enabled/* 2>/dev/null | head -1)" 2>/dev/null)"
NUEVO="$AQUI/drogheda.helgen.online.conf"
HTP=/etc/nginx/hbox-docs.htpasswd
DATOS=/srv/projects/drogheda
UNIDAD=/etc/systemd/system/actas-hbox.service
HOST=drogheda.helgen.online
[ "$(id -u)" = 0 ] || { echo "Córrelo con sudo."; exit 1; }
ok=0; mal=0
prueba() { # nombre, esperado (subcadena), comando
local r; r="$(eval "$3" 2>&1)"
if [[ "$r" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else mal=$((mal+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${r:0:160}"; fi
}
C="curl -s --max-time 10 --resolve $HOST:443:127.0.0.1"
probar_backend() {
echo "## backend (127.0.0.1:8081)"
prueba "salud" '"ok":true' "curl -s --max-time 5 http://127.0.0.1:8081/api/salud"
prueba "/api/_auth sin sesión → 401" "401" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/_auth"
prueba "/api/sesion responde" '"sesion":false' "curl -s http://127.0.0.1:8081/api/sesion"
prueba "incidencias cargadas" '"ok":true' "curl -s -H 'X-Remote-User: vigia' http://127.0.0.1:8081/api/incidencias/resumen"
prueba "standup cargado" '"ok":true' "curl -s -H 'X-Remote-User: vigia' http://127.0.0.1:8081/api/standup/estado"
}
cookie_de_prueba() { # sesión de 5 minutos para leo, firmada con el secreto del servicio (sin contraseña)
python3 - "$DATOS/.sesion-secreto" "$HTP" <<'PY'
import sys, time, hmac, hashlib
sec = open(sys.argv[1]).read().strip().encode()
h = [l.split(":", 1)[1].strip() for l in open(sys.argv[2]) if l.split(":", 1)[0].strip() == "leo"][0]
ver = hashlib.sha256(h.encode()).hexdigest()[:12]; v = int(time.time()) + 300
print("leo~%d~%s~%s" % (v, ver, hmac.new(sec, ("leo|%d|%s" % (v, ver)).encode(), hashlib.sha256).hexdigest()[:40]))
PY
}
probar_nginx() {
echo "## nginx ($HOST)"
prueba "/ sin sesión → 302 a /entrar.html" "entrar.html" "$C -o /dev/null -w '%{http_code} %{redirect_url}' https://$HOST/tableros/pendientes/"
prueba "/entrar.html → 200" "200" "$C -o /dev/null -w '%{http_code}' https://$HOST/entrar.html"
prueba "estilos públicos → 200" "200" "$C -o /dev/null -w '%{http_code}' https://$HOST/stylesheets/helgen.css"
prueba "/publico/entrar.js → 200" "200" "$C -o /dev/null -w '%{http_code}' https://$HOST/publico/entrar.js"
prueba "/api/sesion sin sesión → 200" '"sesion":false' "$C https://$HOST/api/sesion"
prueba "/api/tareas sin sesión → 401 JSON" '"sesion":false' "$C https://$HOST/api/tareas"
prueba "/api/_auth desde fuera → 404" "404" "$C -o /dev/null -w '%{http_code}' https://$HOST/api/_auth"
prueba "X-Remote-User falso no entra" '"sesion":false' "$C -H 'X-Remote-User: leo' https://$HOST/api/tareas"
prueba "entrar con contraseña mala → 401" '"codigo":"' "$C -H 'Content-Type: application/json' -d '{\"usuario\":\"vigia-prueba\",\"password\":\"x\"}' https://$HOST/api/entrar"
prueba "curl -u (Basic) ya no entra" '"sesion":false' "$C -A curl -u leo:x https://$HOST/api/whoami"
local CK; CK="$(cookie_de_prueba 2>/dev/null)"
prueba "con sesión: /api/whoami = leo" '"user":"leo"' "$C -b hbox_sesion=$CK https://$HOST/api/whoami"
prueba "con sesión: la página abre (200)" "200" "$C -b hbox_sesion=$CK -o /dev/null -w '%{http_code}' https://$HOST/tableros/pendientes/"
prueba "POST desde otro sitio → 403" "otro sitio" "$C -b hbox_sesion=$CK -H 'Origin: https://malo.example' -H 'Content-Type: application/json' -d '{}' https://$HOST/api/incidencias"
}
revisar_htpasswd() { # la entrada nueva lee $apr1$ y {SHA}; si alguien quedó con otro formato, no podría entrar
local otros; otros="$(cut -d: -f2 "$HTP" | grep -vcE '^(\$apr1\$|\{SHA\})' || true)"
if [ "${otros:-0}" != "0" ]; then
echo " ✕ $otros cuenta(s) del htpasswd con un formato que la entrada nueva no lee:"; cut -d: -f1,2 "$HTP" | grep -vE ':(\$apr1\$|\{SHA\})' | cut -d: -f1 | sed 's/^/ /'
echo " Restablécelas en Mi cuenta antes de cambiar nginx."; return 1
fi
echo " ✓ todas las contraseñas en \$apr1\$"
}
case "${1:-}" in
servicio)
UANT="$UNIDAD.antes-$(date +%Y%m%d-%H%M%S)"
cp -p "$UNIDAD" "$UANT" || { echo "No pude respaldar $UNIDAD: no cambio nada."; exit 1; }
install -m 644 "$BACK/actas-hbox.service" "$UNIDAD"
systemctl daemon-reload && systemctl restart actas-hbox
for i in 1 2 3 4 5 6 7 8 9 10; do curl -s --max-time 2 http://127.0.0.1:8081/api/salud >/dev/null && break; sleep 1; done
probar_backend
if [ "$mal" != 0 ]; then
echo "El servicio nuevo no pasó ($mal mal): regreso al anterior ($UANT). Revisa 'journalctl -u actas-hbox -n 50'."
cp -p "$UANT" "$UNIDAD" && systemctl daemon-reload && systemctl restart actas-hbox; exit 1
fi
echo "Servicio listo ($ok pruebas). Respaldo del anterior: $UANT"
;;
aplicar)
[ -n "$CONF" ] && [ -f "$CONF" ] || { echo "No encuentro el nginx de $HOST en sites-enabled. No cambio nada."; exit 1; }
echo "Archivo de nginx: $CONF"
probar_backend
[ "$mal" = 0 ] || { echo "El backend todavía no tiene la entrada nueva: corre primero «servicio». No cambié nginx."; exit 1; }
revisar_htpasswd || exit 1
# respaldo FUERA de sites-enabled/ (lo que esté ahí lo carga nginx: un respaldo ahí duplicaría el sitio)
mkdir -p /etc/nginx/respaldos-drogheda
RESP="/etc/nginx/respaldos-drogheda/$(basename "$CONF").antes-entrada-$(date +%Y%m%d-%H%M%S)"
cp -p "$CONF" "$RESP" || { echo "No pude respaldar $CONF: no cambio nada."; exit 1; }
echo "Respaldo: $RESP"
install -m 644 "$NUEVO" "$CONF"
if ! nginx -t 2>&1; then echo "nginx -t falló: regreso al anterior."; cp -p "$RESP" "$CONF"; nginx -t && systemctl reload nginx; exit 1; fi
systemctl reload nginx; sleep 1
ok=0; mal=0; probar_nginx
if [ "$mal" != 0 ]; then
echo "Algo falló ($mal): regreso al nginx anterior."; cp -p "$RESP" "$CONF"; nginx -t && systemctl reload nginx; exit 1
fi
echo; echo "Listo ($ok pruebas). Abre https://$HOST : debe salir la página de entrada. Para regresar: sudo bash $0 deshacer"
;;
deshacer)
[ -n "$CONF" ] || CONF=/etc/nginx/sites-available/drogheda.helgen.online.conf
RESP="$(ls -1t /etc/nginx/respaldos-drogheda/"$(basename "$CONF")".antes-entrada-* 2>/dev/null | head -1)"
[ -n "$RESP" ] || { echo "No hay respaldo de antes de la entrada."; exit 1; }
cp -p "$RESP" "$CONF" && nginx -t && systemctl reload nginx && echo "Regresé a $RESP (ventana gris de antes)."
;;
probar)
probar_backend; probar_nginx; echo "$ok bien · $mal mal"
;;
*) sed -n '2,9p' "$0"; exit 1 ;;
esac

View File

@ -0,0 +1,128 @@
# drogheda.helgen.online — 27-oct-2026: página de entrada propia (backend/acceso.py) en lugar de la ventana de
# HTTP Basic. nginx pregunta a /api/_auth en cada petición; sin sesión manda a /entrar.html (las API reciben 401 JSON).
# Lo instala backend/nginx/cambiar-entrada.sh (respaldo, nginx -t, recarga, pruebas y regreso automático si algo falla).
# Lo de Certbot (443, certificados) es igual que antes.
# Límites por IP (la oficina de Querétaro sale por una sola IP: holgados). Además, acceso.py frena por usuario.
limit_req_zone $binary_remote_addr zone=hbox_entrar:1m rate=30r/m;
limit_req_zone $binary_remote_addr zone=hbox_reporte:1m rate=6r/m;
server {
server_name drogheda.helgen.online;
root /srv/projects/drogheda/hbox-docs/site;
index index.html;
# ---- sesión: todo pide sesión salvo lo marcado «auth_request off» abajo
auth_request /_auth;
auth_request_set $hbox_user $upstream_http_x_hbox_user;
error_page 401 = @entrar;
error_page 500 502 503 504 /publico/caido.html;
location = /_auth {
internal;
proxy_pass http://127.0.0.1:8081/api/_auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Remote-User "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
}
location @entrar {
# sin sesión: a la página de entrada; después regresa a donde ibas (la página lo lee después del #)
return 302 /entrar.html#$request_uri;
}
location @api401 {
default_type application/json;
return 401 '{"ok":false,"sesion":false,"error":"Tu sesión terminó. Vuelve a entrar en /entrar.html"}';
}
location @lento {
default_type application/json;
return 429 '{"ok":false,"codigo":"bloqueado","error":"Demasiados intentos seguidos desde esta conexión. Espera un minuto y vuelve a intentar."}';
}
# ---- públicas (sin sesión): la página de entrada y lo que necesita para verse
location = /entrar.html { auth_request off; add_header Cache-Control "no-store"; }
location ^~ /publico/ { auth_request off; }
location ^~ /fonts/ { auth_request off; }
location = /stylesheets/helgen.css { auth_request off; }
location = /js/tema.js { auth_request off; }
location ~ ^/img/(helgen-mascara\.svg|favicon\.png|helgen-icono-180\.png)$ { auth_request off; }
location = /favicon.ico { auth_request off; try_files /img/favicon.png =404; }
# ---- API sin sesión: entrar, salir, saber si hay sesión, salud, avisos y reporte desde la entrada
location = /api/_auth { return 404; }
location ~ ^/api/(salir|sesion|salud|incidencias/publicas)$ {
auth_request off;
client_max_body_size 16k;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Remote-User "";
}
location = /api/entrar {
auth_request off;
limit_req zone=hbox_entrar burst=20 nodelay;
limit_req_status 429;
error_page 429 = @lento;
client_max_body_size 16k;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Remote-User "";
}
location = /api/incidencias/publica {
auth_request off;
limit_req zone=hbox_reporte burst=4 nodelay;
limit_req_status 429;
error_page 429 = @lento;
client_max_body_size 16k;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Remote-User "";
}
# ---- API con sesión: el usuario lo pone nginx (de la sesión), nunca el navegador
location /api/ {
error_page 401 = @api401;
client_max_body_size 25m;
proxy_read_timeout 120s;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Remote-User $hbox_user;
}
location / {
try_files $uri $uri/ =404;
}
listen [::]:443 ssl; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/drogheda.helgen.online/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/drogheda.helgen.online/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = drogheda.helgen.online) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name drogheda.helgen.online;
return 404; # managed by Certbot
}

64
backend/semilla_1027.py Normal file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Primeras incidencias (27-oct-2026): las dos fallas de acceso de esta semana, para que el tablero de Incidencias arranque
con su historia. Se corre EN EL SERVIDOR después de reiniciar el servicio (lo hace scripts/despliegue-1027.sh):
cd /srv/projects/drogheda/hbox-docs/backend && venv/bin/python3 semilla_1027.py
Idempotente: si ya existe una incidencia con la misma firma, no la vuelve a abrir. Escribe como «claude» por 127.0.0.1:8081.
"""
import json
import os
import urllib.request
API = os.environ.get("VIGIA_API", "http://127.0.0.1:8081")
def api(metodo, ruta, cuerpo=None):
req = urllib.request.Request(API + ruta, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None,
headers={"X-Remote-User": "claude", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode())
SEMILLA = [
{"alta": {"firma": "semilla:acceso-restablecer", "origen": "agente", "tipo": "acceso", "gravedad": "alta",
"titulo": "Después de restablecer la contraseña no se podía volver a entrar (reporte de Adriana, 26-oct)",
"descripcion": "Adriana reportó el 26-oct que, después de restablecer contraseñas, la gente no podía volver a entrar. "
"El 23-oct se restableció a todos con una contraseña inicial y solo 4 de 19 la cambiaron. El 27-oct a "
"las 9:18 Gibran le restableció la suya a Adriana y con eso entró.",
"pagina": "/cuenta.html"},
"cambio": {"estado": "en-curso", "asignado": "claude", "publica": True,
"resumen_publico": "Desde el 27-oct se entra con una página nueva: tu usuario (o tu correo de Helgen) y tu "
"contraseña. Si tu navegador llena una contraseña vieja, bórrala y escribe la tuya.",
"comentario": "Causa: con la ventana gris de antes (HTTP Basic de nginx) el navegador y el teléfono guardaban la "
"contraseña anterior y la volvían a mandar sola; Mi cuenta no tenía campo de usuario, así que el "
"gestor de contraseñas nunca la actualizaba; el usuario con mayúscula («Adriana») no existía para "
"nginx, y el servidor no registraba quién fallaba ni por qué. Arreglo (27-oct): página de entrada "
"propia (usuario o correo, sin importar mayúsculas), sesión que sobrevive al cambio de contraseña, "
"cerrar sesión que sí cierra, el restablecimiento cierra las sesiones viejas, contraseñas sin "
"mayúscula automática en el teléfono, y cada intento queda registrado (sin la contraseña). Queda "
"en curso una semana: el vigía avisa si alguien sigue sin poder entrar."}},
{"alta": {"firma": "semilla:servicio-repetibles", "origen": "agente", "tipo": "datos", "gravedad": "media",
"titulo": "El servicio instalado a mano el 27-oct dejó fuera la prueba Ethernet/serie y no estaba en el repo",
"descripcion": "A las 9:15 del 27-oct se reinstaló /etc/systemd/system/actas-hbox.service con una copia vieja: "
"agregó a Gibran como administrador, pero ACTAS_REPETIBLES ya no incluía prueba-ethernet-serie "
"(la segunda acta de la misma serie se rechazaba con 409). El repo no tenía a Gibran.",
"pagina": "/formularios/ethernet-serie.html"},
"cambio": {"estado": "resuelta", "asignado": "claude",
"comentario": "Arreglado en el despliegue del 27-oct: el servicio del repo ahora lleva a Gibran como "
"administrador y la lista completa de actas repetibles, y se instaló desde el repo. El vigía "
"compara cada hora el servicio instalado contra el del repo y abre una incidencia si no coinciden."}},
]
def main():
for s in SEMILLA:
lista = api("GET", "/api/incidencias?estado=todas&q=" + urllib.request.quote(s["alta"]["titulo"][:30]))
if any(r.get("titulo") == s["alta"]["titulo"] for r in lista.get("incidencias", [])):
print("ya estaba:", s["alta"]["titulo"][:70])
continue
r = api("POST", "/api/incidencias", s["alta"])
api("PATCH", "/api/incidencia?id=" + r["id"], s["cambio"])
print("abierta:", r["id"], s["alta"]["titulo"][:70])
if __name__ == "__main__":
main()

198
backend/standup.py Normal file
View File

@ -0,0 +1,198 @@
"""Standup diario (27-oct-2026): el bot de scrum del sitio.
Cada mañana el agente diario (Claude, tarea programada) lee el tablero y prepara para cada persona con pendientes
abiertos su «parte del día»: instrucciones concretas (qué hacer hoy, en qué orden, con las ligas a los documentos) y
las tres preguntas de siempre sobre SUS pendientes: ¿qué terminaste?, ¿qué te falta?, ¿qué te bloquea?
Cada quien contesta en /tableros/standup/ (el sitio le avisa con una franja arriba hasta que conteste). Al día
siguiente el agente lee las respuestas, arma un lote para el tablero (Leo lo aplica) y un resumen para Leo y Adriana.
Si el agente no corrió, la página igual pregunta por los pendientes abiertos de la persona (sin instrucciones).
Lo engancha app.py al final con registrar(app, globals()).
Datos: DATOS_BASE/standup/AAAA-MM-DD/<usuario>.json {fecha, usuario, nombre, preparado_por, preparado_en,
instrucciones:[{texto, tarea_id?, liga?}], preguntas:[{tarea_id, titulo, pregunta}], nota_bot,
respuesta:{enviada_en, hecho, falta, bloqueos, comentario, por_tarea:[{tarea_id, estado, nota}]}, historial}
Rutas (todas con sesión):
GET /api/standup/hoy[?fecha=] el mío de hoy (o el que el bot dejó en los últimos 3 días sin contestar)
POST /api/standup/respuesta {fecha, hecho, falta, bloqueos, comentario, por_tarea:[{tarea_id, estado, nota}]}
GET /api/standup?fecha= equipo: quién contestó, bloqueos (STANDUP_LECTORES + administradores)
POST /api/standup/preparar {fecha, personas:[{usuario, instrucciones, preguntas, nota_bot}]} (claude + admins)
GET /api/standup/estado {pendiente: bool, fecha} — para la franja de aviso (barato)
Estados por pendiente en la respuesta: hecho · sigo · bloqueado · no-empece.
"""
import datetime
import os
from flask import jsonify, request
ESTADOS_RESP = ("hecho", "sigo", "bloqueado", "no-empece")
DIAS_ATRAS = 3
def registrar(app, g):
carpeta = os.environ.get("STANDUP_DIR", os.path.join(g["DATOS_BASE"], "standup"))
lectores = {u.strip().lower() for u in os.environ.get("STANDUP_LECTORES", "leo,adriana,claude,gibranlp").split(",")
if u.strip()}
preparan = {u.strip().lower() for u in os.environ.get("STANDUP_PREPARAN", "claude").split(",") if u.strip()}
texto, ahora, hist = g["_texto"], g["ahora"], g["_hist"]
def yo():
return g["current_user"]()
def hoy():
return datetime.date.today().isoformat()
def fecha_valida(s):
s = texto(s, 10)
return s if g["FECHA_RE"].match(s or "") else None
def ruta(fecha, usuario):
return os.path.join(carpeta, fecha, g["_norm_id"](usuario) + ".json")
def mis_pendientes(u):
miembros = g["_miembros"]()
out = []
for t in g["_listar_json"](g["TAREAS_DIR"]):
if t.get("estado") == "hecho":
continue
ids = g["_ids_responsable"](t.get("responsable"), miembros)[0]
if g["_norm_id"](u) in ids:
out.append({"id": t.get("id"), "titulo": t.get("titulo"), "estado": t.get("estado"),
"vence": t.get("vence"), "pista": t.get("pista"), "bloqueado_por": t.get("bloqueado_por")})
orden = {"bloqueado": 0, "en-curso": 1, "por-hacer": 2}
out.sort(key=lambda t: (orden.get(t["estado"], 3), t.get("vence") or "9999"))
return out
def armar_generico(u, fecha):
pend = mis_pendientes(u)
m = g["_miembros"]().get(g["_norm_id"](u)) or {}
return {"fecha": fecha, "usuario": g["_norm_id"](u), "nombre": m.get("nombre") or u, "preparado_por": "",
"preparado_en": None, "instrucciones": [], "nota_bot": "",
"preguntas": [{"tarea_id": t["id"], "titulo": t["titulo"], "pregunta": ""} for t in pend[:12]],
"respuesta": None, "_generico": True}
def buscar_mio(u, fecha=None):
"""El de la fecha pedida; si no, el de hoy; si no hay de hoy, el último sin contestar de los últimos días."""
if fecha:
return g["_leer_json"](ruta(fecha, u)), fecha
d = g["_leer_json"](ruta(hoy(), u))
if d:
return d, hoy()
for i in range(1, DIAS_ATRAS + 1):
f = (datetime.date.today() - datetime.timedelta(days=i)).isoformat()
d = g["_leer_json"](ruta(f, u))
if d and not d.get("respuesta"):
return d, f
return None, hoy()
def con_pendientes(d, u):
"""Agrega el estado de hoy de cada pendiente preguntado (pudo moverse desde que el bot preguntó)."""
actuales = {t["id"]: t for t in mis_pendientes(u)}
for p in d.get("preguntas") or []:
t = actuales.get(p.get("tarea_id"))
p["estado_tablero"] = t["estado"] if t else "hecho o ya no es tuyo"
p["vence"] = t.get("vence") if t else None
d["pendientes_abiertos"] = list(actuales.values())
return d
@app.get("/api/standup/hoy")
def standup_hoy():
u = yo()
if not u:
return g["_sin_usuario"]()
d, fecha = buscar_mio(u, fecha_valida(request.args.get("fecha")))
d = d or armar_generico(u, fecha)
return jsonify(ok=True, standup=con_pendientes(d, u), estados=list(ESTADOS_RESP), hoy=hoy())
@app.get("/api/standup/estado")
def standup_estado():
u = yo()
if not u:
return g["_sin_usuario"]()
d, fecha = buscar_mio(u)
return jsonify(ok=True, preparado=bool(d), pendiente=bool(d and not d.get("respuesta")), fecha=fecha)
@app.post("/api/standup/respuesta")
def standup_respuesta():
u = yo()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
fecha = fecha_valida(d.get("fecha")) or hoy()
reg = g["_leer_json"](ruta(fecha, u)) or armar_generico(u, fecha)
reg.pop("_generico", None)
por_tarea = []
for x in d.get("por_tarea") or []:
if isinstance(x, dict) and x.get("tarea_id") and x.get("estado") in ESTADOS_RESP:
por_tarea.append({"tarea_id": texto(x["tarea_id"], 60), "estado": x["estado"], "nota": texto(x.get("nota"), 600)})
resp = {"enviada_en": ahora(), "hecho": texto(d.get("hecho"), 3000), "falta": texto(d.get("falta"), 3000),
"bloqueos": texto(d.get("bloqueos"), 3000), "comentario": texto(d.get("comentario"), 3000),
"por_tarea": por_tarea}
if not (resp["hecho"] or resp["falta"] or resp["bloqueos"] or por_tarea):
return jsonify(ok=False, error="Contesta al menos una pregunta o marca cómo va un pendiente."), 400
cambio = "contestó" if not reg.get("respuesta") else "corrigió su respuesta"
reg["respuesta"] = resp
hist(reg, u, cambio)
g["_escribir_json"](ruta(fecha, u), reg)
return jsonify(ok=True, standup=reg)
@app.post("/api/standup/preparar")
def standup_preparar():
u = yo()
if not u or not (u.lower() in preparan or g["is_admin"]()):
return jsonify(ok=False, error="Solo el bot (claude) o un administrador prepara el standup."), 403
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
fecha = fecha_valida(d.get("fecha")) or hoy()
miembros = g["_miembros"]()
hechos, omitidos = [], []
for p in d.get("personas") or []:
uid = g["_norm_id"](p.get("usuario"))
if uid not in miembros:
omitidos.append({"usuario": uid, "por": "no es miembro del equipo"})
continue
previo = g["_leer_json"](ruta(fecha, uid))
if previo and previo.get("respuesta"):
omitidos.append({"usuario": uid, "por": "ya contestó: no se reescribe"})
continue
instr = []
for x in p.get("instrucciones") or []:
if isinstance(x, str):
x = {"texto": x}
if isinstance(x, dict) and texto(x.get("texto")):
liga = texto(x.get("liga"), 300)
if liga.lower().startswith(("javascript:", "data:")):
liga = ""
instr.append({"texto": texto(x.get("texto"), 600), "tarea_id": texto(x.get("tarea_id"), 60), "liga": liga})
preg = []
for x in p.get("preguntas") or []:
if isinstance(x, dict) and (x.get("tarea_id") or x.get("pregunta")):
preg.append({"tarea_id": texto(x.get("tarea_id"), 60), "titulo": texto(x.get("titulo"), 200),
"pregunta": texto(x.get("pregunta"), 400)})
reg = {"fecha": fecha, "usuario": uid, "nombre": miembros[uid].get("nombre") or uid, "preparado_por": u,
"preparado_en": ahora(), "instrucciones": instr[:12], "preguntas": preg[:15],
"nota_bot": texto(p.get("nota_bot"), 1200), "respuesta": None, "historial": (previo or {}).get("historial", [])}
hist(reg, u, "preparó el standup" if not previo else "rehízo el standup")
g["_escribir_json"](ruta(fecha, uid), reg)
hechos.append(uid)
return jsonify(ok=True, fecha=fecha, preparados=hechos, omitidos=omitidos)
@app.get("/api/standup")
def standup_equipo():
u = yo()
if not u:
return g["_sin_usuario"]()
if not (u.lower() in lectores or g["is_admin"]()):
return jsonify(ok=False, error="La vista del equipo es para Leo, Adriana y el bot."), 403
fecha = fecha_valida(request.args.get("fecha")) or hoy()
base = os.path.join(carpeta, fecha)
regs = g["_listar_json"](base) if os.path.isdir(base) else []
fechas = sorted([f for f in os.listdir(carpeta) if g["FECHA_RE"].match(f)], reverse=True)[:30] if os.path.isdir(carpeta) else []
return jsonify(ok=True, fecha=fecha, fechas=fechas, standups=regs,
contestaron=sum(1 for r in regs if r.get("respuesta")), total=len(regs))
g["_standup"] = {"mis_pendientes": mis_pendientes, "carpeta": carpeta}

101
backend/tests-1027.sh Executable file
View File

@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Pruebas del 27-oct: página de entrada (acceso.py), incidencias (incidencias.py), standup (standup.py), vigía y cuentas.
# Arranca app.py con carpetas y htpasswd temporales (como tests-1023.sh). No toca el servidor.
# Uso: bash backend/tests-1027.sh <ruta-a-hbox-docs> [puerto]
set -u
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8097}
T=$(mktemp -d); export ACTAS_DIR=$T/actas HTPASSWD_FILE=$T/htpasswd HTPASSWD_LOCK=$T/.lock SESION_COOKIE_INSEGURA=1 \
ACTAS_ADMINS=leo,gibranlp PORT INCIDENCIAS_DIR=$T/incidencias
mkdir -p $ACTAS_DIR $T/equipo $T/tareas
python3 - "$REPO" "$T" <<'EOF'
import sys, os, json, importlib.util
repo, t = sys.argv[1], sys.argv[2]
spec = importlib.util.spec_from_file_location("app", os.path.join(repo, "backend", "app.py")); A = importlib.util.module_from_spec(spec); spec.loader.exec_module(A)
with open(os.path.join(t, "htpasswd"), "w") as f:
for u, p in {"leo": "helgen-2026-leo", "adriana": "Puerto-Cobre-1234", "jesush": "faro-ancla-9911"}.items():
f.write(u + ":" + A.apr1(p, ("salt" + u)[:8]) + "\n")
for u, n, c in [("leo", "Leo", "leo@helgen.tech"), ("adriana", "Adriana", "adriana@helgen.tech"), ("jesush", "Jesús Honorato", "jesus@helgen.tech"), ("claude", "Claude", "")]:
json.dump({"id": u, "nombre": n, "area": "ingenieria", "correo": c, "activo": True}, open(os.path.join(t, "equipo", u + ".json"), "w"))
for i, (ti, r, e) in enumerate([("Revisar kit", "adriana", "por-hacer"), ("Instalar 2 en TIMSA", "jesush", "en-curso"), ("Cotizar gabinetes", "adriana, leo", "bloqueado")]):
json.dump({"id": f"t-p{i}", "titulo": ti, "responsable": r, "estado": e, "pista": "compras", "sitios": ["qro"], "historial": []}, open(os.path.join(t, "tareas", f"t-p{i}.json"), "w"))
EOF
python3 $REPO/backend/app.py > $T/app.log 2>&1 &
APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.8
U="http://127.0.0.1:$PORT"; MOZ="Mozilla/5.0 (pruebas)"
ok=0; fail=0
check() { if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:200}"; fi; }
J() { echo "-H Content-Type:application/json"; }
as() { curl -s -H "X-Remote-User: $1" -H "Content-Type: application/json" "${@:2}"; }
echo "## entrada (acceso.py)"
check "módulos cargados" "" "$(grep -i 'no se cargó' $T/app.log)"
check "_auth sin sesión 401" "401" "$(curl -s -o /dev/null -w '%{http_code}' $U/api/_auth)"
check "entrar con mayúscula y espacios" '"user":"adriana"' "$(curl -s -A "$MOZ" -c $T/j $(J) -d '{"usuario":" Adriana","password":"Puerto-Cobre-1234"}' $U/api/entrar)"
check "_auth con cookie 204 + X-Hbox-User" "X-Hbox-User: adriana" "$(curl -s -i -b $T/j $U/api/_auth | tr -d '\r')"
check "entrar por correo" '"user":"jesush"' "$(curl -s -A "$MOZ" -c $T/j2 $(J) -d '{"usuario":"jesus@helgen.tech","password":"faro-ancla-9911"}' $U/api/entrar)"
check "contraseña mala con Bloq Mayús" '"codigo":"contrasena"' "$(curl -s -A "$MOZ" $(J) -d '{"usuario":"jesush","password":"FARO-ANCLA-9911"}' $U/api/entrar)"
check "pista bloq-mayus registrada" "bloq-mayus" "$(cat $T/acceso/intentos-*.jsonl)"
check "la contraseña nunca se guarda" "" "$(grep -i 'faro-ancla\|FARO-ANCLA' $T/acceso/intentos-*.jsonl)"
curl -s -o /dev/null -A "$MOZ" $(J) -d '{"usuario":"mi-clave-secreta-123","password":"x"}' $U/api/entrar
check "lo tecleado en «usuario» se guarda enmascarado" "mi-…(20)" "$(cat $T/acceso/intentos-*.jsonl)"
check "…y nunca completo" "" "$(grep 'mi-clave-secreta' $T/acceso/intentos-*.jsonl)"
for i in 1 2 3 4 5 6 7 8; do curl -s -o /dev/null -A "$MOZ" -H 'X-Real-IP: 10.1.1.1' $(J) -d '{"usuario":"leo","password":"mala-1234"}' $U/api/entrar; done
check "8 fallas de leo desde una IP: esa IP queda en espera" '"codigo":"bloqueado"' "$(curl -s -A "$MOZ" -H 'X-Real-IP: 10.1.1.1' $(J) -d '{"usuario":"leo","password":"helgen-2026-leo"}' $U/api/entrar)"
check "…pero leo entra desde su IP (nadie lo deja fuera)" '"user":"leo"' "$(curl -s -A "$MOZ" -H 'X-Real-IP: 10.2.2.2' $(J) -d '{"usuario":"leo","password":"helgen-2026-leo"}' $U/api/entrar)"
check "Basic ya no entra (ni de script)" "401" "$(curl -s -o /dev/null -w '%{http_code}' -A curl -u leo:helgen-2026-leo $U/api/_auth)"
check "Basic de navegador no" "401" "$(curl -s -o /dev/null -w '%{http_code}' -A "$MOZ" -u leo:helgen-2026-leo $U/api/_auth)"
check "cookie con acento → 401 (no 500)" "401" "$(curl -s -o /dev/null -w '%{http_code}' -b 'hbox_sesion=é~1~2~3' $U/api/_auth)"
check "POST desde otro sitio → 403" "otro sitio" "$(curl -s -H 'X-Remote-User: adriana' -H 'Origin: https://malo.example' $(J) -d '{"descripcion":"csrf"}' $U/api/incidencias)"
check "POST con Sec-Fetch-Site same-site → 403" "otro sitio" "$(curl -s -H 'X-Remote-User: adriana' -H 'Sec-Fetch-Site: same-site' $(J) -d '{"descripcion":"csrf"}' $U/api/incidencias)"
check "cambio propio: cookie nueva" "hbox_sesion=adriana" "$(curl -s -i -b $T/j -c $T/j -H 'X-Remote-User: adriana' $(J) -d '{"actual":"Puerto-Cobre-1234","nueva":"marea-radar-5521","confirma":"marea-radar-5521"}' $U/api/password | tr -d '\r')"
check "la cookie nueva vale" "204" "$(curl -s -o /dev/null -w '%{http_code}' -b $T/j $U/api/_auth)"
check "reset de admin mata la sesión de jesush" "401" "$(as leo -d '{"usuario":"jesush","nueva":"cometa-trigo-4410","confirma":"cometa-trigo-4410"}' $U/api/password >/dev/null; curl -s -o /dev/null -w '%{http_code}' -b $T/j2 $U/api/_auth)"
check "salir" "hbox_sesion=;" "$(curl -s -i -X POST -b $T/j $U/api/salir | tr -d '\r')"
echo "## incidencias"
check "crear (con contexto)" '"id":"INC-0001"' "$(as adriana -d '{"descripcion":"El inventario no carga en el teléfono","tipo":"error","pagina":"/tableros/inventario/","contexto":{"errores":[{"mensaje":"x is undefined","fuente":"inventario.js:12"}]}}' $U/api/incidencias)"
check "sin descripción → 400" "aunque sea una l" "$(as adriana -d '{"tipo":"error"}' $U/api/incidencias)"
check "resumen" '"abiertas":1' "$(as jesush $U/api/incidencias/resumen)"
check "lista abiertas" '"INC-0001"' "$(as jesush $U/api/incidencias)"
check "comentar cualquiera" '"comentarios":[{' "$(as jesush -X PATCH -d '{"comentario":"A mí también"}' "$U/api/incidencia?id=INC-0001")"
check "no editor no cambia gravedad" "Solo quien atiende" "$(as jesush -X PATCH -d '{"gravedad":"baja"}' "$U/api/incidencia?id=INC-0001")"
check "claude atiende: en curso + asignado" '"estado":"en-curso"' "$(as claude -X PATCH -d '{"estado":"en-curso","asignado":"claude","comentario":"Lo reproduje: falta una validación."}' "$U/api/incidencia?id=INC-0001")"
check "quien reportó la marca resuelta" '"estado":"resuelta"' "$(as adriana -X PATCH -d '{"estado":"resuelta"}' "$U/api/incidencia?id=INC-0001")"
check "vigía abre con firma" '"nueva":true' "$(as vigia -d '{"titulo":"leo no puede entrar","descripcion":"3 fallas","tipo":"acceso","origen":"vigia","firma":"acceso:leo"}' $U/api/incidencias)"
check "misma firma suma ocurrencia" '"ocurrencias":2' "$(as vigia -d '{"titulo":"leo no puede entrar","descripcion":"4 fallas","tipo":"acceso","origen":"vigia","firma":"acceso:leo"}' $U/api/incidencias)"
check "un usuario normal no se hace pasar por vigía" '"origen": "sitio"' "$(as adriana -d '{"descripcion":"prueba de origen","origen":"vigia","firma":"x"}' $U/api/incidencias >/dev/null; python3 -c "import json,glob; print(json.dumps([json.load(open(f)) for f in sorted(glob.glob('$T/incidencias/INC-*.json'))][-1]))")"
check "reporte público (sin sesión)" '"ok":true' "$(curl -s $(J) -d '{"nombre":"miguel","contacto":"333 000 0000","descripcion":"No puedo entrar desde ayer","tipo":"acceso"}' $U/api/incidencias/publica)"
check "público: campo trampa (robot) no guarda" '"INC-0000"' "$(curl -s $(J) -d '{"nombre":"x","descripcion":"spam spam","sitio_web":"http://spam"}' $U/api/incidencias/publica)"
check "avisos públicos vacíos" '"avisos":[]' "$(curl -s $U/api/incidencias/publicas)"
check "publicar aviso" '"publica":true' "$(as leo -X PATCH -d '{"publica":true,"resumen_publico":"Algunas personas no podían entrar; ya hay página de entrada nueva."}' "$U/api/incidencia?id=INC-0002")"
check "aviso visible sin sesión (texto público)" "entrada nueva" "$(curl -s $U/api/incidencias/publicas)"
check "publicar sin texto público → 400" "texto corto" "$(as leo -X PATCH -d '{"publica":true}' "$U/api/incidencia?id=INC-0001")"
check "error de navegador" '"ok":true' "$(as jesush -d '{"tipo":"js","mensaje":"TypeError: a is null","fuente":"/tableros/kanban.js","linea":40,"pagina":"/tableros/pendientes/"}' $U/api/incidencias/error)"
check "errores agrupados (editor)" "TypeError" "$(as leo $U/api/incidencias/errores)"
check "errores: no editor 403" "Solo quien atiende" "$(as jesush $U/api/incidencias/errores)"
echo "## standup"
check "genérico sin bot: mis pendientes" "Revisar kit" "$(as adriana $U/api/standup/hoy)"
check "estado: nada preparado" '"preparado":false' "$(as adriana $U/api/standup/estado)"
check "solo el bot prepara" "Solo el bot" "$(as adriana -d '{"personas":[]}' $U/api/standup/preparar)"
HOY=$(date +%F)
check "el bot prepara" '"preparados":["adriana","jesush"]' "$(as claude -d '{"fecha":"'$HOY'","personas":[{"usuario":"adriana","instrucciones":["Cierra el kit con Leo","Revisa la cotización de gabinetes"],"preguntas":[{"tarea_id":"t-p0","titulo":"Revisar kit","pregunta":"¿Ya tienes las cantidades por sitio?"}]},{"usuario":"jesush","instrucciones":[{"texto":"Instala H0031 en la RTG 12","liga":"/formularios/instalacion.html"}],"preguntas":[{"tarea_id":"t-p1","titulo":"Instalar 2"}]},{"usuario":"nadie","instrucciones":["x"]}]}' $U/api/standup/preparar)"
check "estado: pendiente" '"pendiente":true' "$(as adriana $U/api/standup/estado)"
check "instrucciones del bot" "Cierra el kit" "$(as adriana $U/api/standup/hoy)"
check "respuesta vacía → 400" "Contesta" "$(as adriana -d '{"fecha":"'$HOY'"}' $U/api/standup/respuesta)"
check "contestar" '"enviada_en"' "$(as adriana -d '{"fecha":"'$HOY'","hecho":"Kit listo","falta":"Precios","bloqueos":"Espero a Leo","por_tarea":[{"tarea_id":"t-p0","estado":"hecho","nota":"ok"}]}' $U/api/standup/respuesta)"
check "ya no pendiente" '"pendiente":false' "$(as adriana $U/api/standup/estado)"
check "el bot no reescribe a quien ya contestó" "no se reescribe" "$(as claude -d '{"fecha":"'$HOY'","personas":[{"usuario":"adriana","instrucciones":["otra"]}]}' $U/api/standup/preparar)"
check "vista equipo (leo)" '"contestaron":1' "$(as leo "$U/api/standup?fecha=$HOY")"
check "vista equipo: jesush no" "La vista del equipo" "$(as jesush $U/api/standup)"
echo "## vigía (en seco)"
cp $REPO/backend/actas-hbox.service $T/ 2>/dev/null
check "vigía corre" '"hallazgos"' "$(VIGIA_API=$U INCIDENCIAS_DIR=$T/incidencias python3 $REPO/backend/vigia.py --seco 2>&1 | tail -1)"
echo "## sitios con código de 3 letras"
check "ZLO = TIMSA en pendientes" '"ok":true' "$(as leo "$U/api/tareas?sitio=zlo")"
echo
echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
[ "$fail" = 0 ]

265
backend/vigia.py Normal file
View File

@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Vigía del sitio (27-oct-2026): revisa cada hora lo que puede fallar y abre incidencias solo. No usa IA.
Corre EN EL SERVIDOR como leo, con cron (crontab de leo, sin sudo):
7 * * * * cd /srv/projects/drogheda/hbox-docs/backend && venv/bin/python3 vigia.py >> /srv/projects/drogheda/incidencias/_vigia/vigia.log 2>&1
A mano: venv/bin/python3 vigia.py --seco (dice qué abriría, sin abrir nada)
Qué revisa (cada hallazgo lleva una «firma»: si ya hay una incidencia abierta con esa firma, solo le suma una ocurrencia):
1. Entradas: alguien con 3 o más fallas en 24 h y sin entrar después → «X no puede entrar» (alta), con el motivo y las
pistas (Bloq Mayús, mayúscula inicial, espacio de más). Muchos usuarios que no existen → posible robot (baja).
2. Servidor: excepciones de Python y respuestas 5xx del backend (incidencias/_errores/servidor-*.jsonl).
3. Navegadores: el mismo error de JavaScript o la misma llamada fallida 3 veces o por 2 personas (cliente-*.jsonl).
4. Salud: /api/salud responde; el htpasswd es leo:www-data 640; hay espacio en /srv; el secreto de sesión existe.
5. Configuración: el servicio instalado (/etc/systemd/system/actas-hbox.service) dice lo mismo que el del repo
(ACTAS_ADMINS, ACTAS_REPETIBLES, workers) — el 27-oct se instaló a mano uno viejo sin prueba-ethernet-serie.
Deja el reporte de la corrida en incidencias/_vigia/AAAA-MM-DD.json (lo lee el agente diario).
"""
import argparse
import datetime
import grp
import hashlib
import json
import os
import pwd
import shutil
import stat
import sys
import urllib.error
import urllib.request
AQUI = os.path.dirname(os.path.abspath(__file__))
API = os.environ.get("VIGIA_API", "http://127.0.0.1:8081")
USUARIO = "vigia"
def entorno_servicio(ruta):
out = {}
if not os.path.isfile(ruta):
return out
with open(ruta, encoding="utf-8") as f:
for linea in f:
linea = linea.strip()
if linea.startswith("Environment="):
k, _, v = linea[len("Environment="):].partition("=")
out[k.strip()] = v.strip()
elif linea.startswith("ExecStart="):
out["ExecStart"] = linea[len("ExecStart="):]
return out
ENV_REPO = entorno_servicio(os.path.join(AQUI, "actas-hbox.service"))
DATOS = os.path.dirname((os.environ.get("ACTAS_DIR") or ENV_REPO.get("ACTAS_DIR", "/srv/projects/drogheda/actas")).rstrip("/")) \
or "/srv/projects/drogheda"
INC = os.environ.get("INCIDENCIAS_DIR", os.path.join(DATOS, "incidencias"))
HTPASSWD = ENV_REPO.get("HTPASSWD_FILE", "/etc/nginx/hbox-docs.htpasswd")
SERVICIO_VIVO = "/etc/systemd/system/actas-hbox.service"
def huella(*partes):
return hashlib.sha1("|".join(str(p) for p in partes).encode()).hexdigest()[:10]
def leer_jsonl(ruta):
out = []
if os.path.isfile(ruta):
with open(ruta, encoding="utf-8", errors="ignore") as f:
for linea in f:
try:
out.append(json.loads(linea))
except Exception: # noqa: BLE001
pass
return out
def api(metodo, ruta, cuerpo=None):
req = urllib.request.Request(API + ruta, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None,
headers={"X-Remote-User": USUARIO, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode() or "{}")
except Exception: # noqa: BLE001
return e.code, {}
except Exception as e: # noqa: BLE001
return 0, {"error": str(e)}
def revisar_entradas(ahora):
hallazgos = []
desde = (ahora - datetime.timedelta(hours=24)).replace(microsecond=0).isoformat()
filas = []
for mes in {ahora.strftime("%Y-%m"), (ahora - datetime.timedelta(days=1)).strftime("%Y-%m")}:
filas += [r for r in leer_jsonl(os.path.join(DATOS, "acceso", f"intentos-{mes}.jsonl")) if str(r.get("cuando", "")) >= desde]
filas.sort(key=lambda r: r.get("cuando", ""))
por_usuario = {}
desconocidos = set()
for r in filas:
u = r.get("usuario")
if r.get("resultado") == "usuario-no-existe":
desconocidos.add((r.get("tecleado") or "").lower()[:30])
continue
if not u:
continue
p = por_usuario.setdefault(u, {"fallas": 0, "motivos": set(), "pistas": set(), "ultima_falla": None, "ok_despues": False})
if r.get("resultado") == "ok":
if p["fallas"]:
p["ok_despues"] = True
else:
p["fallas"] += 1
p["ok_despues"] = False
p["motivos"].add(r.get("resultado"))
p["pistas"].update(r.get("pistas") or [])
p["ultima_falla"] = r.get("cuando")
for u, p in por_usuario.items():
if p["fallas"] >= 3 and not p["ok_despues"]:
pistas = ", ".join(sorted(p["pistas"])) or "ninguna"
hallazgos.append({
"firma": "acceso:" + u, "tipo": "acceso", "gravedad": "alta",
"titulo": f"{u} no puede entrar al sitio ({p['fallas']} intentos fallidos en 24 h)",
"descripcion": (f"El vigía vio {p['fallas']} intentos fallidos de «{u}» en las últimas 24 horas y ninguno bueno "
f"después. Motivo: {', '.join(sorted(m for m in p['motivos'] if m))}. Pistas: {pistas} "
"(bloq-mayus = escribió con Bloq Mayús; mayuscula-inicial = el teléfono le puso mayúscula; "
"espacios-alrededor = un espacio de más). Si no hay pista, probablemente usa una contraseña "
"vieja guardada en el navegador o no le llegó la nueva."),
"evidencia": [f"última falla {p['ultima_falla']}; pistas: {pistas}"]})
if len(desconocidos) >= 8:
hallazgos.append({"firma": "acceso:desconocidos", "tipo": "acceso", "gravedad": "baja",
"titulo": f"{len(desconocidos)} usuarios que no existen intentaron entrar en 24 h (¿robot?)",
"descripcion": "Intentos con usuarios que no existen. Si son nombres raros, es un robot y el límite de "
"intentos ya lo frena; si son nombres del equipo mal escritos, avisarles su usuario.",
"evidencia": [", ".join(sorted(desconocidos))[:500]]})
return hallazgos, {"intentos_24h": len(filas), "usuarios_con_fallas": {u: p["fallas"] for u, p in por_usuario.items() if p["fallas"]}}
def revisar_errores(ahora):
hallazgos = []
dias = [ahora.date(), ahora.date() - datetime.timedelta(days=1)]
srv, cli = [], []
for d in dias:
srv += leer_jsonl(os.path.join(INC, "_errores", f"servidor-{d:%Y-%m-%d}.jsonl"))
cli += leer_jsonl(os.path.join(INC, "_errores", f"cliente-{d:%Y-%m-%d}.jsonl"))
grupos = {}
for e in srv:
k = huella(e.get("tipo"), e.get("ruta"), (e.get("error") or "")[:120])
gr = grupos.setdefault(k, {"n": 0, "e": e, "usuarios": set()})
gr["n"] += 1
gr["usuarios"].add(e.get("usuario") or "")
for k, gr in grupos.items():
e = gr["e"]
exc = e.get("tipo") == "excepcion"
hallazgos.append({
"firma": "srv:" + k, "tipo": "error", "gravedad": "alta" if exc else "media",
"titulo": f"Servidor: {'excepción' if exc else e.get('tipo')} en {e.get('metodo', '')} {e.get('ruta', '')}"[:140],
"descripcion": f"{gr['n']} vez/veces en 2 días; usuarios: {', '.join(sorted(u for u in gr['usuarios'] if u)) or '—'}.\n"
f"Error: {e.get('error', '')}\n\nTraza (la última):\n{(e.get('traza') or '')[-1500:]}",
"pagina": e.get("ruta", ""), "evidencia": [f"{e.get('cuando')} {e.get('usuario', '')}: {e.get('error', '')}"[:500]]})
grupos = {}
for e in cli:
k = huella(e.get("tipo"), e.get("mensaje"), e.get("fuente"), e.get("linea"), e.get("url_api"))
gr = grupos.setdefault(k, {"n": 0, "e": e, "usuarios": set(), "paginas": set()})
gr["n"] += 1
gr["usuarios"].add(e.get("usuario") or "")
gr["paginas"].add(e.get("pagina") or "")
for k, gr in grupos.items():
if gr["n"] < 3 and len(gr["usuarios"]) < 2:
continue
e = gr["e"]
hallazgos.append({
"firma": "js:" + k, "tipo": "error", "gravedad": "media",
"titulo": f"Navegador: {e.get('mensaje', '')}"[:140],
"descripcion": f"{gr['n']} veces en 2 días, {len(gr['usuarios'])} persona(s): {', '.join(sorted(gr['usuarios']))}.\n"
f"Páginas: {', '.join(sorted(gr['paginas']))[:400]}\nFuente: {e.get('fuente', '')}:{e.get('linea', '')}\n"
f"Navegador: {e.get('navegador', '')}\n\n{(e.get('pila') or '')[:1000]}",
"pagina": e.get("pagina", ""), "evidencia": [f"{e.get('cuando')} {e.get('usuario')}"]})
return hallazgos, {"errores_servidor": len(srv), "errores_cliente": len(cli)}
def revisar_salud():
hallazgos, datos = [], {}
cod, j = api("GET", "/api/salud")
datos["salud"] = cod
if cod != 200:
hallazgos.append({"firma": "salud:backend", "tipo": "error", "gravedad": "alta",
"titulo": "El backend (actas-hbox) no responde en 127.0.0.1:8081",
"descripcion": f"GET /api/salud → {cod} {j}. Sin backend no se puede entrar al sitio. "
"Leo: ssh leo@git.helgen.online 'sudo systemctl status actas-hbox'."})
try:
st = os.stat(HTPASSWD)
dueno, grupo, modo = pwd.getpwuid(st.st_uid).pw_name, grp.getgrgid(st.st_gid).gr_name, stat.S_IMODE(st.st_mode)
datos["htpasswd"] = f"{dueno}:{grupo} {oct(modo)}"
if (dueno, grupo, modo) != ("leo", "www-data", 0o640):
hallazgos.append({"firma": "salud:htpasswd", "tipo": "acceso", "gravedad": "alta",
"titulo": f"El archivo de usuarios quedó {dueno}:{grupo} {oct(modo)} (debe ser leo:www-data 640)",
"descripcion": "Con otros permisos, Mi cuenta y Equipo no pueden cambiar contraseñas, o nginx no "
"puede leerlo. Leo: sudo chown leo:www-data /etc/nginx/hbox-docs.htpasswd && "
"sudo chmod 640 /etc/nginx/hbox-docs.htpasswd"})
except OSError as e:
datos["htpasswd"] = str(e)
uso = shutil.disk_usage(DATOS)
datos["disco_libre_pct"] = round(100 * uso.free / uso.total, 1)
if uso.free / uso.total < 0.10:
hallazgos.append({"firma": "salud:disco", "tipo": "error", "gravedad": "alta",
"titulo": f"Queda {datos['disco_libre_pct']} % de disco en {DATOS}",
"descripcion": "Con el disco lleno no se guardan actas, fotos ni pendientes."})
return hallazgos, datos
def revisar_configuracion():
hallazgos = []
vivo = entorno_servicio(SERVICIO_VIVO)
if not vivo or not ENV_REPO:
return hallazgos, {"servicio": "sin datos"}
difs = []
for k in ("ACTAS_ADMINS", "ACTAS_REPETIBLES", "HTPASSWD_FILE", "PASS_MIN", "ExecStart"):
a = set((vivo.get(k) or "").split(",")) if k in ("ACTAS_ADMINS", "ACTAS_REPETIBLES") else vivo.get(k)
b = set((ENV_REPO.get(k) or "").split(",")) if k in ("ACTAS_ADMINS", "ACTAS_REPETIBLES") else ENV_REPO.get(k)
if a != b:
if isinstance(a, set):
difs.append(f"{k}: instalado sin {sorted(b - a) or '—'}, con de más {sorted(a - b) or '—'}")
else:
difs.append(f"{k}: instalado «{a}» · repo «{b}»")
if difs:
hallazgos.append({"firma": "config:servicio:" + huella(*difs), "tipo": "datos", "gravedad": "media",
"titulo": "El servicio instalado no coincide con backend/actas-hbox.service del repo",
"descripcion": "Diferencias:\n- " + "\n- ".join(difs) + "\n\nUno de los dos está mal: si el cambio "
"del servidor es bueno, súbelo al repo; si no, instala el del repo (DESPLIEGUE.md)."})
return hallazgos, {"servicio_difs": difs}
def main(argv=None):
ap = argparse.ArgumentParser(description="Vigía del sitio: abre incidencias de lo que falla.")
ap.add_argument("--seco", action="store_true", help="solo decir qué abriría")
a = ap.parse_args(argv)
ahora = datetime.datetime.now()
hallazgos, datos = [], {}
for f in (revisar_entradas, revisar_errores, revisar_salud, revisar_configuracion):
try:
h, d = f(ahora) if f in (revisar_entradas, revisar_errores) else f()
hallazgos += h
datos.update(d)
except Exception as e: # noqa: BLE001
datos[f.__name__] = f"falló: {e}"
resultado = []
for h in hallazgos:
if a.seco:
resultado.append({"firma": h["firma"], "titulo": h["titulo"], "accion": "(seco)"})
continue
cuerpo = dict(h, origen="vigia")
cod, j = api("POST", "/api/incidencias", cuerpo)
resultado.append({"firma": h["firma"], "titulo": h["titulo"], "http": cod, "id": j.get("id"),
"nueva": j.get("nueva"), "error": j.get("error")})
reporte = {"cuando": ahora.replace(microsecond=0).isoformat(), "datos": datos, "hallazgos": resultado}
if not a.seco:
carpeta = os.path.join(INC, "_vigia")
os.makedirs(carpeta, exist_ok=True)
with open(os.path.join(carpeta, f"{ahora:%Y-%m-%d}.json"), "w", encoding="utf-8") as f:
json.dump(reporte, f, ensure_ascii=False, indent=2)
print(json.dumps(reporte, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Administración — HBOX</title>
<script src="js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="formularios/acta.css">
<style>
table{border-collapse:collapse;width:100%;font-size:14px;margin-top:6px}

79
docs/agentes.md Normal file
View File

@ -0,0 +1,79 @@
---
title: "Agentes del sitio"
description: "Qué hacen solos el vigía, el agente diario y el bot de standup en drogheda.helgen.online, qué dejan listo para que Leo lo apruebe y qué nunca hacen."
date: 2026-10-27
---
# Agentes del sitio
Desde el 27-oct-2026 el sitio tiene tres ayudantes que trabajan solos. Todo lo que hacen queda aquí mismo: en
[Incidencias](tableros/incidencias.md), en el [Standup](tableros/standup.md), en el historial de cada pendiente y en la
[Bitácora](bitacora.md). Si algo de esto no te gusta, dilo en una incidencia.
| Agente | Qué es | Cuándo corre | Dónde se ve |
|---|---|---|---|
| **Vigía** | Un programa en el servidor, sin IA (`backend/vigia.py`) | Cada hora, en el minuto 7 | [Incidencias](tableros/incidencias.md) con origen «vigía» |
| **Agente diario** | Claude, como tarea programada en la Mac de Leo | Lunes a viernes, 6:37 pm | Comentarios en cada incidencia, ramas `agente/…` en git.helgen.online, lote del día |
| **Bot de standup** | El mismo agente diario | En la misma corrida: deja listo el standup del siguiente día hábil | [Standup](tableros/standup.md) y la franja azul de arriba |
## El vigía
Revisa cada hora y abre una incidencia cuando algo se repite (si ya hay una abierta igual, le suma una ocurrencia):
- **Entradas:** alguien con 3 o más intentos fallidos en 24 horas y ninguno bueno después. La incidencia dice el motivo
y una pista (Bloq Mayús, mayúscula que puso el teléfono, espacio de más). **Nunca guarda contraseñas.**
- **Servidor:** errores de Python y respuestas 5xx del backend.
- **Navegadores:** el mismo error de JavaScript, o la misma llamada que falla, 3 veces o por 2 personas.
- **Salud:** que el backend conteste, que el archivo de usuarios tenga sus permisos (`leo:www-data 640`), que haya disco.
- **Configuración:** que el servicio instalado en el servidor diga lo mismo que el del repositorio (administradores,
actas repetibles). Así se habría visto el 27-oct que el servicio instalado a mano dejó fuera la prueba Ethernet/serie.
## El agente diario
Cada tarde lee las incidencias abiertas, el reporte del vigía y los errores, y actúa con estas reglas:
### Lo hace solo (no necesita autorización)
1. **Atender:** clasificar (tipo y gravedad), decir quién la atiende, juntar duplicadas y **contestar en la incidencia**
qué encontró y qué sigue. Si falta información, la pide ahí mismo a quien reportó.
2. **Reproducir** la falla en una copia del sitio con datos de prueba y escribir la causa.
3. **Arreglar lo que es solo de páginas** — textos, ligas rotas, estilos, el JavaScript de los tableros — en una copia
limpia del repositorio: prueba (`mkdocs build --strict`, pruebas del backend, vista en claro, oscuro y teléfono),
guarda el cambio en git.helgen.online con el folio de la incidencia y **publica solo las páginas** si la comparación
con el servidor muestra que no se lleva nada más. Después comenta en la incidencia qué cambió y cómo deshacerlo.
4. **Cerrar** como resuelta cuando el arreglo ya está publicado y el vigía no ve la falla en 24 horas.
5. **Avisar en la entrada** (aviso público, sin datos internos) cuando una falla impide entrar a varias personas.
### Lo deja listo para que Leo lo apruebe
- Cualquier cambio del **backend** (`backend/*.py`), de **nginx**, del **servicio** o del servidor; reinicios y `sudo`.
- Lo que toca **cuentas, contraseñas o permisos** (también restablecer una contraseña).
- Lo que cambia **datos**: actas, inventario, pendientes. Los cambios del tablero van en un **lote** que Leo aplica
(`./scripts/tablero.sh revisar` → `aplicar`), como siempre.
- Lo de la **página de entrada** y de las **actas de campo** (`entrar.html`, `cuenta.html`, `formularios/`): aunque sean
páginas, si fallan nadie puede trabajar.
Lo deja en una rama `agente/AAAA-MM-DD-INC-xxxx` con su prueba, y en el resumen del día dice exactamente qué comando correr.
### Nunca
Borrar nada · ver, guardar o pedir contraseñas · cambiar permisos · mandar mensajes o correos a nombre de alguien ·
publicar datos del proyecto en la página de entrada · tocar el archivo de parámetros 4G/NTRIP.
## El bot de standup
1. **Prepara** para cada persona con pendientes abiertos su parte del día siguiente: instrucciones concretas (qué hacer,
en qué orden, con la liga al documento) y una pregunta por pendiente. Lee el tablero, las decisiones de Ingeniería,
la última junta y lo que esa persona contestó ayer.
2. **Pregunta** en el [Standup](tableros/standup.md): ¿qué terminaste?, ¿qué te falta?, ¿qué te bloquea? Cada quien
contesta en 2 minutos y, si lo deja marcado, sus tarjetas se mueven solas (a su nombre).
3. **Resume** para Leo y Adriana (pestaña Equipo): quién contestó, qué se terminó y los bloqueos con quién los destraba.
Lo que falte en el tablero (comentarios, pendientes nuevos por un bloqueo) va en el lote del día para que Leo lo aplique.
## Para ingeniería
- Código: `backend/vigia.py`, `backend/incidencias.py`, `backend/standup.py`, `backend/acceso.py` (página de entrada);
instrucciones del agente: `agentes/agente-diario.md` en el repositorio `hbox-docs`; ayuda para llamar la API desde la Mac:
`scripts/agente-api.sh`.
- El agente entra a la API por el servidor (`127.0.0.1:8081`, usuario `claude`); el vigía, como `vigia`.
- Cambiar estas reglas = editar esta página y `agentes/agente-diario.md` en el mismo cambio.

View File

@ -23,6 +23,10 @@ Arriba, lo que pasó en el tablero de pendientes y en las actas en los últimos
### Octubre de 2026
**27 oct**
: **T45 listo** (para el 28-oct, sobre el Actualizar PCB de Michelle): **R80 pasa abajo de Q7**, porque donde estaba lo encerraba el anillo de `/MAIN5V` y `/VEH5V` solo podía llegarle por In2. Así `/VEH5V` va por F.Cu (≈ 7 mΩ desde L5.2; antes 31), `/VIN_SW` queda de 2 mm y las tomas Kelvin salen por el lado de adentro de R80 y van por B.Cu. Además: 6 vías en los EP de U4 y U7 y la limpieza del 26-oct. Probado en una copia: DRC con 0 errores y 0 sin conectar. La RF LTE pasa a **T45b** (30-oct). [Instrucciones para Michelle](hardware/instrucciones-michelle-27oct.md#2-despues-de-t45-martes-28-y-miercoles-29-bloque-b-y-r112) e [instrucciones para Leo](hardware/instrucciones-leo-27oct.md), v1.2; lote `2026-10-27_t45-lista`.
: **Página de entrada propia** (adiós a la ventana gris) por el reporte de Adriana del 26-oct: después de restablecer, la gente no podía volver a entrar porque el navegador y el teléfono mandaban la contraseña vieja. Ahora se entra con el usuario **o el correo**, sin importar mayúsculas; cambiar la contraseña ya no saca; restablecer cierra las sesiones viejas; «Cerrar sesión» sí cierra; cada intento queda registrado (sin la contraseña). [Incidencias](tableros/incidencias.md) INC-0001 · despliegue `DESPLIEGUE.md` §A11.
: [Incidencias del sitio](tableros/incidencias.md) («Problemas del sitio» al pie de cada página y en la entrada), [Standup](tableros/standup.md) diario con instrucciones y preguntas por persona, el **vigía** cada hora y el **agente diario**: qué hacen solos y qué deja listo para Leo, en [Agentes del sitio](agentes.md). Correos de Equipo de la lista de Gibran. Lote `2026-10-27_entrada-incidencias-agentes`.
: [Cotizador ↔ centro de mando](inventario/integracion-cotizador.md): las órdenes de compra llegan del cotizador **sin montos**, con su sitio de entrega (VER · ZLO · ESE · LZC · QRO), aparecen **en camino** en la pestaña del sitio y se **reciben ahí**; la requisición se cierra sola. Base **SQLite** (D-27-1 y D-27-2, Leo). [Instrucciones para el cotizador](inventario/integracion-cotizador-instrucciones.md).
: **Decisiones de Leo (13:06):** D-26-1 = (a), así que G1 sigue el lunes 2-nov con T45 por script sobre el commit de Michelle del martes. D-22-4 = (b), ya en el esquemático con **T41** (`15b956e`): D18–D20 pasan a `/SIMB_*`, del lado de J11. **FOTA (D-22-6): sí, con prioridad.** El volumen lleva STM32F411RET6 (pre-orden de 515), el piloto se queda con RCT6 y 2 de sus tarjetas se retrabajan con RET6 para probar FOTA. **60 v6** en el volumen. F3 (inyector en el caster) se reabrió: estaba en Hecho sin commits de FOTA. Lote `2026-10-27_decisiones-leo-1306`; [instrucciones para Michelle](hardware/instrucciones-michelle-27oct.md) v1.1 con T41 y T45.
: [Instrucciones para Leo](hardware/instrucciones-leo-27oct.md) e [instrucciones para Michelle](hardware/instrucciones-michelle-27oct.md), al día con el tablero. **El piloto V5.6/V5.7 ya se puede pedir.** Leo tiene tres decisiones (D-26-1, que vence hoy, D-22-4 y D-22-6) y la cantidad de la v6 (130 o 60). Lote `2026-10-27_piloto-listo-una-sim`: el piloto queda sin bloqueos («drop the cards» se tomó como quitar los bloqueos; las tarjetas siguen abiertas), la mezcla 135/365 entra al tablero y queda ahí todo el camino de la v6 a G1 (Actualizar PCB, bloques F a H, T29 y el paquete).
: **T49** (decisión del 9-oct): la v6 sin las sondas NTC externas J2 y J4, con R14, C9, D4, R15, C28 y D6 (`3154018`). PB1 y PC4 quedan sin conexión. Solo el esquemático: en el netlist solo salen esas 8 piezas y sus 2 redes, y el ERC queda igual.

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mi cuenta — HBOX</title>
<script src="js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="formularios/acta.css">
<style>
.campo{margin:12px 0}
@ -37,22 +38,27 @@
<section class="paso">
<h2>Cambiar mi contraseña</h2>
<p class="sub">Elige algo que recuerdes sin apuntarlo. Mínimo 8 caracteres.</p>
<!-- 27-oct: un <form> con el usuario (oculto) para que el gestor de contraseñas del navegador o del teléfono
actualice la que tenía guardada; sin él seguía llenando la vieja en la entrada. -->
<form id="fCambio" autocomplete="on" onsubmit="return false">
<input type="text" id="usuarioForm" name="username" autocomplete="username" hidden readonly>
<div class="campo">
<label for="actual">Contraseña actual</label>
<input type="password" id="actual" autocomplete="current-password">
<input type="password" id="actual" name="current-password" autocomplete="current-password">
</div>
<div class="campo">
<label for="nueva">Contraseña nueva</label>
<input type="password" id="nueva" autocomplete="new-password">
<input type="password" id="nueva" name="new-password" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false">
<div class="fuerza"><i id="barra"></i></div>
<div class="pista" id="pista">Mientras más larga, mejor; una frase con espacios sirve.</div>
</div>
<div class="campo">
<label for="confirma">Repite la contraseña nueva</label>
<input type="password" id="confirma" autocomplete="new-password">
<input type="password" id="confirma" name="confirm-password" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false">
<span class="ver" id="ver">Ver lo que escribí</span>
</div>
<button class="enviar" id="guardar">Guardar contraseña nueva</button>
<button class="enviar" id="guardar" type="submit">Guardar contraseña nueva</button>
</form>
<div class="msg" id="msg"></div>
</section>
@ -65,7 +71,10 @@
</div>
<div class="campo">
<label for="nuevaR">Contraseña nueva para esa persona</label>
<input type="text" id="nuevaR" autocomplete="off" placeholder="se la pasas en persona o por mensaje directo">
<!-- 27-oct: sin mayúscula automática ni autocorrector: en el teléfono «hbox2026» se volvía «Hbox2026» -->
<input type="text" id="nuevaR" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false"
placeholder="se la pasas en persona o por mensaje directo">
<span class="ver" id="generar">Generar una fácil de dictar</span>
</div>
<button class="enviar" id="resetear">Restablecer</button>
<div class="msg" id="msgR"></div>
@ -74,7 +83,8 @@
<section class="paso">
<h2>Si olvidaste tu contraseña</h2>
<p class="sub" style="margin:0">
No hay recuperación por correo: pídele a Leo que te la restablezca desde esta misma página.
No hay recuperación por correo: pídele a Leo o a Gibran que te la restablezcan desde esta misma página.
Si no puedes entrar al sitio, repórtalo desde la <a href="/entrar.html">página de entrada</a> («¿No puedes entrar?»).
Si todavía no tienes acceso, pídeselo a Leo (Administración → <a href="tableros/equipo/">Usuarios y equipo</a>).
</p>
</section>
@ -95,6 +105,7 @@ function aviso(el, texto, bien) {
try {
const j = await (await fetch('/api/whoami')).json();
yo = j.user || '';
$('usuarioForm').value = yo;
$('quien').innerHTML = yo
? 'Entraste como <b>' + yo + '</b>' + (j.admin ? ' · administrador' : '')
+ (j.contrasena_inicial ? '<span class="inicial" id="inicial"><b>Sigues con la contraseña inicial</b> que te dieron. '
@ -138,12 +149,15 @@ $('ver').addEventListener('click', () => {
$('ver').textContent = t === 'text' ? 'Ocultar' : 'Ver lo que escribí';
});
// 27-oct: con la página de entrada, cambiar la contraseña ya no te saca: el servidor te da una sesión nueva.
const AVISO_SESION =
'<br><br>Tu navegador todavía recuerda la contraseña anterior, así que puedes seguir '
+ 'navegando sin problema. <b>La nueva la vas a necesitar la próxima vez que entres.</b> '
+ 'Si quieres probarla ahora, usa <a href="salir.html">Cerrar sesión</a>.';
'<br><br>Sigues dentro. Si tu navegador o tu teléfono te ofrece <b>guardar o actualizar la contraseña</b>, acéptalo: '
+ 'así la próxima vez llena la nueva y no la anterior. Si quieres probarla ahora, usa <a href="salir.html">Cerrar sesión</a>.';
const AVISO_RESET =
'<br>Sus sesiones abiertas se cerraron: la próxima vez que abra el sitio le pedirá entrar con esta.';
$('guardar').addEventListener('click', async () => {
$('fCambio').addEventListener('submit', async (ev) => {
ev.preventDefault();
const b = $('guardar');
b.disabled = true; b.textContent = 'Guardando…';
try {
@ -163,6 +177,14 @@ $('guardar').addEventListener('click', async () => {
b.disabled = false; b.textContent = 'Guardar contraseña nueva';
});
// 27-oct: palabras comunes + 4 números, en minúsculas: se dicta por teléfono sin errores de mayúsculas
const PALABRAS = ['puerto', 'grua', 'antena', 'cobre', 'faro', 'barco', 'muelle', 'patio', 'satelite', 'brujula',
'ancla', 'marea', 'radar', 'ruta', 'norte', 'volcan', 'cactus', 'jaguar', 'quetzal', 'tunel', 'trigo', 'cometa'];
$('generar').addEventListener('click', () => {
const r = new Uint32Array(3); crypto.getRandomValues(r);
$('nuevaR').value = PALABRAS[r[0] % PALABRAS.length] + '-' + PALABRAS[r[1] % PALABRAS.length] + '-' + String(1000 + r[2] % 9000);
});
$('resetear').addEventListener('click', async () => {
const u = $('quienReset').value, nueva = $('nuevaR').value;
if (!u) return aviso($('msgR'), 'Elige a quién le vas a restablecer la contraseña.', false);
@ -175,9 +197,13 @@ $('resetear').addEventListener('click', async () => {
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
aviso($('msgR'), 'Contraseña de <b>' + u + '</b> restablecida. Pásasela en persona o por '
+ 'mensaje directo, y dile que la cambie aquí mismo cuando entre.'
+ (u === yo ? AVISO_SESION : ''), true);
const seg = nueva.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
aviso($('msgR'), 'Contraseña de <b>' + u + '</b> restablecida. Pásale exactamente esto, en persona o por mensaje directo: '
+ '<code id="pasar" style="font-size:16px;user-select:all">' + seg + '</code> '
+ '<span class="ver" id="copiar">Copiar</span><br>Usuario: <code>' + u + '</code> · entra en drogheda.helgen.online y la cambia en Mi cuenta.'
+ AVISO_RESET + (u === yo ? AVISO_SESION : ''), true);
const c = $('copiar');
if (c) c.addEventListener('click', () => { navigator.clipboard && navigator.clipboard.writeText(nueva).then(() => { c.textContent = 'Copiada'; }); });
$('nuevaR').value = '';
} catch (e) {
aviso($('msgR'), e.message, false);

83
docs/entrar.html Normal file
View File

@ -0,0 +1,83 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Entrar — Drogheda HBOX</title>
<!-- 27-oct-2026: página de entrada propia (antes: la ventana gris de HTTP Basic de nginx).
Es pública: nginx la sirve sin sesión, junto con /publico/, /fonts/, /stylesheets/helgen.css y /js/tema.js.
No lleva datos del proyecto: solo entrar, avisos marcados como públicos y reportar un problema. -->
<script src="/js/tema.js"></script>
<link rel="icon" type="image/svg+xml" href="/img/helgen-mascara.svg">
<link rel="stylesheet" href="/stylesheets/helgen.css">
<link rel="stylesheet" href="/publico/entrar.css">
</head>
<body>
<main class="en">
<header class="en-cab top">
<svg class="en-masc" viewBox="0 0 200 214.5" aria-hidden="true"><ellipse cx="100" cy="107.25" rx="96" ry="103" style="fill:#2C3C5B"/><path style="fill:#51F3DA" fill-rule="evenodd" d="M96.2 213.2C32.3 210.2 -12.0 145.2 4.7 79.0C15.9 34.9 53.0 3.1 95.7 1.2C161.5 -1.7 211.4 63.4 196.0 132.3C185.1 181.0 142.7 215.4 96.2 213.2ZM104.0 199.9C160.7 196.6 198.8 136.5 181.2 78.1C169.4 39.2 134.8 12.9 97.1 14.5C40.8 16.7 1.8 75.9 18.2 134.3C29.5 174.7 65.4 202.1 104.0 199.9ZM62.5 132.8C60.4 132.5 57.0 131.1 53.2 129.0C44.3 124.0 40.5 116.9 39.4 103.5C39.0 98.4 39.4 83.9 40.1 78.2C40.2 76.8 41.7 67.7 41.7 67.5C41.9 67.2 42.3 67.6 46.7 71.9C52.2 77.4 53.3 78.4 57.7 81.4C60.9 83.6 63.3 85.0 67.7 87.3C82.4 95.3 87.9 104.4 84.6 115.9C82.6 122.8 79.1 126.8 72.0 130.3C67.7 132.4 65.1 133.1 62.5 132.8ZM137.4 132.8C133.3 132.3 126.2 128.4 122.9 125.0C117.9 119.7 115.7 110.4 118.0 103.9C119.8 98.6 123.9 94.1 130.7 89.7C132.1 88.8 133.6 88.0 136.9 86.2C141.2 83.9 145.9 80.8 149.6 77.9C150.6 77.1 152.3 75.5 155.7 72.1C160.4 67.3 160.7 67.1 160.8 67.7C161.0 68.2 162.3 76.5 162.4 77.7C163.1 83.0 163.5 99.2 163.1 104.2C161.9 117.6 157.9 124.4 148.5 129.4C143.2 132.2 140.2 133.1 137.4 132.8ZM65.6 120.9C70.8 119.4 73.8 115.0 73.8 109.1C73.8 103.2 72.1 101.3 62.4 97.0C57.7 94.8 52.6 91.9 49.6 89.7C49.0 89.2 48.9 89.3 48.7 90.3C47.4 95.7 49.0 107.8 51.6 113.2C53.7 117.4 57.0 120.2 60.8 121.1C61.9 121.3 64.5 121.2 65.6 120.9ZM142.2 121.0C146.5 119.8 150.2 115.9 151.9 110.9C153.9 104.7 154.9 95.2 154.0 90.6C153.7 89.3 153.6 89.2 152.9 89.8C149.3 92.3 145.2 94.6 139.0 97.5C130.9 101.2 129.0 103.1 128.7 107.9C128.3 114.7 131.8 119.8 137.6 121.1C138.6 121.3 141.3 121.2 142.2 121.0Z"/></svg>
<div>
<h1>Drogheda · HBOX</h1>
<p>Centro de mando de Helgen — pendientes, actas, inventario y documentos</p>
</div>
</header>
<section class="en-tarjeta" aria-labelledby="t-entrar">
<h2 id="t-entrar">Entrar</h2>
<form id="fEntrar" method="post" action="/api/entrar" autocomplete="on" novalidate>
<label for="usuario">Usuario o correo</label>
<input id="usuario" name="username" type="text" autocomplete="username" autocapitalize="none"
autocorrect="off" spellcheck="false" inputmode="email" placeholder="adriana · adriana@helgen.tech" required>
<label for="password">Contraseña</label>
<div class="en-pw">
<input id="password" name="password" type="password" autocomplete="current-password" autocapitalize="none"
autocorrect="off" spellcheck="false" required>
<button type="button" class="en-ver" id="ver" aria-label="Mostrar la contraseña" aria-pressed="false">Ver</button>
</div>
<p class="en-mayus" id="mayus" hidden>Tienes activadas las mayúsculas (Bloq Mayús).</p>
<label class="en-check"><input type="checkbox" id="recordar" checked> Mantener la sesión 30 días en este equipo</label>
<button type="submit" class="en-boton" id="bEntrar">Entrar</button>
<div class="en-msg" id="msg" role="alert" aria-live="assertive"></div>
</form>
</section>
<section class="en-avisos" id="avisos" hidden aria-labelledby="t-avisos">
<h2 id="t-avisos">Avisos del sitio</h2>
<ul id="avisosLista"></ul>
</section>
<details class="en-ayuda" id="ayuda">
<summary>¿No puedes entrar o algo falla? Repórtalo aquí</summary>
<div class="en-ayuda__c">
<ul class="en-tips">
<li><b>Tu usuario</b> es tu nombre en minúsculas y sin acentos, como te lo dieron (por ejemplo <code>adriana</code>, <code>jesush</code>), o tu correo de Helgen.</li>
<li><b>¿Te restablecieron la contraseña?</b> Usa la nueva, tal como te la pasaron. Si tu teléfono o tu navegador llena la contraseña solo, bórrala y escríbela a mano; cuando entres, acepta «actualizar la contraseña guardada».</li>
<li><b>¿Sigue sin dejarte?</b> Escríbenos aquí abajo. Llega directo a quien atiende el sitio y se revisa todos los días.</li>
</ul>
<form id="fReporte" novalidate>
<div class="en-dos">
<div><label for="rNombre">Tu usuario o tu nombre</label>
<input id="rNombre" type="text" maxlength="80" autocapitalize="none" autocomplete="off"></div>
<div><label for="rContacto">Cómo te contactamos</label>
<input id="rContacto" type="text" maxlength="120" placeholder="teléfono o correo" autocomplete="off"></div>
</div>
<fieldset class="en-tipo">
<legend>Qué pasa</legend>
<label><input type="radio" name="rTipo" value="acceso" checked> No puedo entrar</label>
<label><input type="radio" name="rTipo" value="error"> Otra falla del sitio</label>
</fieldset>
<label for="rDesc">Cuéntanos con tus palabras</label>
<textarea id="rDesc" rows="4" maxlength="2000" placeholder="Ej.: me restablecieron la contraseña ayer y me dice que no coincide; estoy en el teléfono."></textarea>
<input type="text" id="rTrampa" name="sitio_web" tabindex="-1" autocomplete="off" class="en-trampa" aria-hidden="true">
<button type="submit" class="en-boton sec" id="bReporte">Enviar reporte</button>
<div class="en-msg" id="msgR" role="status" aria-live="polite"></div>
</form>
</div>
</details>
<p class="en-pie">Helgen Technologies · Querétaro · Solo personal autorizado.</p>
</main>
<script src="/publico/entrar.js"></script>
</body>
</html>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de módulo — simpleRTK2B Pro (RTK)</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de diagnóstico en banco — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de daño o incidente — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de diagnóstico en campo — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de ensamble — unidad HBOX v5</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de envío — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba de banco — Ethernet y puertos serie (V5)</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de instalación en campo — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
<style>
details.como{background:var(--hg-plano);border:1px solid var(--hg-linea);border-radius:10px;padding:8px 14px;margin:0 0 12px;font-size:14px;color:var(--hg-texto)}

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de integración — RTK + 4G</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de módulo — 4G NTRIP Master</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba de banco — módem 4G en batería</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba en sitio — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
<style>
details.como{background:var(--hg-plano);border:1px solid var(--hg-linea);border-radius:10px;padding:8px 14px;margin:0 0 12px;font-size:14px;color:var(--hg-texto)}

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de recepción e inspección — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de reflasheo y configuración — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de prueba — EinarBoard V4 (fw 0.6.0)</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de prueba — EinarBoard V5</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ver acta — HBOX</title>
<script src="../js/tema.js"></script>
<script src="/js/incidencias.js"></script>
<link rel="stylesheet" href="acta.css">
<style>
.doc{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:18px}

View File

@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, einarboard, v6, documento-de-diseno, es
project: Project Drogheda
author: "Helgen Technologies (redacción: Claude; revisión: Leo)"
date: 2026-10-22
version: 1.2
status: "ACTIVO — estado al 22-oct-2026, después de T44 (HBox_PCB bd27501) · 27-oct: T48 (una sola SIM), T49 (sin sondas NTC externas) y T41 (TVS de la SIM junto a J11) en el esquemático (15b956e)"
version: 1.3
status: "ACTIVO — estado al 22-oct-2026, después de T44 (HBox_PCB bd27501) · 27-oct: T48 (una sola SIM), T49 (sin sondas NTC externas) y T41 (TVS de la SIM junto a J11) en el esquemático (15b956e); T45 listo para el 28-oct"
audience: "Leo, Michelle (layout), Jesús (banco), Aldair (fabricación), Adriana (PM), equipo de firmware"
---
@ -21,6 +21,7 @@ audience: "Leo, Michelle (layout), Jesús (banco), Aldair (fabricación), Adrian
- En el netlist solo cambian esas 13 piezas, esas redes y la red del pin 1 de D18–D20. ERC: 0 errores.
- **Cantidad (Leo, 27-oct): 60 v6 en el volumen**, después del piloto de 20.
- Las tablas de este documento (pines, conectores, BOM) son del 22-oct y todavía muestran esas piezas. Michelle actualiza el PCB y borra el cobre suelto según sus [instrucciones del 27-oct](instrucciones-michelle-27oct.md).
- **T45 (listo; se aplica el 28-oct sobre ese Actualizar PCB):** R80 pasa a (174.6, 108.87), abajo de Q7, porque donde estaba lo encerraba el anillo de `/MAIN5V`. `/VEH5V` llega de L5 por F.Cu (≈ 7 mΩ), `/VIN_SW` es de 2.0 mm y las tomas Kelvin VIN y CLN salen del lado de adentro de R80 y van por B.Cu. También pone 6 vías en los EP de U4 y U7 y hace la limpieza del 26-oct. La RF LTE pasa a T45b (30-oct) ([instrucciones de Michelle, §2](instrucciones-michelle-27oct.md#2-despues-de-t45-martes-28-y-miercoles-29-bloque-b-y-r112)).
**Descargas:** [este documento en PDF](2026-10-22_Drogheda_Documento-de-Diseno_v6_ES_v1.0.pdf) · [esquemático completo en PDF](img/diseno-v6/hbox-watchdog-v6-esquematico.pdf) · [tabla de designadores viejo → nuevo (T43)](designadores-t43-22oct.md#8-tablas-viejo-nuevo)
@ -886,6 +887,8 @@ Designadores de T43 en todo el historial, aunque los mensajes de los commits use
| 22-oct | Este documento, v1.0 | — |
| 23-oct | Este documento, v1.1 (revisión independiente): columna «Renglones del BOM» con los números LCSC distintos (§11), hoja A1 en el §8 y encabezados de tabla legibles en el PDF | — |
| 27-oct | Este documento, v1.2: avisos de T48, T49 y T41 (una sola SIM, sin sondas NTC externas, TVS junto a J11), D-22-4 decidida, 60 v6 y existencias de la pre-orden | — |
| 27-oct, 15:45 | T45 listo (`tools/t45_v6.py`): R80 abajo de Q7, `/VEH5V` por F.Cu, tomas Kelvin por B.Cu, 6 vías en los EP de U4 y U7, limpieza. Probado en una copia; se aplica el 28-oct | — |
| 27-oct | Este documento, v1.3: aviso de T45 | — |
## 15. Referencias

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

View File

@ -10,6 +10,7 @@ Estado de cada versión de la EinarBoard al **27-oct-2026**, con sus documentos
- **V5.6 y V5.7.** T46 corrigió el CPL (`51dc51b`) y **T47 dejó el BOM sin faltantes en JLC** (`bdda3fd`): RCT6, AP64352QSP-13, LIS2DH12TR, AO4406A de UMW, L2 de 3.3 µH y, en la V5.7, L4 de 15 µH. No hay que consignar nada. Pasos del pedido y decisiones pendientes (D-26-1, D-22-4, D-22-6): [**▶ instrucciones para Leo**](instrucciones-leo-27oct.md).
- **Volumen: 135 V5.6 + 365 V5.7 + 60 v6** (Leo, 27-oct). Sigue en NO-GO hasta pasar el banco con 0.7.0-pre. **FOTA aprobada (D-22-6):** el volumen lleva STM32F411RET6 (pre-orden de 515) y 2 tarjetas del piloto se retrabajan con RET6 para probarla.
- **13:06, Leo:** D-26-1 = (a), así que G1 sigue el 2-nov con T45 por script, y D-22-4 = (b), ya en el esquemático con T41 (`15b956e`).
- **15:45: T45 listo** y probado en una copia: **R80 pasa abajo de Q7** para que `/VEH5V` llegue por F.Cu, con las tomas Kelvin por el lado de adentro; 6 vías en los EP de U4 y U7 y la limpieza del 26-oct. La RF LTE pasa a T45b (30-oct). Se aplica el 28-oct sobre el Actualizar PCB de Michelle.
- **v6.** T48 dejó solo la **nano-SIM (4FF) J11** y quitó la eSIM, y T49 quitó las sondas NTC externas J2 y J4 (decisión del 9-oct). Con T41, las tres son del esquemático; Michelle actualiza el PCB el 28-oct: [**▶ instrucciones para Michelle**](instrucciones-michelle-27oct.md). En el tablero ya está todo el camino a G1 (bloques F a H, T29 y el paquete).
!!! danger "26-oct (noche): revisión independiente del pedido de V5.6 y V5.7 (27-oct: A1 y A2 cerrados con T46 y T47)"
@ -64,7 +65,7 @@ Estado de cada versión de la EinarBoard al **27-oct-2026**, con sus documentos
| <span class="badge-v55">V5.5</span> | En campo (15 pzas, 22-ago). No se fabrica más. | — | `hbox-watchdog-v5` (tag `v5.5-as-fabbed`) | `hbox-v5-bringup_0.6.0` CAMPO · 0.6.3 banco |
| <span class="badge-v56">V5.6</span> | **27-oct: piloto listo para pedir** ([instrucciones](instrucciones-leo-27oct.md)): CPL corregido con T46 (`51dc51b`) y BOM sin faltantes con T47 (`bdda3fd`). El 26-oct, la [revisión independiente](revision-pedido-26oct.md) encontró 7 piezas sin existencia en JLC y 21 rotaciones del CPL que corregir. Antes: **Auditoría final del 22-oct: GO al piloto** con tres condiciones de pedido (vías tapadas + rayos X en U6, visor de JLC, D10 → SMBJ33CA). Paquete `HBOX_v5_Rev6_PROD` verificado = cobre con relleno fresco; DRC y paridad limpios. P-1 cerrado a las 13:00 (relleno fresco en el repo, re-verificado; falta el commit) ([auditoría final](auditoria-final-22oct.md)). **T42a** aplicado (D-22-7: a): BOM con existencia y `/IGN_IN` a 0.3 mm ([victorias rápidas](v56-v57-victorias-rapidas-22oct.md)). **T43:** designadores en orden y Rev6 regenerado ([designadores](designadores-t43-22oct.md)). **T44** (23-oct): serigrafía de 0.8 mm, Rev6 regenerado, solo cambia la serigrafía ([T44](v6-t44-serigrafia.md)). [**Documento de diseño**](diseno-v56.md). | 113 (piloto de 5 primero, se pide hoy) | `hbox-watchdog-v5` | 0.7.0: `board_v56.h` listo; 0.7.0-pre por escribir (1 día) |
| <span class="badge-v57">V5.7</span> | **27-oct: piloto listo para pedir** ([instrucciones](instrucciones-leo-27oct.md)): CPL corregido con T46 (`51dc51b`) y BOM sin faltantes con T47 (`bdda3fd`; L4 pasa a SRP1265A-150M). El 26-oct, la [revisión independiente](revision-pedido-26oct.md) encontró 7 piezas sin existencia en JLC y 20 rotaciones del CPL que corregir. Antes: **Auditoría final del 22-oct: GO a los 5 prototipos** con las mismas condiciones. Fuente U25 a 5.32 V / 600 kHz: 9–32 V sin la SD-15B; C149/C150 se quedan (D-21-2) y el timbre se mide en banco; D10 → SMBJ33CA es obligatorio aquí (con el arnés al revés abre F2, que es SMD) ([auditoría final](auditoria-final-22oct.md)). **T42a** aplicado, con la 2.ª vía de `/SYS5V` (E-11) ([victorias rápidas](v56-v57-victorias-rapidas-22oct.md)). **T43:** designadores en orden, «IGN 12-24V» y Rev7 regenerado ([designadores](designadores-t43-22oct.md)). **T44** (23-oct): serigrafía de 0.8 mm, Rev7 regenerado, solo cambia la serigrafía ([T44](v6-t44-serigrafia.md)). [**Documento de diseño**](diseno-v57.md). | 327 (5 prototipos primero, se piden hoy) | `hbox-watchdog-v57` (convive con v5) | el mismo 0.7.0 (mismos pines de MCU) |
| <span class="badge-v6">v6</span> | Revisada el 22-oct (`d5f5002` + T40): **NO LISTA**. T28 cerró 2 de los 8 bloqueantes de la [auditoría v6](v6-auditoria-produccion-16oct.md), T25 regresó los barrenos, T38 corrigió courtyards, MH6 y el `/3V3` bajo los barrenos, **T39 hizo el bloque A** (D34 entre U25 y L22, MH8 libre) y **T40 liberó MH4**. Faltan los bloques B a H y D-22-4 ([estado, §7](v6-estado-21oct.md#7-t40-22-oct-tarde-mh4-libre)). **T43:** designadores en orden ([tabla](designadores-t43-22oct.md)). **T44:** serigrafía de 0.8 mm, 338 de 350; 12 para Michelle ([T44](v6-t44-serigrafia.md)). **26-oct:** revisión del fin de semana: avance en el bloque B, **regresión en la toma VIN de R80** y los bloques C, D y E sin empezar; G1 en riesgo, D-26-1 para Leo ([revisión](v6-revision-26oct.md)). **27-oct:** T48 (una sola SIM: nano-SIM J11), T49 (sin J2/J4) y T41 (TVS de la SIM junto a J11) en el esquemático; Michelle actualiza el PCB y Claude corre T45 ([instrucciones](instrucciones-michelle-27oct.md)). [**Documento de diseño**](diseno-v6.md). Plan: [fines de semana 24-oct → 22-nov, rev. 1.7](v6-plan-fines-de-semana.md). | 130 + reemplazos (piloto de 20, pedido previsto el 2-nov) | `hbox-watchdog-v6` | 0.7.x (`board_v6.h` + NTRIP), no existe |
| <span class="badge-v6">v6</span> | Revisada el 22-oct (`d5f5002` + T40): **NO LISTA**. T28 cerró 2 de los 8 bloqueantes de la [auditoría v6](v6-auditoria-produccion-16oct.md), T25 regresó los barrenos, T38 corrigió courtyards, MH6 y el `/3V3` bajo los barrenos, **T39 hizo el bloque A** (D34 entre U25 y L22, MH8 libre) y **T40 liberó MH4**. Faltan los bloques B a H y D-22-4 ([estado, §7](v6-estado-21oct.md#7-t40-22-oct-tarde-mh4-libre)). **T43:** designadores en orden ([tabla](designadores-t43-22oct.md)). **T44:** serigrafía de 0.8 mm, 338 de 350; 12 para Michelle ([T44](v6-t44-serigrafia.md)). **26-oct:** revisión del fin de semana: avance en el bloque B, **regresión en la toma VIN de R80** y los bloques C, D y E sin empezar; G1 en riesgo, D-26-1 para Leo ([revisión](v6-revision-26oct.md)). **27-oct:** T48 (una sola SIM: nano-SIM J11), T49 (sin J2/J4) y T41 (TVS de la SIM junto a J11) en el esquemático; Michelle actualiza el PCB y Claude corre T45 ([instrucciones](instrucciones-michelle-27oct.md)). **27-oct, 15:45:** T45 listo: R80 abajo de Q7 (`/VEH5V` por F.Cu y tomas Kelvin por adentro), EP de U4 y U7 y limpieza; la RF LTE pasa a T45b. [**Documento de diseño**](diseno-v6.md). Plan: [fines de semana 24-oct → 22-nov, rev. 1.7](v6-plan-fines-de-semana.md). | 130 + reemplazos (piloto de 20, pedido previsto el 2-nov) | `hbox-watchdog-v6` | 0.7.x (`board_v6.h` + NTRIP), no existe |
| <span class="badge-v4">V4</span> | En prueba y campo. Sin producción nueva. | — | `hbox-watchdog-v4` | `hbox-tarjetaV4_0.6.0` CAMPO |
| <span class="badge-v3">V3</span> | 3 unidades en campo. Registro congelado. | — | `hbox-watchdog-v3-PROD` (no editar) | `hbox-tarjetaV3_4.0.0` |
@ -199,8 +200,10 @@ El firmware que sustituye al 4G NTRIP Master no existe. Decisión vigente: **no
**Siguiente paso** (27-oct; [plan de fines de semana](v6-plan-fines-de-semana.md) e [instrucciones para Michelle](instrucciones-michelle-27oct.md)). D-26-1 quedó en (a) el 27-oct:
- **27-oct, 13:06:** Leo decidió D-26-1 = (a), G1 el 2-nov con T45 por script, y D-22-4 = (b), TVS de la SIM junto a J11 (T41 en el esquemático).
- **Mar 28:** Michelle hace *Actualizar PCB* con T48 + T49 + T41, borra el cobre suelto y pone D18–D20 junto a J11. Claude corre T45 sobre ese commit.
- **Mar 28 – vie 30:** resto del bloque B, R112 (bloque A) y bloques C, D y E (Michelle).
- **Mar 28:** Michelle hace *Actualizar PCB* con T48 + T49 + T41, borra el cobre suelto y pone D18–D20 junto a J11. Claude corre T45 sobre ese commit (listo: R80 abajo de Q7, tomas Kelvin, EP de U4 y U7, limpieza).
- **Mar 28 – mié 29:** resto del bloque B y R112 (Michelle).
- **Jue 30, temprano:** T45b, la RF LTE y el `/VBAT_MODEM` de C13 a los pines 55–57 (Claude).
- **Jue 30 – vie 31:** bloques C, D y E (Michelle).
- **Sáb 31 y dom 1-nov:** T29a y T29b (Claude); bloques F, G, H1 y H2 y la puerta L-48 (Michelle). El domingo en la tarde, T43 + T44, re-auditoría final y paquete (Claude).
- **Lun 2-nov:** pedido del piloto de 20 (G1).
- **Fines de semana 7–8 y 14–15 nov:** firmware v6. Banco del piloto el 21–22 nov (G2 ≈ 22-nov).

View File

@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, v5.6, v5.7, v6, piloto, jlcpcb, sim, de
project: Project Drogheda
author: "Helgen Technologies (redacción: Claude)"
date: 2026-10-27
version: 1.1
status: "ACTIVO — 27-oct-2026, 13:30: T46, T47, T48, T49 y T41 en HBox_PCB (15b956e). Leo decidió a las 13:06 D-26-1 (a), D-22-4 (b), FOTA sí y 60 v6. Lotes 2026-10-27_piloto-listo-una-sim y 2026-10-27_decisiones-leo-1306"
version: 1.2
status: "ACTIVO — 27-oct-2026, 15:45: T46, T47, T48, T49 y T41 en HBox_PCB (15b956e). Leo decidió a las 13:06 D-26-1 (a), D-22-4 (b), FOTA sí y 60 v6. T45 listo y probado (R80 pasa abajo de Q7); la RF LTE pasa a T45b (30-oct). Lotes 2026-10-27_piloto-listo-una-sim, 2026-10-27_decisiones-leo-1306 y 2026-10-27_t45-lista"
audience: "Leo (decide y paga), Aldair (pedido), Adriana (compras)"
---
@ -13,6 +13,12 @@ audience: "Leo (decide y paga), Aldair (pedido), Adriana (compras)"
**Lo que pediste a las 11:00:** resolver las piezas del piloto de la forma más fácil y rápida, dejar la v6 con una sola SIM, cargar la mezcla 135/365, quitar las tarjetas que bloqueaban el piloto, revisar el tablero e instrucciones al día para ti y para Michelle. Todo está hecho, salvo lo que solo tú puedes decidir o pagar.
!!! warning "27-oct, 15:45: T45 está lista y probada, con dos cambios"
- **R80 se mueve.** Con la colocación de Michelle, R80.1 quedó encerrado por el anillo de `/MAIN5V` (Q7 → C77…C81 → U17.1/24): ninguna pista de F.Cu podía llegarle desde L5 sin cruzar `/MAIN5V`, y por eso su ruta iba por In2. T45 pasa R80 justo abajo de Q7: el pad 2 a los pines de Q7 y el pad 1 junto a C100. Así `/VEH5V` llega por F.Cu (≈ 7 mΩ desde L5.2; antes 31), `/VIN_SW` queda de 2 mm y las tomas VIN y CLN salen por el lado de adentro de R80 y van por B.Cu. En una copia de la tarjeta: DRC con 0 errores y 0 sin conectar, y la prueba de Kelvin pasa ([figura](instrucciones-michelle-27oct.md#2-despues-de-t45-martes-28-y-miercoles-29-bloque-b-y-r112)).
- **La RF LTE sale de T45 y pasa a T45b** (jueves 30, sobre el commit de Michelle del bloque B). Para llevarla por F.Cu desde U6.60 hay que cruzar `/VBAT_MODEM`. El plan pide pasar ahí el VBAT a B.Cu (polígono de ≥ 3 mm y ≥ 6 vías por lado) y recorrer C14 o C15. Eso es el mismo cobre que el polígono de VBAT del bloque C, así que lo hago yo junto con la RF, y Michelle hace el VBAT solo de L1.2 a C13. **G1 sigue el lunes 2-nov.**
- En T45 quedan, como estaban, las 6 vías de los EP de U4 y U7 (y fuera el cuello de GND de U4) y la limpieza del 26-oct.
- **No tienes que decidir nada.** Si prefieres que R80 se quede donde está, las únicas rutas son la de hoy por In2 (sin Kelvin) o llevar `/VEH5V` por B.Cu. Dímelo antes de mañana a las 9:00.
!!! success "27-oct, 13:06: decidiste las cuatro"
- **D-26-1 = (a).** Lo tomé de tu «ok let's go» con la recomendación. G1 sigue el lunes 2-nov. El martes, Michelle sube Actualizar PCB y Claude corre **T45** sobre ese commit. Si querías (b), dilo y se recorren las fechas.
- **D-22-4 = (b), ya hecho con T41** (`15b956e`): D18–D20 pasan a `/SIMB_*` en el esquemático. Michelle las coloca junto a J11 en el mismo Actualizar PCB.
@ -128,7 +134,8 @@ Unas horas después de pagar, JLC manda las imágenes de producción para que la
| Mirar una V5.5 armada: muesca de los JST, pin 1 de U6 y franja de los electrolíticos (A8) | Jesús | 28-oct |
| FOTA 0: pruebas rápidas de banco y equipo (ST-Link con pinzas, relé de 24 V) | Jesús y Claude | 30-oct |
| v6: Actualizar PCB (T48 + T49 + T41), bloques A–E y fin de semana F–H ([instrucciones](instrucciones-michelle-27oct.md)) | Michelle | 28-oct a 1-nov |
| **T45** sobre el commit de Michelle del martes: regreso de `/VEH5V` con toma Kelvin, RF LTE en F.Cu, vías de los EP de U4 y U7 y limpieza | Claude | 28-oct |
| **T45** sobre el commit de Michelle del martes: R80 abajo de Q7 con `/VEH5V` por F.Cu y tomas Kelvin, vías de los EP de U4 y U7 y limpieza. Ya está listo y probado | Claude | 28-oct |
| **T45b** sobre el commit de Michelle del miércoles: RF LTE en F.Cu con su cerca de vías, y `/VBAT_MODEM` de C13 a los pines 55–57 con el cruce por B.Cu | Claude | 30-oct |
| **FOTA, con prioridad:** F0 (pruebas de banco con una V4/V5.5), F1 (bootloader) desde el 30-oct, F2 (receptor) y F4 (matriz). Comprar 4 RET6 de distribuidor y retrabajar 2 tarjetas del piloto | Claude, Jesús, Adriana | 30-oct a 20-nov |
**Vencidas que quedan a tu nombre** (fuera de las del §1):
@ -160,4 +167,10 @@ Lote `2026-10-27_decisiones-leo-1306` (13:30), con tus decisiones:
- F1 queda sin bloqueo y F3 se reabrió.
- `t-fota-ret6-retrabajo` nueva (Adriana y Jesús): 4 RET6 de distribuidor y U3 cambiado en 2 tarjetas del piloto.
Lote `2026-10-27_t45-lista` (15:45):
- `t-v6-t45`: el script está listo y probado. Mueve R80 y ya no incluye la RF LTE.
- `t-v6-t45b-rf` nueva (Claude, 30-oct): la RF LTE y el cruce de `/VBAT_MODEM` por B.Cu.
- Notas en `t-v6-kelvin-vin-r80`, `t-v6-limpieza-26oct`, `t-v6-b-main5v-vsys`, `t-v6-b-bstfb-igate` y `t-v6-c-modem`. El VBAT de C13 a los pines 55–57 y C14–C18 pasan de Michelle a T45b.
*Fuentes: [revisión independiente del pedido (26-oct)](revision-pedido-26oct.md) · [revisión de la v6 (26-oct)](v6-revision-26oct.md) · [plan de fines de semana de la v6](v6-plan-fines-de-semana.md) · [costeo del 26-oct](costeo-jlc-26oct.md) · existencias y precios de JLC leídos el 27-oct a las 11:16 · [JAE SF72S006VBDR2500 (Digi-Key)](https://www.digikey.com/en/products/detail/jae-electronics/SF72S006VBDR2500/6834082) · [4G NTRIP Master (ArduSimple)](https://ardusimple.com/product/4g-ntrip-master) · [hoja de datos del AP64352 (Diodes)](https://diodes.com/datasheet/download/AP64352.pdf).*

View File

@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, einarboard, v6, layout, pcb, michelle,
project: Project Drogheda
author: "Helgen Technologies (redacción: Claude; decide: Leo)"
date: 2026-10-27
version: 1.1
status: "ACTIVO — v6 en HBox_PCB 15b956e: esquemático con T48, T49 y T41; el PCB es tu 9a6f250 del 26-oct. Leo decidió el 27-oct, 13:06: D-26-1 (a) y D-22-4 (b)"
version: 1.2
status: "ACTIVO — v6 en HBox_PCB 15b956e: esquemático con T48, T49 y T41; el PCB es tu 9a6f250 del 26-oct. Leo decidió el 27-oct, 13:06: D-26-1 (a) y D-22-4 (b). 27-oct, 15:45: T45 listo y probado en una copia (R80 pasa abajo de Q7); la RF LTE pasa a T45b, el jueves 30"
audience: "Michelle (layout de la v6), Leo"
---
@ -22,9 +22,10 @@ Lo que cambió desde tu commit del lunes (`9a6f250`):
- T48, T49 y T41 solo cambiaron el **esquemático**. El PCB lo actualizas tú mañana temprano (§1).
!!! success "Leo decidió (27-oct, 13:06): G1 sigue el lunes 2-nov, con T45 por script"
- **D-26-1 = (a).** En cuanto subas el §1, Claude corre **T45** sobre tu commit (≈ 2 h): el regreso de `/VEH5V` con la toma Kelvin de R80, la RF LTE en F.Cu con su cerca de vías, las vías de los EP de U4 y U7 y la limpieza del 26-oct. **Mientras corre T45 no abras la v6.** Leo te avisa con el hash para que hagas `git pull`.
- **D-26-1 = (a).** En cuanto subas el §1, Claude corre **T45** sobre tu commit (≈ 1 h con el DRC): **pasa R80 abajo de Q7** para que `/VEH5V` llegue por F.Cu, rehace las tomas Kelvin de R80, pone las 6 vías de los EP de U4 y U7 y hace la limpieza del 26-oct (§2). **Mientras corre T45 no abras la v6.** Leo te avisa con el hash para que hagas `git pull`.
- **La RF LTE pasa a T45b** (Claude, jueves 30 temprano, sobre tu commit del bloque B). Para llevarla por F.Cu hay que pasar `/VBAT_MODEM` a B.Cu donde se cruzan y recorrer C14 o C15 (§3).
- **D-22-4 = (b).** D18–D20 van junto a J11 (§1.4).
- Así, de tus pendientes salen el regreso de `/VEH5V` y la limpieza (los hace T45), y en los bloques C y E te queda solo la colocación.
- Así, de tus pendientes salen el regreso de `/VEH5V`, la limpieza y las vías de los EP (T45), y la RF LTE con el tramo de `/VBAT_MODEM` de C13 a los pines 55–57 (T45b). En los bloques C y E te queda la colocación.
**Reglas de siempre:**
@ -115,13 +116,37 @@ git commit -am "v6 T48+T49+T41: Actualizar PCB (sin eSIM ni sondas NTC; TVS de l
git push
```
- [ ] Avísale a Leo con el hash: **Claude corre T45** sobre ese commit (≈ 2 h) y te avisa para que hagas `git pull`. **Mientras tanto no abras la v6.**
- [ ] Avísale a Leo con el hash: **Claude corre T45** sobre ese commit (≈ 1 h) y te avisa para que hagas `git pull`. **Mientras tanto no abras la v6.**
**Lo que queda libre:** junto a J11, el resto del espacio de U13 (≈ 7 × 11 mm de F.Cu, en verde en la figura). Donde estaban J2 y J4 (orilla norte, x ≈ 105–137) quedan libres los lugares de J2, J4, D4, D6, R14, C9, R15 y C28. Úsalos si te ayudan en los bloques D o H1, por ejemplo para sacar `/SW_IN` de In1.
## 2. Después de T45 (martes 28 y miércoles 29): bloque B y R112
**T45 (Claude) hace** el regreso de `/VEH5V` (L5.2 → R80.1 por F.Cu de ≥ 2 mm) con la toma VIN Kelvin desde R80.1, y la limpieza del 26-oct ([revisión, §3 y §4](v6-revision-26oct.md#3-la-regresion-la-toma-vin-de-r80)). Revisa lo que dejó en esa zona antes de mover piezas de ahí; si algo te estorba, avísale a Claude en lugar de deshacerlo.
![T45: R80 abajo de Q7, /VEH5V por F.Cu y las tomas Kelvin por adentro](img/2026-10-27_Drogheda_v6-T45-R80-Kelvin_v1.0.png)
**Qué hace T45** (`tools/t45_v6.py`, probado el 27-oct en una copia de tu `9a6f250`: DRC con 0 errores y 0 sin conectar, y la prueba de Kelvin del script pasa). Arregla la regresión de la [revisión del 26-oct, §3](v6-revision-26oct.md#3-la-regresion-la-toma-vin-de-r80):
- **R80 se mueve** de (178.96, 103.89) a **(174.6, 108.87)**, vertical, justo abajo de Q7: el pad 2 pegado a los pines S (1–3) y el pad 1 junto a C100. Donde estaba, R80.1 quedaba encerrado por el anillo de `/MAIN5V` (Q7 → C77…C81 → U17.1/24): ninguna pista de F.Cu podía llegarle desde L5 sin cruzar `/MAIN5V`. Por eso tu ruta tenía que ir por In2.
- **`/VEH5V`:** de C100.1 a R80.1 con 1.5 mm (el cuello entre C99.2 y R86.2 no da para más), entrando por el lado de afuera del pad. De C100 a C101 se ensancha a 2.0 mm. De L5.2 a R80.1 quedan ≈ 7 mΩ, contra 31 antes. Se borran la vía de (180.1, 103.2) y los 8 mm de In2.
- **`/VIN_SW`:** de R80.2 a los pines S de Q7 con 2.0 mm (1.7 mm de largo). Se borra la vuelta vieja de 1.0 mm.
- **Tomas Kelvin:** VIN y CLN salen de R80.1 y R80.2 por el lado de adentro (entre los dos pads, bajo el cuerpo), bajan por una vía a **B.Cu** con 0.25 mm y suben junto a C86 (VIN → C86/C87 → U17.6) y en (179.2, 105.5) (CLN → tu pista de 0.5 mm → U17.7). El script comprueba que ninguna de las dos toque cobre de potencia fuera de su pad.
- **`/MAIN5V` de JP5.1 y R85.1:** la pista de y = 107.5 pasaba por el lugar nuevo de R80 y se borró. Ahora toman el 5 V de Q7.5 con **0.35 mm entre los pines 3 y 4 de Q7** (corriente de µA). Para dejarla pasar, la barra de los pines S de Q7 se acortó 0.23 mm. La vía de GND de (175.0, 108.7) se borró porque quedaba bajo R80.
- **EP de U4 y de U7:** 6 vías de GND en cada uno, y fuera el cuello de GND de 0.25 mm de U4 (figura de abajo).
- **Limpieza del 26-oct:** las 3 vías y las 3 pistas sueltas.
![T45: las vías de los EP de U4 y U7](img/2026-10-27_Drogheda_v6-T45-EP-U4-U7_v1.0.png)
**Avisos del DRC que T45 deja a propósito:**
- 11 de ancho de pista: las tomas Kelvin, de 0.25–0.3 mm, son de `/VEH5V` y `/VIN_SW`, que son redes de 1 mm, y la pista de 0.35 mm de `/MAIN5V` a JP5.1. **No las ensanches.**
- 8 de serigrafía: las referencias de JP5, R85, R86, R80 y C99 quedaron encima de R80 o entre sí. Las acomoda T44 al cierre.
**Qué cambia en tu bloque B:**
- **No muevas R80, Q7, C86, C87, C100 ni C101**, ni las vías y pistas de las tomas (las de B.Cu, entre x = 174.6 y 179.8 y entre y = 103.9 y 109.25). Si algo te estorba, avísale a Claude.
- El lugar viejo de R80, entre Q7, C77 y C86, quedó libre: úsalo para el polígono de `/MAIN5V` y para acercar C77–C81 a U17.1/24.
- Cuando muevas R85/R86 a U17.18 (B6), **la rama de arriba de R85 toma el 5 V de los capacitores de VSYS**, como dice el plan; JP5.1 se queda con su pista de 0.35 mm. Con R86 fuera de ahí, el cuello de `/VEH5V` de 1.5 mm junto a C99.2 se puede ensanchar a 2 mm (opcional).
- B2 ya queda: Q7 pegado a R80 y `/VIN_SW` de 2.0 mm.
| Pendiente | Qué | Listo cuando | Vence |
|---|---|---|---|
@ -130,17 +155,19 @@ git push
| `t-v6-b-bateria` | L4 a ≤ 5 mm de U17.21/22. `/SW_BAT` como polígono de ≥ 2 mm. `/BAT_PLUS` de ≥ 1.5 mm, con la toma BAT (U17.23) aparte. In1 e In2 libres a 5 mm de U17 y de L4 | Sin avisos de ancho en `/SW_BAT` ni en `/BAT_PLUS` | 29-oct |
| `t-v6-a-r112` | R112 a ≤ 3 mm de J19.3 (hoy 4.7), solo en F.Cu. El tramo largo después de R112 (`/IGN_SENSE`) puede seguir por In2 | — | 29-oct |
Commits: `v6 bloque B: …` y `v6 bloque A: R112`. **No muevas L5, R80 ni la toma VIN** sin avisar: la prueba de Kelvin de T45 depende de cómo queden.
Commits: `v6 bloque B: …` y `v6 bloque A: R112`. Súbelos **el miércoles 29 en la tarde**: sobre ese commit Claude corre T45b el jueves temprano (§3).
## 3. Jueves 30 y viernes 31: bloques C, D y E
Están en el [plan de fines de semana, §4](v6-plan-fines-de-semana.md#4-domingo-25-bloques-c-d-y-e-modem-gnss-y-ethernet). T45 ya trae la RF LTE y las vías de los EP; a ti te queda la colocación.
Están en el [plan de fines de semana, §4](v6-plan-fines-de-semana.md#4-domingo-25-bloques-c-d-y-e-modem-gnss-y-ethernet).
**Jueves 30, temprano: Claude corre T45b** sobre tu commit del miércoles. Lleva la RF LTE por F.Cu (U6.60 → C19/R9 → C5 → D8 → J8) con su cerca de vías y hace el tramo de `/VBAT_MODEM` de C13 a los pines 55–57 con el cruce por B.Cu que pide el plan (§4.1, punto 3); si hace falta, recorre C14 o C15. Mientras corre, no abras la v6; luego `git pull`. Las vías de los EP ya las puso T45.
| Pendiente | Qué te toca | Vence |
|---|---|---|
| `t-v6-c-modem` | C44/C34 a ≤ 3 mm y C52 junto a U4.2. R28/R27 a ≤ 2 mm de U4.5, con el nodo FB corto. `/VBAT_MODEM` como polígono de ≥ 3 mm hasta los pines 55/56/57; C13 a ≤ 5 mm de esos pines y C4 a ≤ 3 mm de L1.2. TP5 a 2.54 mm de TP6. Las 6 vías del EP de U4 y el cuello de GND entre U4.1 y U4.2 ya los resuelve T45. **No toques la RF LTE ni su cerca de vías** (T45) | 31-oct |
| `t-v6-c-modem` | C44/C34 a ≤ 3 mm y C52 junto a U4.2. R28/R27 a ≤ 2 mm de U4.5, con el nodo FB corto. `/VBAT_MODEM` como polígono de ≥ 3 mm **de L1.2 a C13**; de C13 a los pines 55/56/57, con C14–C18, lo deja T45b. C4 a ≤ 3 mm de L1.2. TP5 a 2.54 mm de TP6. **No toques la RF LTE, su cerca de vías ni C14–C18** (T45b) | 31-oct |
| `t-v6-d-gnss` | C70 a ≤ 2 mm de U25.1. `/ANT_BIAS` a ≥ 1 mm de la RF. `/SW_IN` y `/SW_5V` fuera de In1 (la opción del plan: F1 junto a Q11). TP9 junto a U18.49 | 31-oct |
| `t-v6-e-eth` | U7: D12 a ≤ 2 mm del pin 1, L2 a ≤ 5 mm y `/ETH_FB` de ≤ 5 mm en F.Cu. Las 6 vías del EP ya las pone T45 | 31-oct |
| `t-v6-e-eth` | U7: D12 a ≤ 2 mm del pin 1, L2 a ≤ 5 mm y `/ETH_FB` de ≤ 5 mm en F.Cu. Las 6 vías del EP ya las puso T45 | 31-oct |
Cierre (plan §4.4): DRC con 0 errores y 0 sin conectar; `/RF_LTE` y `/RF_ANT_LTE` solo en F.Cu, sin vías y con In1 sólido debajo; EP de U4 y de U7 con 6 vías cada uno; J8 y J13 con 2 vías por pad de tierra. Commit: `v6 bloques C + D + E`.
@ -161,3 +188,5 @@ Escríbele a Leo con el commit, el designador y la coordenada, y una captura. Si
*Coordenadas y conteos sacados del PCB `9a6f250` y del esquemático `15b956e`, con scripts que simulan lo que borra Actualizar PCB y buscan los extremos de pista sin conexión, igual que KiCad: extremo dentro de otra pista de la misma capa, de una vía, de un pad o del relleno de una zona de su red. Los lugares de D18–D20 se revisaron contra los pads, pistas y vías de alrededor. Las figuras salen del mismo PCB.*
*v1.1 (27-oct, 13:30): decisiones de Leo de las 13:06 (D-26-1 a, D-22-4 b), T41 en el §1, T45 en vez del regreso de `/VEH5V` y la limpieza.*
*v1.2 (27-oct, 15:45): T45 listo y probado en una copia (R80 abajo de Q7, tomas Kelvin por B.Cu, EP de U4 y U7, limpieza), con sus dos figuras en el §2. La RF LTE y el tramo de `/VBAT_MODEM` de C13 a los pines pasan a T45b, el jueves 30.*

View File

@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, einarboard, v6, layout, revision, kelvi
project: Project Drogheda
author: Helgen Technologies (revisión: Claude; decide: Leo)
date: 2026-10-26
version: 1.0
status: "REVISADO — HBox_PCB 9a6f250 (Michelle, 26-oct). Avance real en el bloque B, una regresión (toma VIN de R80 sin Kelvin y /VEH5V por In2) y los bloques C, D y E sin empezar."
version: 1.1
status: "REVISADO — HBox_PCB 9a6f250 (Michelle, 26-oct). Avance real en el bloque B, una regresión (toma VIN de R80 sin Kelvin y /VEH5V por In2) y los bloques C, D y E sin empezar. 27-oct: Leo eligió (a) en D-26-1; T45 (listo) arregla la regresión moviendo R80."
audience: Leo (D-26-1, D-22-4), Michelle (layout de la v6)
---
@ -72,6 +72,9 @@ Cada toma tiene que salir de **su** pad de R80 y no tocar ningún otro cobre de
- **Arreglo:** `/VEH5V` de L5.2 a R80.1 directo por F.Cu de ≥ 2 mm (§3.2 punto 1 del plan), sin In2 ni C76 en medio, y la toma VIN como pista propia de 0.25–0.3 mm que salga de R80.1 **por el lado de adentro** y pase por C86/C87 hasta U17.6. Después, correr de nuevo la prueba de Kelvin.
- **CLN ya quedó bien:** antes pasaba por la vía junto al pad 2 y ahora sale del pad.
!!! success "27-oct: T45 lo arregla moviendo R80"
Al escribir T45 resultó que, con R80 donde quedó, R80.1 está encerrado por el anillo de `/MAIN5V` (Q7 → C77…C81 → U17.1/24): no hay ninguna ruta de F.Cu desde L5 que no lo cruce, ni de 0.3 mm. Por eso la ruta iba por In2. T45 pasa R80 a (174.6, 108.87), justo abajo de Q7: `/VEH5V` va de C100 a R80.1 por F.Cu (≈ 7 mΩ desde L5.2), `/VIN_SW` queda de 2.0 mm a los pines S de Q7 y las tomas VIN y CLN salen por el lado de adentro de R80 y van por B.Cu. La prueba de Kelvin del script pasa y el DRC de una copia da 0 errores y 0 sin conectar. Se aplica el 28-oct sobre el Actualizar PCB de Michelle ([sus instrucciones, §2](instrucciones-michelle-27oct.md#2-despues-de-t45-martes-28-y-miercoles-29-bloque-b-y-r112)).
## 4. Qué sigue
**Para Michelle, en este orden:**

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

View File

@ -10,6 +10,8 @@ Qué hay **en cada sitio** —Querétaro (taller) y los cuatro puertos: ICAVE, T
**[Abrir el inventario →](../tableros/inventario.md)** · por sitio: [ICAVE](../tableros/inventario.md?sitio=icave) · [TIMSA](../tableros/inventario.md?sitio=timsa) · [EIT](../tableros/inventario.md?sitio=eit) · [LCT](../tableros/inventario.md?sitio=lzc) · [Querétaro](../tableros/inventario.md?sitio=qro)
**Compras (27-oct):** las órdenes de compra del cotizador van a llegar solas, en camino a su sitio, para darles **Recibir** aquí: [cómo funciona](integracion-cotizador.md).
## Qué hay aquí
| Página | Para qué |

View File

@ -0,0 +1,150 @@
---
title: "Instrucciones para el proyecto del cotizador: ligar órdenes de compra al centro de mando"
date: 2026-10-27
tags: [project-drogheda, cotizador, compras, inventario, integracion, instrucciones]
---
# Instrucciones para el proyecto del cotizador
**27-oct-2026 · v1.0.** Para la sesión de Claude que trabaja en `cotizador-helgen` (Apps Script + Sheets + Cloudflare).
Diseño completo y decisiones: [Cotizador ↔ centro de mando](integracion-cotizador.md). Aquí está **qué cambiar en el
cotizador**, en orden, con sus pruebas. Todo respeta las reglas del `CLAUDE.md` del cotizador (§7): totales en el servidor,
datos de compras solo en su libro, `CAMBIOS_API`, manual actualizado en el mismo turno, pruebas en PRUEBAS antes del real.
## Para pegar en el proyecto del cotizador
> Lee `CLAUDE.md` completo y después este documento (`2026-10-27_Drogheda_Instrucciones-Cotizador-Integracion_ES_v1.0.md`).
> Vamos a ligar las órdenes de compra de los proyectos ligados (hoy DROGHEDA, `PRY-2026-0002`) con el centro de mando
> drogheda.helgen.online, siguiendo el contrato de la sección «Contrato». Trabaja por pasos (1 a 7), con respaldos antes
> de tocar hojas, pruebas en Node y una ronda en PRUEBAS con `"prueba": true` antes de publicar. No mandes montos al centro
> de mando. No pongas el secreto en el código ni en el chat: va en las propiedades del script (`PORTAL_SECRETO`) y me lo
> pides a mí cuando haga falta. Al terminar, actualiza `CLAUDE.md` (§4.5.3, §4.5.4, §5, §6.12, §6.13 y una §6.15 nueva)
> y el Manual del Cotizador.
## Lo que decidió Leo (27-oct)
- El material se **recibe en el centro de mando** (el responsable del sitio da «Recibir»). El cotizador **cierra solo**
la requisición cuando el centro de mando dice que llegó todo. «Recibí el material» en el cotizador se queda como respaldo
para proyectos no ligados y para cuando el centro de mando no esté disponible.
- La autorización, los montos, los pagos y los proveedores **se quedan en el cotizador**.
- Cada OC de un proyecto ligado lleva un **sitio de entrega** de 3 letras: `VER` Veracruz (ICAVE) · `ZLO` Manzanillo
(TIMSA) · `ESE` Ensenada (EIT) · `LZC` Lázaro Cárdenas (LCT) · `QRO` Querétaro.
## Paso 1 · Propiedades del script
| Propiedad | Valor | Nota |
|---|---|---|
| `PORTAL_URL` | `https://drogheda.helgen.online` | |
| `PORTAL_SECRETO` | (lo genera Leo; el mismo que `COTIZADOR_SECRETO` en el servidor) | Nunca en el código, en `CLAUDE.md` ni en el chat |
| `PORTAL_PROYECTOS` | `{"PRY-2026-0002": "drogheda"}` | Llave interna del proyecto → proyecto del centro de mando |
| `PORTAL_SITIOS` | `VER,ZLO,ESE,LZC,QRO` | Con sus nombres en la interfaz (Veracruz · ICAVE, Manzanillo · TIMSA, …) |
Agregar `script.external_request` (ya existe por la IA de claves SAT si está activa) y `script.scriptapp` (para el
activador del paso 6) a `appsscript.json`: el dueño (efrain@) tendrá que volver a autorizar.
## Paso 2 · Sitio de entrega e ítem del inventario en la requisición
- `Requisiciones`: columna nueva **`sitio_entrega`** (al final, como se agregó `req_folio`; las filas viejas quedan vacías).
Obligatoria cuando el proyecto está en `PORTAL_PROYECTOS`; el servidor lo revisa en `guardarRequisicion` (no solo la
interfaz). En la interfaz: un selector con los 5 sitios, junto al proyecto.
- `RequisicionesRenglones`: columna nueva **`inventario_item`** (código del ítem, opcional). En la interfaz, cada renglón
de un proyecto ligado tiene un buscador con los ítems del centro de mando (`GET /api/integracion/inventario/items`,
guardado en `CacheService` 6 h). Se puede dejar vacío: el centro de mando lo liga después.
- `OrdenesCompra`: columnas nuevas **`sitio_entrega`** (se copia de la requisición al autorizar), **`paqueteria`**,
**`guia`**, **`fecha_estimada`**, **`en_camino_en`**, **`en_camino_por`**. Los renglones de la OC guardan el
`inventario_item` de su renglón de requisición (si compras divide o junta renglones, conserva el ítem).
## Paso 3 · Estado «En camino»
- Nueva función *(UI)* **`marcarEnCamino(folio, {paqueteria, guia, fechaEstimada})`**: la puede usar **compras** o **el
solicitante de esa requisición**. Solo desde Autorizada/Emitida o Pagada; guía obligatoria salvo «lo trae el proveedor»;
fecha estimada no anterior a hoy. Con candado e idempotencia (`ENC_<idSolicitud>`), como las demás.
- Requisición y OC pasan a **«En camino»** (estado nuevo entre Pagada y Recibida; también puede venir de Autorizada si se
paga después). El solicitante lo ve en su historial con la fecha, sin montos.
- Una OC «En camino» ya no se cancela desde el cotizador sin motivo explícito (avisa al centro de mando con `oc.cancelada`).
- `CAMBIOS_API`: `marcarEnCamino` cambia `requisiciones` y `oc`.
## Paso 4 · Avisos al centro de mando (empujar)
Función interna **`avisarPortal_(tipo, folioOC)`**: arma el cuerpo del contrato con los datos de la OC **sin montos**
(nada de costo, importe, subtotal, IVA, total, tipo de cambio ni margen), lo firma y lo manda con `UrlFetchApp.fetch`
(`muteHttpExceptions: true`). Se llama **después** de que el cambio quedó guardado:
| Momento | `tipo` |
|---|---|
| `autorizarRequisicion` (emite la OC) | `oc.autorizada` |
| `registrarPagoProveedor` | `oc.pagada` |
| `marcarEnCamino` | `oc.en_camino` |
| `cancelarOrdenCompra` | `oc.cancelada` |
| `cerrarRequisicionCompras` (compras cierra a mano) | `req.cerrada` |
- Solo si el proyecto de la OC está en `PORTAL_PROYECTOS`.
- `evento_id` = `<folio OC>:<tipo>:<n>` (n = cuántas veces se mandó ese tipo para esa OC), para que un reintento no duplique.
- Si la respuesta no es 200 `ok`, el aviso se guarda en una pestaña nueva **`PortalPendientes`** del libro de compras
(`evento_id`, `cuerpo`, `intentos`, `ultimo_error`, `creado`) y lo reintenta el activador del paso 6. La acción del
usuario **no** falla por eso: se le muestra «Guardado. El centro de mando se actualizará en unos minutos».
- Firma: `X-Hbox-Tiempo` = hora ISO con zona; `X-Hbox-Firma` = `sha256=` + hex de
`Utilities.computeHmacSha256Signature(tiempo + "\n" + cuerpo, PORTAL_SECRETO)`.
## Paso 5 · Recepciones (consultar y cerrar solo)
Función **`traerRecepcionesPortal()`**: `GET /api/integracion/cotizador/recepciones?desde=<PORTAL_ULTIMA_CONSULTA>`
(firmada igual, cuerpo vacío). Por cada recepción:
- `completa: true` → cierra la requisición con `cerrarReq_` (estado Cerrada, OC Recibida) y nota «Recibido en ZLO por
jesush en el centro de mando, 30-oct-2026». Si la requisición ya estaba Cerrada, no hace nada.
- `completa: false` → solo agrega una nota de recepción parcial («Llegaron 3 de 4 soportes») y deja la requisición En camino.
- Guarda `PORTAL_ULTIMA_CONSULTA` al final, solo si todo salió bien.
- Bitácora en cada cierre automático (quién: «centro de mando»).
## Paso 6 · Activador cada 15 minutos
`instalarActivadorPortal()` (se corre una vez desde el editor): `ScriptApp.newTrigger('cadaQuinceMinutos').timeBased()
.everyMinutes(15)`. `cadaQuinceMinutos` = reintentar `PortalPendientes` + `traerRecepcionesPortal`. Aprovecha para llenar el
caché (la idea de §6.14).
## Paso 7 · Pruebas y publicación
1. **Node** (`tests/portal.test.js`): el cuerpo nunca lleva montos; la firma coincide con un vector de prueba; sitio
obligatorio solo en proyectos ligados; `evento_id` estable; un 500 del portal deja el aviso en `PortalPendientes`;
recepción completa cierra, parcial solo anota, repetida no hace nada.
2. **PRUEBAS** con `"prueba": true`: requisición → autorizar → en camino → (Leo da «Recibir» en el centro de mando) →
la requisición se cierra sola en ≤ 15 min. El centro de mando guarda las de prueba aparte y no toca el inventario.
3. Respaldos de los libros (como siempre), `clasp push`, comparar el editor, `clasp deploy` (misma API_URL),
`wrangler deploy`.
4. Primera OC real de DROGHEDA: revisar juntos en el centro de mando (Compras y la pestaña del sitio en Inventario).
## Contrato (lo que espera el centro de mando)
```json
POST /api/integracion/cotizador/evento
{ "evento_id": "OC-2026-0012:oc.en_camino:1",
"tipo": "oc.en_camino",
"cuando": "2026-10-28T10:15:00-06:00",
"quien": "tania@helgen.tech",
"prueba": false,
"oc": { "folio": "OC-2026-0012", "req_folio": "REQ-2026-0031",
"proyecto_folio": "PRY-2026-0002", "proyecto_clave": "DRO",
"sitio": "ZLO", "proveedor": "Ferretería del Pacífico",
"fecha_entrega": "2026-10-30", "paqueteria": "Estafeta", "guia": "123456789",
"solicitante": "jesus@helgen.tech", "autorizo": "tania@helgen.tech",
"renglones": [ { "n": 1, "descripcion": "Soporte para RTG", "unidad": "pza", "cantidad": 4, "item": "SOP-RTG" },
{ "n": 2, "descripcion": "Tornillo 3/8 x 2 in", "unidad": "pza", "cantidad": 32, "item": null } ] } }
→ 200 { "ok": true, "estado_portal": "en-camino", "tarea_id": "t-oc-2026-0012", "por_ligar": [2] }
→ 200 { "ok": true, "repetido": true } (mismo evento_id)
→ 400 { "ok": false, "error": "Sitio inválido: MZO (usa VER, ZLO, ESE, LZC o QRO)" }
→ 401 { "ok": false, "error": "Firma inválida o vencida" }
GET /api/integracion/inventario/items
→ 200 { "ok": true, "items": [ { "codigo": "SOP-RTG", "descripcion": "Soporte para RTG", "unidad": "pza", "categoria": "instalacion" } ] }
GET /api/integracion/cotizador/recepciones?desde=2026-10-28T00:00:00-06:00
→ 200 { "ok": true, "hasta": "2026-10-30T12:40:00-06:00",
"recepciones": [ { "oc_folio": "OC-2026-0012", "req_folio": "REQ-2026-0031", "sitio": "ZLO", "completa": false,
"recibio": "jesush", "cuando": "2026-10-30T12:31:00-06:00",
"renglones": [ { "n": 1, "pedido": 4, "recibido": 4 }, { "n": 2, "pedido": 32, "recibido": 20 } ] } ] }
```
Hasta que el centro de mando publique estas rutas (fase 1 del lado del sitio), el cotizador puede programar y probar todo
contra un servidor simulado en Node con estas mismas respuestas.

View File

@ -0,0 +1,154 @@
---
title: "Cotizador ↔ centro de mando: órdenes de compra, inventario por sitio y tareas"
date: 2026-10-27
tags: [project-drogheda, hbox, compras, inventario, cotizador, integracion, sql]
estado: propuesta con 3 decisiones tomadas (27-oct); falta construir
---
# Cotizador ↔ centro de mando: órdenes de compra, inventario por sitio y tareas
**27-oct-2026 · v1.0 · Claude (CTO/CSO) con Leo.** Responde al pendiente `t-cotizador-integracion`: que la
requisición, la orden de compra y la llegada del material sean un solo hilo que termine en el inventario de cada sitio,
con su pendiente en el tablero. Las instrucciones para el proyecto del cotizador están en
[Instrucciones para el cotizador](integracion-cotizador-instrucciones.md).
![Flujo de una orden de compra del cotizador al inventario del sitio](img/2026-10-27_Drogheda_Flujo-OC-Inventario_v1.0.png)
## Resumen
1. **El cotizador se queda con el dinero y la autorización.** Ya tiene requisición → autorización de compras → orden de
compra (OC) → pago → «Recibí el material», con roles y montos protegidos. No se duplica en el sitio.
2. **El centro de mando recibe lo operativo de cada OC, sin montos:** folio, proyecto, **sitio de entrega** (código de 3
letras), proveedor, renglones con cantidad y su ítem del inventario, fechas, paquetería y guía.
3. **«En camino» es nuevo:** cuando el proveedor manda el material, compras o quien pidió lo confirma en el cotizador con
la guía. En ese momento el material aparece **en camino** en la pestaña del sitio del inventario.
4. **Se recibe en el sitio, en el centro de mando** (decisión de Leo, 27-oct): el responsable del sitio da **Recibir** con
lo que llegó de verdad; el cotizador se entera solo y cierra la requisición.
5. **Cada OC abre su pendiente** (pista Compras, su sitio, el responsable del sitio, vence en la fecha de entrega) y se
cierra solo cuando se recibe todo.
6. **Base de datos: SQLite** en el servidor del sitio (decisión de Leo, 27-oct), empezando por las órdenes de compra; el
inventario pasa después, con una prueba de que los números no cambian.
## Decisiones
| # | Decisión | Estado |
|---|---|---|
| D-27-1 | El material de una OC se **recibe en el centro de mando** (pestaña del sitio, botón Recibir). El cotizador cierra la requisición solo. | **Tomada** (Leo, 27-oct) |
| D-27-2 | Base de datos **SQLite** en el servidor del sitio (`/srv/projects/drogheda/drogheda.db`). PostgreSQL solo si otra aplicación tiene que escribir directo en ella. | **Tomada** (Leo, 27-oct) |
| D-27-3 | Código de 3 letras por sitio = el de su aeropuerto y su puerto (UN/LOCODE): **VER** Veracruz (ICAVE) · **ZLO** Manzanillo (TIMSA) · **ESE** Ensenada (EIT) · **LZC** Lázaro Cárdenas (LCT) · **QRO** Querétaro. Son los que vienen en guías y documentos de embarque; el sitio ya los acepta. | Propuesta de Claude (Leo pidió códigos de 3 letras) |
| D-27-4 | La **autorización** de la OC se queda en el cotizador (rol de compras). El centro de mando **nunca** muestra montos: todo el equipo lo ve. | Propuesta |
| D-27-5 | Solo las OC de proyectos ligados van al centro de mando. Hoy: DROGHEDA = `PRY-2026-0002-DRO` → proyecto `drogheda`. | Propuesta |
## Lo que hay hoy
| | Cotizador | Centro de mando (drogheda.helgen.online) |
|---|---|---|
| Dónde vive | Google Apps Script + Sheets + Drive; interfaz en Cloudflare; entra con cuenta @helgen.tech | Servidor de Helgen (Flask + nginx), página de entrada propia desde el 27-oct |
| Proyecto | `PRY-2026-0002-DRO` «Helgen — DROGHEDA», MXN, sin contrato | `drogheda` |
| Requisición | `REQ-AAAA-NNNN`: Solicitada → Autorizada → Pagada → Cerrada (también Devuelta, Rechazada, Cancelada). Piden sin precios: adriana@, miguel@, paola@, jesus@ (solo requisiciones) y efrain@, tania@, leo@ | — |
| Orden de compra | `OC-AAAA-NNNN`: Emitida → Pagada → Recibida, o Cancelada. Sale siempre de una requisición autorizada. **Sin recepciones parciales** | — |
| Sitio de entrega | No existe (solo «lugar de entrega» en texto) | `icave` · `timsa` · `eit` · `lzc` · `qro` en cada pendiente, movimiento y miembro |
| Material | Renglones de texto libre (descripción, unidad, cantidad) | Ítems con código, existencia por sitio, envíos «en camino» con Recibir (también parcial), kit por instalación |
| Tareas | — | Pendientes con pista, sitio, responsable y vencimiento |
**Lo que falta para unirlos:** el sitio de entrega en la requisición, el ítem del inventario en cada renglón, el estado
«En camino», un canal entre los dos y una forma de cerrar la requisición desde la recepción.
**Una restricción que define el diseño:** el cotizador puede llamar al centro de mando (Apps Script tiene `UrlFetchApp`),
pero el centro de mando **no** puede llamar al cotizador, porque cada llamada al cotizador exige una sesión de Google de
una persona (su regla 10, que no se quita). Por eso: el cotizador **avisa** (empuja) cada cambio de una OC, y **consulta**
cada 15 minutos lo que ya se recibió.
## El flujo, paso por paso
| Paso | Dónde | Quién | Qué pasa |
|---|---|---|---|
| 1. Pedir | Cotizador | Quien necesita el material | Requisición en DROGHEDA con **sitio de entrega** (obligatorio en proyectos ligados) y renglones; cada renglón puede llevar su **ítem del inventario** (lista que el cotizador baja del centro de mando). |
| 2. Autorizar | Cotizador | Compras (Efraín, Tania, Leo) | Emite la OC como hoy. Aviso `oc.autorizada` → el centro de mando crea la OC (sin montos) y el pendiente «Recibir OC-… en ZLO» para el responsable del sitio, que vence en la fecha de entrega. |
| 3. Pagar | Cotizador | Compras | Como hoy. Aviso `oc.pagada` (solo el estado). |
| 4. En camino | Cotizador | Compras o quien pidió | «El proveedor ya lo mandó»: paquetería, guía y fecha estimada. Aviso `oc.en_camino` → en la pestaña del sitio aparece cada renglón **en camino** (un envío desde «Proveedor o fuera del inventario», con el folio de la OC como referencia). Los renglones sin ítem quedan en **Compras → por ligar** del centro de mando. |
| 5. Recibir | Centro de mando | Responsable del sitio (Alán VER, Jesús Honorato ZLO, Miguel ESE, Antonio LZC, Jesús Romero QRO) | **Recibir** con lo que llegó de verdad (puede ser parcial). La OC queda «recibida» o «recibida en parte»; el pendiente se cierra solo cuando llega todo. |
| 6. Cerrar | Cotizador (solo) | — | Cada 15 min el cotizador consulta las recepciones: si llegó todo, cierra la requisición («Recibido en ZLO por jesush en el centro de mando, 30-oct») y la OC queda Recibida. Si llegó una parte, lo anota en las notas de recepción. |
| Cancelar | Cotizador | Compras | Aviso `oc.cancelada` → el centro de mando cancela los envíos abiertos y el pendiente. |
## Datos (SQLite)
Un archivo, `/srv/projects/drogheda/drogheda.db`, en modo WAL (aguanta a los dos procesos del servicio). Se respalda
con el resto de `/srv/projects/drogheda`.
```sql
CREATE TABLE sitios (codigo TEXT PRIMARY KEY, -- VER ZLO ESE LZC QRO
id_portal TEXT UNIQUE NOT NULL, -- icave timsa eit lzc qro
nombre TEXT NOT NULL, responsable TEXT);
CREATE TABLE proyectos (id TEXT PRIMARY KEY, -- drogheda
pry_folio TEXT UNIQUE NOT NULL, -- PRY-2026-0002
clave TEXT, nombre TEXT);
CREATE TABLE ordenes (folio TEXT PRIMARY KEY, -- OC-2026-0012
req_folio TEXT, proyecto_id TEXT REFERENCES proyectos(id),
sitio TEXT REFERENCES sitios(codigo), proveedor TEXT,
estado TEXT CHECK (estado IN ('autorizada','pagada','en-camino','recibida-parcial','recibida','cancelada')),
fecha_entrega TEXT, paqueteria TEXT, guia TEXT, en_camino_en TEXT,
solicitante TEXT, autorizo TEXT, tarea_id TEXT, prueba INTEGER DEFAULT 0,
creado_en TEXT, actualizado_en TEXT);
CREATE TABLE renglones (oc_folio TEXT REFERENCES ordenes(folio), n INTEGER,
descripcion TEXT, unidad TEXT, cantidad REAL,
item_codigo TEXT, -- ítem del inventario (NULL = por ligar)
envio_id TEXT, -- el envío «en camino» que se creó en el inventario
recibido REAL DEFAULT 0, PRIMARY KEY (oc_folio, n));
CREATE TABLE eventos (evento_id TEXT PRIMARY KEY, -- idempotencia: el mismo aviso dos veces no duplica
oc_folio TEXT, tipo TEXT, cuando TEXT, quien TEXT,
origen TEXT CHECK (origen IN ('cotizador','portal')), datos TEXT);
```
**Fases.** 1) Órdenes de compra en SQLite; el inventario sigue en sus JSON y la OC crea sus envíos con la API de hoy.
2) El inventario (ítems y movimientos) pasa a SQLite con una comparación de existencias por sitio antes y después.
3) **Activos**: una fila por unidad y por pieza con serie (HBOX, tarjetas, módulos) con su sitio, estado, vehículo y actas
— lo que hoy se arma leyendo las actas.
## Cómo se hablan (API del centro de mando para el cotizador)
**Seguridad.** Las rutas `/api/integracion/…` no usan la sesión de una persona: cada llamada va firmada con un secreto
compartido (HMAC-SHA256 del cuerpo y la hora). El secreto vive en las propiedades del script del cotizador
(`PORTAL_SECRETO`) y en el servicio del servidor (`COTIZADOR_SECRETO`); **nunca en el código ni en un chat**. Se rechaza
una firma de hace más de 5 minutos.
```
POST /api/integracion/cotizador/evento
Encabezados: X-Hbox-Tiempo: 2026-10-28T10:15:00-06:00
X-Hbox-Firma: sha256=<hex de HMAC(secreto, tiempo + "\n" + cuerpo)>
Cuerpo:
{ "evento_id": "OC-2026-0012:oc.en_camino:1",
"tipo": "oc.en_camino", // oc.autorizada · oc.pagada · oc.en_camino · oc.cancelada · req.cerrada
"cuando": "2026-10-28T10:15:00-06:00", "quien": "tania@helgen.tech", "prueba": false,
"oc": { "folio": "OC-2026-0012", "req_folio": "REQ-2026-0031",
"proyecto_folio": "PRY-2026-0002", "proyecto_clave": "DRO", "sitio": "ZLO",
"proveedor": "Ferretería del Pacífico", "fecha_entrega": "2026-10-30",
"paqueteria": "Estafeta", "guia": "123456789", "solicitante": "jesus@helgen.tech",
"renglones": [ { "n": 1, "descripcion": "Soporte para RTG", "unidad": "pza", "cantidad": 4, "item": "SOP-RTG" } ] } }
Respuesta: { "ok": true, "estado_portal": "en-camino", "tarea_id": "t-oc-2026-0012", "por_ligar": [] }
(el mismo evento_id otra vez → { "ok": true, "repetido": true })
GET /api/integracion/inventario/items → [{codigo, descripcion, unidad, categoria}] (para el selector)
GET /api/integracion/cotizador/recepciones?desde=… → [{oc_folio, req_folio, sitio, completa, recibio, cuando,
renglones: [{n, pedido, recibido}]}]
```
`"prueba": true` (rondas del proyecto de PRUEBAS del cotizador) se guarda aparte y **no** toca el inventario ni el tablero.
## Qué se construye y quién
| Lado | Qué | Quién |
|---|---|---|
| Centro de mando | `backend/compras.py` (SQLite, las 3 rutas firmadas, envíos en camino, pendiente por OC, cierre al recibir); página **Compras** (OC por sitio y estado, renglones por ligar, liga desde el envío en camino del inventario); ruta en nginx sin sesión para `/api/integracion/` (la firma la revisa el backend); pruebas | Claude (fase 1), Leo despliega |
| Cotizador | Sitio de entrega y su regla; ítem por renglón; estado «En camino»; avisos firmados con reintento; consulta de recepciones cada 15 min y cierre automático; pruebas en Node y en PRUEBAS | El proyecto del cotizador (Claude con Efraín) — ver [instrucciones](integracion-cotizador-instrucciones.md) |
| Los dos | El secreto compartido (Leo lo genera y lo pone en los dos lados) y una prueba de punta a punta con una OC de PRUEBA | Leo |
## Riesgos y cómo se cubren
- **El centro de mando no contesta** cuando el cotizador avisa → el cotizador guarda el aviso en una pestaña de
pendientes y lo reintenta; el `evento_id` evita duplicados.
- **Una OC llega antes que su proyecto o su sitio** → se rechaza con un error claro (sitio inválido, proyecto no ligado)
y el cotizador lo muestra a compras.
- **Doble captura durante la transición** → las OC de antes no se migran; solo las nuevas de proyectos ligados.
- **Fuga del secreto** → se cambia en los dos lados; las firmas viejas dejan de valer al momento.
- **Montos** → el centro de mando no los recibe; si un aviso los trae, se descartan antes de guardar.

193
docs/js/incidencias.js Normal file
View File

@ -0,0 +1,193 @@
/* Problemas del sitio (27-oct-2026) — al pie de TODAS las páginas (las de MkDocs por extra_javascript y las
sueltas: actas, Mi cuenta, administración, expediente, con <script src="/js/incidencias.js" defer>).
- Sección «Problemas del sitio»: reportar lo que falla en esta página (POST /api/incidencias) y ver cuántas hay
abiertas (GET /api/incidencias/resumen) con liga al tablero /tableros/incidencias/.
- Errores de JavaScript y llamadas a la API que fallan (5xx) se mandan solos a /api/incidencias/error (máximo 5
por página); el vigía del servidor los junta y abre la incidencia si se repiten.
- Si la sesión terminó (una API responde 401), franja arriba con la liga a la página de entrada.
Sin servidor, la página queda igual. Estilos aquí mismo para que sirva también en las páginas sueltas. */
(function () {
if (window.__hbIncidencias) return;
window.__hbIncidencias = true;
var errores = [], llamadas = [], enviados = 0, MAX_ENVIOS = 5, avisoSesion = false;
var fetchOriginal = window.fetch ? window.fetch.bind(window) : null;
function ruta() { return location.pathname + location.search; }
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
function ahoraLocal() { try { return new Date().toLocaleString('es-MX'); } catch (e) { return String(new Date()); } }
function mandarError(reg) {
if (!fetchOriginal || enviados >= MAX_ENVIOS) return;
enviados++;
reg.pagina = ruta();
fetchOriginal('/api/incidencias/error', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reg)
}).catch(function () {});
}
// ---- errores de JavaScript
window.addEventListener('error', function (e) {
if (!e || !e.message) return;
var r = { tipo: 'js', mensaje: String(e.message).slice(0, 300), fuente: (e.filename || '').replace(location.origin, ''),
linea: e.lineno || 0, pila: e.error && e.error.stack ? String(e.error.stack).slice(0, 1000) : '' };
errores.push({ mensaje: r.mensaje, fuente: r.fuente + ':' + r.linea, cuando: ahoraLocal() });
if (errores.length > 10) errores.shift();
mandarError(r);
});
window.addEventListener('unhandledrejection', function (e) {
var m = e && e.reason ? (e.reason.message || String(e.reason)) : 'promesa rechazada';
if (/Failed to fetch|NetworkError|Load failed|AbortError/i.test(m)) return; // sin red: no es un error del sitio
errores.push({ mensaje: String(m).slice(0, 300), fuente: 'promesa', cuando: ahoraLocal() });
if (errores.length > 10) errores.shift();
mandarError({ tipo: 'promesa', mensaje: String(m).slice(0, 300), pila: e.reason && e.reason.stack ? String(e.reason.stack).slice(0, 1000) : '' });
});
// ---- llamadas a la API que fallan (y sesión terminada)
if (fetchOriginal) {
window.fetch = function (entrada, opciones) {
var url = typeof entrada === 'string' ? entrada : (entrada && entrada.url) || '';
var metodo = (opciones && opciones.method) || (entrada && entrada.method) || 'GET';
return fetchOriginal(entrada, opciones).then(function (r) {
try {
var u = new URL(url, location.href);
if (u.origin === location.origin && u.pathname.indexOf('/api/') === 0 && r.status >= 400
&& u.pathname !== '/api/incidencias/error') {
llamadas.push({ url: u.pathname, metodo: metodo, estado: String(r.status), cuando: ahoraLocal() });
if (llamadas.length > 10) llamadas.shift();
if (r.status === 401) sesionTerminada();
else if (r.status >= 500) mandarError({ tipo: 'api', mensaje: metodo + ' ' + u.pathname + ' → ' + r.status, url_api: u.pathname, estado_http: r.status });
}
} catch (e) {}
return r;
});
};
}
function sesionTerminada() {
if (avisoSesion) return;
avisoSesion = true;
var d = document.createElement('div');
d.className = 'hg-sesion-fin'; d.setAttribute('role', 'alert');
d.innerHTML = '<b>Tu sesión terminó</b> (cambió tu contraseña o pasaron 30 días). '
+ '<a href="/entrar.html#' + encodeURI(ruta()) + '">Entrar de nuevo</a> — lo que no hayas guardado sigue en esta pestaña.';
document.body.insertBefore(d, document.body.firstChild);
}
// ---- estilos (tokens de stylesheets/helgen.css, que cargan todas las páginas)
var css = ''
+ '.hg-inc{margin:40px 0 8px;padding:16px 18px;border:1px solid var(--hg-linea);border-radius:12px;background:var(--hg-plano-2);'
+ 'font-family:var(--hg-fuente);font-size:14.5px;line-height:1.45;color:var(--hg-texto)}'
+ '.hg-inc *{box-sizing:border-box}'
+ '.hg-inc__fila{display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center;justify-content:space-between}'
+ '.hg-inc h2.hg-inc__t{margin:0!important;font-family:var(--hg-fuente-titulo);font-weight:600;font-size:17px!important;line-height:1.2;color:var(--hg-texto-fuerte);border:0;padding:0}'
+ '.hg-inc p.hg-inc__s{margin:3px 0 0!important;color:var(--hg-texto-2);font-size:13.5px}'
+ '.hg-inc__acc{display:flex;flex-wrap:wrap;gap:8px;align-items:center}'
+ '.hg-inc button,.hg-inc a.hg-inc__b{display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:14px;font-weight:600;padding:8px 13px;'
+ 'border-radius:9px;border:1px solid var(--hg-linea-2);background:transparent;color:var(--hg-liga);cursor:pointer;text-decoration:none;line-height:1.2}'
+ '.hg-inc button:hover,.hg-inc a.hg-inc__b:hover{background:var(--hg-elevado)}'
+ '.hg-inc button.hg-inc__p{background:var(--hg-acento);border-color:var(--hg-acento);color:var(--hg-sobre-acento)}'
+ '.hg-inc button.hg-inc__p:hover{filter:brightness(1.06);background:var(--hg-acento)}'
+ '.hg-inc__n{display:inline-block;min-width:20px;padding:0 6px;border-radius:10px;background:var(--hg-aviso-bg);color:var(--hg-aviso);font-size:12px;text-align:center}'
+ '.hg-inc__ult{margin:10px 0 0;padding:0;list-style:none;font-size:13.5px;color:var(--hg-texto-2)}'
+ '.hg-inc__ult li{margin:3px 0!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
+ '.hg-inc__ult a{color:var(--hg-liga)}'
+ '.hg-inc form{margin:14px 0 0;border-top:1px solid var(--hg-linea);padding-top:14px}'
+ '.hg-inc label{display:block;font-weight:600;font-size:13px;margin:10px 0 5px;color:var(--hg-texto-2)}'
+ '.hg-inc__tipos{display:flex;flex-wrap:wrap;gap:6px}'
+ '.hg-inc__tipos label{display:inline-flex;align-items:center;gap:6px;margin:0;font-weight:500;font-size:13.5px;color:var(--hg-texto);'
+ 'padding:6px 11px;border:1px solid var(--hg-linea-2);border-radius:20px;cursor:pointer}'
+ '.hg-inc__tipos input{accent-color:var(--hg-acento);margin:0}'
+ '.hg-inc__tipos label:has(input:checked){border-color:var(--hg-acento);background:var(--hg-acento-suave)}'
+ '.hg-inc textarea{width:100%;min-height:90px;padding:10px 12px;border:1px solid var(--hg-linea-2);border-radius:9px;font:inherit;font-size:15px;'
+ 'background:var(--hg-plano);color:var(--hg-texto);resize:vertical}'
+ '.hg-inc textarea:focus{outline:2px solid var(--hg-foco);outline-offset:1px;border-color:transparent}'
+ '.hg-inc__tec{display:flex!important;align-items:center;gap:8px;font-weight:500!important;color:var(--hg-texto-2)!important}'
+ '.hg-inc__tec input{accent-color:var(--hg-acento);margin:0}'
+ '.hg-inc__fin{display:flex;gap:8px;margin-top:12px}'
+ '.hg-inc__msg{display:none;margin:10px 0 0;padding:9px 12px;border-radius:9px;font-size:14px}'
+ '.hg-inc__msg.ok{display:block;background:var(--hg-hecho-bg);border:1px solid color-mix(in srgb,var(--hg-hecho) 40%,transparent)}'
+ '.hg-inc__msg.err{display:block;background:var(--hg-bloqueado-bg);border:1px solid color-mix(in srgb,var(--hg-bloqueado) 45%,transparent)}'
+ '.hg-sesion-fin{background:var(--hg-bloqueado-bg);color:var(--hg-texto-fuerte);font-size:14px;padding:9px 16px;text-align:center;'
+ 'border-bottom:1px solid color-mix(in srgb,var(--hg-bloqueado) 45%,transparent);position:sticky;top:0;z-index:10}'
+ '.hg-sesion-fin a{color:var(--hg-bloqueado);font-weight:700}'
+ '@media print{.hg-inc,.hg-sesion-fin{display:none}}';
var TIPOS = [['error', 'Error o pantalla rota'], ['datos', 'Datos incorrectos'], ['lento', 'Lento'],
['acceso', 'Permisos o acceso'], ['sugerencia', 'Sugerencia'], ['otro', 'Otro']];
function montar() {
if (document.querySelector('.hg-inc')) return;
var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
var s = document.createElement('section');
s.className = 'hg-inc'; s.id = 'problemas-del-sitio'; s.setAttribute('aria-labelledby', 'hg-inc-t');
s.innerHTML = ''
+ '<div class="hg-inc__fila"><div><h2 class="hg-inc__t" id="hg-inc-t">Problemas del sitio</h2>'
+ '<p class="hg-inc__s">¿Algo no funciona en esta página? Repórtalo: se revisa todos los días y te contestamos ahí mismo.</p></div>'
+ '<div class="hg-inc__acc"><button type="button" class="hg-inc__p" id="hg-inc-abrir">Reportar un problema</button>'
+ '<a class="hg-inc__b" href="/tableros/incidencias/">Incidencias <span class="hg-inc__n" id="hg-inc-n">…</span></a></div></div>'
+ '<ul class="hg-inc__ult" id="hg-inc-ult"></ul>'
+ '<form id="hg-inc-f" hidden novalidate>'
+ '<label>Qué pasa</label><div class="hg-inc__tipos">'
+ TIPOS.map(function (t, i) { return '<label><input type="radio" name="hg-inc-tipo" value="' + t[0] + '"' + (i === 0 ? ' checked' : '') + '> ' + t[1] + '</label>'; }).join('')
+ '</div><label for="hg-inc-d">Cuéntanos con tus palabras: qué hiciste, qué esperabas y qué pasó</label>'
+ '<textarea id="hg-inc-d" maxlength="4000"></textarea>'
+ '<label class="hg-inc__tec"><input type="checkbox" id="hg-inc-c" checked> Incluir los datos técnicos de esta página (dirección, navegador y errores recientes)</label>'
+ '<div class="hg-inc__fin"><button type="submit" class="hg-inc__p" id="hg-inc-e">Enviar reporte</button>'
+ '<button type="button" id="hg-inc-x">Cancelar</button></div>'
+ '<div class="hg-inc__msg" id="hg-inc-m" role="status" aria-live="polite"></div></form>';
var donde = document.querySelector('.md-content__inner') || document.querySelector('.wrap') || document.querySelector('main') || document.body;
donde.appendChild(s);
var f = s.querySelector('#hg-inc-f'), msg = s.querySelector('#hg-inc-m');
s.querySelector('#hg-inc-abrir').addEventListener('click', function () {
f.hidden = !f.hidden; if (!f.hidden) s.querySelector('#hg-inc-d').focus();
});
s.querySelector('#hg-inc-x').addEventListener('click', function () { f.hidden = true; });
f.addEventListener('submit', function (ev) {
ev.preventDefault();
var d = s.querySelector('#hg-inc-d').value.trim();
if (d.length < 5) { msg.className = 'hg-inc__msg err'; msg.textContent = 'Cuéntanos qué pasó (aunque sea una línea).'; return; }
var tipo = (s.querySelector('input[name="hg-inc-tipo"]:checked') || {}).value || 'otro';
var cuerpo = { descripcion: d, tipo: tipo, pagina: ruta() };
if (s.querySelector('#hg-inc-c').checked) {
cuerpo.contexto = { pantalla: innerWidth + '×' + innerHeight, idioma: navigator.language, referer: document.referrer,
hora_local: ahoraLocal(), errores: errores.slice(-10), llamadas: llamadas.slice(-10) };
}
var b = s.querySelector('#hg-inc-e'); b.disabled = true; b.textContent = 'Enviando…';
(fetchOriginal || fetch)('/api/incidencias', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cuerpo)
}).then(function (r) { return r.json().catch(function () { return { ok: false, error: 'HTTP ' + r.status }; }); })
.then(function (j) {
if (j && j.ok) {
msg.className = 'hg-inc__msg ok';
msg.innerHTML = '<b>Recibido: <a href="/tableros/incidencias/?id=' + esc(j.id) + '">' + esc(j.id) + '</a>.</b> '
+ 'Ahí ves cómo va y te contestamos. Gracias.';
s.querySelector('#hg-inc-d').value = '';
resumen();
} else { msg.className = 'hg-inc__msg err'; msg.textContent = (j && j.error) || 'No se pudo enviar.'; }
})
.catch(function () { msg.className = 'hg-inc__msg err'; msg.textContent = 'No pude contactar al servidor. Intenta otra vez en un momento.'; })
.then(function () { b.disabled = false; b.textContent = 'Enviar reporte'; });
});
resumen();
}
function resumen() {
(fetchOriginal || fetch)('/api/incidencias/resumen', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
var n = document.getElementById('hg-inc-n'), ul = document.getElementById('hg-inc-ult');
if (!n) return;
if (!j || !j.ok) { n.textContent = '—'; return; }
n.textContent = j.abiertas;
n.title = j.abiertas + ' abiertas' + (j.mias ? ' · ' + j.mias + ' tuyas' : '');
ul.innerHTML = (j.ultimas || []).map(function (x) {
return '<li><a href="/tableros/incidencias/?id=' + esc(x.id) + '">' + esc(x.id) + '</a> · ' + esc(x.titulo) + '</li>';
}).join('');
})
.catch(function () {});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', montar);
else montar();
})();

22
docs/js/standup.js Normal file
View File

@ -0,0 +1,22 @@
/* Standup diario (27-oct-2026): si el bot ya te dejó tu parte del día y no has contestado, franja arriba con la liga
a /tableros/standup/. Lo dice GET /api/standup/estado. No aparece en la propia página del standup. */
(function () {
if (/\/tableros\/standup\/?$/.test(location.pathname)) return;
function arrancar() {
fetch('/api/standup/estado', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j || !j.pendiente || document.querySelector('.hg-standup')) return;
var d = document.createElement('div');
d.className = 'hg-standup'; d.setAttribute('role', 'status');
var hoy = new Date(); var f = hoy.getFullYear() + '-' + String(hoy.getMonth() + 1).padStart(2, '0') + '-' + String(hoy.getDate()).padStart(2, '0');
d.innerHTML = '<b>Tu standup ' + (j.fecha === f ? 'de hoy' : 'del ' + j.fecha.slice(8) + '/' + j.fecha.slice(5, 7)) + ' te espera:</b> '
+ 'tus instrucciones del día y tres preguntas sobre tus pendientes. <a href="/tableros/standup/">Contestar (2 minutos)</a>';
var main = document.querySelector('.md-main');
if (main) main.parentNode.insertBefore(d, main); else document.body.insertBefore(d, document.body.firstChild);
})
.catch(function () {});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', arrancar);
else arrancar();
})();

23
docs/publico/caido.html Normal file
View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Un momento — Drogheda HBOX</title>
<!-- 27-oct-2026: nginx muestra esta página cuando el backend (actas-hbox) no contesta, por ejemplo mientras reinicia. -->
<script src="/js/tema.js"></script>
<link rel="stylesheet" href="/stylesheets/helgen.css">
<link rel="stylesheet" href="/publico/entrar.css">
</head>
<body>
<main class="en">
<header class="en-cab top"><div><h1>Un momento…</h1><p>Drogheda · HBOX</p></div></header>
<section class="en-tarjeta">
<p style="margin:0 0 10px">El servidor del sitio está reiniciando o no contesta. Suele tardar menos de un minuto.</p>
<button class="en-boton" onclick="location.reload()">Intentar de nuevo</button>
<p style="margin:14px 0 0;font-size:14px;color:var(--hg-texto-2)">Si en 5 minutos sigue igual, avísale a Leo o a Gibran por mensaje.</p>
</section>
</main>
</body>
</html>

87
docs/publico/entrar.css Normal file
View File

@ -0,0 +1,87 @@
/* Página de entrada (27-oct-2026). Pública: no depende de nada que pida sesión.
Colores y letras: tokens de stylesheets/helgen.css (oscuro por omisión; claro con js/tema.js). */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0; min-height: 100vh; background: var(--hg-fondo); color: var(--hg-texto);
font: 16px/1.5 var(--hg-fuente); -webkit-font-smoothing: antialiased;
}
a { color: var(--hg-liga); }
code { font-family: var(--hg-fuente-mono); font-size: .92em; background: var(--hg-elevado); border-radius: 4px; padding: 0 .3em; }
:focus-visible { outline: 2px solid var(--hg-foco); outline-offset: 2px; }
.en { max-width: 460px; margin: 0 auto; padding: 40px 16px 48px; }
.en-cab { position: relative; display: flex; align-items: center; gap: 14px; margin: 0 0 22px; padding-right: 44px; }
.en-masc { width: 46px; height: 49px; flex: none; }
.en-cab h1 { margin: 0; font-family: var(--hg-fuente-titulo); font-weight: 700; font-size: 27px; line-height: 1.05;
letter-spacing: -0.01em; color: var(--hg-texto-fuerte); }
.en-cab p { margin: 4px 0 0; font-size: 14px; color: var(--hg-texto-2); line-height: 1.35; }
/* botón de modo (lo agrega js/tema.js dentro de header.top) */
.hg-modo { position: absolute; top: 4px; right: 0; width: 34px; height: 34px; display: grid; place-items: center;
border-radius: 8px; border: 1px solid var(--hg-linea-2); background: transparent; color: var(--hg-texto-2); cursor: pointer; padding: 0; }
.hg-modo:hover { color: var(--hg-texto-fuerte); background: var(--hg-elevado); }
.hg-modo svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.en-tarjeta, .en-avisos, .en-ayuda {
background: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: 14px; padding: 20px 20px 18px;
box-shadow: var(--hg-sombra-suave);
}
.en-tarjeta h2, .en-avisos h2 { margin: 0 0 12px; font-family: var(--hg-fuente-titulo); font-weight: 600; font-size: 21px;
line-height: 1.2; color: var(--hg-texto-fuerte); }
label { display: block; font-weight: 600; font-size: 14px; margin: 14px 0 6px; color: var(--hg-texto-2); }
.en-tarjeta label:first-of-type { margin-top: 0; }
input[type=text], input[type=password], textarea {
width: 100%; padding: 12px 13px; border: 1px solid var(--hg-linea-2); border-radius: 10px; font: inherit; font-size: 16px;
background: var(--hg-plano-2); color: var(--hg-texto);
}
input::placeholder, textarea::placeholder { color: var(--hg-texto-3); }
input:focus, textarea:focus { outline: 2px solid var(--hg-foco); outline-offset: 1px; border-color: transparent; }
textarea { resize: vertical; min-height: 92px; }
.en-pw { position: relative; }
.en-pw input { padding-right: 64px; }
.en-ver { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); border: 0; background: transparent;
color: var(--hg-liga); font: inherit; font-size: 14px; font-weight: 600; padding: 8px 10px; cursor: pointer; border-radius: 8px; }
.en-ver:hover { background: var(--hg-elevado); }
.en-mayus { margin: 8px 0 0; font-size: 13.5px; color: var(--hg-aviso); }
.en-check { display: flex; align-items: center; gap: 9px; font-weight: 500; font-size: 14px; color: var(--hg-texto-2); margin: 14px 0 0; cursor: pointer; }
.en-check input { width: 18px; height: 18px; accent-color: var(--hg-acento); margin: 0; }
.en-boton {
display: block; width: 100%; margin: 18px 0 0; padding: 13px 16px; border-radius: 10px; border: 1px solid var(--hg-acento);
background: var(--hg-acento); color: var(--hg-sobre-acento); font: inherit; font-weight: 700; font-size: 16px; cursor: pointer;
}
.en-boton:hover { filter: brightness(1.06); }
.en-boton:disabled { opacity: .6; cursor: progress; }
.en-boton.sec { background: transparent; color: var(--hg-liga); border-color: var(--hg-linea-2); }
.en-boton.sec:hover { background: var(--hg-elevado); }
.en-msg { display: none; margin: 14px 0 0; padding: 11px 13px; border-radius: 10px; font-size: 14.5px; line-height: 1.45; }
.en-msg.err { display: block; background: var(--hg-bloqueado-bg); color: var(--hg-texto-fuerte);
border: 1px solid color-mix(in srgb, var(--hg-bloqueado) 50%, transparent); }
.en-msg.err b { color: var(--hg-bloqueado); }
.en-msg.ok { display: block; background: var(--hg-hecho-bg); color: var(--hg-texto-fuerte);
border: 1px solid color-mix(in srgb, var(--hg-hecho) 45%, transparent); }
.en-msg.ok b { color: var(--hg-hecho); }
.en-avisos { margin-top: 16px; }
.en-avisos ul { margin: 0; padding: 0; list-style: none; }
.en-avisos li { display: flex; gap: 10px; align-items: baseline; padding: 8px 0; border-top: 1px solid var(--hg-linea-suave); font-size: 14.5px; }
.en-avisos li:first-child { border-top: 0; padding-top: 0; }
.en-pill { flex: none; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 6px; background: var(--hg-aviso-bg); color: var(--hg-aviso); }
.en-pill.ok { background: var(--hg-hecho-bg); color: var(--hg-hecho); }
.en-avisos small { color: var(--hg-texto-3); white-space: nowrap; }
.en-ayuda { margin-top: 16px; padding: 0; }
.en-ayuda summary { cursor: pointer; padding: 16px 20px; font-weight: 700; color: var(--hg-liga); list-style: none; }
.en-ayuda summary::-webkit-details-marker { display: none; }
.en-ayuda summary::before { content: "+"; display: inline-block; width: 1.1em; font-weight: 700; }
.en-ayuda[open] summary::before { content: "–"; }
.en-ayuda__c { padding: 0 20px 18px; }
.en-tips { margin: 0 0 6px; padding-left: 1.15em; font-size: 14.5px; color: var(--hg-texto-suave); }
.en-tips li { margin: 0 0 8px; }
.en-dos { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
@media (max-width: 440px) { .en-dos { grid-template-columns: 1fr; } }
.en-tipo { border: 0; padding: 0; margin: 14px 0 0; }
.en-tipo legend { font-weight: 600; font-size: 14px; color: var(--hg-texto-2); padding: 0; margin-bottom: 6px; }
.en-tipo label { display: inline-flex; align-items: center; gap: 7px; margin: 0 16px 0 0; font-weight: 500; color: var(--hg-texto); }
.en-tipo input { accent-color: var(--hg-acento); width: 17px; height: 17px; margin: 0; }
.en-trampa { position: absolute !important; left: -9999px !important; width: 1px; height: 1px; opacity: 0; }
.en-pie { margin: 22px 0 0; text-align: center; font-size: 12.5px; color: var(--hg-texto-3); }

132
docs/publico/entrar.js Normal file
View File

@ -0,0 +1,132 @@
/* Página de entrada (27-oct-2026).
- Si ya hay sesión, salta directo a donde ibas (nginx manda aquí con #/ruta/original).
- Entrar: POST /api/entrar → el servidor pone la cookie de sesión; el mensaje dice qué pasó.
- Avisos públicos del sitio (incidencias marcadas «publica») y reporte sin sesión (POST /api/incidencias/publica). */
(function () {
var $ = function (id) { return document.getElementById(id); };
function destino() {
// Solo rutas de ESTE sitio: se arma la URL completa y se exige el mismo origen (un «/\t/otro.com» o «/\\otro.com»
// se vuelve «//otro.com» en el navegador: comparar el origen lo cierra). Nunca regresa a la entrada.
var h = '';
try { h = decodeURIComponent((location.hash || '').slice(1)); } catch (e) { h = ''; }
if (!h || h.charAt(0) !== '/') return '/';
try {
var u = new URL(h, location.origin);
if (u.origin !== location.origin || /^\/entrar\.html/.test(u.pathname)) return '/';
return u.href; // absoluta y del mismo origen (un «/.//otro.com» relativo sería otro sitio)
} catch (e) { return '/'; }
}
function ir() { location.replace(destino()); }
function mensaje(el, html, bien) { el.className = 'en-msg ' + (bien ? 'ok' : 'err'); el.innerHTML = html; }
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
// ¿ya hay sesión?
fetch('/api/sesion', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { if (j && j.sesion) ir(); })
.catch(function () {});
// usuario recordado (solo el nombre, nunca la contraseña)
try { var u0 = localStorage.getItem('hbox.usuario'); if (u0) { $('usuario').value = u0; } } catch (e) {}
($('usuario').value ? $('password') : $('usuario')).focus();
$('ver').addEventListener('click', function () {
var p = $('password'), mostrar = p.type === 'password';
p.type = mostrar ? 'text' : 'password';
this.textContent = mostrar ? 'Ocultar' : 'Ver';
this.setAttribute('aria-pressed', mostrar ? 'true' : 'false');
this.setAttribute('aria-label', mostrar ? 'Ocultar la contraseña' : 'Mostrar la contraseña');
});
function revisarMayus(ev) {
if (ev.getModifierState) $('mayus').hidden = !ev.getModifierState('CapsLock');
}
$('password').addEventListener('keydown', revisarMayus);
$('password').addEventListener('keyup', revisarMayus);
$('fEntrar').addEventListener('submit', function (ev) {
ev.preventDefault();
var b = $('bEntrar'), msg = $('msg');
var usuario = $('usuario').value.trim(), password = $('password').value;
if (!usuario || !password) { mensaje(msg, 'Escribe tu usuario y tu contraseña.', false); return; }
b.disabled = true; b.textContent = 'Entrando…';
fetch('/api/entrar', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ usuario: usuario, password: password, recordar: $('recordar').checked })
}).then(function (r) {
return r.json().catch(function () { return { ok: false, error: 'El servidor no respondió bien (HTTP ' + r.status + ').' }; });
}).then(function (j) {
if (j && j.ok) {
try { localStorage.setItem('hbox.usuario', usuario); } catch (e) {}
mensaje(msg, '<b>Listo, ' + esc(j.user) + '.</b> Abriendo el sitio…', true);
ir();
return;
}
var t = esc((j && j.error) || 'No se pudo entrar.');
if (j && j.codigo === 'contrasena') {
$('password').value = ''; $('password').focus();
} else if (j && j.codigo === 'usuario') {
$('usuario').focus();
}
if (j && (j.codigo === 'bloqueado' || j.codigo === 'contrasena')) {
t += ' <a href="#" id="abrirAyuda">Reportar el problema</a>.';
}
mensaje(msg, t, false);
var a = $('abrirAyuda');
if (a) a.addEventListener('click', function (e) { e.preventDefault(); abrirAyuda(); });
}).catch(function () {
mensaje(msg, 'No pude contactar al servidor. Revisa tu conexión e intenta otra vez; si sigue, repórtalo abajo.', false);
}).then(function () { b.disabled = false; b.textContent = 'Entrar'; });
});
function abrirAyuda() {
var d = $('ayuda'); d.open = true;
if (!$('rNombre').value) $('rNombre').value = $('usuario').value.trim();
d.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(function () { $('rDesc').focus(); }, 300);
}
$('ayuda').addEventListener('toggle', function () {
if (this.open && !$('rNombre').value) $('rNombre').value = $('usuario').value.trim();
});
$('fReporte').addEventListener('submit', function (ev) {
ev.preventDefault();
var b = $('bReporte'), msg = $('msgR');
var tipo = (document.querySelector('input[name="rTipo"]:checked') || {}).value || 'acceso';
var cuerpo = { nombre: $('rNombre').value.trim(), contacto: $('rContacto').value.trim(), descripcion: $('rDesc').value.trim(),
tipo: tipo, sitio_web: $('rTrampa').value, pantalla: screen.width + '×' + screen.height };
if (cuerpo.descripcion.length < 5) { mensaje(msg, 'Cuéntanos qué pasa (aunque sea una línea).', false); return; }
if (!cuerpo.nombre && !cuerpo.contacto) { mensaje(msg, 'Dinos quién eres o cómo contactarte.', false); return; }
b.disabled = true; b.textContent = 'Enviando…';
fetch('/api/incidencias/publica', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cuerpo)
}).then(function (r) { return r.json().catch(function () { return { ok: false, error: 'HTTP ' + r.status }; }); })
.then(function (j) {
if (j && j.ok) {
mensaje(msg, '<b>Recibido: ' + esc(j.id) + '.</b> Se revisa todos los días; si dejaste cómo contactarte, te buscamos. '
+ 'Guarda el folio por si preguntas.', true);
$('rDesc').value = '';
} else {
mensaje(msg, esc((j && j.error) || 'No se pudo enviar.'), false);
}
})
.catch(function () { mensaje(msg, 'No pude contactar al servidor. Avísale a Leo o a Gibran por mensaje.', false); })
.then(function () { b.disabled = false; b.textContent = 'Enviar reporte'; });
});
// avisos públicos
fetch('/api/incidencias/publicas', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j || !j.ok || !j.avisos || !j.avisos.length) return;
var ul = $('avisosLista');
j.avisos.forEach(function (a) {
var li = document.createElement('li');
li.innerHTML = '<span class="en-pill' + (a.resuelta ? ' ok' : '') + '">' + (a.resuelta ? 'Resuelto' : 'En atención') + '</span>'
+ '<span>' + esc(a.titulo) + '</span> <small>' + esc(a.actualizado) + '</small>';
ul.appendChild(li);
});
$('avisos').hidden = false;
})
.catch(function () {});
})();

View File

@ -14,27 +14,21 @@
<p id="estado">Cerrando sesión…</p>
<div id="listo" style="display:none">
<p><b>Sesión cerrada.</b></p>
<p>Para entrar con otro usuario, abre de nuevo el sitio y aparecerá la ventana de
inicio de sesión.</p>
<p style="color:var(--tinta2);font-size:14px">Si por seguridad del navegador la ventana
no aparece, <b>cierra por completo esta pestaña</b> (o el navegador) para terminar la sesión.</p>
<p style="margin-top:18px"><a class="volver" href="/">← Volver a la documentación</a></p>
<p>Para entrar otra vez, o con otro usuario, usa la página de entrada.</p>
<p style="margin-top:18px"><a class="volver" href="/entrar.html">Entrar de nuevo →</a></p>
</div>
</section>
</div>
<script>
// Envía credenciales inválidas a un endpoint que siempre responde 401, para que el
// navegador reemplace las credenciales guardadas de Basic Auth.
// 27-oct: la sesión es una cookie del servidor (página de entrada): POST /api/salir la borra.
// (El servidor ya no acepta la contraseña que el navegador recuerde de la ventana gris de antes: solo la cookie.)
(function () {
function done(){ document.getElementById('estado').style.display='none';
var listo = false;
function done(){ if (listo) return; listo = true;
document.getElementById('estado').style.display='none';
document.getElementById('listo').style.display='block'; }
try {
var x = new XMLHttpRequest();
x.open('GET', '/logout?ts=' + Date.now(), true, 'salir', 'salir' + Date.now());
x.onloadend = done;
x.send();
} catch (e) { done(); }
setTimeout(done, 1500);
fetch('/api/salir', { method: 'POST', credentials: 'same-origin' }).catch(function () {}).then(done);
setTimeout(done, 2000);
})();
</script>
</body>

View File

@ -447,3 +447,9 @@ svg.hg-i { fill: none !important; stroke: currentColor; }
padding: 9px 16px; text-align: center; border-bottom: 1px solid color-mix(in srgb, var(--hg-aviso) 45%, transparent); }
.hg-pass-inicial a { color: var(--hg-aviso); font-weight: 700; text-decoration: underline; }
@media print { .hg-pass-inicial { display: none; } }
/* Standup diario (27-oct): franja «Tu standup de hoy te espera» (js/standup.js) */
.hg-standup { background: var(--hg-info-bg); color: var(--hg-texto-fuerte); font-size: 14px; line-height: 1.45;
padding: 9px 16px; text-align: center; border-bottom: 1px solid color-mix(in srgb, var(--hg-info) 40%, transparent); }
.hg-standup a { color: var(--hg-info); font-weight: 700; text-decoration: underline; }
@media print { .hg-standup { display: none; } }

View File

@ -162,7 +162,8 @@ function formMiembro(c, m, conAcceso) {
if (conAcceso) {
f.acceso = el('input', { type: 'checkbox', style: 'width:auto' }); f.acceso.checked = !m.tiene_login && HTPASSWD_OK; f.acceso.disabled = !HTPASSWD_OK;
const l = el('label', { class: 'chip-sel', style: 'margin-top:12px' }); l.append(f.acceso, document.createTextNode(' Crear acceso al sitio (usuario y contraseña)')); c.append(l);
f.pass = el('input', { type: 'text', autocomplete: 'off', placeholder: 'mínimo ' + PASS_MIN + ' caracteres; se muestra una sola vez' });
// 27-oct: sin mayúscula automática ni autocorrector (el teléfono cambiaba «hbox…» por «Hbox…» y la persona no podía entrar)
f.pass = el('input', { type: 'text', autocomplete: 'off', autocapitalize: 'none', autocorrect: 'off', spellcheck: 'false', placeholder: 'mínimo ' + PASS_MIN + ' caracteres; se muestra una sola vez' });
const cp = campo('Contraseña inicial', f.pass, 'La persona la cambia en Mi cuenta la primera vez que entre.'); c.append(cp);
const gen = el('button', { type: 'button', class: 'sec', text: 'Generar una', style: 'padding:6px 10px;font-size:13px' });
gen.addEventListener('click', () => { const A = 'abcdefghjkmnpqrstuvwxyz23456789'; let p = ''; const r = new Uint32Array(12); crypto.getRandomValues(r); r.forEach(v => p += A[v % A.length]); f.pass.value = p.slice(0, 4) + '-' + p.slice(4, 8) + '-' + p.slice(8, 12); });

View File

@ -0,0 +1,182 @@
/* Tablero de Incidencias del sitio (27-oct-2026).
Servidor (backend/incidencias.py): GET /api/incidencias?estado=&tipo=&origen=&q= · GET /api/incidencia?id= ·
PATCH /api/incidencia?id= (comentario; estado, gravedad, asignado, publica, resumen_publico, tarea_id, duplicado_de) ·
GET /api/incidencias/errores (quien atiende) · GET /api/accesos/intentos (administradores, backend/acceso.py).
?id=INC-0003 abre esa incidencia. Reportar = el formulario del pie (js/incidencias.js). */
(function () {
const $ = id => document.getElementById(id);
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'];
const fecha = iso => { const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}:\d{2}))?/.exec(iso || ''); return m ? (+m[3]) + ' ' + MES[+m[2] - 1] + (m[4] ? ', ' + m[4] : '') : ''; };
const ESTADO = { nueva: 'Nueva', 'en-revision': 'En revisión', 'en-curso': 'En curso', esperando: 'Esperando respuesta', resuelta: 'Resuelta', descartada: 'Descartada' };
const ORIGEN = { sitio: 'reportada en el sitio', entrada: 'desde la entrada', vigia: 'detectada por el vigía', agente: 'abierta por el agente' };
const MOTIVO = { 'usuario-no-existe': 'usuario que no existe', 'contrasena-incorrecta': 'contraseña incorrecta', bloqueado: 'bloqueado por intentos', 'sin-datos': 'sin datos' };
const PISTA = { 'bloq-mayus': 'Bloq Mayús', 'mayuscula-inicial': 'mayúscula inicial', 'espacios-alrededor': 'espacio de más' };
let DATOS = null, abiertas = new Set(), ED = false, YO = '', TIPOS = {};
const params = new URLSearchParams(location.search);
if (params.get('id')) abiertas.add(params.get('id'));
async function api(url, op) {
const r = await fetch(url, Object.assign({ credentials: 'same-origin', cache: 'no-store' }, op || {}));
const j = await r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status }));
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
return j;
}
const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
function aviso(t, bien) { const a = $('aviso'); a.className = 'msg ' + (bien ? 'ok' : 'err'); a.textContent = t; }
async function cargar() {
const q = new URLSearchParams({ estado: $('fEstado').value, tipo: $('fTipo').value, origen: $('fOrigen').value, q: $('fBuscar').value.trim() });
try {
DATOS = await api('/api/incidencias?' + q);
ED = DATOS.puede_editar; YO = DATOS.yo;
if (!$('fTipo').options[1]) DATOS.tipos.forEach(t => { TIPOS[t.id] = t.nombre; $('fTipo').append(new Option(t.nombre, t.id)); });
DATOS.tipos.forEach(t => { TIPOS[t.id] = t.nombre; });
pintar();
} catch (e) { aviso('No pude cargar las incidencias: ' + e.message); $('meta').textContent = ''; }
}
function pintar() {
const c = DATOS.conteo;
$('meta').textContent = c.abiertas + ' abiertas · ' + (c.por_estado.resuelta || 0) + ' resueltas · ' + (YO ? YO + (ED ? ' (atiendes incidencias)' : '') : '');
const vig = DATOS.incidencias.filter(x => x.origen === 'vigia' && ['nueva', 'en-revision', 'en-curso', 'esperando'].includes(x.estado)).length;
$('kpis').innerHTML = [
['Abiertas', c.abiertas, c.abiertas ? 'mal' : 'bien'], ['Sin revisar', c.por_estado.nueva || 0, (c.por_estado.nueva || 0) ? 'mal' : ''],
['No pueden entrar', c.por_tipo.acceso || 0, (c.por_tipo.acceso || 0) ? 'mal' : ''], ['Errores (abiertas)', c.por_tipo.error || 0, ''],
['Del vigía (en esta vista)', vig, '']
].map(k => '<div class="kpi ' + k[2] + '"><div class="n">' + k[1] + '</div><div class="l">' + k[0] + '</div></div>').join('');
$('cnt').textContent = DATOS.incidencias.length + ' en esta vista';
const L = $('lista');
if (!DATOS.incidencias.length) { L.innerHTML = '<p class="vacio">Nada por aquí. Si algo falla, repórtalo con el botón de arriba o desde el pie de cualquier página.</p>'; return; }
L.innerHTML = '';
DATOS.incidencias.forEach(x => L.append(tarjeta(x)));
}
function tarjeta(x) {
const cerrada = x.estado === 'resuelta' || x.estado === 'descartada';
const d = document.createElement('article');
d.className = 'inc g-' + (x.gravedad || 'media') + (cerrada ? ' cerrada' : '');
d.id = x.id;
const quien = x.reportado_por || (x.nombre ? x.nombre + ' (sin sesión)' : (x.origen === 'vigia' ? 'vigía' : '—'));
d.innerHTML = '<div class="inc__sup"><span class="inc__id">' + esc(x.id) + '</span>'
+ '<span class="pill e-' + esc(x.estado) + '">' + esc(ESTADO[x.estado] || x.estado) + '</span>'
+ '<span class="pill ' + esc(x.gravedad) + '">' + esc(x.gravedad) + '</span>'
+ '<span>' + esc(TIPOS[x.tipo] || x.tipo) + '</span>'
+ (x.ocurrencias > 1 ? '<span class="pill aviso" title="Veces que se repitió">×' + x.ocurrencias + '</span>' : '')
+ (x.publica ? '<span class="pill info">en la entrada</span>' : '')
+ (x.comentarios ? '<span>· ' + x.comentarios + ' comentario' + (x.comentarios > 1 ? 's' : '') + '</span>' : '') + '</div>'
+ '<div class="inc__tit" role="button" tabindex="0">' + esc(x.titulo) + '</div>'
+ '<div class="inc__meta">' + esc(ORIGEN[x.origen] || x.origen) + ' · ' + esc(quien) + ' · ' + esc(fecha(x.creado_en))
+ (x.pagina ? ' · <a href="' + esc(x.pagina) + '">' + esc(x.pagina.length > 50 ? x.pagina.slice(0, 49) + '…' : x.pagina) + '</a>' : '')
+ (x.asignado ? ' · atiende <b>' + esc(x.asignado) + '</b>' : '')
+ (x.tarea_id ? ' · pendiente <a href="/tableros/pendientes/?id=' + encodeURIComponent(x.tarea_id) + '">' + esc(x.tarea_id) + '</a>' : '')
+ (x.duplicado_de ? ' · duplicada de <a href="?id=' + esc(x.duplicado_de) + '">' + esc(x.duplicado_de) + '</a>' : '')
+ '</div><div class="inc__det" hidden></div>';
const tit = d.querySelector('.inc__tit');
const abrir = () => alternar(d, x.id);
tit.addEventListener('click', abrir);
tit.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); abrir(); } });
if (abiertas.has(x.id)) setTimeout(() => { alternar(d, x.id, true); d.scrollIntoView({ block: 'start' }); }, 0);
return d;
}
async function alternar(card, id, forzar) {
const det = card.querySelector('.inc__det');
if (!det.hidden && !forzar) { det.hidden = true; abiertas.delete(id); return; }
abiertas.add(id); det.hidden = false; det.innerHTML = '<p class="vacio">Cargando…</p>';
try {
const j = await api('/api/incidencia?id=' + encodeURIComponent(id));
detalle(det, j.incidencia, j.puede_editar);
} catch (e) { det.innerHTML = '<div class="err">' + esc(e.message) + '</div>'; }
}
function contexto(r) {
const c = r.contexto || {}, out = [];
if (r.navegador) out.push('Navegador: ' + r.navegador);
['pantalla', 'idioma', 'hora_local', 'referer', 'ip'].forEach(k => { if (c[k]) out.push(k + ': ' + c[k]); });
(c.errores || []).forEach(e => out.push('error JS: ' + (e.mensaje || '') + ' @ ' + (e.fuente || '') + ' (' + (e.cuando || '') + ')'));
(c.llamadas || []).forEach(e => out.push('API: ' + (e.metodo || '') + ' ' + (e.url || '') + ' → ' + (e.estado || '') + ' (' + (e.cuando || '') + ')'));
(r.evidencia || []).forEach(e => out.push('evidencia: ' + e));
if (r.contacto) out.push('Contacto: ' + r.contacto);
return out.join('\n');
}
function detalle(det, r, ed) {
const ctx = contexto(r);
const yoReporte = (r.reportado_por || '').toLowerCase() === (YO || '').toLowerCase();
let h = (r.descripcion ? '<p class="inc__desc">' + esc(r.descripcion) + '</p>' : '')
+ (ctx ? '<details><summary>Datos técnicos</summary><div class="inc__ctx">' + esc(ctx) + '</div></details>' : '')
+ '<ul class="inc__com">' + (r.comentarios || []).map(c => '<li class="' + (c.quien === 'claude' || c.quien === 'vigia' ? 'bot' : '') + '"><div class="q">'
+ esc(c.quien) + ' · ' + esc(fecha(c.cuando)) + '</div><div class="t">' + esc(c.texto) + '</div></li>').join('') + '</ul>';
if (ed) {
h += '<div class="inc__ed">'
+ '<div><label>Estado</label><select data-c="estado">' + Object.keys(ESTADO).map(e => '<option value="' + e + '"' + (e === r.estado ? ' selected' : '') + '>' + ESTADO[e] + '</option>').join('') + '</select></div>'
+ '<div><label>Gravedad</label><select data-c="gravedad">' + ['alta', 'media', 'baja'].map(e => '<option' + (e === r.gravedad ? ' selected' : '') + '>' + e + '</option>').join('') + '</select></div>'
+ '<div><label>Tipo</label><select data-c="tipo">' + Object.keys(TIPOS).map(e => '<option value="' + e + '"' + (e === r.tipo ? ' selected' : '') + '>' + esc(TIPOS[e]) + '</option>').join('') + '</select></div>'
+ '<div><label>Atiende</label><input type="text" data-c="asignado" value="' + esc(r.asignado) + '" placeholder="claude, gibranlp…"></div>'
+ '<div><label>Pendiente del tablero</label><input type="text" data-c="tarea_id" value="' + esc(r.tarea_id) + '" placeholder="t-…"></div>'
+ '<div><label>Duplicada de</label><input type="text" data-c="duplicado_de" value="' + esc(r.duplicado_de) + '" placeholder="INC-0001"></div>'
+ '</div>'
+ '<div class="inc__fila"><label class="inc__pub"><input type="checkbox" data-c="publica"' + (r.publica ? ' checked' : '') + '> Mostrar en la página de entrada</label>'
+ '<input type="text" data-c="resumen_publico" value="' + esc(r.resumen_publico) + '" placeholder="Texto corto para la entrada (sin datos internos)" style="flex:1;min-width:220px"></div>';
}
h += '<div class="inc__fila"><textarea placeholder="Comentar (lo ve quien reportó)"></textarea>'
+ '<button class="b-guardar">' + (ed ? 'Guardar' : 'Comentar') + '</button>'
+ (!ed && yoReporte && !['resuelta', 'descartada'].includes(r.estado) ? '<button class="sec b-resuelta">Ya quedó</button>' : '')
+ (!ed && yoReporte && ['resuelta', 'descartada'].includes(r.estado) ? '<button class="sec b-reabrir">Sigue pasando</button>' : '')
+ '</div>';
det.innerHTML = h;
const enviar = async (extra) => {
const cuerpo = Object.assign({}, extra || {});
const t = det.querySelector('textarea').value.trim();
if (t) cuerpo.comentario = t;
if (ed) det.querySelectorAll('[data-c]').forEach(i => {
const k = i.dataset.c, v = i.type === 'checkbox' ? i.checked : i.value;
if (k === 'publica' ? v !== !!r.publica : String(v) !== String(r[k] == null ? '' : r[k])) cuerpo[k] = v;
});
if (!Object.keys(cuerpo).length) return;
try {
await api('/api/incidencia?id=' + encodeURIComponent(r.id), json('PATCH', cuerpo));
aviso(r.id + ' actualizada.', true);
await cargar();
} catch (e) { aviso(e.message); }
};
det.querySelector('.b-guardar').addEventListener('click', () => enviar());
const br = det.querySelector('.b-resuelta'); if (br) br.addEventListener('click', () => enviar({ estado: 'resuelta' }));
const bo = det.querySelector('.b-reabrir'); if (bo) bo.addEventListener('click', () => enviar({ estado: 'nueva' }));
}
async function errores() {
if (!ED) return;
try {
const j = await api('/api/incidencias/errores?dias=2');
$('bErrores').hidden = false;
const fila = (g, lado) => {
const e = g.ejemplo || {};
const que = lado === 'servidor' ? (e.tipo + ' ' + (e.metodo || '') + ' ' + (e.ruta || '') + ' — ' + (e.error || '')) : ((e.tipo || '') + ': ' + (e.mensaje || '') + (e.fuente ? ' @ ' + e.fuente + ':' + e.linea : ''));
return '<tr><td>' + (lado === 'servidor' ? 'servidor' : 'navegador') + '</td><td class="mono">' + esc(que) + '</td><td>' + g.veces + '</td><td>' + esc(g.usuarios.join(', ')) + '</td><td>' + esc(fecha(g.ultimo)) + '</td><td class="mono">' + esc(e.pagina || '') + '</td></tr>';
};
const filas = j.servidor.map(g => fila(g, 'servidor')).concat(j.cliente.map(g => fila(g, 'cliente')));
$('errores').innerHTML = filas.length ? '<div class="tblwrap"><table class="t"><thead><tr><th>Dónde</th><th>Qué</th><th>Veces</th><th>Quién</th><th>Última</th><th>Página</th></tr></thead><tbody>' + filas.join('') + '</tbody></table></div>' : '<p class="vacio">Sin errores registrados.</p>';
} catch (e) { /* sin permiso o sin servidor */ }
try {
const j = await api('/api/accesos/intentos?dias=7');
$('bEntradas').hidden = false;
const filas = Object.entries(j.por_usuario).sort((a, b) => b[1].fallas - a[1].fallas).map(([u, p]) =>
'<tr><td><b>' + esc(u) + '</b></td><td>' + p.ok + '</td><td>' + (p.fallas ? '<span class="pill falla">' + p.fallas + '</span>' : '0') + '</td><td>'
+ esc(Object.entries(p.motivos).map(([m, n]) => (MOTIVO[m] || m) + ' ×' + n).join(', ')) + '</td><td>'
+ esc(Object.entries(p.pistas).map(([m, n]) => (PISTA[m] || m) + ' ×' + n).join(', ')) + '</td><td>' + esc(fecha(p.ultimo_ok)) + '</td><td>' + esc(fecha(p.ultima_falla)) + '</td></tr>');
$('entradas').innerHTML = filas.length ? '<div class="tblwrap"><table class="t"><thead><tr><th>Usuario</th><th>Entró</th><th>Fallas</th><th>Por qué</th><th>Pistas</th><th>Última entrada</th><th>Última falla</th></tr></thead><tbody>' + filas.join('') + '</tbody></table></div>' : '<p class="vacio">Nadie ha entrado por la página nueva todavía.</p>';
} catch (e) { /* solo administradores */ }
}
$('bReportar').addEventListener('click', () => {
const b = document.getElementById('hg-inc-abrir');
if (b) { const s = document.getElementById('problemas-del-sitio'); s.scrollIntoView({ behavior: 'smooth' }); const f = document.getElementById('hg-inc-f'); if (f && f.hidden) b.click(); }
});
let t = null;
['fEstado', 'fTipo', 'fOrigen'].forEach(id => $(id).addEventListener('change', cargar));
$('fBuscar').addEventListener('input', () => { clearTimeout(t); t = setTimeout(cargar, 300); });
if (params.get('id')) $('fEstado').value = 'todas';
cargar().then(errores);
})();

View File

@ -0,0 +1,43 @@
/* Incidencias del sitio (27-oct-2026) — sobre tableros/tablero.css */
.tb-inc .acciones { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 14px; }
.tb-inc .acciones .cnt { color: var(--hg-texto-3); font-size: 13px; }
.tb-inc .inc-lista { display: flex; flex-direction: column; gap: 10px; }
.tb-inc .inc { background: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: 12px; padding: 12px 14px; }
.tb-inc .inc.g-alta { border-left: 4px solid var(--hg-bloqueado); }
.tb-inc .inc.g-media { border-left: 4px solid var(--hg-aviso); }
.tb-inc .inc.g-baja { border-left: 4px solid var(--hg-linea-2); }
.tb-inc .inc.cerrada { opacity: .78; border-left-color: var(--hg-hecho); }
.tb-inc .inc__sup { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; font-size: 12.5px; color: var(--hg-texto-2); }
.tb-inc .inc__id { font-family: var(--hg-fuente-mono); font-weight: 700; color: var(--hg-texto-fuerte); }
.tb-inc .inc__tit { margin: 6px 0 2px; font-weight: 650; font-size: 15.5px; line-height: 1.35; color: var(--hg-texto-fuerte); cursor: pointer; overflow-wrap: anywhere; }
.tb-inc .inc__tit:hover { color: var(--hg-liga-hover); }
.tb-inc .inc__meta { font-size: 13px; color: var(--hg-texto-3); }
.tb-inc .inc__meta a { color: var(--hg-liga); }
.tb-inc .inc__det { margin-top: 12px; border-top: 1px solid var(--hg-linea-suave); padding-top: 12px; }
.tb-inc .inc__desc { white-space: pre-wrap; font-size: 14.5px; margin: 0 0 10px; overflow-wrap: anywhere; }
.tb-inc .inc__ctx { font-family: var(--hg-fuente-mono); font-size: 12px; color: var(--hg-texto-2); background: var(--hg-plano-2);
border: 1px solid var(--hg-linea-suave); border-radius: 8px; padding: 8px 10px; margin: 0 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow: auto; }
.tb-inc .inc__com { margin: 10px 0 0; padding: 0; list-style: none; }
.tb-inc .inc__com li { margin: 0 0 8px; padding: 8px 10px; border-radius: 9px; background: var(--hg-plano-2); font-size: 14px; }
.tb-inc .inc__com li.bot { background: var(--hg-info-bg); }
.tb-inc .inc__com .q { font-size: 12px; color: var(--hg-texto-3); margin-bottom: 2px; }
.tb-inc .inc__com .t { white-space: pre-wrap; overflow-wrap: anywhere; }
.tb-inc .inc__ed { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px 12px; margin: 10px 0 0; }
.tb-inc .inc__ed label { margin: 0 0 4px; }
.tb-inc .inc__fila { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 10px; }
.tb-inc .inc__fila textarea { flex: 1 1 260px; min-height: 64px; }
.tb-inc .inc__pub { display: flex; gap: 8px; align-items: center; font-weight: 500; color: var(--hg-texto-2); font-size: 13.5px; margin: 0; }
.tb-inc .inc__pub input { width: auto; }
.tb-inc .pill.alta { background: var(--hg-bloqueado-bg); color: var(--hg-bloqueado); }
.tb-inc .pill.media { background: var(--hg-aviso-bg); color: var(--hg-aviso); }
.tb-inc .pill.baja { background: var(--hg-pend-bg); color: var(--hg-texto-2); }
.tb-inc .pill.e-nueva { background: var(--hg-info-bg); color: var(--hg-info); }
.tb-inc .pill.e-en-revision, .tb-inc .pill.e-en-curso { background: var(--hg-curso-bg); color: var(--hg-curso); }
.tb-inc .pill.e-esperando { background: var(--hg-aviso-bg); color: var(--hg-aviso); }
.tb-inc .pill.e-resuelta { background: var(--hg-hecho-bg); color: var(--hg-hecho); }
.tb-inc .pill.e-descartada { background: var(--hg-pend-bg); color: var(--hg-texto-3); }
.tb-inc .inc-errores, .tb-inc .inc-entradas { margin-top: 22px; }
.tb-inc .inc-errores td, .tb-inc .inc-entradas td { font-size: 13.5px; }
.tb-inc .mono { font-family: var(--hg-fuente-mono); font-size: 12.5px; overflow-wrap: anywhere; }
.tb-inc .inc__fila input[type=text] { min-width: 0; }
.tb-inc .inc__ed input, .tb-inc .inc__ed select { width: 100%; }

View File

@ -0,0 +1,55 @@
---
title: "Incidencias del sitio"
description: "Lo que falla en drogheda.helgen.online: reportes del equipo, de la página de entrada y lo que el vigía detecta solo. Se revisa todos los días."
template: tablero.html
date: 2026-10-27
hide:
- toc
---
<link rel="stylesheet" href="/tableros/incidencias.css">
<div class="tb tb-inc">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Incidencias del sitio</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc"><button id="bReportar"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Reportar un problema</button><a class="boton sec" href="/tableros/standup/">Standup</a></div>
</header>
<details class="info tb-como">
<summary>Cómo funciona</summary>
<ol>
<li><b>Cualquiera reporta</b> desde el pie de cualquier página («Problemas del sitio»): el reporte se lleva la página, el navegador y los errores recientes. Quien <b>no puede entrar</b> lo reporta desde la <a href="/entrar.html">página de entrada</a>, sin sesión.</li>
<li><b>El vigía</b> (un programa en el servidor, cada hora) junta los errores del navegador y del servidor y los intentos de entrada fallidos; si algo se repite, abre la incidencia solo (origen «vigía») o le suma ocurrencias a la que ya está abierta.</li>
<li><b>El agente diario</b> (Claude, de lunes a viernes a las 6:37 pm) revisa las abiertas: las clasifica, contesta aquí, junta duplicadas, reproduce la falla y prepara el arreglo. Lo que no necesita autorización lo hace solo; lo que toca contraseñas, permisos, datos o el servidor lo deja listo para que Leo lo apruebe. Las reglas están en <a href="/agentes/">Agentes del sitio</a>.</li>
<li>Quien reportó puede marcar la suya como <b>resuelta</b> o <b>reabrirla</b>. «Mostrar en la entrada» publica un aviso corto en la página de entrada (por ejemplo, cuando algo impide entrar).</li>
</ol>
</details>
<div class="kpis" id="kpis"></div>
<div class="acciones">
<select id="fEstado" aria-label="Estado">
<option value="abiertas">Abiertas</option><option value="cerradas">Resueltas y descartadas</option><option value="todas">Todas</option>
</select>
<select id="fTipo" aria-label="Tipo"><option value="">Todos los tipos</option></select>
<select id="fOrigen" aria-label="Origen"><option value="">Todos los orígenes</option><option value="sitio">Reportadas en el sitio</option><option value="entrada">Desde la entrada</option><option value="vigia">Detectadas por el vigía</option><option value="agente">Abiertas por el agente</option></select>
<input type="search" id="fBuscar" placeholder="Buscar folio o texto…" style="flex:1;min-width:150px">
<span class="cnt" id="cnt"></span>
</div>
<div class="msg" id="aviso"></div>
<div id="lista" class="inc-lista"></div>
<section class="bloque inc-errores" id="bErrores" hidden>
<h2>Errores detectados (últimos 2 días)</h2>
<p class="vacio">Lo que mandan solos los navegadores (JavaScript, llamadas que fallan) y lo que registra el servidor. El vigía abre incidencia cuando se repite.</p>
<div id="errores"></div>
</section>
<section class="bloque inc-entradas" id="bEntradas" hidden>
<h2>Intentos de entrada (últimos 7 días)</h2>
<p class="vacio">Por usuario: entradas buenas, fallas y por qué. Nunca se guarda la contraseña. Solo administradores.</p>
<div id="entradas"></div>
</section>
</div>
<script src="/tableros/incidencias-tablero.js"></script>

View File

@ -37,6 +37,8 @@ hide:
<h2>Últimas decisiones</h2>
<p class="sub">Lo decidido manda hasta que otra decisión con fecha posterior lo cambie. Fuente: notas del proyecto; detalle en la sección de hardware.</p>
<ul class="decisiones">
<li><b>27-oct (12:20)</b> — <b>D-27-1 y D-27-2 (Leo): las órdenes de compra se reciben en el sitio y la base es SQLite.</b> El cotizador se queda con la autorización, los montos y los pagos; el centro de mando recibe cada OC de DROGHEDA sin montos, con su sitio de entrega, la pone <b>en camino</b> en la pestaña del sitio y el responsable del sitio da <b>Recibir</b>; la requisición se cierra sola. Propuestas que confirma Leo: <b>D-27-3</b> códigos VER · ZLO · ESE · LZC · QRO (aeropuerto y puerto), <b>D-27-4</b> sin montos en el sitio, <b>D-27-5</b> solo PRY-2026-0002-DRO. <a href="/inventario/integracion-cotizador/#decisiones">Integración del cotizador</a> · <a href="/inventario/integracion-cotizador-instrucciones/">Instrucciones para el cotizador</a></li>
<li><b>27-oct (11:30)</b> — <b>Entrada al sitio con página propia</b> (Claude, por el reporte de Adriana del 26-oct). La ventana gris de HTTP Basic se va: nginx pregunta al backend por la sesión (cookie firmada de 30 días), se entra con usuario o correo, y cambiar o restablecer la contraseña ya no deja a nadie fuera. Con ella llegan las <a href="/tableros/incidencias/">incidencias del sitio</a>, el <a href="/tableros/standup/">standup</a> y los <a href="/agentes/">agentes</a> (vigía cada hora, agente diario). Se publica con <code>./scripts/despliegue-1027.sh</code>; se regresa con <code>cambiar-entrada.sh deshacer</code>.</li>
<li><b>23-oct (08:53)</b> — <b>D-22-9 decidido: (b) en las tres tarjetas, también en el piloto</b> (<b>Leo</b>: «yes D-22-9»; para la v6 lo eligió el 22-oct en la noche). Designadores de la serigrafía a <b>0.8 mm con trazo de 0.15 mm</b>, colocados por script (T44) junto a su pieza, sin mover piezas ni tocar cobre. V5.6 239 de 239 y V5.7 244 de 244 (<code>93d13e3</code>); Rev6/Rev7 regenerados: solo cambia la serigrafía superior, y BOM, CPL y cobre quedan iguales. v6 338 de 350 (<code>bd27501</code>); los 12 que no caben quedan para Michelle (mover una pieza, u ocultarlo y usar el plano de ensamble). La regla del <code>.kicad_dru</code> ya pide 0.8 / 0.15 en las tres. <a href="/hardware/v6-t44-serigrafia/">T44</a> · <a href="/hardware/designadores-t43-22oct/#5-d-22-9-tamano-de-los-designadores-en-la-serigrafia-decide-leo">D-22-9</a></li>
<li><b>22-oct (17:40)</b> — <b>Designadores en orden en V5.6, V5.7 y v6 (T43)</b> (D-22-8, <b>Leo</b>). V5.6 y V5.7 llevan la misma numeración, en orden de posición en la V5.6; todos los conectores van con J y no hay huecos. Lo hizo Claude por script, y la v6 por su propia posición. Netlist, ERC y DRC iguales; paridad 0; paquetes Rev6/Rev7 regenerados, en los que solo cambia la serigrafía. <b>D-22-9 (decide Leo):</b> los designadores miden 0.5 mm con trazo de 0.1 mm, debajo del mínimo de JLC (0.8 / 0.15). Claude recomienda pedir el piloto así, con los mapas, y subirlos a 0.8 mm en la pasada de T42b. <a href="/hardware/designadores-t43-22oct/">T43</a> · <a href="/hardware/designadores-t43-22oct/#5-d-22-9-tamano-de-los-designadores-en-la-serigrafia-decide-leo">D-22-9</a></li>
<li><b>22-oct (17:00)</b> — <b>V5.6/V5.7: victorias rápidas (T42a)</b> (D-22-7: <b>Leo eligió (a) y lo aplicó a las 17:20</b>). BOM con existencia para las 430: D10 = C83325 (los códigos de la auditoría no servían), U15 → TRS3232E (±15 kV), 22 µF → C45783 (C86817 no alcanzaba) y 8 Extended menos; <code>/IGN_IN</code> a 0.3 mm en In1 y su vía 0.35 mm abajo (de 0.15 a 0.54 mm de las señales); 2.ª vía de <code>/SYS5V</code> en la V5.7 (E-11). DRC igual al de la auditoría. Claude recomienda (a): T42a completo antes del pedido del piloto (≈ 40 min). Hallazgo: la clase HV24 solo atrapa <code>/24V_IN</code> → T42b antes de las 108/322. <a href="/hardware/v56-v57-victorias-rapidas-22oct/">Documento</a> · <a href="/hardware/v56-v57-victorias-rapidas-22oct/#5-d-22-7-que-entra-al-piloto-decide-leo">D-22-7</a></li>

View File

@ -0,0 +1,140 @@
/* Standup diario (27-oct-2026). Servidor: backend/standup.py.
Mi standup: GET /api/standup/hoy → instrucciones del bot + preguntas por pendiente; POST /api/standup/respuesta.
Si la persona lo deja marcado, mueve sus pendientes con PATCH /api/tarea?id= (queda a su nombre en el historial).
Equipo (Leo, Adriana, el bot): GET /api/standup?fecha= . La dirección guarda la vista: ?vista=equipo&fecha=AAAA-MM-DD. */
(function () {
const $ = id => document.getElementById(id);
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'];
const DIA = ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'];
const largo = f => { const d = new Date(f + 'T12:00:00'); return isNaN(d) ? f : DIA[d.getDay()] + ' ' + d.getDate() + ' ' + MES[d.getMonth()]; };
const hora = iso => (iso || '').slice(11, 16);
const ESTADO_T = { 'por-hacer': 'Por hacer', 'en-curso': 'En curso', bloqueado: 'Bloqueado', hecho: 'Hecho' };
const OPC = [['hecho', 'Hecho'], ['sigo', 'Sigo con él'], ['bloqueado', 'Bloqueado'], ['no-empece', 'No empecé']];
const A_TABLERO = { hecho: 'hecho', sigo: 'en-curso', bloqueado: 'bloqueado' };
const params = new URLSearchParams(location.search);
let vista = params.get('vista') === 'equipo' ? 'equipo' : 'mio', puedeEquipo = false;
async function api(url, op) {
const r = await fetch(url, Object.assign({ credentials: 'same-origin', cache: 'no-store' }, op || {}));
const j = await r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status }));
if (!r.ok || !j.ok) { const e = new Error(j.error || ('HTTP ' + r.status)); e.status = r.status; throw e; }
return j;
}
const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
function aviso(t, bien) { const a = $('aviso'); a.className = 'msg ' + (bien ? 'ok' : 'err'); a.innerHTML = t; a.scrollIntoView({ block: 'nearest' }); }
function botones() {
$('vistas').innerHTML = '<button class="' + (vista === 'mio' ? '' : 'sec') + '" data-v="mio">Mi standup</button>'
+ (puedeEquipo ? '<button class="' + (vista === 'equipo' ? '' : 'sec') + '" data-v="equipo">Equipo</button>' : '');
$('vistas').querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
vista = b.dataset.v; const p = new URLSearchParams(location.search); p.set('vista', vista); history.replaceState(null, '', '?' + p);
botones(); (vista === 'equipo' ? equipo : mio)();
}));
}
async function mio() {
const C = $('cont'); C.innerHTML = '<p class="vacio">Cargando…</p>';
let j;
try { j = await api('/api/standup/hoy'); } catch (e) { C.innerHTML = '<div class="err">' + esc(e.message) + '</div>'; return; }
const s = j.standup;
$('meta').textContent = largo(s.fecha) + (s.fecha !== j.hoy ? ' (pendiente de contestar)' : '') + ' · ' + (s.nombre || s.usuario);
let h = '';
if (s.respuesta) {
h += '<div class="su-hecho"><b>Ya contestaste</b> a las ' + esc(hora(s.respuesta.enviada_en)) + '. Gracias. Puedes corregir abajo y volver a enviar.</div>';
}
if ((s.instrucciones || []).length || s.nota_bot) {
h += '<section class="su-bot"><h2>Tus instrucciones de hoy</h2><ol>' + (s.instrucciones || []).map(i =>
'<li>' + esc(i.texto) + (i.liga ? ' — <a href="' + esc(i.liga) + '">abrir</a>' : '')
+ (i.tarea_id ? ' <span class="pill pend">' + esc(i.tarea_id) + '</span>' : '') + '</li>').join('') + '</ol>'
+ (s.nota_bot ? '<p class="nota">' + esc(s.nota_bot) + '</p>' : '')
+ '<p class="nota">Las preparó ' + esc(s.preparado_por || 'el bot') + ' el ' + esc(largo((s.preparado_en || '').slice(0, 10))) + ' a las ' + esc(hora(s.preparado_en)) + ', con el tablero de ese momento.</p></section>';
} else {
h += '<p class="vacio">Hoy el bot no dejó instrucciones; contesta sobre tus pendientes abiertos.</p>';
}
const previas = {};
((s.respuesta || {}).por_tarea || []).forEach(p => { previas[p.tarea_id] = p; });
const preg = s.preguntas || [];
h += '<section class="bloque"><h2>Tus pendientes</h2>';
if (!preg.length) h += '<p class="vacio">No tienes pendientes abiertos a tu nombre. Si estás trabajando en algo que no está en el tablero, cuéntalo abajo.</p>';
preg.forEach((p, i) => {
const prev = previas[p.tarea_id] || {};
h += '<div class="su-p" data-id="' + esc(p.tarea_id) + '"><div class="su-p__t"><a href="/tableros/pendientes/?id=' + encodeURIComponent(p.tarea_id) + '">' + esc(p.titulo) + '</a></div>'
+ '<div class="su-p__m">' + esc(p.tarea_id) + ' · ' + esc(ESTADO_T[p.estado_tablero] || p.estado_tablero || '') + (p.vence ? ' · vence ' + esc(p.vence) : '') + '</div>'
+ (p.pregunta ? '<div class="su-p__q">' + esc(p.pregunta) + '</div>' : '')
+ '<div class="su-op">' + OPC.map(o => '<label class="' + (o[0] === 'bloqueado' ? 'b' : '') + '"><input type="radio" name="p' + i + '" value="' + o[0] + '"' + (prev.estado === o[0] ? ' checked' : '') + '> ' + o[1] + '</label>').join('') + '</div>'
+ '<input type="text" class="nota" maxlength="600" placeholder="Una línea: qué avanzaste o qué te falta" value="' + esc(prev.nota || '') + '"></div>';
});
h += '</section>';
const r = s.respuesta || {};
h += '<section class="bloque su-q"><h2>Las tres preguntas</h2>'
+ '<label for="qHecho">¿Qué terminaste desde el último standup?</label><textarea id="qHecho" maxlength="3000">' + esc(r.hecho || '') + '</textarea>'
+ '<label for="qFalta">¿Qué te falta? ¿Qué sigue hoy?</label><textarea id="qFalta" maxlength="3000">' + esc(r.falta || '') + '</textarea>'
+ '<label for="qBloq">¿Qué te bloquea o qué necesitas de alguien?</label><textarea id="qBloq" maxlength="3000" placeholder="Nada, si no hay. Si es de alguien, di de quién.">' + esc(r.bloqueos || '') + '</textarea>'
+ '<label for="qCom">Algo más (opcional)</label><textarea id="qCom" maxlength="3000">' + esc(r.comentario || '') + '</textarea>'
+ '<div class="su-fin"><button id="bEnviar">' + (s.respuesta ? 'Volver a enviar' : 'Enviar mi standup') + '</button>'
+ '<label><input type="checkbox" id="mover" checked> Actualizar mis pendientes en el tablero</label></div></section>';
C.innerHTML = h;
$('bEnviar').addEventListener('click', () => enviar(s, preg));
}
async function enviar(s, preg) {
const b = $('bEnviar'); b.disabled = true; b.textContent = 'Enviando…';
const por_tarea = [];
document.querySelectorAll('.su-p').forEach((d, i) => {
const o = d.querySelector('input[type=radio]:checked');
if (o) por_tarea.push({ tarea_id: d.dataset.id, estado: o.value, nota: d.querySelector('input.nota').value.trim() });
});
try {
await api('/api/standup/respuesta', json('POST', { fecha: s.fecha, por_tarea, hecho: $('qHecho').value.trim(), falta: $('qFalta').value.trim(),
bloqueos: $('qBloq').value.trim(), comentario: $('qCom').value.trim() }));
let movidos = 0, fallas = [];
if ($('mover').checked) {
for (const p of por_tarea) {
const q = preg.find(x => x.tarea_id === p.tarea_id) || {};
const destino = A_TABLERO[p.estado];
if (!destino || destino === q.estado_tablero) continue;
try {
await api('/api/tarea?id=' + encodeURIComponent(p.tarea_id), json('PATCH', { estado: destino, _nota: 'desde el standup' + (p.nota ? ': ' + p.nota : '') }));
movidos++;
} catch (e) { fallas.push(p.tarea_id + ' (' + e.message + ')'); }
}
}
aviso('<b>Listo, gracias.</b> ' + (movidos ? movidos + ' pendiente' + (movidos > 1 ? 's' : '') + ' movido' + (movidos > 1 ? 's' : '') + ' en el tablero. ' : '')
+ (fallas.length ? 'No pude mover: ' + esc(fallas.join(', ')) + ' — muévelos a mano en Pendientes.' : ''), !fallas.length);
document.querySelectorAll('.hg-standup').forEach(x => x.remove());
await mio();
} catch (e) { aviso(esc(e.message)); b.disabled = false; b.textContent = 'Enviar mi standup'; }
}
async function equipo(fecha) {
const C = $('cont'); C.innerHTML = '<p class="vacio">Cargando…</p>';
fecha = fecha || params.get('fecha') || '';
let j;
try { j = await api('/api/standup' + (fecha ? '?fecha=' + encodeURIComponent(fecha) : '')); } catch (e) { C.innerHTML = '<div class="err">' + esc(e.message) + '</div>'; return; }
$('meta').textContent = largo(j.fecha) + ' · contestaron ' + j.contestaron + ' de ' + j.total;
let h = '<div class="acciones" style="display:flex;gap:8px;align-items:center;margin:0 0 12px"><select id="fFecha">'
+ (j.fechas.length ? j.fechas : [j.fecha]).map(f => '<option value="' + f + '"' + (f === j.fecha ? ' selected' : '') + '>' + largo(f) + '</option>').join('') + '</select></div>';
if (!j.standups.length) h += '<p class="vacio">El bot no preparó standup ese día.</p>';
else {
const bloqueos = j.standups.filter(s => s.respuesta && (s.respuesta.bloqueos || (s.respuesta.por_tarea || []).some(p => p.estado === 'bloqueado')));
if (bloqueos.length) h += '<section class="bloque"><h2>Bloqueos</h2><ul>' + bloqueos.map(s => '<li><b>' + esc(s.nombre) + ':</b> <span class="bloq">'
+ esc(s.respuesta.bloqueos || (s.respuesta.por_tarea || []).filter(p => p.estado === 'bloqueado').map(p => p.tarea_id + (p.nota ? ' — ' + p.nota : '')).join('; ')) + '</span></li>').join('') + '</ul></section>';
h += '<div class="tblwrap"><table class="t su-eq"><thead><tr><th>Persona</th><th>Contestó</th><th>Terminó</th><th>Le falta</th><th>Pendientes</th></tr></thead><tbody>'
+ j.standups.sort((a, b) => (b.respuesta ? 1 : 0) - (a.respuesta ? 1 : 0) || String(a.nombre).localeCompare(b.nombre)).map(s => {
const r = s.respuesta;
return '<tr class="' + (r ? '' : 'sin') + '"><td><b>' + esc(s.nombre) + '</b></td><td>' + (r ? esc(hora(r.enviada_en)) : '—') + '</td><td>' + esc(r ? r.hecho : '') + '</td><td>' + esc(r ? r.falta : '') + '</td><td>'
+ (r ? (r.por_tarea || []).map(p => esc(p.tarea_id) + ': ' + esc(p.estado) + (p.nota ? ' — ' + esc(p.nota) : '')).join('<br>') : esc((s.preguntas || []).length + ' preguntas')) + '</td></tr>';
}).join('') + '</tbody></table></div>';
}
C.innerHTML = h;
$('fFecha').addEventListener('change', e => { const p = new URLSearchParams(location.search); p.set('fecha', e.target.value); history.replaceState(null, '', '?' + p); equipo(e.target.value); });
}
(async () => {
try { const w = await api('/api/whoami'); puedeEquipo = !!w.admin || ['leo', 'adriana', 'claude', 'gibranlp'].includes((w.user || '').toLowerCase()); } catch (e) {}
if (vista === 'equipo' && !puedeEquipo) vista = 'mio';
botones();
(vista === 'equipo' ? equipo : mio)();
})();
})();

32
docs/tableros/standup.css Normal file
View File

@ -0,0 +1,32 @@
/* Standup diario (27-oct-2026) — sobre tableros/tablero.css */
.tb-su .su-bot { background: var(--hg-info-bg); border: 1px solid color-mix(in srgb, var(--hg-info) 35%, transparent);
border-radius: 12px; padding: 14px 16px; margin: 0 0 16px; }
.md-typeset .tb-su .su-bot h2 { margin: 0 0 8px; font-size: 18px; }
.tb-su .su-bot ol { margin: 4px 0 0 1.3em; }
.tb-su .su-bot li { margin: 0 0 7px; }
.tb-su .su-bot .nota { color: var(--hg-texto-suave); font-size: 14px; margin: 8px 0 0; white-space: pre-wrap; }
.tb-su .su-p { background: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: 12px; padding: 12px 14px; margin: 0 0 10px; }
.tb-su .su-p__t { font-weight: 650; font-size: 15px; line-height: 1.35; color: var(--hg-texto-fuerte); overflow-wrap: anywhere; }
.tb-su .su-p__t a { color: inherit; text-decoration: none; }
.tb-su .su-p__t a:hover { color: var(--hg-liga-hover); }
.tb-su .su-p__m { font-size: 12.5px; color: var(--hg-texto-3); margin: 3px 0 0; }
.tb-su .su-p__q { font-size: 14px; color: var(--hg-info); margin: 6px 0 0; }
.tb-su .su-op { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
.tb-su .su-op label { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-weight: 500; font-size: 13.5px; color: var(--hg-texto);
padding: 6px 12px; border: 1px solid var(--hg-linea-2); border-radius: 20px; cursor: pointer; }
.tb-su .su-op input { width: auto; margin: 0; accent-color: var(--hg-acento); }
.tb-su .su-op label:has(input:checked) { border-color: var(--hg-acento); background: var(--hg-acento-suave); }
.tb-su .su-op label.b:has(input:checked) { border-color: var(--hg-bloqueado); background: var(--hg-bloqueado-bg); }
.tb-su .su-p input.nota { margin-top: 8px; }
.tb-su .su-q label { margin-top: 14px; font-size: 14px; color: var(--hg-texto); }
.tb-su .su-q textarea { min-height: 70px; }
.tb-su .su-fin { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 16px 0 0; }
.tb-su .su-fin label { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 500; color: var(--hg-texto-2); }
.tb-su .su-fin label input { width: auto; }
.tb-su .su-hecho { background: var(--hg-hecho-bg); border: 1px solid color-mix(in srgb, var(--hg-hecho) 40%, transparent);
border-radius: 12px; padding: 12px 14px; margin: 0 0 16px; }
.tb-su .su-eq td { font-size: 13.5px; white-space: pre-wrap; }
.tb-su .su-eq tr.sin td:first-child { color: var(--hg-texto-3); }
.tb-su .su-eq .bloq { color: var(--hg-bloqueado); font-weight: 600; }
.tb-su textarea, .tb-su input.nota { width: 100%; display: block; }
.tb-su .su-q textarea { min-height: 76px; }

33
docs/tableros/standup.md Normal file
View File

@ -0,0 +1,33 @@
---
title: "Standup"
description: "El standup diario: tus instrucciones del día y tres preguntas sobre tus pendientes. El bot lo prepara cada tarde para el día siguiente y convierte las respuestas en cambios del tablero."
template: tablero.html
ancho: 60rem
date: 2026-10-27
hide:
- toc
---
<link rel="stylesheet" href="/tableros/standup.css">
<div class="tb tb-su">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Standup</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc" id="vistas"></div>
</header>
<details class="info tb-como">
<summary>Cómo funciona</summary>
<ol>
<li><b>Cada tarde</b> (lunes a viernes, 6:37 pm) el bot (Claude) lee el tablero y te deja aquí tu <b>parte del día siguiente</b>: qué hacer hoy, en qué orden y con qué documento, y una pregunta concreta por cada pendiente tuyo. El sitio te avisa con una franja azul arriba hasta que contestes.</li>
<li><b>Contesta en 2 minutos:</b> marca cómo va cada pendiente (hecho · sigo · bloqueado · no empecé) y escribe lo que terminaste, lo que te falta y lo que te bloquea. Si dejas marcada «Actualizar mis pendientes en el tablero», tu tarjeta se mueve sola (queda a tu nombre en el historial).</li>
<li><b>Esa misma tarde</b> el bot lee las respuestas de todos: arma los cambios del tablero que faltan (Leo los aprueba), avisa de los bloqueos a quien los puede destrabar y prepara las instrucciones nuevas. Leo y Adriana ven el resumen del equipo en la pestaña <b>Equipo</b>.</li>
<li>Si el bot no corrió, la página igual te pregunta por tus pendientes abiertos.</li>
</ol>
</details>
<div class="msg" id="aviso"></div>
<div id="cont"></div>
</div>
<script src="/tableros/standup-tablero.js"></script>

View File

@ -11,6 +11,7 @@ ICONOS = {
"tableros/pendientes.md": "helgen/pendientes",
"bitacora.md": "helgen/bitacora",
"tableros/juntas.md": "helgen/fecha",
"tableros/standup.md": "helgen/mios", # 27-oct: el bot de standup
# Tableros
"tableros/index.md": "helgen/pendientes",
"tableros/ingenieria.md": "helgen/ingenieria",
@ -21,6 +22,8 @@ ICONOS = {
"tableros/equipo.md": "helgen/equipo",
"tableros/unidad.md": "helgen/unidad",
"tableros/campo.md": "helgen/visitas",
"tableros/incidencias.md": "helgen/aviso", # 27-oct: problemas del sitio
"agentes.md": "helgen/admin", # 27-oct: vigía, agente diario y bot de standup
# Documentos
"guia-ingeniero.md": "helgen/manuales",
"produccion/index.md": "helgen/fabrica",

View File

@ -52,6 +52,7 @@ extra_css:
extra_javascript:
- js/sitio.js
- js/standup.js # 27-oct: franja «Tu standup de hoy te espera»
hooks:
- hooks/iconos.py # íconos del menú lateral (también para páginas que llegan por sync-docs.sh)
@ -85,6 +86,7 @@ nav:
- Inicio: index.md
- Pendientes: tableros/pendientes.md
- Juntas: tableros/juntas.md
- Standup: tableros/standup.md
- Bitácora: bitacora.md
- Tableros:
@ -98,6 +100,8 @@ nav:
- Equipo: tableros/equipo.md
- Expediente de unidad: tableros/unidad.md
- Visitas de campo: tableros/campo.md
- Incidencias del sitio: tableros/incidencias.md
- Agentes del sitio: agentes.md
- Documentos:
- Guía del ingeniero: guia-ingeniero.md
@ -117,6 +121,8 @@ nav:
- Prueba final: procedimientos/v5-prueba-final-unidad.md
- Configuración: procedimientos/v5-configuracion-unidad.md
- Política de inventario: inventario/index.md
- Compras y cotizador: inventario/integracion-cotizador.md
- Instrucciones al cotizador: inventario/integracion-cotizador-instrucciones.md
- Taller:
- taller/index.md
- Diagnóstico en banco:

View File

@ -10,4 +10,6 @@
<link rel="apple-touch-icon" href="{{ 'img/helgen-icono-180.png' | url }}">
<link rel="preload" href="{{ 'fonts/atkinson-hyperlegible-next-latin.woff2' | url }}" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ 'fonts/league-spartan-latin.woff2' | url }}" as="font" type="font/woff2" crossorigin>
{#- 27-oct: «Problemas del sitio» al pie de cada página; va en el <head> para ver los errores y las llamadas de la página -#}
<script src="{{ 'js/incidencias.js' | url }}"></script>
{% endblock %}

20
scripts/agente-api.sh Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Llamar la API del sitio desde la Mac, por SSH, como «claude» (27-oct-2026). La usan el agente diario y el bot de
# standup (agentes/agente-diario.md). Pasa por 127.0.0.1:8081 en el servidor: no necesita la sesión de nadie.
# ./scripts/agente-api.sh GET '/api/incidencias?estado=abiertas'
# ./scripts/agente-api.sh POST /api/standup/preparar archivo.json
# ./scripts/agente-api.sh PATCH '/api/incidencia?id=INC-0003' cambio.json
set -euo pipefail
REMOTE="${REMOTE:-leo@git.helgen.online}"; PORT="${SSH_PORT:-22}"
M="${1:?método}"; P="${2:?ruta}"; F="${3:-}"
case "$M" in GET|POST|PATCH) ;; *) echo "Método no permitido: $M (GET, POST o PATCH; el agente no borra)" >&2; exit 2 ;; esac
case "$P" in /api/incidencia*|/api/standup*|/api/tareas*|/api/tarea\?*|/api/equipo*|/api/inventario|/api/inventario\?*|/api/actividad*|/api/hoy|/api/salud|/api/sitios*) ;;
*) echo "Ruta fuera de lo que el agente usa: $P" >&2; exit 2 ;; esac
if [ "$M" = "PATCH" ] && [[ "$P" == /api/tarea* ]]; then echo "El tablero se cambia con lotes (Leo los aplica), no por aquí." >&2; exit 2; fi
Q=$(printf '%q' "$P")
if [ -n "$F" ]; then
ssh -p "$PORT" "$REMOTE" "curl -s -X $M -H 'X-Remote-User: claude' -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:8081$Q" < "$F"
else
ssh -p "$PORT" "$REMOTE" "curl -s -X $M -H 'X-Remote-User: claude' http://127.0.0.1:8081$Q"
fi
echo

View File

@ -18,7 +18,10 @@ echo "Sitio publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)"
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt backend/seed_1013.py \
backend/parche-nginx-api.py backend/actas-hbox.service backend/tablero.py backend/instalaciones.py \
backend/cuentas.py "$REMOTE:$BACKEND_PATH/"
backend/cuentas.py backend/acceso.py backend/incidencias.py backend/standup.py backend/vigia.py \
backend/semilla_1027.py "$REMOTE:$BACKEND_PATH/"
# Página de entrada (27-oct): el nginx nuevo y el script que lo instala con respaldo (backend/nginx/cambiar-entrada.sh).
rsync -az -e "ssh -p $PORT" backend/nginx/ "$REMOTE:$BACKEND_PATH/nginx/"
# Archivos de altas para cuentas.py (23-oct): sin contraseñas; la contraseña la pide el script en la terminal.
rsync -az -e "ssh -p $PORT" backend/cuentas/ "$REMOTE:$BACKEND_PATH/cuentas/"
echo "Backend copiado en $REMOTE:$BACKEND_PATH"

46
scripts/despliegue-1027.sh Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# 27-oct-2026 — un solo comando para publicar: página de entrada (adiós a la ventana gris), incidencias del sitio,
# standup, vigía cada hora, correos de Equipo, las primeras incidencias y el lote del día.
# En la Mac: cd ~/Documents/Claude/Projects/Drogheda/hbox-docs && ./scripts/despliegue-1027.sh
# Pide tu contraseña de sudo UNA vez (paso 5) y dos «si» (correos y lote). Si nginx falla, regresa solo al anterior.
# Para regresar a la ventana gris: ssh -t leo@git.helgen.online 'sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh deshacer'
set -euo pipefail
cd "$(dirname "$0")/.."
R=leo@git.helgen.online; B=/srv/projects/drogheda/hbox-docs/backend
FIRMA=$'\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE'
paso() { echo; echo "==> $*"; }
paso "1/8 Documentos del repo Drogheda → sitio"
./scripts/sync-docs.sh
paso "2/8 Pruebas del backend (si tienes Flask en la Mac)"
if python3 -c "import flask" 2>/dev/null; then bash backend/tests-1027.sh .; else echo "(sin Flask en la Mac: las corrió Claude, 47/47)"; fi
paso "3/8 Commit y push (hbox-docs y el submódulo en Drogheda)"
git add -A && git commit -q -m "27-oct: página de entrada, incidencias, standup, vigía y agente diario; integración del cotizador${FIRMA}" || echo "(nada nuevo en hbox-docs)"
git push
( cd .. && git add hbox-docs CLAUDE.md 02-Architecture-Software/2026-10-27_Drogheda_*.md 02-Architecture-Software/img/2026-10-27_Drogheda_*.png \
&& git commit -q -m "hbox-docs: entrada, incidencias, standup y agentes (27-oct); propuesta del cotizador${FIRMA}" || echo "(nada nuevo en Drogheda)"; git push )
paso "4/8 Sitio y backend al servidor"
./scripts/deploy.sh
paso "5/8 Servidor (sudo): servicio nuevo y nginx con página de entrada — con pruebas y regreso automático"
ssh -t $R "sudo bash $B/nginx/cambiar-entrada.sh servicio && sudo bash $B/nginx/cambiar-entrada.sh aplicar"
paso "6/8 Correos de Equipo (lista de Gibran confirmada por Leo) — escribe «si»"
ssh -t $R "cd $B && venv/bin/python3 cuentas.py alta cuentas/2026-10-27_correos.json"
paso "7/8 Vigía cada hora (crontab de leo, sin sudo), primeras incidencias y una corrida del vigía"
ssh $R "mkdir -p /srv/projects/drogheda/incidencias/_vigia \
&& (crontab -l 2>/dev/null | grep -v 'vigia.py'; echo '7 * * * * cd $B && venv/bin/python3 vigia.py >> /srv/projects/drogheda/incidencias/_vigia/vigia.log 2>&1') | crontab - \
&& crontab -l | grep vigia && cd $B && venv/bin/python3 semilla_1027.py && venv/bin/python3 vigia.py | tail -c 700; echo"
paso "8/8 Tablero: lote 2026-10-27_entrada-incidencias-agentes"
./scripts/tablero.sh revisar
read -r -p "¿Aplico los lotes nuevos? (si/no) " r
[ "$r" = "si" ] && ./scripts/tablero.sh aplicar || echo "No apliqué nada. Cuando quieras: ./scripts/tablero.sh aplicar"
echo
echo "Listo. Abre https://drogheda.helgen.online: sale la página de entrada (tu usuario o tu correo y tu contraseña)."
echo "Falta a mano: Equipo → Leo → «Otro correo» = leo@helgen.services (ceo@ es alias)."

View File

@ -250,6 +250,13 @@ declare -a MAP=(
"03-Architecture-Hardware/img/2026-10-27_Drogheda_v6-T48-SIM-Actualizar-PCB_v1.0.png|docs/hardware/img/2026-10-27_Drogheda_v6-T48-SIM-Actualizar-PCB_v1.0.png"
"03-Architecture-Hardware/img/2026-10-27_Drogheda_v6-T48-SIM-Actualizar-PCB_v1.1.png|docs/hardware/img/2026-10-27_Drogheda_v6-T48-SIM-Actualizar-PCB_v1.1.png"
"03-Architecture-Hardware/img/2026-10-27_Drogheda_v6-T49-NTC-Actualizar-PCB_v1.0.png|docs/hardware/img/2026-10-27_Drogheda_v6-T49-NTC-Actualizar-PCB_v1.0.png"
# 2026-10-27, 15:45: T45 (R80 abajo de Q7, tomas Kelvin; EP de U4 y U7)
"03-Architecture-Hardware/img/2026-10-27_Drogheda_v6-T45-R80-Kelvin_v1.0.png|docs/hardware/img/2026-10-27_Drogheda_v6-T45-R80-Kelvin_v1.0.png"
"03-Architecture-Hardware/img/2026-10-27_Drogheda_v6-T45-EP-U4-U7_v1.0.png|docs/hardware/img/2026-10-27_Drogheda_v6-T45-EP-U4-U7_v1.0.png"
# 2026-10-27: cotizador ↔ centro de mando (órdenes de compra, inventario por sitio y tareas; D-27-1 a D-27-5) e instrucciones
"02-Architecture-Software/2026-10-27_Drogheda_Integracion-Cotizador-Inventario_ES_v1.0.md|docs/inventario/integracion-cotizador.md"
"02-Architecture-Software/2026-10-27_Drogheda_Instrucciones-Cotizador-Integracion_ES_v1.0.md|docs/inventario/integracion-cotizador-instrucciones.md"
"02-Architecture-Software/img/2026-10-27_Drogheda_Flujo-OC-Inventario_v1.0.png|docs/inventario/img/2026-10-27_Drogheda_Flujo-OC-Inventario_v1.0.png"
)
n=0