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:
parent
23f98d3f5b
commit
742e534af2
@ -5,7 +5,8 @@
|
|||||||
> This file is the mechanics: where each piece of the site lives and how to change it.
|
> 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
|
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.
|
Adriana, Michelle, Jesús and the field technicians work from it; Claude closes every task on it too.
|
||||||
|
|
||||||
## Read first (start of every task)
|
## 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` |
|
| **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`) |
|
| **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` |
|
| **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 |
|
| **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 |
|
| **Ú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` |
|
| **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` |
|
||||||
|
|||||||
@ -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 |
|
| 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 |
|
| 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
|
## B · Verificación después de desplegar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. el backend responde y sabe quién eres
|
# 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 -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 -u alan:PASS https://drogheda.helgen.online/api/whoami # {"user":"alan","admin":false}
|
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.
|
Si `user` sale vacío, falta la línea `X-Remote-User` del paso 4.
|
||||||
|
|||||||
110
agentes/agente-diario.md
Normal file
110
agentes/agente-diario.md
Normal 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
403
backend/acceso.py
Normal 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}
|
||||||
@ -11,7 +11,8 @@ Environment=ADJUNTOS_DIR=/srv/projects/drogheda/adjuntos
|
|||||||
Environment=ADJUNTO_MAX_MB=8
|
Environment=ADJUNTO_MAX_MB=8
|
||||||
Environment=ARCHIVO_MAX_MB=20
|
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_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
|
# 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.
|
# de ACTAS_DIR: viven junto a /actas. Descomenta solo para moverlas de lugar.
|
||||||
#Environment=TAREAS_DIR=/srv/projects/drogheda/tareas
|
#Environment=TAREAS_DIR=/srv/projects/drogheda/tareas
|
||||||
@ -28,8 +29,16 @@ Environment=ACTAS_ADMINS=leo
|
|||||||
Environment=HTPASSWD_FILE=/etc/nginx/hbox-docs.htpasswd
|
Environment=HTPASSWD_FILE=/etc/nginx/hbox-docs.htpasswd
|
||||||
Environment=PASS_MIN=8
|
Environment=PASS_MIN=8
|
||||||
Environment=PORT=8081
|
Environment=PORT=8081
|
||||||
ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -w 2 -b 127.0.0.1:8081 app:app
|
# Página de entrada (27-oct, backend/acceso.py): nginx pregunta a /api/_auth en CADA petición (páginas y archivos).
|
||||||
Restart=on-failure
|
# 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
|
RestartSec=3
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@ -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
|
return jsonify(ok=False, error=f"La nueva contraseña debe tener al menos {PASS_MIN} caracteres."), 400
|
||||||
if nueva != confirma:
|
if nueva != confirma:
|
||||||
return jsonify(ok=False, error="Las dos contraseñas nuevas no coinciden."), 400
|
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:
|
if not es_reset and nueva == actual:
|
||||||
return jsonify(ok=False, error="La nueva contraseña es igual a la actual."), 400
|
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)
|
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
|
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",
|
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",
|
AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "pruebas", "fabricacion",
|
||||||
"software", "campo", "asistente")
|
"software", "campo", "asistente")
|
||||||
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
|
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
|
||||||
@ -1162,6 +1169,8 @@ def usuarios_crear():
|
|||||||
password = str(d.get("password_inicial") or "")
|
password = str(d.get("password_inicial") or "")
|
||||||
if len(password) < PASS_MIN:
|
if len(password) < PASS_MIN:
|
||||||
return jsonify(ok=False, error=f"La contraseña inicial debe tener al menos {PASS_MIN} caracteres."), 400
|
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"),
|
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", ""),
|
"sitio": d.get("sitio", ""), "correo": d.get("correo", ""), "correo_alt": d.get("correo_alt", ""),
|
||||||
"telefono": d.get("telefono", ""), "activo": True}
|
"telefono": d.get("telefono", ""), "activo": True}
|
||||||
@ -2310,14 +2319,18 @@ def actividad():
|
|||||||
# ------------------------------------------------------------------ tablero de Instalaciones (22-oct)
|
# ------------------------------------------------------------------ tablero de Instalaciones (22-oct)
|
||||||
# backend/instalaciones.py: GET /api/instalaciones y el acta repetible «prueba-sitio». Si el archivo
|
# 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).
|
# 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),
|
||||||
import importlib.util as _ilu
|
# incidencias.py (problemas del sitio, errores del navegador y del servidor) y standup.py (el bot de scrum).
|
||||||
_spec = _ilu.spec_from_file_location("instalaciones", os.path.join(os.path.dirname(os.path.abspath(__file__)), "instalaciones.py"))
|
# Cada uno se carga aparte: si uno falla, los demás y el resto del servicio arrancan igual.
|
||||||
_inst = _ilu.module_from_spec(_spec)
|
import importlib.util as _ilu
|
||||||
_spec.loader.exec_module(_inst)
|
for _mod in ("instalaciones", "acceso", "incidencias", "standup"):
|
||||||
_inst.registrar(app, globals())
|
try:
|
||||||
except Exception as _e: # noqa: BLE001
|
_spec = _ilu.spec_from_file_location(_mod, os.path.join(os.path.dirname(os.path.abspath(__file__)), _mod + ".py"))
|
||||||
app.logger.error("instalaciones.py no se cargó: %s", _e)
|
_m = _ilu.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_m)
|
||||||
|
_m.registrar(app, globals())
|
||||||
|
except Exception as _e: # noqa: BLE001
|
||||||
|
app.logger.error("%s.py no se cargó: %s", _mod, _e)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))
|
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))
|
||||||
|
|||||||
54
backend/cuentas/2026-10-27_correos.json
Normal file
54
backend/cuentas/2026-10-27_correos.json
Normal 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
404
backend/incidencias.py
Normal 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}
|
||||||
58
backend/lotes/2026-10-27_entrada-incidencias-agentes.json
Normal file
58
backend/lotes/2026-10-27_entrada-incidencias-agentes.json
Normal 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." }
|
||||||
|
]
|
||||||
|
}
|
||||||
72
backend/lotes/2026-10-27_t45-lista.json
Normal file
72
backend/lotes/2026-10-27_t45-lista.json
Normal 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
114
backend/nginx/cambiar-entrada.sh
Executable 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
|
||||||
128
backend/nginx/drogheda.helgen.online.conf
Normal file
128
backend/nginx/drogheda.helgen.online.conf
Normal 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
64
backend/semilla_1027.py
Normal 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
198
backend/standup.py
Normal 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
101
backend/tests-1027.sh
Executable 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
265
backend/vigia.py
Normal 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())
|
||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Administración — HBOX</title>
|
<title>Administración — HBOX</title>
|
||||||
<script src="js/tema.js"></script>
|
<script src="js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="formularios/acta.css">
|
<link rel="stylesheet" href="formularios/acta.css">
|
||||||
<style>
|
<style>
|
||||||
table{border-collapse:collapse;width:100%;font-size:14px;margin-top:6px}
|
table{border-collapse:collapse;width:100%;font-size:14px;margin-top:6px}
|
||||||
|
|||||||
79
docs/agentes.md
Normal file
79
docs/agentes.md
Normal 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.
|
||||||
@ -23,6 +23,10 @@ Arriba, lo que pasó en el tablero de pendientes y en las actas en los últimos
|
|||||||
### Octubre de 2026
|
### Octubre de 2026
|
||||||
|
|
||||||
**27 oct**
|
**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.
|
: **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).
|
: [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.
|
: **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.
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Mi cuenta — HBOX</title>
|
<title>Mi cuenta — HBOX</title>
|
||||||
<script src="js/tema.js"></script>
|
<script src="js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="formularios/acta.css">
|
<link rel="stylesheet" href="formularios/acta.css">
|
||||||
<style>
|
<style>
|
||||||
.campo{margin:12px 0}
|
.campo{margin:12px 0}
|
||||||
@ -37,22 +38,27 @@
|
|||||||
<section class="paso">
|
<section class="paso">
|
||||||
<h2>Cambiar mi contraseña</h2>
|
<h2>Cambiar mi contraseña</h2>
|
||||||
<p class="sub">Elige algo que recuerdes sin apuntarlo. Mínimo 8 caracteres.</p>
|
<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">
|
<div class="campo">
|
||||||
<label for="actual">Contraseña actual</label>
|
<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>
|
||||||
<div class="campo">
|
<div class="campo">
|
||||||
<label for="nueva">Contraseña nueva</label>
|
<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="fuerza"><i id="barra"></i></div>
|
||||||
<div class="pista" id="pista">Mientras más larga, mejor; una frase con espacios sirve.</div>
|
<div class="pista" id="pista">Mientras más larga, mejor; una frase con espacios sirve.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="campo">
|
<div class="campo">
|
||||||
<label for="confirma">Repite la contraseña nueva</label>
|
<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>
|
<span class="ver" id="ver">Ver lo que escribí</span>
|
||||||
</div>
|
</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>
|
<div class="msg" id="msg"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -65,7 +71,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="campo">
|
<div class="campo">
|
||||||
<label for="nuevaR">Contraseña nueva para esa persona</label>
|
<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>
|
</div>
|
||||||
<button class="enviar" id="resetear">Restablecer</button>
|
<button class="enviar" id="resetear">Restablecer</button>
|
||||||
<div class="msg" id="msgR"></div>
|
<div class="msg" id="msgR"></div>
|
||||||
@ -74,7 +83,8 @@
|
|||||||
<section class="paso">
|
<section class="paso">
|
||||||
<h2>Si olvidaste tu contraseña</h2>
|
<h2>Si olvidaste tu contraseña</h2>
|
||||||
<p class="sub" style="margin:0">
|
<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>).
|
Si todavía no tienes acceso, pídeselo a Leo (Administración → <a href="tableros/equipo/">Usuarios y equipo</a>).
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@ -95,6 +105,7 @@ function aviso(el, texto, bien) {
|
|||||||
try {
|
try {
|
||||||
const j = await (await fetch('/api/whoami')).json();
|
const j = await (await fetch('/api/whoami')).json();
|
||||||
yo = j.user || '';
|
yo = j.user || '';
|
||||||
|
$('usuarioForm').value = yo;
|
||||||
$('quien').innerHTML = yo
|
$('quien').innerHTML = yo
|
||||||
? 'Entraste como <b>' + yo + '</b>' + (j.admin ? ' · administrador' : '')
|
? '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. '
|
+ (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í';
|
$('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 =
|
const AVISO_SESION =
|
||||||
'<br><br>Tu navegador todavía recuerda la contraseña anterior, así que puedes seguir '
|
'<br><br>Sigues dentro. Si tu navegador o tu teléfono te ofrece <b>guardar o actualizar la contraseña</b>, acéptalo: '
|
||||||
+ 'navegando sin problema. <b>La nueva la vas a necesitar la próxima vez que entres.</b> '
|
+ '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>.';
|
||||||
+ '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');
|
const b = $('guardar');
|
||||||
b.disabled = true; b.textContent = 'Guardando…';
|
b.disabled = true; b.textContent = 'Guardando…';
|
||||||
try {
|
try {
|
||||||
@ -163,6 +177,14 @@ $('guardar').addEventListener('click', async () => {
|
|||||||
b.disabled = false; b.textContent = 'Guardar contraseña nueva';
|
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 () => {
|
$('resetear').addEventListener('click', async () => {
|
||||||
const u = $('quienReset').value, nueva = $('nuevaR').value;
|
const u = $('quienReset').value, nueva = $('nuevaR').value;
|
||||||
if (!u) return aviso($('msgR'), 'Elige a quién le vas a restablecer la contraseña.', false);
|
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();
|
const j = await r.json();
|
||||||
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
|
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 '
|
const seg = nueva.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
+ 'mensaje directo, y dile que la cambie aquí mismo cuando entre.'
|
aviso($('msgR'), 'Contraseña de <b>' + u + '</b> restablecida. Pásale exactamente esto, en persona o por mensaje directo: '
|
||||||
+ (u === yo ? AVISO_SESION : ''), true);
|
+ '<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 = '';
|
$('nuevaR').value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aviso($('msgR'), e.message, false);
|
aviso($('msgR'), e.message, false);
|
||||||
|
|||||||
83
docs/entrar.html
Normal file
83
docs/entrar.html
Normal 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>
|
||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de módulo — simpleRTK2B Pro (RTK)</title>
|
<title>Acta de módulo — simpleRTK2B Pro (RTK)</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de diagnóstico en banco — HBOX</title>
|
<title>Acta de diagnóstico en banco — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de daño o incidente — HBOX</title>
|
<title>Acta de daño o incidente — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de diagnóstico en campo — HBOX</title>
|
<title>Acta de diagnóstico en campo — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de ensamble — unidad HBOX v5</title>
|
<title>Acta de ensamble — unidad HBOX v5</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de envío — HBOX</title>
|
<title>Acta de envío — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Prueba de banco — Ethernet y puertos serie (V5)</title>
|
<title>Prueba de banco — Ethernet y puertos serie (V5)</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de instalación en campo — HBOX</title>
|
<title>Acta de instalación en campo — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
<style>
|
<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)}
|
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)}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de integración — RTK + 4G</title>
|
<title>Acta de integración — RTK + 4G</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de módulo — 4G NTRIP Master</title>
|
<title>Acta de módulo — 4G NTRIP Master</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Prueba de banco — módem 4G en batería</title>
|
<title>Prueba de banco — módem 4G en batería</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Prueba en sitio — HBOX</title>
|
<title>Prueba en sitio — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
<style>
|
<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)}
|
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)}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de recepción e inspección — HBOX</title>
|
<title>Acta de recepción e inspección — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de reflasheo y configuración — HBOX</title>
|
<title>Acta de reflasheo y configuración — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de prueba — EinarBoard V4 (fw 0.6.0)</title>
|
<title>Acta de prueba — EinarBoard V4 (fw 0.6.0)</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Acta de prueba — EinarBoard V5</title>
|
<title>Acta de prueba — EinarBoard V5</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Ver acta — HBOX</title>
|
<title>Ver acta — HBOX</title>
|
||||||
<script src="../js/tema.js"></script>
|
<script src="../js/tema.js"></script>
|
||||||
|
<script src="/js/incidencias.js"></script>
|
||||||
<link rel="stylesheet" href="acta.css">
|
<link rel="stylesheet" href="acta.css">
|
||||||
<style>
|
<style>
|
||||||
.doc{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:18px}
|
.doc{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:18px}
|
||||||
|
|||||||
@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, einarboard, v6, documento-de-diseno, es
|
|||||||
project: Project Drogheda
|
project: Project Drogheda
|
||||||
author: "Helgen Technologies (redacción: Claude; revisión: Leo)"
|
author: "Helgen Technologies (redacción: Claude; revisión: Leo)"
|
||||||
date: 2026-10-22
|
date: 2026-10-22
|
||||||
version: 1.2
|
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)"
|
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"
|
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.
|
- 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.
|
- **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).
|
- 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)
|
**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 | — |
|
| 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 | — |
|
| 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 | 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
|
## 15. Referencias
|
||||||
|
|
||||||
|
|||||||
BIN
docs/hardware/img/2026-10-27_Drogheda_v6-T45-EP-U4-U7_v1.0.png
Normal file
BIN
docs/hardware/img/2026-10-27_Drogheda_v6-T45-EP-U4-U7_v1.0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
BIN
docs/hardware/img/2026-10-27_Drogheda_v6-T45-R80-Kelvin_v1.0.png
Normal file
BIN
docs/hardware/img/2026-10-27_Drogheda_v6-T45-R80-Kelvin_v1.0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
@ -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).
|
- **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.
|
- **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`).
|
- **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).
|
- **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)"
|
!!! 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-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-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-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-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` |
|
| <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:
|
**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).
|
- **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:** 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 – vie 30:** resto del bloque B, R112 (bloque A) y bloques C, D y E (Michelle).
|
- **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).
|
- **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).
|
- **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).
|
- **Fines de semana 7–8 y 14–15 nov:** firmware v6. Banco del piloto el 21–22 nov (G2 ≈ 22-nov).
|
||||||
|
|||||||
@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, v5.6, v5.7, v6, piloto, jlcpcb, sim, de
|
|||||||
project: Project Drogheda
|
project: Project Drogheda
|
||||||
author: "Helgen Technologies (redacción: Claude)"
|
author: "Helgen Technologies (redacción: Claude)"
|
||||||
date: 2026-10-27
|
date: 2026-10-27
|
||||||
version: 1.1
|
version: 1.2
|
||||||
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"
|
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)"
|
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.
|
**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"
|
!!! 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-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.
|
- **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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| **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):
|
**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ó.
|
- 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.
|
- `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).*
|
*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).*
|
||||||
|
|||||||
@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, einarboard, v6, layout, pcb, michelle,
|
|||||||
project: Project Drogheda
|
project: Project Drogheda
|
||||||
author: "Helgen Technologies (redacción: Claude; decide: Leo)"
|
author: "Helgen Technologies (redacción: Claude; decide: Leo)"
|
||||||
date: 2026-10-27
|
date: 2026-10-27
|
||||||
version: 1.1
|
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)"
|
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"
|
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).
|
- 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"
|
!!! 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).
|
- **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:**
|
**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
|
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.
|
**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
|
## 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.
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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 |
|
| 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-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 |
|
| `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
|
## 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 |
|
| 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-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`.
|
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.*
|
*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.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.*
|
||||||
|
|||||||
@ -4,8 +4,8 @@ tags: [project-drogheda, hardware, hbox, einarboard, v6, layout, revision, kelvi
|
|||||||
project: Project Drogheda
|
project: Project Drogheda
|
||||||
author: Helgen Technologies (revisión: Claude; decide: Leo)
|
author: Helgen Technologies (revisión: Claude; decide: Leo)
|
||||||
date: 2026-10-26
|
date: 2026-10-26
|
||||||
version: 1.0
|
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."
|
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)
|
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.
|
- **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.
|
- **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
|
## 4. Qué sigue
|
||||||
|
|
||||||
**Para Michelle, en este orden:**
|
**Para Michelle, en este orden:**
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
@ -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)
|
**[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í
|
## Qué hay aquí
|
||||||
|
|
||||||
| Página | Para qué |
|
| Página | Para qué |
|
||||||
|
|||||||
150
docs/inventario/integracion-cotizador-instrucciones.md
Normal file
150
docs/inventario/integracion-cotizador-instrucciones.md
Normal 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.
|
||||||
154
docs/inventario/integracion-cotizador.md
Normal file
154
docs/inventario/integracion-cotizador.md
Normal 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
193
docs/js/incidencias.js
Normal 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
22
docs/js/standup.js
Normal 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
23
docs/publico/caido.html
Normal 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
87
docs/publico/entrar.css
Normal 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
132
docs/publico/entrar.js
Normal 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 () {});
|
||||||
|
})();
|
||||||
@ -14,27 +14,21 @@
|
|||||||
<p id="estado">Cerrando sesión…</p>
|
<p id="estado">Cerrando sesión…</p>
|
||||||
<div id="listo" style="display:none">
|
<div id="listo" style="display:none">
|
||||||
<p><b>Sesión cerrada.</b></p>
|
<p><b>Sesión cerrada.</b></p>
|
||||||
<p>Para entrar con otro usuario, abre de nuevo el sitio y aparecerá la ventana de
|
<p>Para entrar otra vez, o con otro usuario, usa la página de entrada.</p>
|
||||||
inicio de sesión.</p>
|
<p style="margin-top:18px"><a class="volver" href="/entrar.html">Entrar de nuevo →</a></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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// Envía credenciales inválidas a un endpoint que siempre responde 401, para que el
|
// 27-oct: la sesión es una cookie del servidor (página de entrada): POST /api/salir la borra.
|
||||||
// navegador reemplace las credenciales guardadas de Basic Auth.
|
// (El servidor ya no acepta la contraseña que el navegador recuerde de la ventana gris de antes: solo la cookie.)
|
||||||
(function () {
|
(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'; }
|
document.getElementById('listo').style.display='block'; }
|
||||||
try {
|
fetch('/api/salir', { method: 'POST', credentials: 'same-origin' }).catch(function () {}).then(done);
|
||||||
var x = new XMLHttpRequest();
|
setTimeout(done, 2000);
|
||||||
x.open('GET', '/logout?ts=' + Date.now(), true, 'salir', 'salir' + Date.now());
|
|
||||||
x.onloadend = done;
|
|
||||||
x.send();
|
|
||||||
} catch (e) { done(); }
|
|
||||||
setTimeout(done, 1500);
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -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); }
|
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; }
|
.hg-pass-inicial a { color: var(--hg-aviso); font-weight: 700; text-decoration: underline; }
|
||||||
@media print { .hg-pass-inicial { display: none; } }
|
@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; } }
|
||||||
|
|||||||
@ -162,7 +162,8 @@ function formMiembro(c, m, conAcceso) {
|
|||||||
if (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;
|
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);
|
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 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' });
|
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); });
|
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); });
|
||||||
|
|||||||
182
docs/tableros/incidencias-tablero.js
Normal file
182
docs/tableros/incidencias-tablero.js
Normal 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);
|
||||||
|
})();
|
||||||
43
docs/tableros/incidencias.css
Normal file
43
docs/tableros/incidencias.css
Normal 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%; }
|
||||||
55
docs/tableros/incidencias.md
Normal file
55
docs/tableros/incidencias.md
Normal 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>
|
||||||
@ -37,6 +37,8 @@ hide:
|
|||||||
<h2>Últimas decisiones</h2>
|
<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>
|
<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">
|
<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>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: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>
|
<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>
|
||||||
|
|||||||
140
docs/tableros/standup-tablero.js
Normal file
140
docs/tableros/standup-tablero.js
Normal 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
32
docs/tableros/standup.css
Normal 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
33
docs/tableros/standup.md
Normal 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>
|
||||||
@ -11,6 +11,7 @@ ICONOS = {
|
|||||||
"tableros/pendientes.md": "helgen/pendientes",
|
"tableros/pendientes.md": "helgen/pendientes",
|
||||||
"bitacora.md": "helgen/bitacora",
|
"bitacora.md": "helgen/bitacora",
|
||||||
"tableros/juntas.md": "helgen/fecha",
|
"tableros/juntas.md": "helgen/fecha",
|
||||||
|
"tableros/standup.md": "helgen/mios", # 27-oct: el bot de standup
|
||||||
# Tableros
|
# Tableros
|
||||||
"tableros/index.md": "helgen/pendientes",
|
"tableros/index.md": "helgen/pendientes",
|
||||||
"tableros/ingenieria.md": "helgen/ingenieria",
|
"tableros/ingenieria.md": "helgen/ingenieria",
|
||||||
@ -21,6 +22,8 @@ ICONOS = {
|
|||||||
"tableros/equipo.md": "helgen/equipo",
|
"tableros/equipo.md": "helgen/equipo",
|
||||||
"tableros/unidad.md": "helgen/unidad",
|
"tableros/unidad.md": "helgen/unidad",
|
||||||
"tableros/campo.md": "helgen/visitas",
|
"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
|
# Documentos
|
||||||
"guia-ingeniero.md": "helgen/manuales",
|
"guia-ingeniero.md": "helgen/manuales",
|
||||||
"produccion/index.md": "helgen/fabrica",
|
"produccion/index.md": "helgen/fabrica",
|
||||||
|
|||||||
@ -52,6 +52,7 @@ extra_css:
|
|||||||
|
|
||||||
extra_javascript:
|
extra_javascript:
|
||||||
- js/sitio.js
|
- js/sitio.js
|
||||||
|
- js/standup.js # 27-oct: franja «Tu standup de hoy te espera»
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
- hooks/iconos.py # íconos del menú lateral (también para páginas que llegan por sync-docs.sh)
|
- 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
|
- Inicio: index.md
|
||||||
- Pendientes: tableros/pendientes.md
|
- Pendientes: tableros/pendientes.md
|
||||||
- Juntas: tableros/juntas.md
|
- Juntas: tableros/juntas.md
|
||||||
|
- Standup: tableros/standup.md
|
||||||
- Bitácora: bitacora.md
|
- Bitácora: bitacora.md
|
||||||
|
|
||||||
- Tableros:
|
- Tableros:
|
||||||
@ -98,6 +100,8 @@ nav:
|
|||||||
- Equipo: tableros/equipo.md
|
- Equipo: tableros/equipo.md
|
||||||
- Expediente de unidad: tableros/unidad.md
|
- Expediente de unidad: tableros/unidad.md
|
||||||
- Visitas de campo: tableros/campo.md
|
- Visitas de campo: tableros/campo.md
|
||||||
|
- Incidencias del sitio: tableros/incidencias.md
|
||||||
|
- Agentes del sitio: agentes.md
|
||||||
|
|
||||||
- Documentos:
|
- Documentos:
|
||||||
- Guía del ingeniero: guia-ingeniero.md
|
- Guía del ingeniero: guia-ingeniero.md
|
||||||
@ -117,6 +121,8 @@ nav:
|
|||||||
- Prueba final: procedimientos/v5-prueba-final-unidad.md
|
- Prueba final: procedimientos/v5-prueba-final-unidad.md
|
||||||
- Configuración: procedimientos/v5-configuracion-unidad.md
|
- Configuración: procedimientos/v5-configuracion-unidad.md
|
||||||
- Política de inventario: inventario/index.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:
|
||||||
- taller/index.md
|
- taller/index.md
|
||||||
- Diagnóstico en banco:
|
- Diagnóstico en banco:
|
||||||
|
|||||||
@ -10,4 +10,6 @@
|
|||||||
<link rel="apple-touch-icon" href="{{ 'img/helgen-icono-180.png' | url }}">
|
<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/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>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
20
scripts/agente-api.sh
Executable file
20
scripts/agente-api.sh
Executable 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
|
||||||
@ -18,7 +18,10 @@ echo "Sitio publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)"
|
|||||||
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
|
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
|
||||||
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt backend/seed_1013.py \
|
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/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.
|
# 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/"
|
rsync -az -e "ssh -p $PORT" backend/cuentas/ "$REMOTE:$BACKEND_PATH/cuentas/"
|
||||||
echo "Backend copiado en $REMOTE:$BACKEND_PATH"
|
echo "Backend copiado en $REMOTE:$BACKEND_PATH"
|
||||||
|
|||||||
46
scripts/despliegue-1027.sh
Executable file
46
scripts/despliegue-1027.sh
Executable 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)."
|
||||||
@ -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.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-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"
|
"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
|
n=0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user