Reorganizacion del sitio (8 pestanas, Historia), tableros kanban/ingenieria/produccion, inventario, sitios, acta de instalacion, backend nuevo (13-oct)

This commit is contained in:
Leopoldo Rodríguez 2026-10-13 19:44:07 -06:00
parent 217311a418
commit 9f3d41ed49
47 changed files with 3759 additions and 237 deletions

View File

@ -9,7 +9,11 @@ Servidor: `git.helgen.online` · dominio público: `https://drogheda.helgen.onli
| Sitio (docs, formularios, tableros) | `/srv/projects/drogheda/hbox-docs/site` | `./scripts/deploy.sh` desde la Mac | | Sitio (docs, formularios, tableros) | `/srv/projects/drogheda/hbox-docs/site` | `./scripts/deploy.sh` desde la Mac |
| Backend de actas (`app.py`) | `/srv/projects/drogheda/hbox-docs/backend` | `git pull` en el servidor + `systemctl restart actas-hbox` | | Backend de actas (`app.py`) | `/srv/projects/drogheda/hbox-docs/backend` | `git pull` en el servidor + `systemctl restart actas-hbox` |
| Actas recibidas (JSON + índice CSV) | `/srv/projects/drogheda/actas` | las escribe el backend | | Actas recibidas (JSON + índice CSV) | `/srv/projects/drogheda/actas` | las escribe el backend |
| **Fotos de campo** | `/srv/projects/drogheda/adjuntos` | las escribe el backend — **nuevo** | | **Fotos de campo** | `/srv/projects/drogheda/adjuntos` | las escribe el backend |
| **Pendientes del tablero (kanban)** | `/srv/projects/drogheda/tareas` | las escribe el backend — **13-oct** |
| **Inventario (ítems y movimientos)** | `/srv/projects/drogheda/inventario` | las escribe el backend — **13-oct** |
| **Sitios** | `/srv/projects/drogheda/sitios` | las escribe el backend — **13-oct** |
| **Plan de producción** | `/srv/projects/drogheda/plan-produccion.json` | lo escribe el backend (admin) — **13-oct** |
| Usuarios (HTTP Basic) | `/etc/nginx/hbox-docs.htpasswd` | `htpasswd` | | Usuarios (HTTP Basic) | `/etc/nginx/hbox-docs.htpasswd` | `htpasswd` |
**Lo importante:** `deploy.sh` publica **solo el sitio**. Si cambió `backend/app.py` —como en esta **Lo importante:** `deploy.sh` publica **solo el sitio**. Si cambió `backend/app.py` —como en esta
@ -136,6 +140,90 @@ cambiar la suya sola: restablécesela desde la propia página, que la vuelve a g
--- ---
## A2 · Despliegue 13-oct — tableros, inventario, sitios e instalación en campo
Esta versión agrega al backend cuatro colecciones nuevas (pendientes del tablero, plan de
producción, inventario con movimientos, sitios) y páginas que las usan (`tableros/`, `inventario/`,
`sitios/`, `formularios/instalacion.html`). Igual que siempre: **el sitio se publica desde la Mac;
el backend se actualiza en el servidor**. Si solo publicas el sitio, los tableros dirán «No se pudo
cargar» porque hablan con endpoints que el backend viejo no tiene.
### 1 · Desde la Mac — publicar el sitio
```bash
cd ~/Documents/Claude/Projects/Drogheda/hbox-docs
git add -A && git commit -m "tableros kanban, inventario, sitios, acta de instalación (13-oct)"
git push
./scripts/deploy.sh
```
### 2 · En el servidor — backend y carpetas (las carpetas, una sola vez)
```bash
ssh leo@git.helgen.online
cd /srv/projects/drogheda/hbox-docs && git pull
# carpetas de las colecciones nuevas (junto a /actas; el backend las deduce de ACTAS_DIR)
sudo mkdir -p /srv/projects/drogheda/{tareas,inventario/items,inventario/movimientos,sitios}
sudo chown -R leo:leo /srv/projects/drogheda/{tareas,inventario,sitios}
sudo chown leo:leo /srv/projects/drogheda # (sin -R) el backend escribe plan-produccion.json en esta carpeta
sudo systemctl restart actas-hbox
curl -s http://127.0.0.1:8081/api/salud # {"ok":true,"servicio":"actas-hbox"}
curl -s -H 'X-Remote-User: leo' http://127.0.0.1:8081/api/tareas/resumen # {"ok":true,"total":0,...}
```
Variables de entorno: **no hace falta tocar el `.service`**. Si quieres mover las carpetas, descomenta
en `/etc/systemd/system/actas-hbox.service` las líneas `TAREAS_DIR`, `INVENTARIO_DIR`, `SITIOS_DIR`,
`PLAN_FILE` (vienen comentadas en `backend/actas-hbox.service`) y luego `daemon-reload` + `restart`.
### 3 · Siembra inicial (una sola vez; repetirla no duplica nada)
```bash
cd /srv/projects/drogheda/hbox-docs
./backend/venv/bin/python3 backend/seed_1013.py --usuario leo
```
Crea: el plan (570 → 113 V5.6 / 327 V5.7 / 130 v6), los 4 sitios (icave, lzc, eit, timsa — ubicación
y contactos «por confirmar» donde no se sabe), 18 ítems de inventario con **stock 0** (Jesús hace el
conteo físico con movimientos «ajuste») y los 12 pendientes del tablero del 13-oct. Lo que ya exista
con el mismo `id` se deja intacto. Para importar los pendientes del Task Tracker de Notion, exporta
una lista JSON con `id`, `titulo`, `pista`, `estado`, `responsable` y pásala con `--extra archivo.json`.
### 4 · Verificar
```bash
curl -s -u leo:PASS https://drogheda.helgen.online/api/tareas | head -c 300
curl -s -u leo:PASS https://drogheda.helgen.online/api/plan | head -c 200
curl -s -u leo:PASS https://drogheda.helgen.online/api/inventario | head -c 200
curl -s -u leo:PASS https://drogheda.helgen.online/api/sitios | head -c 200
```
Y desde el navegador: `/tableros/kanban.html` (arrastra un pendiente y revisa que el historial
anote quién lo movió), `/inventario/inventario.html` (registra un «ajuste» con el conteo real),
`/sitios/sitio.html?id=icave`, `/formularios/instalacion.html` con la unidad **PRUEBA**.
### 5 · Respaldo
Se suman tres carpetas y un archivo a la rutina de siempre:
```bash
rsync -az leo@git.helgen.online:/srv/projects/drogheda/tareas/ ~/respaldo-tareas/
rsync -az leo@git.helgen.online:/srv/projects/drogheda/inventario/ ~/respaldo-inventario/
rsync -az leo@git.helgen.online:/srv/projects/drogheda/sitios/ ~/respaldo-sitios/
rsync -az leo@git.helgen.online:/srv/projects/drogheda/plan-produccion.json ~/respaldo-plan/
```
| Síntoma | Causa | Arreglo |
|---|---|---|
| Los tableros dicen «No se pudo cargar (HTTP 404)» | backend viejo | paso 2 (`git pull` + restart) |
| «No puedo identificarte (falta X-Remote-User…)» al crear o mover | nginx no manda la cabecera | paso 4 del despliegue anterior |
| El tablero sale vacío | falta la siembra | paso 3 |
| «Solo un administrador…» al borrar o editar sitios/plan | ese usuario no está en `ACTAS_ADMINS` | así debe ser; pide a Leo |
| `PermissionError` al guardar el plan | `/srv/projects/drogheda` no es de `leo` | `chown` del paso 2 |
---
## B · Verificación después de desplegar ## B · Verificación después de desplegar
```bash ```bash

54
NUEVOS-1013.md Normal file
View File

@ -0,0 +1,54 @@
# Archivos nuevos y modificados — 13-oct-2026 (Constructor: WP2–WP5)
Para quien arma la navegación (`mkdocs.yml`) y `scripts/sync-docs.sh`: no toqué ninguno de los dos.
Las rutas son relativas a `docs/`; el título propuesto es el que iría en el `nav`. Los `.html` se
copian tal cual; los `.md` llevan frontmatter con `title` y `date`.
## Páginas nuevas (con título propuesto para el nav)
| Ruta en `docs/` | Título propuesto | Sección sugerida |
|---|---|---|
| `tableros/index.md` | **Tableros** (índice: qué es cada tablero) | 📋 Tableros (pestaña nueva) |
| `tableros/kanban.html` | Pendientes (kanban) | 📋 Tableros |
| `tableros/ingenieria.html` | Ingeniería — ¿dónde está el hardware? | 📋 Tableros |
| `tableros/produccion.html` | Producción — pedido 570 | 📋 Tableros (y enlazar desde Producción) |
| `inventario/index.md` | **Inventario** (política: quién registra, cuándo, mínimos) | 📦 Inventario (o dentro de Producción) |
| `inventario/inventario.html` | Inventario de materiales | 📦 Inventario |
| `sitios/index.md` | **Sitios** (lista viva + cómo dar de alta) | 🚜 Campo |
| `sitios/sitio.html` | Página de un sitio (`?id=icave`) | 🚜 Campo |
| `formularios/instalacion.html` | Acta de instalación en campo | 🚜 Campo → «Instalación en el puerto» (junto al checklist de puesta en marcha) |
Archivos de apoyo (no van al nav): `tableros/tablero.css`, `tableros/kanban.js`.
Sugerencia para `actas/index.md` (no lo edité, para no chocar con la reorganización): agregar la fila
«[Instalación en campo](../formularios/instalacion.html) · Una por unidad y vehículo; sitio, alimentación,
antenas, RTK FIX, fotos · [Sitios](../sitios/index.md)» en la tabla **Campo**.
## Páginas existentes modificadas
| Ruta | Cambio |
|---|---|
| `formularios/acta.js` | nuevo tipo de ítem `sitio` (select alimentado por `/api/sitios`; `guardaEn:'sitio'`); `fillFrom` respeta un select cuyas opciones aún no cargan |
| `formularios/envio.html`, `recepcion.html`, `diagnostico.html`, `dano.html` | un ítem `sitio` (opcional) en la sección 0; en daño solo aparece si fue en campo / transporte / no se sabe |
| `unidad.html` | bloque **Trazabilidad**: cadena de producción (tarjeta → módulos → integración → ensamble → configuración) y cadena de campo (envío → recepción → instalación → diagnósticos → daños) con sitio y fechas |
## Backend y despliegue
| Ruta | Cambio |
|---|---|
| `backend/app.py` | colecciones `tareas`, `plan`, `inventario` (+ `movimientos`), `sitios`; `/api/actas` y `/api/unidad` exponen `sitio`/`fecha`/`componentes` |
| `backend/seed_1013.py` | **nuevo** — siembra idempotente (plan, 4 sitios, 18 ítems, 12 pendientes; `--extra` para importar Notion) |
| `backend/tests-1013.sh`, `backend/TESTS-1013.md` | **nuevos** — pruebas con `curl` (61) y resumen de las pruebas de navegador |
| `backend/README.md` | sección 5: colecciones, variables, ejemplos `curl` por endpoint |
| `backend/actas-hbox.service` | variables `TAREAS_DIR`, `INVENTARIO_DIR`, `SITIOS_DIR`, `PLAN_FILE` comentadas (valores por omisión junto a `/actas`) |
| `DESPLIEGUE.md` | sección **A2 · Despliegue 13-oct** (git pull, mkdir, restart, siembra, verificación, respaldo, problemas frecuentes) y filas nuevas en «Qué vive dónde» |
## Enlaces que las páginas nuevas esperan encontrar
Las páginas enlazan a estas rutas del sitio (todas existen hoy; si la reorganización las mueve, hay que
actualizar `tableros/ingenieria.html` (constante `VERSIONES` y lista de decisiones) y `tableros/produccion.html` (constante `FORM`)):
`hardware/v56-layout-pasada-2/`, `hardware/v56-auditoria-layout-13oct/`, `hardware/v56-eco/`, `hardware/v56-datasheet/`,
`hardware/v56-pinout/`, `hardware/v57-eco-fuente/`, `hardware/v57-layout-guia/`, `hardware/v6-proximos-pasos/`,
`hardware/v6-plan-correccion/`, `hardware/v6-revision-lecciones-v55/`, `hardware/EinarBoard-v6_Plan-de-Cobre_ES.html`,
`manuales/manual-tarjeta-V5/`, `manuales/manual-tarjeta-v6/`, `tablero.html`, `unidad.html`, `actas/`,
`diagnostico/tablero-campo.html` y los formularios de `formularios/`.

View File

@ -22,8 +22,11 @@ mkdocs serve # http://127.0.0.1:8000
``` ```
**Política de publicación:** solo se publica lo que está en la lista de **Política de publicación:** solo se publica lo que está en la lista de
`scripts/sync-docs.sh`. Nada de finanzas, briefs de diseño ni auditorías EMC; para `scripts/sync-docs.sh`. Se publica lo que sirve al técnico o al estado de ingeniería
agregar un documento al sitio se agrega a esa lista (y con eso queda auditado en git). (procedimientos, actas, manuales, ECOs, auditorías de diseño y layout, guías de layout);
nada de finanzas, fundraising ni briefs comerciales. Para agregar un documento al sitio se
agrega a esa lista (y con eso queda auditado en git). Lo superado no se borra: baja a
«Referencia e histórico» con un banner que dice qué lo sustituye.
## Configuración del servidor (una sola vez) ## Configuración del servidor (una sola vez)

View File

@ -61,3 +61,101 @@ Tras un `git pull` que cambie `app.py`:
```bash ```bash
sudo systemctl restart actas-hbox sudo systemctl restart actas-hbox
``` ```
## 5. Colecciones del 13-oct — tableros, plan, inventario y sitios
Mismo esquema que las actas: **un JSON por registro** más un `_indice.csv` que se regenera en
cada cambio. Carpetas (variables de entorno, con su valor por omisión junto a `ACTAS_DIR`):
| Variable | Omisión | Qué guarda |
|---|---|---|
| `TAREAS_DIR` | `/srv/projects/drogheda/tareas` | pendientes del tablero (kanban) |
| `INVENTARIO_DIR` | `/srv/projects/drogheda/inventario` | `items/` y `movimientos/` |
| `SITIOS_DIR` | `/srv/projects/drogheda/sitios` | un JSON por sitio (`icave.json`…) |
| `PLAN_FILE` | `/srv/projects/drogheda/plan-produccion.json` | el plan del pedido (570 → 113/327/130) |
Reglas comunes: toda escritura exige el usuario autenticado (`X-Remote-User`; sin él, 403);
cada registro guarda `creado_por/creado_en/modificado_por/modificado_en` y un `historial`
de `{quien, cuando, cambio}`; **solo un administrador borra** (`ACTAS_ADMINS`). Un `POST`
puede traer su propio `id` (lo usa `seed_1013.py` para ser idempotente): si ya existe → 409.
Siembra inicial (plan, 4 sitios, ítems de inventario, pendientes del 13-oct), idempotente:
```bash
cd /srv/projects/drogheda/hbox-docs && ./backend/venv/bin/python3 backend/seed_1013.py
# agrega --usuario leo (omisión) para firmar la siembra; --extra tareas.json para importar más pendientes
```
### Pendientes (tablero kanban) — `tareas`
Campos: `id`, `titulo`, `descripcion`, `pista` ∈ ingenieria·produccion·mecanica·compras·campo,
`estado` ∈ por-hacer·en-curso·bloqueado·hecho, `responsable`, `fecha_limite` (AAAA-MM-DD o null),
`enlaces` [{titulo,url}], `bloqueada_por` [ids], `orden`, `etiquetas` [], `version_tarjeta` ∈ V5.5·V5.6·V5.7·v6·—.
```bash
U=leo:PASS; D=https://drogheda.helgen.online
curl -s -u $U "$D/api/tareas?pista=ingenieria&estado=en-curso&responsable=michelle"
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/tareas \
-d '{"titulo":"V5.6 pasada 2","pista":"ingenieria","responsable":"michelle","estado":"en-curso",
"version_tarjeta":"V5.6","fecha_limite":"2026-10-17","enlaces":[{"titulo":"Pasada 2","url":"/hardware/v56-layout-pasada-2/"}]}'
# mover de columna (y posición): PATCH parcial; el historial anota quién y qué cambió
curl -s -u $U -H 'Content-Type: application/json' -X PATCH "$D/api/tarea?id=T261013-ab12" \
-d '{"estado":"hecho","orden":0,"_nota":"commit d9f1 subido"}'
# renumerar una columna después de arrastrar (no escribe historial)
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/tareas/orden \
-d '{"estado":"en-curso","ids":["T261013-ab12","t-v57-fuente"]}'
curl -s -u $U -X DELETE "$D/api/tarea?id=T261013-ab12" # solo admin → 403 para los demás
curl -s -u $U $D/api/tareas/resumen # conteo pista×estado + vencidas
```
### Plan de producción — `plan`
```bash
curl -s -u $U $D/api/plan # si no existe el archivo devuelve el plan por omisión con "_default": true
curl -s -u $U -H 'Content-Type: application/json' -X PUT $D/api/plan -d '{
"pedido_total": 570,
"versiones": [{"version":"V5.6","objetivo":113,"nota":"con Mean Well SD-15B","serie_prefijo":"V56","fecha":"2026-11-15"},
{"version":"V5.7","objetivo":327,"nota":"fuente a bordo","serie_prefijo":"V57"},
{"version":"v6","objetivo":130,"nota":"+ reemplazos futuros","serie_prefijo":"V6"}]}' # solo admin
```
`etapas` (embudo) se puede omitir: se conserva la que ya había. Cada etapa dice de qué tipos de
acta se cuenta (`tipos`), si solo cuentan las aprobadas (`ok`) y si solo cuentan unidades HBOX
(`solo_unidades`). El tablero de producción hace el conteo en el navegador con `/api/actas`.
### Inventario — `inventario` + `movimientos`
El stock **no se edita a mano**: es la suma de los movimientos del ítem (`entrada` +n, `salida` −n,
`ajuste` = conteo físico; el servidor calcula el delta). Una salida mayor al stock registrado → 409.
```bash
curl -s -u $U "$D/api/inventario?categoria=fuente&aplica_a=V5.6&alertas=1" # alerta = stock < minimo
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario \
-d '{"codigo":"SD-15B-05","descripcion":"Mean Well SD-15B-05","categoria":"fuente","aplica_a":["V5.6"],
"unidad":"pza","minimo":113,"ubicacion":"Anaquel B1","proveedor":"Mouser","codigo_proveedor":"709-SD15B05","stock_inicial":0}'
curl -s -u $U -H 'Content-Type: application/json' -X PATCH "$D/api/inventario/item?id=SD-15B-05" -d '{"ubicacion":"Anaquel B2","minimo":120}'
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movimiento \
-d '{"item_id":"SD-15B-05","tipo":"entrada","cantidad":50,"motivo":"llegó pedido Mouser","referencia":"OC-0042"}'
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movimiento \
-d '{"item_id":"EB-V56","tipo":"salida","cantidad":1,"motivo":"ensamble","referencia":"HBOX5-E07-A010-R0006"}'
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movimiento \
-d '{"item_id":"EB-V56","tipo":"ajuste","cantidad":12,"motivo":"conteo físico 13-oct"}'
curl -s -u $U "$D/api/inventario/movimientos?item_id=EB-V56&n=50"
curl -s -u $U -o inventario.csv $D/api/inventario/export.csv
```
### Sitios — `sitios`
```bash
curl -s -u $U "$D/api/sitios" # activos; &todos=1 incluye los inactivos
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/sitios -d '{"id":"icave","nombre":"ICAVE",
"cliente":"Hutchison Ports","ubicacion":"Veracruz, Ver.","alias":["Veracruz"],
"contactos":[{"nombre":"por confirmar","rol":"supervisor de patio","telefono":"","correo":""}],"notas":""}' # solo admin
curl -s -u $U -H 'Content-Type: application/json' -X PATCH "$D/api/sitio?id=icave" -d '{"activo":true,"ubicacion":"Veracruz, Ver."}' # solo admin
curl -s -u $U "$D/api/sitio/unidades?id=icave"
```
`/api/sitio/unidades` junta las actas de envío, recepción, instalación, diagnóstico y daño cuyo
campo `sitio` sea el slug del sitio (`coincidencia: "sitio"`), y también las que solo coinciden por
texto (`terminal` de la flota, destino del envío) con el nombre o un alias (`coincidencia: "texto"`),
agrupadas por unidad con su marca de daño vigente.

143
backend/TESTS-1013.md Normal file
View File

@ -0,0 +1,143 @@
# Pruebas del 13-oct — backend (tareas, plan, inventario, sitios) y páginas
Todo se corrió en el sandbox contra `backend/app.py` arrancado con carpetas temporales
(`ACTAS_DIR=<tmp>/actas`; las demás colecciones se deducen de ahí). Sin nginx: el usuario se
inyecta con la cabecera `X-Remote-User` tal como la fija nginx en producción. Nada de lo de
abajo toca el servidor real.
## 1 · Backend — `backend/tests-1013.sh` (61 comprobaciones)
El script arranca `app.py` en un puerto libre, ejercita cada endpoint nuevo con `curl` como
**leo** (admin) y como **alan** (técnico), corre la siembra dos veces y una importación `--extra`.
Se puede repetir en cualquier máquina con Python 3 + Flask:
```bash
bash backend/tests-1013.sh /ruta/a/hbox-docs 8098
```
Resultado de la corrida (13-oct-2026):
```
## salud y endpoints viejos
✓ salud
✓ whoami leo admin
✓ actas vacías
## tareas
✓ sin usuario → 403
✓ POST (alan)
✓ POST id duplicado → 409
✓ POST pista inválida → 400
✓ POST fecha inválida → 400
✓ GET filtro pista
✓ GET filtro responsable
✓ vencida calculada
✓ PATCH mover (alan) → historial con quién
✓ PATCH sin cambios
✓ PATCH estado inválido → 400
✓ PATCH inexistente → 404
✓ POST orden (renumera columna: t-b cambia de columna, t-a de posición)
✓ resumen
✓ resumen vencidas
✓ DELETE alan → 403
✓ DELETE leo → ok
✓ índice CSV escrito
## plan
✓ GET sin archivo → por omisión
✓ PUT alan → 403
✓ PUT leo
✓ PUT conserva etapas
✓ PUT sin versiones → 400
## inventario
✓ POST ítem (alan)
✓ POST código duplicado → 409
✓ POST categoría inválida → 400
✓ POST con stock_inicial → entrada
✓ salida sin stock → 409
✓ entrada
✓ salida
✓ ajuste (conteo 4 → delta −3)
✓ sin motivo → 400
✓ alerta = stock < mínimo
✓ PATCH stock a mano → 400
✓ PATCH ubicación → historial
✓ movimientos del ítem
✓ export CSV
✓ export CSV cabecera
## sitios
✓ POST alan → 403
✓ POST leo
✓ POST duplicado → 409
✓ PATCH alan → 403
✓ PATCH leo → historial
✓ GET sitios
✓ unidades del sitio
✓ coincidencia por slug
✓ coincidencia por texto (terminal/destino)
✓ instalada con fecha
✓ marca de daño en la unidad
✓ sitio inexistente → 404
✓ /api/unidad trae sitio
✓ /api/actas trae sitio
## siembra (idempotente)
✓ primera corrida crea
✓ sitios: 3 nuevos (icave ya existía)
✓ segunda corrida no duplica
✓ inventario no duplica
✓ --extra importa
✓ resumen tras siembra (1 + 12 sembrados + 1 extra)
RESULTADO: 61 correctas · 0 fallidas · datos en <tmp>
```
Qué cubre, por colección:
| Colección | Probado |
|---|---|
| `tareas` | 403 sin usuario; POST con y sin `id`; 409 por id duplicado; 400 por pista/estado/fecha inválidos; filtros `pista`, `responsable`; `vencida`; PATCH que mueve de columna y deja `historial` con **quién**; PATCH sin cambios; 404; `POST /api/tareas/orden`; `resumen` (conteo y vencidas); DELETE 403 para técnico y ok para admin; `_indice.csv` |
| `plan` | GET devuelve el plan por omisión (`_default`) sin archivo; PUT 403 para técnico; PUT admin guarda `actualizado_por` y conserva las `etapas`; 400 sin versiones |
| `inventario` | POST ítem; 409 código duplicado (insensible a mayúsculas); 400 categoría inválida; `stock_inicial` → entrada; salida mayor al stock → 409; entrada/salida/ajuste con `delta` y `stock_despues`; 400 sin motivo; `alerta` = stock < mínimo; PATCH `stock` rechazado; PATCH con historial; listado de movimientos; CSV con cabecera `text/csv` |
| `sitios` | POST/PATCH 403 para técnico; POST admin; 409 duplicado; PATCH con historial; GET; `/api/sitio/unidades` con coincidencia por slug (`sitio`) y por texto (`terminal`, destino de envío), fecha de instalación, marca de daño; 404 |
| actas existentes | `/api/actas` y `/api/unidad` ahora traen `sitio` (y `fecha`, `componentes` en las propias) sin romper lo demás |
| `seed_1013.py` | primera corrida crea plan, 4 sitios, 18 ítems, 12 pendientes; la segunda no duplica nada; `--extra archivo.json` importa pendientes adicionales (Notion) de forma idempotente |
Además, antes del script: `python3 -m py_compile backend/app.py` sin errores; los endpoints
viejos (`/api/salud`, `/api/whoami`, `/api/actas`, `/api/acta`, `/api/adjuntos`, `/api/unidad`,
`/api/series`, `/api/estado`, `/api/danos`) se usaron desde las páginas durante las pruebas de
navegador sin cambios de comportamiento.
## 2 · Páginas — navegador real (Chromium headless con Playwright)
Se sirvió `docs/` tal cual con un proxy que reenvía `/api/` al backend y pone `X-Remote-User`
(un servidor como **leo** y otro como **alan**). Pruebas en teléfono (390×844, táctil) y escritorio.
| Página | Probado | Resultado |
|---|---|---|
| `tableros/kanban.html` | carga 12 pendientes sembrados; en teléfono se ve «Mover a…» y mueve de columna; el historial del detalle dice `alan · estado: por-hacer → en-curso`; alan no ve «Borrar»; crear pendiente con fecha pasada → tarjeta roja (vencida); filtro por pista; en escritorio **arrastrar y soltar** a Hecho; leo borra con confirmación | ✓ sin errores de consola |
| `tableros/ingenieria.html` | 4 tarjetas de versión con enlaces; pendientes filtrados a Ingeniería; contador por versión | ✓ |
| `tableros/produccion.html` | KPIs, plan vs real, embudo, bloqueos, alertas, daños; leo ve «Editar plan» y guarda una fecha objetivo; alan no ve el botón | ✓ |
| `inventario/inventario.html` | 18 ítems; ajuste a 15 desde el diálogo; salida de 99 → error visible «No hay stock suficiente»; historial del ítem; nuevo ítem con stock inicial 3; filtro «solo alertas»; export CSV con `Content-Disposition` | ✓ (el único mensaje de consola es el 409 esperado) |
| `formularios/instalacion.html` | lista de sitios desde `/api/sitios`; técnico precargado; unidad «otra»; exige las 3 fotos obligatorias; con fotos subidas envía: «Registro enviado … con 3 foto(s)»; segunda visita muestra el historial | ✓ |
| `formularios/envio.html` · `dano.html` | el select de sitio existe; en daño solo aparece al elegir «En campo / transporte / no se sabe» | ✓ |
| `sitios/sitio.html` | LZC muestra la unidad recién instalada; ICAVE muestra RTK-014 «por texto» (acta vieja sin sitio) y DAÑADA; alan no ve «Editar sitio»; leo edita ubicación + contacto y crea un sitio nuevo desde el selector | ✓ |
| `unidad.html` | bloque «Trazabilidad»: cadena de producción (módulos deducidos del número de unidad cuando no hay ensamble) y cadena de campo con sitio y fecha | ✓ |
Artefactos de Playwright (no van al repo): capturas `kanban-leo.png`, `produccion.png`,
`inventario-movil.png`, `instalacion-movil.png`, `sitio-icave.png`, `unidad.png`.
Dos falsos negativos que salieron en el camino y son del arnés de prueba, no de las páginas:
(1) Playwright hace scroll entre `mousedown` y `dragstart`, así que con una ventana corta
arrastraba la tarjeta equivocada — se resolvió con una ventana alta; (2) disparar `change`
sintético en el campo «otra unidad» y luego el `change` nativo al perder el foco hacía dos
`loadSerie()` — se resolvió haciendo blur normal (Tab), como lo haría un usuario.
## 3 · Sitio estático
- `mkdocs build --strict` sobre una copia: **55 advertencias, las mismas 55 de antes** (PDF y
binarios que no existen en esta copia). Ninguna menciona `tableros/`, `inventario/`, `sitios/`,
`instalacion.html` ni `unidad.html`. Las tres páginas `.md` nuevas se construyen (quedan fuera
del `nav` hasta que se aplique la nueva navegación; ver `NUEVOS-1013.md`).
- Comprobación de referencias en los HTML nuevos y modificados (todo `src`/`href` relativo y
los enlaces `../hardware/…/` dentro del JS): **0 rotas**.
- `node --check` sobre `tableros/kanban.js` y `formularios/acta.js`: sin errores de sintaxis.
- El `<script>` embebido en `sitios/index.md` sobrevive al render de Markdown (verificado en
el HTML generado).

View File

@ -12,6 +12,12 @@ 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 Environment=ACTAS_ADMINS=leo
# Colecciones del 13-oct (tableros, inventario, sitios). Si no se fijan, el backend las deduce
# de ACTAS_DIR: viven junto a /actas. Descomenta solo para moverlas de lugar.
#Environment=TAREAS_DIR=/srv/projects/drogheda/tareas
#Environment=INVENTARIO_DIR=/srv/projects/drogheda/inventario
#Environment=SITIOS_DIR=/srv/projects/drogheda/sitios
#Environment=PLAN_FILE=/srv/projects/drogheda/plan-produccion.json
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

View File

@ -25,6 +25,15 @@ Rutas:
Actas compuestas (integracion, ensamble): traen d["componentes"] = {clave: serie_modulo}. En los tipos Actas compuestas (integracion, ensamble): traen d["componentes"] = {clave: serie_modulo}. En los tipos
de TIPOS_UNICOS un módulo no puede aparecer en dos actas reales (409). de TIPOS_UNICOS un módulo no puede aparecer en dos actas reales (409).
Colecciones agregadas el 13-oct-2026 (tableros, inventario, sitios) — un JSON por registro + _indice.csv,
campos de auditoría creado_por/creado_en/modificado_por/modificado_en/historial; cualquier usuario
autenticado crea y modifica, solo un administrador borra:
GET/POST /api/tareas · PATCH/DELETE /api/tarea?id= · GET /api/tareas/resumen · POST /api/tareas/orden
GET /api/plan · PUT /api/plan (admin)
GET/POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento
GET /api/inventario/movimientos?item_id= · GET /api/inventario/export.csv
GET /api/sitios · POST /api/sitios (admin) · PATCH /api/sitio?id= (admin) · GET /api/sitio/unidades?id=
""" """
import os, re, json, csv, datetime, uuid, hashlib import os, re, json, csv, datetime, uuid, hashlib
from flask import Flask, request, jsonify, abort, send_file from flask import Flask, request, jsonify, abort, send_file
@ -292,7 +301,8 @@ def listar():
"resultado": d.get("resultado"), "recibido": d.get("_recibido")} "resultado": d.get("resultado"), "recibido": d.get("_recibido")}
if isinstance(d.get("componentes"), dict): if isinstance(d.get("componentes"), dict):
fila["componentes"] = d["componentes"] fila["componentes"] = d["componentes"]
for k in ("clasificacion", "accion", "motivo", "maquina", "terminal", "unidad", "resumen"): for k in ("clasificacion", "accion", "motivo", "maquina", "terminal", "unidad", "resumen",
"sitio", "fecha", "maquina_id"):
if d.get(k): if d.get(k):
fila[k] = d[k] fila[k] = d[k]
if d.get("archivos"): if d.get("archivos"):
@ -616,10 +626,13 @@ def expediente():
"tecnico": d.get("tecnico"), "resultado": d.get("resultado"), "tecnico": d.get("tecnico"), "resultado": d.get("resultado"),
"clasificacion": d.get("clasificacion"), "accion": d.get("accion"), "clasificacion": d.get("clasificacion"), "accion": d.get("accion"),
"motivo": d.get("motivo"), "maquina": d.get("maquina_id") or d.get("maquina"), "motivo": d.get("motivo"), "maquina": d.get("maquina_id") or d.get("maquina"),
"sitio": d.get("sitio") or None,
"fotos": [f2.get("ruta") for f2 in (d.get("fotos") or []) if f2.get("ruta")], "fotos": [f2.get("ruta") for f2 in (d.get("fotos") or []) if f2.get("ruta")],
"archivos": [{"ruta": a.get("ruta"), "nombre": a.get("nombre")} "archivos": [{"ruta": a.get("ruta"), "nombre": a.get("nombre")}
for a in (d.get("archivos") or []) if a.get("ruta")]} for a in (d.get("archivos") or []) if a.get("ruta")]}
if d.get("serie") == serie: if d.get("serie") == serie:
if isinstance(d.get("componentes"), dict):
fila["componentes"] = d["componentes"] # ensamble/integración: para la cadena de producción
actas.append(fila) actas.append(fila)
elif isinstance(d.get("componentes"), dict) and serie in d["componentes"].values(): elif isinstance(d.get("componentes"), dict) and serie in d["componentes"].values():
fila["componentes"] = d["componentes"] fila["componentes"] = d["componentes"]
@ -717,5 +730,869 @@ def danos():
return jsonify(ok=True, total=len(out), series=out) return jsonify(ok=True, total=len(out), series=out)
# =====================================================================================
# Colecciones 13-oct-2026: tareas (tablero kanban), plan de producción, inventario, sitios
# =====================================================================================
# Mismo esquema que las actas: un archivo JSON por registro dentro de su carpeta, más un
# _indice.csv que se reescribe en cada cambio (para abrirlo en Excel). Los directorios se
# toman de variables de entorno y, si no están, viven junto a ACTAS_DIR.
DATOS_BASE = os.path.dirname(BASE.rstrip("/")) or "/srv/projects/drogheda"
TAREAS_DIR = os.environ.get("TAREAS_DIR", os.path.join(DATOS_BASE, "tareas"))
INVENTARIO_DIR = os.environ.get("INVENTARIO_DIR", os.path.join(DATOS_BASE, "inventario"))
SITIOS_DIR = os.environ.get("SITIOS_DIR", os.path.join(DATOS_BASE, "sitios"))
PLAN_FILE = os.environ.get("PLAN_FILE", os.path.join(DATOS_BASE, "plan-produccion.json"))
PISTAS = ("ingenieria", "produccion", "mecanica", "compras", "campo")
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—")
CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable",
"conector", "componente-critico", "otro")
UNIDADES_INV = ("pza", "m")
TIPOS_MOV = ("entrada", "salida", "ajuste")
# Tipos de acta que "pasan por un sitio": son los que se listan en la página de cada sitio.
TIPOS_SITIO = ("envio", "recepcion", "instalacion-campo", "diagnostico-campo", "dano")
FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def ahora():
return datetime.datetime.now().replace(microsecond=0).isoformat()
def quien():
"""Usuario que escribe. Las colecciones nuevas exigen identificarlo: sin X-Remote-User no se escribe."""
return current_user()
def _sin_usuario():
return jsonify(ok=False, error="No puedo identificarte (falta X-Remote-User en nginx). "
"Vuelve a iniciar sesión; si sigue, avisa a Leo."), 403
def _json_body():
try:
d = request.get_json(force=True)
except Exception:
return None
return d if isinstance(d, dict) else None
def _leer_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _escribir_json(path, d):
"""Escritura atómica: primero a un temporal en la misma carpeta, luego os.replace."""
carpeta = os.path.dirname(path) or "."
os.makedirs(carpeta, exist_ok=True)
tmp = os.path.join(carpeta, ".tmp-" + uuid.uuid4().hex[:8] + ".json")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def _listar_json(carpeta):
out = []
if not os.path.isdir(carpeta):
return out
for fn in sorted(os.listdir(carpeta)):
if fn.endswith(".json") and not fn.startswith("."):
d = _leer_json(os.path.join(carpeta, fn))
if isinstance(d, dict):
out.append(d)
return out
def _reescribir_indice(carpeta, registros, columnas):
"""_indice.csv de la colección completa (pequeña): se regenera entero en cada cambio."""
if not os.path.isdir(carpeta):
return
idx = os.path.join(carpeta, "_indice.csv")
with open(idx, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(columnas)
for r in registros:
fila = []
for c in columnas:
v = r.get(c, "")
if isinstance(v, list):
v = " | ".join(str(x.get("titulo", x) if isinstance(x, dict) else x) for x in v)
fila.append("" if v is None else v)
w.writerow(fila)
def _hist(d, usuario, cambio):
d.setdefault("historial", []).append({"quien": usuario, "cuando": ahora(), "cambio": cambio})
d["modificado_por"] = usuario
d["modificado_en"] = ahora()
def _corto(v, n=60):
if isinstance(v, list):
return f"[{len(v)}]" if len(v) > 3 else ", ".join(str(x.get("titulo", x) if isinstance(x, dict) else x) for x in v)
s = "" if v is None else str(v)
return s if len(s) <= n else s[:n - 1] + "…"
def _texto(v, n=4000):
return str(v if v is not None else "").strip()[:n]
def _entero(v, defecto=0):
try:
return int(v)
except Exception:
return defecto
def _bool(v, defecto=True):
if isinstance(v, bool):
return v
if v in (None, ""):
return defecto
return str(v).strip().lower() in ("1", "true", "si", "sí", "yes")
def _fecha_o_none(v):
s = _texto(v, 10)
return s if s and FECHA_RE.match(s) else None
def _enlaces(v):
out = []
for e in (v if isinstance(v, list) else []):
if isinstance(e, dict) and e.get("url"):
url = _texto(e.get("url"), 500)
if url.lower().startswith(("javascript:", "data:")):
continue
out.append({"titulo": _texto(e.get("titulo"), 120) or url, "url": url})
return out[:30]
def _lista_textos(v, n=30, largo=60):
if isinstance(v, str):
v = [x for x in re.split(r"[,\n;]+", v)]
return [_texto(x, largo) for x in (v if isinstance(v, list) else []) if _texto(x, largo)][:n]
def _ruta_registro(carpeta, rid):
return os.path.join(carpeta, rid + ".json")
# ------------------------------------------------------------------ tareas (tablero)
COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "fecha_limite", "version_tarjeta",
"orden", "etiquetas", "bloqueada_por", "creado_por", "creado_en", "modificado_por", "modificado_en"]
def _tarea_valida(d, base=None):
"""Normaliza los campos de una tarjeta del tablero. Devuelve (tarea, error).
Con base=None es un alta (todos los campos con su valor por defecto); con base es un PATCH
(solo se tocan las llaves que vienen)."""
t = dict(base) if base else {}
alta = base is None
def pon(k, val):
t[k] = val
if alta or "titulo" in d:
titulo = _texto(d.get("titulo"), 200)
if not titulo:
return None, "Falta el título del pendiente."
pon("titulo", titulo)
if alta or "descripcion" in d:
pon("descripcion", _texto(d.get("descripcion"), 4000))
if alta or "pista" in d:
p = _texto(d.get("pista"), 20).lower() or "produccion"
if p not in PISTAS:
return None, f"Pista inválida: {p}. Usa una de {', '.join(PISTAS)}."
pon("pista", p)
if alta or "estado" in d:
e = _texto(d.get("estado"), 20).lower() or "por-hacer"
if e not in ESTADOS:
return None, f"Estado inválido: {e}. Usa uno de {', '.join(ESTADOS)}."
pon("estado", e)
if alta or "responsable" in d:
pon("responsable", _texto(d.get("responsable"), 80))
if alta or "fecha_limite" in d:
f = d.get("fecha_limite")
if f not in (None, "") and not _fecha_o_none(f):
return None, "La fecha límite debe ser AAAA-MM-DD."
pon("fecha_limite", _fecha_o_none(f))
if alta or "enlaces" in d:
pon("enlaces", _enlaces(d.get("enlaces")))
if alta or "bloqueada_por" in d:
pon("bloqueada_por", [slug(x, 60) for x in (d.get("bloqueada_por") or []) if slug(x, 60)][:20])
if alta or "orden" in d:
pon("orden", _entero(d.get("orden"), 0))
if alta or "etiquetas" in d:
pon("etiquetas", _lista_textos(d.get("etiquetas"), 15, 40))
if alta or "version_tarjeta" in d:
v = _texto(d.get("version_tarjeta"), 10) or "—"
if v not in VERSIONES_TARJETA:
return None, f"Versión de tarjeta inválida: {v}. Usa una de {', '.join(VERSIONES_TARJETA)}."
pon("version_tarjeta", v)
return t, None
def _guardar_tarea(t):
_escribir_json(_ruta_registro(TAREAS_DIR, t["id"]), t)
_reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS)
def _vencida(t, hoy=None):
hoy = hoy or datetime.date.today().isoformat()
return bool(t.get("fecha_limite")) and t.get("estado") != "hecho" and t["fecha_limite"] < hoy
@app.get("/api/tareas")
def tareas_listar():
pista = _texto(request.args.get("pista"), 20).lower()
estado = _texto(request.args.get("estado"), 20).lower()
resp = _texto(request.args.get("responsable"), 80).lower()
version = _texto(request.args.get("version"), 10)
hoy = datetime.date.today().isoformat()
out = []
for t in _listar_json(TAREAS_DIR):
if pista and t.get("pista") != pista:
continue
if estado and t.get("estado") != estado:
continue
if resp and resp not in str(t.get("responsable", "")).lower():
continue
if version and t.get("version_tarjeta") != version:
continue
t["vencida"] = _vencida(t, hoy)
out.append(t)
out.sort(key=lambda t: (ESTADOS.index(t.get("estado")) if t.get("estado") in ESTADOS else 9,
t.get("orden", 0), t.get("creado_en", "")))
return jsonify(ok=True, total=len(out), tareas=out, pistas=PISTAS, estados=ESTADOS,
versiones=VERSIONES_TARJETA, admin=is_admin(), user=current_user())
@app.post("/api/tareas")
def tareas_crear():
u = quien()
if not u:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
t, err = _tarea_valida(d)
if err:
return jsonify(ok=False, error=err), 400
rid = slug(d.get("id"), 60) or ("T" + datetime.datetime.now().strftime("%y%m%d") + "-" + uuid.uuid4().hex[:4])
if os.path.exists(_ruta_registro(TAREAS_DIR, rid)):
return jsonify(ok=False, existe=True, error=f"Ya existe el pendiente {rid}."), 409
t["id"] = rid
if "orden" not in d:
# al final de su columna
mismos = [x for x in _listar_json(TAREAS_DIR) if x.get("estado") == t["estado"]]
t["orden"] = (max([x.get("orden", 0) for x in mismos]) + 1) if mismos else 0
t["creado_por"] = u
t["creado_en"] = ahora()
t["modificado_por"] = u
t["modificado_en"] = t["creado_en"]
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "creado en " + t["estado"]}]
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t), 201
@app.patch("/api/tarea")
def tarea_modificar():
u = quien()
if not u:
return _sin_usuario()
rid = slug(request.args.get("id"), 60)
if not rid:
return jsonify(ok=False, error="falta id"), 400
ruta = _ruta_registro(TAREAS_DIR, rid)
base = _leer_json(ruta)
if not base:
return jsonify(ok=False, error=f"No existe el pendiente {rid}."), 404
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
# id, auditoría e historial no se editan por aquí
for k in ("id", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"):
d.pop(k, None)
t, err = _tarea_valida(d, base)
if err:
return jsonify(ok=False, error=err), 400
cambios = []
for k in d:
if k in t and t.get(k) != base.get(k):
if k == "orden":
continue # la posición dentro de la columna no ensucia el historial
cambios.append(f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}")
nota = _texto(d.get("_nota"), 300)
if not cambios and not nota:
if t.get("orden") != base.get("orden"):
# solo cambió la posición: se guarda sin entrada en el historial
t["modificado_por"] = u
t["modificado_en"] = ahora()
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, sin_cambios=True)
_hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota)
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t)
@app.post("/api/tareas/orden")
def tareas_orden():
"""Renumera una columna completa: {estado, ids:[...]} → orden 0..n. No escribe historial."""
u = quien()
if not u:
return _sin_usuario()
d = _json_body() or {}
estado = _texto(d.get("estado"), 20).lower()
ids = [slug(x, 60) for x in (d.get("ids") or []) if slug(x, 60)]
if estado not in ESTADOS or not ids:
return jsonify(ok=False, error="Se esperaba {estado, ids:[...]}"), 400
n = 0
for i, rid in enumerate(ids):
ruta = _ruta_registro(TAREAS_DIR, rid)
t = _leer_json(ruta)
if not t:
continue
if t.get("estado") != estado or t.get("orden") != i:
if t.get("estado") != estado:
_hist(t, u, f"estado: {t.get('estado')} → {estado}")
t["estado"] = estado
t["orden"] = i
t["modificado_por"] = u
t["modificado_en"] = ahora()
_escribir_json(ruta, t)
n += 1
if n:
_reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS)
return jsonify(ok=True, actualizadas=n)
@app.delete("/api/tarea")
def tarea_borrar():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede borrar pendientes. "
"Si ya no aplica, muévelo a Hecho."), 403
rid = slug(request.args.get("id"), 60)
if not rid:
return jsonify(ok=False, error="falta id"), 400
ruta = _ruta_registro(TAREAS_DIR, rid)
if not os.path.isfile(ruta):
return jsonify(ok=False, error=f"No existe el pendiente {rid}."), 404
os.remove(ruta)
_reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS)
return jsonify(ok=True, borrado=rid)
@app.get("/api/tareas/resumen")
def tareas_resumen():
hoy = datetime.date.today().isoformat()
conteo = {p: {e: 0 for e in ESTADOS} for p in PISTAS}
vencidas = []
total = 0
for t in _listar_json(TAREAS_DIR):
total += 1
p, e = t.get("pista"), t.get("estado")
if p in conteo and e in conteo[p]:
conteo[p][e] += 1
if _vencida(t, hoy):
vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "fecha_limite")})
vencidas.sort(key=lambda x: x.get("fecha_limite") or "")
return jsonify(ok=True, total=total, hoy=hoy, por_pista=conteo, vencidas=vencidas)
# ------------------------------------------------------------------ plan de producción
PLAN_DEFECTO = {
"pedido_total": 570,
"nota": "Pedido de 570 HBOX (13-oct-2026). Los reemplazos futuros se hacen en v6.",
"versiones": [
{"version": "V5.6", "objetivo": 113, "nota": "con Mean Well SD-15B", "serie_prefijo": "V56", "fecha": None},
{"version": "V5.7", "objetivo": 327, "nota": "fuente a bordo", "serie_prefijo": "V57", "fecha": None},
{"version": "v6", "objetivo": 130, "nota": "+ reemplazos futuros", "serie_prefijo": "V6", "fecha": None},
],
# Embudo: cada etapa dice de qué tipo(s) de acta se cuenta. 'ok' = solo resultado distinto de FALLA.
"etapas": [
{"clave": "tarjeta", "nombre": "Tarjeta probada", "tipos": ["prueba-v5"], "ok": True},
{"clave": "modulos", "nombre": "Módulos (ArduSimple y 4G)", "tipos": ["modulo-ard", "modulo-4g"], "ok": True},
{"clave": "integracion", "nombre": "Integración", "tipos": ["integracion"], "ok": True},
{"clave": "ensamble", "nombre": "Ensamble", "tipos": ["ensamble"], "ok": True},
{"clave": "prueba-final", "nombre": "Prueba final", "tipos": ["ensamble"], "ok": True,
"nota": "va dentro del acta de ensamble"},
{"clave": "configuracion", "nombre": "Configuración", "tipos": ["reflasheo", "prueba-ethernet-serie"], "ok": True,
"solo_unidades": True},
{"clave": "envio", "nombre": "Envío", "tipos": ["envio"], "ok": False, "solo_unidades": True},
{"clave": "instalacion", "nombre": "Instalación", "tipos": ["instalacion-campo"], "ok": True, "solo_unidades": True},
],
"actualizado_por": None,
"actualizado_en": None,
}
@app.get("/api/plan")
def plan_leer():
p = _leer_json(PLAN_FILE)
if not p:
p = dict(PLAN_DEFECTO)
p["_default"] = True
return jsonify(ok=True, plan=p, admin=is_admin())
@app.put("/api/plan")
def plan_escribir():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede cambiar el plan."), 403
u = quien()
if not u:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
base = _leer_json(PLAN_FILE) or dict(PLAN_DEFECTO)
p = {"pedido_total": _entero(d.get("pedido_total", base.get("pedido_total")), 0),
"nota": _texto(d.get("nota", base.get("nota")), 500)}
vers = []
for v in (d.get("versiones") if isinstance(d.get("versiones"), list) else base.get("versiones", [])):
if not isinstance(v, dict) or not _texto(v.get("version"), 10):
continue
vers.append({"version": _texto(v.get("version"), 10), "objetivo": _entero(v.get("objetivo"), 0),
"nota": _texto(v.get("nota"), 200), "serie_prefijo": slug(v.get("serie_prefijo"), 10),
"fecha": _fecha_o_none(v.get("fecha"))})
if not vers:
return jsonify(ok=False, error="El plan necesita al menos una versión con objetivo."), 400
p["versiones"] = vers[:10]
etapas = []
for e in (d.get("etapas") if isinstance(d.get("etapas"), list) else base.get("etapas", [])):
if not isinstance(e, dict) or not slug(e.get("clave"), 30):
continue
etapas.append({"clave": slug(e.get("clave"), 30), "nombre": _texto(e.get("nombre"), 80) or e.get("clave"),
"tipos": [slug(t) for t in (e.get("tipos") or []) if slug(t)][:6],
"ok": _bool(e.get("ok"), True), "solo_unidades": _bool(e.get("solo_unidades"), False),
"nota": _texto(e.get("nota"), 200)})
p["etapas"] = etapas[:12] or PLAN_DEFECTO["etapas"]
p["historial"] = (base.get("historial") or [])[-30:] + [{"quien": u, "cuando": ahora(), "cambio": "plan actualizado"}]
p["actualizado_por"] = u
p["actualizado_en"] = ahora()
_escribir_json(PLAN_FILE, p)
return jsonify(ok=True, plan=p)
# ------------------------------------------------------------------ inventario
INV_ITEMS = lambda: os.path.join(INVENTARIO_DIR, "items")
INV_MOVS = lambda: os.path.join(INVENTARIO_DIR, "movimientos")
COLS_INV = ["id", "codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta",
"ubicacion", "proveedor", "codigo_proveedor", "modificado_por", "modificado_en"]
COLS_MOV = ["id", "cuando", "quien", "item_id", "tipo", "cantidad", "delta", "stock_despues", "motivo", "referencia"]
def _item_valido(d, base=None):
t = dict(base) if base else {}
alta = base is None
if alta or "codigo" in d:
c = _texto(d.get("codigo"), 40).upper()
if not c:
return None, "Falta el código del ítem."
t["codigo"] = c
if alta or "descripcion" in d:
desc = _texto(d.get("descripcion"), 200)
if not desc:
return None, "Falta la descripción."
t["descripcion"] = desc
if alta or "categoria" in d:
cat = _texto(d.get("categoria"), 30).lower() or "otro"
if cat not in CATEGORIAS_INV:
return None, f"Categoría inválida: {cat}. Usa una de {', '.join(CATEGORIAS_INV)}."
t["categoria"] = cat
if alta or "aplica_a" in d:
t["aplica_a"] = _lista_textos(d.get("aplica_a"), 8, 10)
if alta or "unidad" in d:
un = _texto(d.get("unidad"), 5).lower() or "pza"
if un not in UNIDADES_INV:
return None, "Unidad inválida: usa pza o m."
t["unidad"] = un
if alta or "minimo" in d:
t["minimo"] = max(0, _entero(d.get("minimo"), 0))
for k, n in (("ubicacion", 120), ("proveedor", 120), ("codigo_proveedor", 60), ("notas", 1000)):
if alta or k in d:
t[k] = _texto(d.get(k), n)
if alta:
t["stock"] = 0
t["alerta"] = bool(t.get("minimo", 0)) and t.get("stock", 0) < t.get("minimo", 0)
return t, None
def _guardar_item(t):
t["alerta"] = bool(t.get("minimo", 0)) and t.get("stock", 0) < t.get("minimo", 0)
_escribir_json(_ruta_registro(INV_ITEMS(), t["id"]), t)
_reescribir_indice(INV_ITEMS(), _listar_json(INV_ITEMS()), COLS_INV)
def _movs_de(item_id):
return sorted([m for m in _listar_json(INV_MOVS()) if m.get("item_id") == item_id],
key=lambda m: m.get("cuando", ""))
def _stock_desde_movimientos(item_id):
return sum(_entero(m.get("delta"), 0) for m in _movs_de(item_id))
@app.get("/api/inventario")
def inventario_listar():
cat = _texto(request.args.get("categoria"), 30).lower()
aplica = _texto(request.args.get("aplica_a"), 10)
solo_alertas = request.args.get("alertas") == "1"
items = []
for it in _listar_json(INV_ITEMS()):
it["alerta"] = bool(it.get("minimo", 0)) and it.get("stock", 0) < it.get("minimo", 0)
if cat and it.get("categoria") != cat:
continue
if aplica and aplica not in (it.get("aplica_a") or []):
continue
if solo_alertas and not it["alerta"]:
continue
items.append(it)
items.sort(key=lambda x: (CATEGORIAS_INV.index(x["categoria"]) if x.get("categoria") in CATEGORIAS_INV else 99,
x.get("codigo", "")))
return jsonify(ok=True, total=len(items), alertas=sum(1 for x in items if x["alerta"]), items=items,
categorias=CATEGORIAS_INV, unidades=UNIDADES_INV, tipos_movimiento=TIPOS_MOV,
admin=is_admin(), user=current_user())
@app.post("/api/inventario")
def inventario_crear():
u = quien()
if not u:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
t, err = _item_valido(d)
if err:
return jsonify(ok=False, error=err), 400
rid = slug(d.get("id"), 40) or slug(t["codigo"], 40)
if not rid:
return jsonify(ok=False, error="El código debe tener letras o números."), 400
if os.path.exists(_ruta_registro(INV_ITEMS(), rid)):
return jsonify(ok=False, existe=True, error=f"Ya existe el ítem {rid}."), 409
for otro in _listar_json(INV_ITEMS()):
if otro.get("codigo") == t["codigo"]:
return jsonify(ok=False, existe=True, error=f"El código {t['codigo']} ya lo usa el ítem {otro.get('id')}."), 409
t["id"] = rid
t["creado_por"] = u
t["creado_en"] = ahora()
t["modificado_por"] = u
t["modificado_en"] = t["creado_en"]
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "ítem creado"}]
stock_inicial = _entero(d.get("stock_inicial"), 0)
_guardar_item(t)
if stock_inicial:
_registrar_movimiento(t, "entrada", stock_inicial, "stock inicial", "", u)
return jsonify(ok=True, item=t), 201
@app.patch("/api/inventario/item")
def inventario_modificar():
u = quien()
if not u:
return _sin_usuario()
rid = slug(request.args.get("id"), 40)
ruta = _ruta_registro(INV_ITEMS(), rid) if rid else ""
base = _leer_json(ruta) if rid else None
if not base:
return jsonify(ok=False, error=f"No existe el ítem {rid}."), 404
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
if "stock" in d:
return jsonify(ok=False, error="El stock no se edita a mano: registra un movimiento (entrada, salida o ajuste)."), 400
for k in ("id", "stock", "alerta", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"):
d.pop(k, None)
t, err = _item_valido(d, base)
if err:
return jsonify(ok=False, error=err), 400
if "codigo" in d and t["codigo"] != base.get("codigo"):
for otro in _listar_json(INV_ITEMS()):
if otro.get("id") != rid and otro.get("codigo") == t["codigo"]:
return jsonify(ok=False, error=f"El código {t['codigo']} ya lo usa el ítem {otro.get('id')}."), 409
cambios = [f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}" for k in d if k in t and t.get(k) != base.get(k)]
if not cambios:
return jsonify(ok=True, item=t, sin_cambios=True)
_hist(t, u, "; ".join(cambios))
_guardar_item(t)
return jsonify(ok=True, item=t)
def _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u):
"""Escribe el movimiento y recalcula el stock del ítem como suma de todos sus movimientos."""
actual = _stock_desde_movimientos(item["id"])
if tipo == "entrada":
delta = cantidad
elif tipo == "salida":
delta = -cantidad
else: # ajuste: 'cantidad' es el conteo físico; el delta es lo que falta para llegar a él
delta = cantidad - actual
m = {"id": "M" + datetime.datetime.now().strftime("%y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4],
"item_id": item["id"], "codigo": item.get("codigo"), "tipo": tipo, "cantidad": cantidad, "delta": delta,
"motivo": motivo, "referencia": referencia, "quien": u, "cuando": ahora(),
"stock_antes": actual, "stock_despues": actual + delta}
_escribir_json(_ruta_registro(INV_MOVS(), m["id"]), m)
_reescribir_indice(INV_MOVS(), _listar_json(INV_MOVS()), COLS_MOV)
item["stock"] = _stock_desde_movimientos(item["id"])
item["modificado_por"] = u
item["modificado_en"] = m["cuando"]
_guardar_item(item)
return m
@app.post("/api/inventario/movimiento")
def inventario_movimiento():
u = quien()
if not u:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
rid = slug(d.get("item_id"), 40)
item = _leer_json(_ruta_registro(INV_ITEMS(), rid)) if rid else None
if not item:
return jsonify(ok=False, error=f"No existe el ítem {rid}."), 404
tipo = _texto(d.get("tipo"), 10).lower()
if tipo not in TIPOS_MOV:
return jsonify(ok=False, error="Tipo de movimiento inválido: entrada, salida o ajuste."), 400
try:
cantidad = int(d.get("cantidad"))
except Exception:
return jsonify(ok=False, error="La cantidad debe ser un número entero."), 400
if tipo != "ajuste" and cantidad <= 0:
return jsonify(ok=False, error="La cantidad debe ser mayor que cero."), 400
if tipo == "ajuste" and cantidad < 0:
return jsonify(ok=False, error="El conteo físico no puede ser negativo."), 400
motivo = _texto(d.get("motivo"), 200)
if not motivo:
return jsonify(ok=False, error="Escribe el motivo del movimiento."), 400
referencia = _texto(d.get("referencia"), 120)
if tipo == "salida" and _stock_desde_movimientos(rid) - cantidad < 0:
return jsonify(ok=False, error=f"No hay stock suficiente registrado ({_stock_desde_movimientos(rid)} {item.get('unidad')}). "
"Registra primero la entrada o haz un ajuste con el conteo físico."), 409
m = _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u)
return jsonify(ok=True, movimiento=m, item=item), 201
@app.get("/api/inventario/movimientos")
def inventario_movimientos():
rid = slug(request.args.get("item_id"), 40)
n = min(500, max(1, _entero(request.args.get("n"), 200)))
movs = _movs_de(rid) if rid else sorted(_listar_json(INV_MOVS()), key=lambda m: m.get("cuando", ""))
movs.reverse()
return jsonify(ok=True, total=len(movs), movimientos=movs[:n])
@app.get("/api/inventario/export.csv")
def inventario_export():
import io
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta",
"ubicacion", "proveedor", "codigo_proveedor", "notas", "modificado_por", "modificado_en"])
for it in sorted(_listar_json(INV_ITEMS()), key=lambda x: (x.get("categoria", ""), x.get("codigo", ""))):
alerta = bool(it.get("minimo", 0)) and it.get("stock", 0) < it.get("minimo", 0)
w.writerow([it.get("codigo"), it.get("descripcion"), it.get("categoria"), " ".join(it.get("aplica_a") or []),
it.get("unidad"), it.get("stock", 0), it.get("minimo", 0), "SI" if alerta else "",
it.get("ubicacion"), it.get("proveedor"), it.get("codigo_proveedor"), it.get("notas"),
it.get("modificado_por"), it.get("modificado_en")])
nombre = "inventario-hbox-" + datetime.date.today().isoformat() + ".csv"
return app.response_class("" + buf.getvalue(), mimetype="text/csv",
headers={"Content-Disposition": f'attachment; filename="{nombre}"'})
# ------------------------------------------------------------------ sitios
COLS_SITIOS = ["id", "nombre", "cliente", "ubicacion", "activo", "contactos", "modificado_por", "modificado_en"]
def _sitio_valido(d, base=None):
t = dict(base) if base else {}
alta = base is None
if alta or "nombre" in d:
n = _texto(d.get("nombre"), 80)
if not n:
return None, "Falta el nombre del sitio."
t["nombre"] = n
for k, n in (("cliente", 120), ("ubicacion", 200), ("notas", 2000)):
if alta or k in d:
t[k] = _texto(d.get(k), n)
if alta or "alias" in d:
t["alias"] = _lista_textos(d.get("alias"), 10, 40)
if alta or "contactos" in d:
cs = []
for c in (d.get("contactos") if isinstance(d.get("contactos"), list) else []):
if isinstance(c, dict) and _texto(c.get("nombre"), 80):
cs.append({"nombre": _texto(c.get("nombre"), 80), "rol": _texto(c.get("rol"), 80),
"telefono": _texto(c.get("telefono"), 40), "correo": _texto(c.get("correo"), 120)})
t["contactos"] = cs[:20]
if alta or "activo" in d:
t["activo"] = _bool(d.get("activo"), True)
return t, None
def _guardar_sitio(t):
_escribir_json(_ruta_registro(SITIOS_DIR, t["id"]), t)
_reescribir_indice(SITIOS_DIR, _listar_json(SITIOS_DIR), COLS_SITIOS)
@app.get("/api/sitios")
def sitios_listar():
sitios = _listar_json(SITIOS_DIR)
if request.args.get("todos") != "1":
sitios = [s for s in sitios if s.get("activo", True)]
sitios.sort(key=lambda s: (s.get("orden", 50), s.get("nombre", "")))
return jsonify(ok=True, total=len(sitios), sitios=sitios, admin=is_admin(), user=current_user())
@app.post("/api/sitios")
def sitios_crear():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador da de alta sitios. Pide a Leo que lo cree."), 403
u = quien()
if not u:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
t, err = _sitio_valido(d)
if err:
return jsonify(ok=False, error=err), 400
rid = slug(d.get("id") or t["nombre"], 30).lower()
if not rid:
return jsonify(ok=False, error="El id del sitio debe tener letras o números (p. ej. icave)."), 400
if os.path.exists(_ruta_registro(SITIOS_DIR, rid)):
return jsonify(ok=False, existe=True, error=f"Ya existe el sitio {rid}."), 409
t["id"] = rid
t["orden"] = _entero(d.get("orden"), 50)
t["creado_por"] = u
t["creado_en"] = ahora()
t["modificado_por"] = u
t["modificado_en"] = t["creado_en"]
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "sitio creado"}]
_guardar_sitio(t)
return jsonify(ok=True, sitio=t), 201
@app.patch("/api/sitio")
def sitio_modificar():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador edita los datos de un sitio."), 403
u = quien()
if not u:
return _sin_usuario()
rid = slug(request.args.get("id"), 30).lower()
base = _leer_json(_ruta_registro(SITIOS_DIR, rid)) if rid else None
if not base:
return jsonify(ok=False, error=f"No existe el sitio {rid}."), 404
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
for k in ("id", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"):
d.pop(k, None)
t, err = _sitio_valido(d, base)
if err:
return jsonify(ok=False, error=err), 400
if "orden" in d:
t["orden"] = _entero(d.get("orden"), 50)
cambios = [f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}" for k in d if k in t and t.get(k) != base.get(k)]
if not cambios:
return jsonify(ok=True, sitio=t, sin_cambios=True)
_hist(t, u, "; ".join(cambios))
_guardar_sitio(t)
return jsonify(ok=True, sitio=t)
def _acta_en_sitio(d, sitio):
"""'sitio' si el acta trae el slug del sitio; 'texto' si solo coincide por nombre/alias
(terminal de la flota, destino del envío); None si no es de este sitio."""
if str(d.get("sitio") or "").lower() == sitio["id"]:
return "sitio"
claves = {sitio["id"].lower(), str(sitio.get("nombre", "")).lower()} | {a.lower() for a in (sitio.get("alias") or [])}
claves.discard("")
for campo in ("terminal", "clasificacion", "destino", "donde"):
v = str(d.get(campo) or "").strip().lower()
if not v:
continue
for k in claves:
if v == k or v.startswith(k + " ") or v.startswith(k + "(") or v.startswith(k + " ("):
return "texto"
return None
@app.get("/api/sitio/unidades")
def sitio_unidades():
"""Unidades y actas que pasaron por un sitio (envío, recepción, instalación, diagnóstico, daño)."""
rid = slug(request.args.get("id"), 30).lower()
sitio = _leer_json(_ruta_registro(SITIOS_DIR, rid)) if rid else None
if not sitio:
return jsonify(ok=False, error=f"No existe el sitio {rid}."), 404
actas = []
for tipo in TIPOS_SITIO:
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
continue
for fn in sorted(os.listdir(carpeta)):
if not fn.endswith(".json"):
continue
d = _leer_json(os.path.join(carpeta, fn))
if not isinstance(d, dict) or "PRUEBA" in str(d.get("serie", "")).upper():
continue
modo = _acta_en_sitio(d, sitio)
if not modo:
continue
actas.append({"tipo": tipo, "archivo": fn, "serie": d.get("serie"), "fecha": d.get("fecha"),
"recibido": d.get("_recibido"), "tecnico": d.get("tecnico"), "resultado": d.get("resultado"),
"accion": d.get("accion"), "clasificacion": d.get("clasificacion"), "motivo": d.get("motivo"),
"maquina": d.get("maquina_id") or d.get("maquina"), "resumen": d.get("resumen"),
"coincidencia": modo, "fotos": len(d.get("fotos") or [])})
def _clave(r):
return ((r.get("fecha") or (r.get("recibido") or "")[:10]), r.get("recibido") or "")
actas.sort(key=_clave, reverse=True)
unidades = {}
for a in actas:
s = a.get("serie")
if not s:
continue
u = unidades.setdefault(s, {"serie": s, "maquina": None, "instalada": None, "ultima": None, "actas": 0,
"tipos": [], "coincidencia": "texto"})
u["actas"] += 1
if a["tipo"] not in u["tipos"]:
u["tipos"].append(a["tipo"])
if a.get("maquina") and not u["maquina"]:
u["maquina"] = a["maquina"]
if a["coincidencia"] == "sitio":
u["coincidencia"] = "sitio"
if a["tipo"] == "instalacion-campo" and a.get("resultado") != "FALLA":
f = a.get("fecha") or (a.get("recibido") or "")[:10]
if not u["instalada"] or f > u["instalada"]:
u["instalada"] = f
if not u["ultima"]:
u["ultima"] = {"tipo": a["tipo"], "fecha": a.get("fecha") or (a.get("recibido") or "")[:10],
"accion": a.get("accion"), "resultado": a.get("resultado")}
for s, u in unidades.items():
e = estado_serie(s)
u["marca"] = e["marca"]
u["marca_acta"] = e["acta"]
lista = sorted(unidades.values(), key=lambda u: (u["marca"] is None, u["serie"]))
return jsonify(ok=True, sitio=sitio, unidades=lista, actas=actas, total_unidades=len(lista),
total_actas=len(actas), danos=[u for u in lista if u["marca"]], admin=is_admin())
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")))

239
backend/seed_1013.py Normal file
View File

@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Siembra inicial del 13-oct-2026: plan de producción, los 4 sitios, los ítems de inventario
y los pendientes del tablero (brief §4). Idempotente: lo que ya existe (mismo id) se deja como está.
Uso (en el servidor, como el usuario del servicio, normalmente leo):
cd /srv/projects/drogheda/hbox-docs
./backend/venv/bin/python3 backend/seed_1013.py # usa los mismos valores por omisión que app.py
TAREAS_DIR=/otra/ruta ./backend/venv/bin/python3 backend/seed_1013.py --usuario leo
./backend/venv/bin/python3 backend/seed_1013.py --extra tareas-notion.json # importa más pendientes
./backend/venv/bin/python3 backend/seed_1013.py --solo plan,sitios # una parte
Escribe directo en disco con las mismas funciones del backend (no pasa por nginx), así que no
necesita contraseña. El servicio puede seguir corriendo mientras se siembra.
--extra espera un JSON con una lista de pendientes con los mismos campos que POST /api/tareas
(cada uno con su "id" para que la importación también sea idempotente).
"""
import os, sys, json, argparse, datetime
AQUI = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, AQUI)
import app as backend # noqa: E402 (usa ACTAS_DIR/TAREAS_DIR/INVENTARIO_DIR/SITIOS_DIR/PLAN_FILE del entorno)
HOY = datetime.date.today().isoformat()
def marca(u):
t = backend.ahora()
return {"creado_por": u, "creado_en": t, "modificado_por": u, "modificado_en": t}
# ------------------------------------------------------------------ datos
SITIOS = [
{"id": "icave", "nombre": "ICAVE", "cliente": "Hutchison Ports", "ubicacion": "Veracruz, Ver.",
"alias": ["ICAVE (Veracruz)", "Oficina ICAVE"], "orden": 10,
"contactos": [{"nombre": "por confirmar", "rol": "supervisor de patio (autoriza acceso a máquinas)", "telefono": "", "correo": ""}],
"notas": "Terminal de contenedores. El acceso a cada máquina se pide por correo (Operaciones). "
"Compara el HBOX RTK con un CalAmp legado (viajes en Wialon)."},
{"id": "lzc", "nombre": "LZC", "cliente": "Hutchison Ports", "ubicacion": "Lázaro Cárdenas, Mich.",
"alias": ["LCT", "Lázaro Cárdenas"], "orden": 20,
"contactos": [{"nombre": "por confirmar", "rol": "", "telefono": "", "correo": ""}],
"notas": "En notas antiguas aparece como LCT."},
{"id": "eit", "nombre": "EIT", "cliente": "Hutchison Ports", "ubicacion": "por confirmar",
"alias": [], "orden": 30,
"contactos": [{"nombre": "por confirmar", "rol": "", "telefono": "", "correo": ""}], "notas": ""},
{"id": "timsa", "nombre": "TIMSA", "cliente": "Hutchison Ports", "ubicacion": "por confirmar",
"alias": [], "orden": 40,
"contactos": [{"nombre": "por confirmar", "rol": "", "telefono": "", "correo": ""}], "notas": ""},
]
# stock 0: Leo y Jesús lo llenan con el conteo físico (movimiento 'ajuste').
INVENTARIO = [
("EB-V55", "EinarBoard V5.5 (tarjeta probada o por probar)", "tarjeta", ["V5.5"], 0, "JLCPCB", "", "Lote de 15 (22-ago). Inspeccionar U6 con lupa antes de energizar."),
("EB-V56", "EinarBoard V5.6", "tarjeta", ["V5.6"], 0, "Aldair (por confirmar)", "", "Pedido 113. Pasta EP U6 ≤50 %, vías tapadas, X-ray/AOI en U6."),
("EB-V57", "EinarBoard V5.7 (fuente a bordo)", "tarjeta", ["V5.7"], 0, "Aldair (por confirmar)", "", "Pedido 327."),
("EB-V6", "EinarBoard v6 (GNSS + 4G a bordo)", "tarjeta", ["v6"], 0, "Aldair (por confirmar)", "", "Pedido 130 + reemplazos."),
("ARD-SRTK2B", "ArduSimple simpleRTK2B Pro (ZED-F9P)", "modulo", ["V5.5", "V5.6", "V5.7"], 0, "ArduSimple", "", "500 pedidos (26-sep)."),
("MODEM-4G-NTRIP", "4G NTRIP Master", "modulo", ["V5.5", "V5.6", "V5.7"], 0, "ArduSimple", "", ""),
("SD-15B-05", "Fuente Mean Well SD-15B-05 (24 V → 5 V)", "fuente", ["V5.5", "V5.6"], 113, "Mouser / Digikey", "709-SD15B05", "Solo V5.6: 113 para el pedido. V5.7 y v6 no la usan."),
("PACK-LFP-26650-1S4P", "Paquete LFP 26650 1S4P (alta temperatura)", "bateria", ["V5.6", "V5.7", "v6"], 0, "por confirmar", "", "JP2 = 0 (LiFePO4). Flotación 3.50 V (R55 5.6 k)."),
("GAB-IP67", "Gabinete IP67 (máquina)", "gabinete", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", ""),
("CAJA-HBOX", "Caja plástica HBOX", "caja", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "Pendiente: el cable Ethernet no cabe (tablero, pista Mecánica)."),
("ANT-GNSS", "Antena GNSS (SMA, con cable)", "antena", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "~4 semanas de entrega."),
("ANT-LTE", "Antena LTE (2 por unidad)", "antena", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "Dos por unidad."),
("CABLE-ETH", "Cable Ethernet (exterior)", "cable", ["V5.6", "V5.7", "v6"], 0, "por confirmar", "", "Ver pendiente de la caja HBOX."),
("CONN-GX16", "Conector GX16 (aéreo + panel) y arnés", "conector", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "1 = +24 V, 2 = GND, 3 = ignición, 4 = GND."),
("FUSIBLE-DIN", "Fusible DIN (gabinete)", "componente-critico", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", ""),
("LTC4040", "LTC4040 (U6, QFN 4x5) — cargador/respaldo", "componente-critico", ["V5.5", "V5.6", "V5.7", "v6"], 0, "LCSC / ADI", "C2057152", "Largo plazo de entrega. Regla DFM: pasta EP ≤50 %."),
("TPS54360B", "TPS54360B — buck 24 V → 5 V a bordo (U25)", "componente-critico", ["V5.7", "v6"], 0, "LCSC / TI", "", "Fuente a bordo de V5.7 y v6."),
("SRP1265A", "Inductor SRP1265A (fuente a bordo)", "componente-critico", ["V5.7", "v6"], 0, "LCSC / Bourns", "", ""),
]
TAREAS = [
{"id": "t-v56-pasada-2", "pista": "ingenieria", "estado": "en-curso", "responsable": "michelle", "version_tarjeta": "V5.6",
"titulo": "V5.6 pasada 2 — correcciones antes del pedido",
"descripcion": "BAT_PLUS fuera de In1/In2 (bloqueante), MAIN5V, SYS5V, SW3_NODE, pistas bajo U16 a B.Cu, sueltos, rev 5.6, stackup. "
"Los conectores se quedan donde están (opción A). Zona x 170–195 / y 42–72 libre para la fuente de V5.7.",
"enlaces": [{"titulo": "Instrucciones pasada 2", "url": "/hardware/v56-layout-pasada-2/"},
{"titulo": "Auditoría 13-oct (inglés)", "url": "/hardware/v56-auditoria-layout-13oct/"}],
"etiquetas": ["layout", "bloqueante"]},
{"id": "t-v57-fuente", "pista": "ingenieria", "estado": "en-curso", "responsable": "leo", "version_tarjeta": "V5.7",
"titulo": "V5.7 — colocar y rutear la fuente a bordo (guía de layout)",
"descripcion": "Bloque U25 (TPS54360B) + F2 SMD en la esquina superior derecha. Solo las zonas acordadas con Michelle.",
"enlaces": [{"titulo": "Guía de layout V5.7", "url": "/hardware/v57-layout-guia/"},
{"titulo": "ECO V5.7", "url": "/hardware/v57-eco-fuente/"}],
"etiquetas": ["layout"]},
{"id": "t-v57-cierre", "pista": "ingenieria", "estado": "por-hacer", "responsable": "michelle", "version_tarjeta": "V5.7",
"titulo": "V5.7 — replicar correcciones de V5.6 y cerrar layout",
"descripcion": "Después de que Leo deje la fuente colocada y V5.6 pasada 2 esté cerrada.",
"bloqueada_por": ["t-v56-pasada-2", "t-v57-fuente"],
"enlaces": [{"titulo": "Guía de layout V5.7", "url": "/hardware/v57-layout-guia/"}], "etiquetas": ["layout"]},
{"id": "t-v56-revision-gerber", "pista": "ingenieria", "estado": "por-hacer", "responsable": "claude", "version_tarjeta": "V5.6",
"titulo": "V5.6 — revisión pad a pad + Gerber/BOM/CPL",
"descripcion": "Paridad con netlist, DRC, pasta EP U6 ≤50 %, vías tapadas; generar el paquete de fabricación.",
"bloqueada_por": ["t-v56-pasada-2"], "etiquetas": ["auditoría", "fabricación"]},
{"id": "t-v6-cierre-pedido", "pista": "ingenieria", "estado": "por-hacer", "responsable": "leo, claude", "version_tarjeta": "v6",
"titulo": "v6 — cierre para pedido (según «Próximos pasos para ordenar»)",
"enlaces": [{"titulo": "Próximos pasos v6", "url": "/hardware/v6-proximos-pasos/"},
{"titulo": "Plan de corrección", "url": "/hardware/v6-plan-correccion/"}],
"descripcion": "Aplicar a P7/P8 lo decidido para P12 en V5.6; regla DFM U6; no habrá v6.1.", "etiquetas": ["fabricación"]},
{"id": "t-caja-ethernet", "pista": "mecanica", "estado": "bloqueado", "responsable": "", "version_tarjeta": "—",
"titulo": "Caja plástica HBOX — el cable Ethernet no cabe: definir y aplicar el cambio",
"descripcion": "Falta definir la causa exacta (largo del boot RJ45, tamaño de la abertura o radio de curvatura) y quién lo corrige "
"(pieza impresa en 3D o proveedor). Al definirlo: escribir el procedimiento y mover a En curso.",
"etiquetas": ["caja", "por asignar"]},
{"id": "t-paquetes-aldair", "pista": "compras", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—",
"titulo": "Enviar paquetes de fabricación V5.6, V5.7 y v6 a Aldair; cotización para 570 unidades (113 / 327 / 130)",
"descripcion": "Gerber + BOM + CPL + planos. Aldair = fabricación/ensamble (rol por confirmar).",
"bloqueada_por": ["t-v56-revision-gerber"], "etiquetas": ["pedido 570"]},
{"id": "t-compras-materiales", "pista": "compras", "estado": "por-hacer", "responsable": "leo, jesús", "version_tarjeta": "—",
"titulo": "Mean Well SD-15B-05 ×113 (solo V5.6); paquetes LFP; gabinetes; cajas HBOX; antenas",
"descripcion": "Chips y antenas RTK: ~4 semanas de entrega. Registrar las llegadas como entradas en el inventario.",
"enlaces": [{"titulo": "Inventario", "url": "/inventario/inventario.html"}], "etiquetas": ["pedido 570"]},
{"id": "t-plan-570", "pista": "produccion", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—",
"titulo": "Plan 570: cargar objetivos por versión y fechas",
"enlaces": [{"titulo": "Tablero de producción", "url": "/tableros/produccion.html"}],
"descripcion": "Editar el plan desde el tablero de producción (solo admin): fecha objetivo por versión."},
{"id": "t-inventario-inicial", "pista": "produccion", "estado": "por-hacer", "responsable": "jesús", "version_tarjeta": "—",
"titulo": "Inventario inicial: conteo físico de tarjetas, módulos, gabinetes, cajas, packs",
"enlaces": [{"titulo": "Inventario", "url": "/inventario/inventario.html"}],
"descripcion": "Un movimiento de tipo «ajuste» por ítem con el conteo físico y el motivo «conteo inicial»."},
{"id": "t-sitios-alta", "pista": "campo", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—",
"titulo": "Dar de alta los 4 sitios (ICAVE, LZC, EIT, TIMSA) con contactos y unidades instaladas",
"enlaces": [{"titulo": "Sitios", "url": "/sitios/"}],
"descripcion": "Los 4 sitios ya existen con «por confirmar»: completar ubicación y contactos (Editar sitio, solo admin)."},
{"id": "t-acta-instalacion", "pista": "campo", "estado": "por-hacer", "responsable": "claude", "version_tarjeta": "—",
"titulo": "Acta de instalación en campo: formulario nuevo + instrucciones",
"enlaces": [{"titulo": "Acta de instalación", "url": "/formularios/instalacion.html"}],
"descripcion": "Formulario publicado el 13-oct; falta probarlo en una instalación real y ajustar el checklist."},
]
# ------------------------------------------------------------------ siembra
def sembrar_plan(u, fuerza=False):
if os.path.isfile(backend.PLAN_FILE) and not fuerza:
return "plan: ya existe, se deja (usa --forzar-plan para reescribirlo)"
p = json.loads(json.dumps(backend.PLAN_DEFECTO))
p["actualizado_por"] = u
p["actualizado_en"] = backend.ahora()
p["historial"] = [{"quien": u, "cuando": p["actualizado_en"], "cambio": "plan sembrado (seed_1013)"}]
backend._escribir_json(backend.PLAN_FILE, p)
return "plan: creado (570 → 113 V5.6 / 327 V5.7 / 130 v6)"
def sembrar_sitios(u):
n = 0
for s in SITIOS:
if os.path.isfile(backend._ruta_registro(backend.SITIOS_DIR, s["id"])):
continue
t, err = backend._sitio_valido(s)
if err:
print(" sitio", s["id"], "->", err); continue
t["id"] = s["id"]
t["orden"] = s.get("orden", 50)
t.update(marca(u))
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "sitio sembrado (seed_1013)"}]
backend._guardar_sitio(t)
n += 1
return f"sitios: {n} creados, {len(SITIOS) - n} ya existían"
def sembrar_inventario(u):
n = 0
for codigo, desc, cat, aplica, minimo, prov, cod_prov, notas in INVENTARIO:
rid = backend.slug(codigo, 40)
if os.path.isfile(backend._ruta_registro(backend.INV_ITEMS(), rid)):
continue
t, err = backend._item_valido({"codigo": codigo, "descripcion": desc, "categoria": cat, "aplica_a": aplica,
"unidad": "pza", "minimo": minimo, "proveedor": prov,
"codigo_proveedor": cod_prov, "notas": notas, "ubicacion": ""})
if err:
print(" item", codigo, "->", err); continue
t["id"] = rid
t.update(marca(u))
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "ítem sembrado (seed_1013), stock 0"}]
backend._guardar_item(t)
n += 1
return f"inventario: {n} ítems creados, {len(INVENTARIO) - n} ya existían (stock 0: falta el conteo físico)"
def sembrar_tareas(u, lista, origen="seed_1013"):
n, orden = 0, {}
existentes = backend._listar_json(backend.TAREAS_DIR)
for t in existentes:
orden[t.get("estado")] = max(orden.get(t.get("estado"), -1), t.get("orden", 0))
for d in lista:
rid = backend.slug(d.get("id"), 60)
if not rid:
print(" pendiente sin id, se omite:", d.get("titulo")); continue
if os.path.isfile(backend._ruta_registro(backend.TAREAS_DIR, rid)):
continue
t, err = backend._tarea_valida(d)
if err:
print(" pendiente", rid, "->", err); continue
t["id"] = rid
if "orden" not in d:
orden[t["estado"]] = orden.get(t["estado"], -1) + 1
t["orden"] = orden[t["estado"]]
t.update(marca(u))
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": f"creado en {t['estado']} ({origen})"}]
backend._guardar_tarea(t)
n += 1
return f"pendientes ({origen}): {n} creados, {len(lista) - n} ya existían u omitidos"
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--usuario", default="leo", help="quién firma la siembra en los campos de auditoría")
ap.add_argument("--solo", default="plan,sitios,inventario,tareas", help="partes a sembrar, separadas por coma")
ap.add_argument("--extra", help="JSON con una lista de pendientes adicionales (p. ej. importación de Notion)")
ap.add_argument("--forzar-plan", action="store_true", help="reescribe el plan aunque ya exista")
a = ap.parse_args()
partes = {p.strip() for p in a.solo.split(",")}
print("Carpetas:", {"tareas": backend.TAREAS_DIR, "inventario": backend.INVENTARIO_DIR,
"sitios": backend.SITIOS_DIR, "plan": backend.PLAN_FILE})
for d in (backend.TAREAS_DIR, backend.INV_ITEMS(), backend.INV_MOVS(), backend.SITIOS_DIR,
os.path.dirname(backend.PLAN_FILE)):
os.makedirs(d, exist_ok=True)
if "plan" in partes:
print(sembrar_plan(a.usuario, a.forzar_plan))
if "sitios" in partes:
print(sembrar_sitios(a.usuario))
if "inventario" in partes:
print(sembrar_inventario(a.usuario))
if "tareas" in partes:
print(sembrar_tareas(a.usuario, TAREAS))
if a.extra:
with open(a.extra, encoding="utf-8") as f:
extra = json.load(f)
if not isinstance(extra, list):
sys.exit("--extra debe ser una lista JSON de pendientes")
print(sembrar_tareas(a.usuario, extra, origen=os.path.basename(a.extra)))
print("Listo.")
if __name__ == "__main__":
main()

100
backend/tests-1013.sh Executable file
View File

@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Pruebas del backend 13-oct: arranca app.py con carpetas temporales y ejercita cada endpoint nuevo.
# Uso: bash tests-1013.sh <ruta-a-hbox-docs> [puerto]
set -u
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8098}
T=$(mktemp -d); export ACTAS_DIR=$T/actas PORT
mkdir -p $ACTAS_DIR
python3 $REPO/backend/app.py > $T/app.log 2>&1 &
APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.5
U="http://127.0.0.1:$PORT"
LEO=(-s -H "X-Remote-User: leo" -H "Content-Type: application/json")
ALAN=(-s -H "X-Remote-User: alan" -H "Content-Type: application/json")
ok=0; fail=0
check() { # check "nombre" "esperado" "obtenido"
if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:160}"; fi; }
echo "## salud y endpoints viejos"
check "salud" '"servicio":"actas-hbox"' "$(curl -s $U/api/salud)"
check "whoami leo admin" '"admin":true' "$(curl "${LEO[@]}" $U/api/whoami)"
check "actas vacías" '"total":0' "$(curl -s $U/api/actas)"
echo "## tareas"
check "sin usuario → 403" "No puedo identificarte" "$(curl -s -H 'Content-Type: application/json' -X POST $U/api/tareas -d '{"titulo":"x"}')"
check "POST (alan)" '"id":"t-a"' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"Pendiente A","pista":"ingenieria","responsable":"michelle","fecha_limite":"2026-10-01","version_tarjeta":"V5.6","enlaces":[{"titulo":"ECO","url":"/hardware/v56-eco/"}]}')"
check "POST id duplicado → 409" '"existe":true' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"dup"}')"
check "POST pista inválida → 400" "Pista inv" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","pista":"ventas"}')"
check "POST fecha inválida → 400" "AAAA-MM-DD" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","fecha_limite":"13/10/2026"}')"
curl "${LEO[@]}" -X POST $U/api/tareas -d '{"id":"t-b","titulo":"Pendiente B","pista":"compras","bloqueada_por":["t-a"]}' > /dev/null
check "GET filtro pista" '"total":1' "$(curl -s "$U/api/tareas?pista=compras")"
check "GET filtro responsable" '"total":1' "$(curl -s "$U/api/tareas?responsable=mich")"
check "vencida calculada" '"vencida":true' "$(curl -s "$U/api/tareas?pista=ingenieria")"
check "PATCH mover (alan) → historial con quién" '"quien":"alan"' "$(curl "${ALAN[@]}" -X PATCH "$U/api/tarea?id=t-a" -d '{"estado":"en-curso","orden":0}')"
check "PATCH sin cambios" '"sin_cambios":true' "$(curl "${ALAN[@]}" -X PATCH "$U/api/tarea?id=t-a" -d '{"estado":"en-curso"}')"
check "PATCH estado inválido → 400" "Estado inv" "$(curl "${ALAN[@]}" -X PATCH "$U/api/tarea?id=t-a" -d '{"estado":"listo"}')"
check "PATCH inexistente → 404" "No existe" "$(curl "${ALAN[@]}" -X PATCH "$U/api/tarea?id=nada" -d '{"titulo":"x"}')"
check "POST orden (renumera columna: t-b cambia de columna, t-a de posición)" '"actualizadas":2' "$(curl "${ALAN[@]}" -X POST $U/api/tareas/orden -d '{"estado":"en-curso","ids":["t-b","t-a"]}')"
check "resumen" '"en-curso": 1' "$(curl -s $U/api/tareas/resumen | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["por_pista"]["ingenieria"]))')"
check "resumen vencidas" '"id":"t-a"' "$(curl -s $U/api/tareas/resumen)"
check "DELETE alan → 403" "Solo un administrador" "$(curl "${ALAN[@]}" -X DELETE "$U/api/tarea?id=t-b")"
check "DELETE leo → ok" '"borrado":"t-b"' "$(curl "${LEO[@]}" -X DELETE "$U/api/tarea?id=t-b")"
check "índice CSV escrito" "t-a" "$(cat $T/tareas/_indice.csv)"
echo "## plan"
check "GET sin archivo → por omisión" '"_default":true' "$(curl -s $U/api/plan)"
check "PUT alan → 403" "Solo un administrador" "$(curl "${ALAN[@]}" -X PUT $U/api/plan -d '{"pedido_total":570}')"
check "PUT leo" '"actualizado_por":"leo"' "$(curl "${LEO[@]}" -X PUT $U/api/plan -d '{"pedido_total":570,"versiones":[{"version":"V5.6","objetivo":113,"serie_prefijo":"V56","fecha":"2026-11-30"},{"version":"V5.7","objetivo":327},{"version":"v6","objetivo":130}]}')"
check "PUT conserva etapas" '"clave":"instalacion"' "$(curl -s $U/api/plan)"
check "PUT sin versiones → 400" "al menos una versi" "$(curl "${LEO[@]}" -X PUT $U/api/plan -d '{"versiones":[]}')"
echo "## inventario"
check "POST ítem (alan)" '"id":"EB-V56"' "$(curl "${ALAN[@]}" -X POST $U/api/inventario -d '{"codigo":"EB-V56","descripcion":"EinarBoard V5.6","categoria":"tarjeta","aplica_a":["V5.6"],"minimo":5}')"
check "POST código duplicado → 409" '"existe":true' "$(curl "${ALAN[@]}" -X POST $U/api/inventario -d '{"codigo":"eb-v56","descripcion":"dup"}')"
check "POST categoría inválida → 400" "inv\\u00e1lida: tornillo" "$(curl "${ALAN[@]}" -X POST $U/api/inventario -d '{"codigo":"X1","descripcion":"x","categoria":"tornillo"}')"
check "POST con stock_inicial → entrada" '"stock": 7' "$(curl "${ALAN[@]}" -X POST $U/api/inventario -d '{"codigo":"SD-15B-05","descripcion":"Mean Well","categoria":"fuente","minimo":113,"stock_inicial":7}' | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["item"]))')"
check "salida sin stock → 409" "No hay stock suficiente" "$(curl "${ALAN[@]}" -X POST $U/api/inventario/movimiento -d '{"item_id":"EB-V56","tipo":"salida","cantidad":3,"motivo":"ensamble"}')"
check "entrada" '"stock_despues":10' "$(curl "${ALAN[@]}" -X POST $U/api/inventario/movimiento -d '{"item_id":"EB-V56","tipo":"entrada","cantidad":10,"motivo":"llegó pedido","referencia":"OC-1"}')"
check "salida" '"stock_despues":7' "$(curl "${ALAN[@]}" -X POST $U/api/inventario/movimiento -d '{"item_id":"EB-V56","tipo":"salida","cantidad":3,"motivo":"ensamble","referencia":"HBOX5-E01-A001-R0001"}')"
check "ajuste (conteo 4 → delta −3)" '"delta":-3' "$(curl "${ALAN[@]}" -X POST $U/api/inventario/movimiento -d '{"item_id":"EB-V56","tipo":"ajuste","cantidad":4,"motivo":"conteo físico"}')"
check "sin motivo → 400" "Escribe el motivo" "$(curl "${ALAN[@]}" -X POST $U/api/inventario/movimiento -d '{"item_id":"EB-V56","tipo":"entrada","cantidad":1}')"
check "alerta = stock < mínimo" '"alerta": true' "$(curl -s "$U/api/inventario?alertas=1" | python3 -c 'import sys,json; j=json.load(sys.stdin); print(json.dumps([i for i in j["items"] if i["id"]=="EB-V56"]))')"
check "PATCH stock a mano → 400" "no se edita a mano" "$(curl "${ALAN[@]}" -X PATCH "$U/api/inventario/item?id=EB-V56" -d '{"stock":99}')"
check "PATCH ubicación → historial" "ubicacion:" "$(curl "${ALAN[@]}" -X PATCH "$U/api/inventario/item?id=EB-V56" -d '{"ubicacion":"Anaquel A2","minimo":3}')"
check "movimientos del ítem" '"total":3' "$(curl -s "$U/api/inventario/movimientos?item_id=EB-V56")"
check "export CSV" 'codigo,descripcion' "$(curl -s $U/api/inventario/export.csv)"
check "export CSV cabecera" 'text/csv' "$(curl -s -D - -o /dev/null $U/api/inventario/export.csv)"
echo "## sitios"
check "POST alan → 403" "Solo un administrador" "$(curl "${ALAN[@]}" -X POST $U/api/sitios -d '{"id":"icave","nombre":"ICAVE"}')"
check "POST leo" '"id":"icave"' "$(curl "${LEO[@]}" -X POST $U/api/sitios -d '{"id":"icave","nombre":"ICAVE","cliente":"Hutchison Ports","ubicacion":"Veracruz","alias":["ICAVE (Veracruz)"],"contactos":[{"nombre":"Abraham","rol":"supervisor"}]}')"
check "POST duplicado → 409" '"existe":true' "$(curl "${LEO[@]}" -X POST $U/api/sitios -d '{"id":"icave","nombre":"ICAVE"}')"
check "PATCH alan → 403" "Solo un administrador" "$(curl "${ALAN[@]}" -X PATCH "$U/api/sitio?id=icave" -d '{"ubicacion":"x"}')"
check "PATCH leo → historial" "ubicacion: Veracruz" "$(curl "${LEO[@]}" -X PATCH "$U/api/sitio?id=icave" -d '{"ubicacion":"Veracruz, Ver."}')"
check "GET sitios" '"nombre":"ICAVE"' "$(curl -s $U/api/sitios)"
curl "${ALAN[@]}" -X POST $U/api/actas -d '{"tipo":"instalacion-campo","serie":"HBOX5-E01-A001-R0001","tecnico":"Alan","conforme":true,"resultado":"OK","fecha":"2026-10-10","sitio":"icave","maquina_id":"FL-JUN-060","accion":"Instalada y reportando"}' > /dev/null
curl "${ALAN[@]}" -X POST $U/api/actas -d '{"tipo":"diagnostico-campo","serie":"RTK-014","tecnico":"Alan","conforme":true,"resultado":"FALLA","fecha":"2026-10-11","terminal":"ICAVE","maquina_id":"FL-JUN-060"}' > /dev/null
curl "${ALAN[@]}" -X POST $U/api/actas -d '{"tipo":"dano","serie":"RTK-014","tecnico":"Alan","conforme":true,"resultado":"REGISTRADO","fecha":"2026-10-11","sitio":"icave","clasificacion":"Agua","accion":"Marcada como dañada","resumen":"agua adentro"}' > /dev/null
curl "${ALAN[@]}" -X POST $U/api/actas -d '{"tipo":"envio","serie":"HBOX5-E02-A002-R0002","tecnico":"Alan","conforme":true,"resultado":"ENVIADA","fecha":"2026-10-12","clasificacion":"ICAVE (Veracruz)","accion":"Enviada"}' > /dev/null
R=$(curl -s "$U/api/sitio/unidades?id=icave")
check "unidades del sitio" '"total_unidades":3' "$R"
check "coincidencia por slug" '"coincidencia":"sitio"' "$R"
check "coincidencia por texto (terminal/destino)" '"coincidencia":"texto"' "$R"
check "instalada con fecha" '"instalada":"2026-10-10"' "$R"
check "marca de daño en la unidad" '"marca":"DANADA"' "$R"
check "sitio inexistente → 404" "No existe el sitio" "$(curl -s "$U/api/sitio/unidades?id=nada")"
check "/api/unidad trae sitio" '"sitio":"icave"' "$(curl -s "$U/api/unidad?serie=HBOX5-E01-A001-R0001")"
check "/api/actas trae sitio" '"sitio":"icave"' "$(curl -s "$U/api/actas")"
echo "## siembra (idempotente)"
S1=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1)
check "primera corrida crea" "pendientes (seed_1013): 12 creados" "$S1"
check "sitios: 3 nuevos (icave ya existía)" "sitios: 3 creados, 1 ya existían" "$S1"
S2=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1)
check "segunda corrida no duplica" "12 ya existían" "$S2"
check "inventario no duplica" "0 ítems creados" "$S2"
echo '[{"id":"t-notion-1","titulo":"Importado de Notion","pista":"ingenieria","estado":"hecho"}]' > $T/extra.json
check "--extra importa" "extra.json): 1 creados" "$(python3 $REPO/backend/seed_1013.py --solo nada --extra $T/extra.json 2>&1)"
check "resumen tras siembra (1 + 12 sembrados + 1 extra)" '"total":14' "$(curl -s $U/api/tareas/resumen)"
echo; echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
[ $fail -eq 0 ]

View File

@ -41,6 +41,7 @@ El acta de ensamble no deja elegir un módulo sin aprobar, sin integración o ya
| Acta | Qué registra | Procedimiento | | Acta | Qué registra | Procedimiento |
|---|---|---| |---|---|---|
| [Instalación en campo](../formularios/instalacion.html) | Una por unidad y vehículo: sitio, equipo, alimentación 24 V, antenas, RTK FIX, señal 4G, fotos | [Checklist de puesta en marcha](../procedimientos/v5-checklist-puesta-en-marcha.md) · [Sitios](../sitios/index.md) |
| [Diagnóstico en campo](../formularios/diagnostico.html) | Una por visita; muestra las visitas anteriores y admite fotos desde el teléfono | [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) · [Tablero de campo](../diagnostico/tablero-campo.html) | | [Diagnóstico en campo](../formularios/diagnostico.html) | Una por visita; muestra las visitas anteriores y admite fotos desde el teléfono | [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) · [Tablero de campo](../diagnostico/tablero-campo.html) |
| [Envío](../formularios/envio.html) · [Recepción](../formularios/recepcion.html) | El tramo entre el banco y el puerto | [Ciclo de vida](../procedimientos/ciclo-de-vida.md) | | [Envío](../formularios/envio.html) · [Recepción](../formularios/recepcion.html) | El tramo entre el banco y el puerto | [Ciclo de vida](../procedimientos/ciclo-de-vida.md) |
| [Daño o incidente](../formularios/dano.html) | Cualquier serie; la marca en todos los formularios. «Reparada» la levanta, «Baja» la hace permanente | [Ciclo de vida](../procedimientos/ciclo-de-vida.md#dano-o-incidente) | | [Daño o incidente](../formularios/dano.html) | Cualquier serie; la marca en todos los formularios. «Reparada» la levanta, «Baja» la hace permanente | [Ciclo de vida](../procedimientos/ciclo-de-vida.md#dano-o-incidente) |

View File

@ -4,17 +4,42 @@ title: "Campo"
# Campo # Campo
Unidad en la máquina, envíos y recepciones. Regla: no reinicies ni desconectes nada antes de capturar la evidencia ([por qué](../diagnostico/procedimiento-diagnostico-campo.md)). Unidad **en la máquina o en camino**: instalación en el puerto, falla en sitio, envíos, recepciones y daños. Regla de oro: **no reinicies ni desconectes nada antes de capturar la evidencia** ([por qué](../diagnostico/procedimiento-diagnostico-campo.md)). Todo cuelga de la serie de la unidad y de su **sitio**.
## Sitios de instalación
| Sitio | Puerto | Página |
|---|---|---|
| ICAVE | Veracruz | [Sitio ICAVE](../sitios/sitio.html?id=icave) |
| LZC | Lázaro Cárdenas | [Sitio LZC](../sitios/sitio.html?id=lzc) |
| EIT | Ensenada | [Sitio EIT](../sitios/sitio.html?id=eit) |
| TIMSA | Manzanillo | [Sitio TIMSA](../sitios/sitio.html?id=timsa) |
Cada sitio muestra sus unidades instaladas y sus actas de instalación y diagnóstico. Dar de alta un sitio nuevo: [Sitios](../sitios/index.md) (solo administradores). Las páginas de sitio las publica el otro agente (WP5); si una liga no abre todavía, usa el [índice de sitios](../sitios/index.md).
## Qué situación, qué procedimiento, qué acta
| Situación | Procedimiento | Acta | | Situación | Procedimiento | Acta |
|---|---|---| |---|---|---|
| Falla en la máquina | [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) | [Diagnóstico en campo](../formularios/diagnostico.html) | | **Instalar en el puerto** (gabinete montado, 24 V, antenas, RTK FIX) | [Checklist de puesta en marcha](../procedimientos/v5-checklist-puesta-en-marcha.md) — imprimir, una hoja por unidad | [Instalación en campo](../formularios/instalacion.html) — sitio, unidad, vehículo, antena, alimentación, RTK FIX, fotos |
| Instalar en el puerto | [Checklist de puesta en marcha](../procedimientos/v5-checklist-puesta-en-marcha.md) | Impreso, al expediente | | **Falla en la máquina** | [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) | [Diagnóstico en campo](../formularios/diagnostico.html) — una por visita, con fotos desde el teléfono |
| Sale una unidad | [Ciclo de vida §6](../procedimientos/ciclo-de-vida.md#6-envio) | [Envío](../formularios/envio.html) | | Sale una unidad del banco | [Ciclo de vida §6](../procedimientos/ciclo-de-vida.md#6-envio) | [Envío](../formularios/envio.html) |
| Llega una unidad | [Ciclo de vida §7](../procedimientos/ciclo-de-vida.md#7-recepcion-e-inspeccion) | [Recepción e inspección](../formularios/recepcion.html) | | Llega una unidad al puerto o al taller | [Ciclo de vida §7](../procedimientos/ciclo-de-vida.md#7-recepcion-e-inspeccion) | [Recepción e inspección](../formularios/recepcion.html) |
| Algo se dañó | [Ciclo de vida: daño](../procedimientos/ciclo-de-vida.md#dano-o-incidente) | [Daño o incidente](../formularios/dano.html) | | Algo se dañó (cualquier serie) | [Ciclo de vida: daño](../procedimientos/ciclo-de-vida.md#dano-o-incidente) | [Daño o incidente](../formularios/dano.html) |
- [Expediente de una unidad](../unidad.html): cada acta, foto y log por serie. El checklist impreso se sube al [expediente](../unidad.html) junto con el acta de instalación. El acta de instalación es nueva (oct-2026): mientras no esté publicada, el checklist impreso sigue siendo el registro.
- [Tablero de campo](../diagnostico/tablero-campo.html): todas las visitas.
- [Índice de actas](../actas/index.md) ## Lo que hay que saber en campo
- Para el cliente: [manual del cliente](../manuales/manual-cliente.md) · [PDF](../manuales/Drogheda-Manual-Instalacion-Operacion_v2.0.pdf)
- **Firmware vigente en campo: 0.6.0** (V4 y V5). 0.6.3 es de banco; no se flashea en la máquina. Reflashear es trabajo de [taller](../taller/index.md).
- En **V5.5** no se apaga y prende la etapa RTK en batería «para ver si vuelve»: el módem queda oscuro hasta RESET 4G o 24 V.
- La unidad en batería reporta cada 10 minutos (V5.6 en adelante) o de forma continua hasta agotar el paquete (V5.5 con 0.6.0). Si deja de reportar en batería, no es forzosamente una falla: ve el [manual del cliente](../manuales/manual-cliente.md).
- Despliegue GPS-RTK (24-sep): bases RTK y gabinetes en ICAVE, EIT, LZC y TIMSA desde la semana del 28-sep; meta de unidades RTK en los puertos a mediados de noviembre.
## Tableros y expediente
- [Expediente de una unidad](../unidad.html): cada acta, foto y log por serie, incluida la cadena de campo (envío → instalación → diagnóstico).
- [Tablero de campo](../diagnostico/tablero-campo.html): todas las visitas de diagnóstico.
- [Sitios](../sitios/index.md): unidades por puerto.
- [Índice de actas](../actas/index.md): cómo se llenan.
- Para el cliente: [manual del cliente](../manuales/manual-cliente.md) · [PDF de instalación y operación](../manuales/Drogheda-Manual-Instalacion-Operacion_v2.0.pdf)

View File

@ -17,6 +17,9 @@
item.tipo = 'foto' → cámara del teléfono; sube a /api/adjuntos y guarda la ruta item.tipo = 'foto' → cámara del teléfono; sube a /api/adjuntos y guarda la ruta
item.tipo = 'archivo' → captura de log (.txt .log .csv .nmea .ubx); sube a /api/archivos item.tipo = 'archivo' → captura de log (.txt .log .csv .nmea .ubx); sube a /api/archivos
item.tipo = 'fecha' → selector de fecha; arranca con la de hoy item.tipo = 'fecha' → selector de fecha; arranca con la de hoy
item.tipo = 'sitio' → lista de sitios (puertos/terminales) desde /api/sitios; con
guardaEn:'sitio' el slug queda en la raíz del acta y la página
del sitio la encuentra (sitios/sitio.html?id=)
Modos de serie: Modos de serie:
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005" (a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
@ -45,6 +48,13 @@
return blob || file; return blob || file;
} }
/* Sitios (puertos / terminales) para el ítem tipo 'sitio': una sola consulta por página. */
let sitiosProm = null;
function cargarSitios() {
if (!sitiosProm) sitiosProm = fetch('/api/sitios', { cache: 'no-store' }).then(r => r.json()).then(j => j.sitios || []).catch(() => []);
return sitiosProm;
}
function opciones(sel, prefijo, desde, hasta, pad) { function opciones(sel, prefijo, desde, hasta, pad) {
sel.append($('option', { value: '', text: '— elegir —' })); sel.append($('option', { value: '', text: '— elegir —' }));
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' })); sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
@ -309,6 +319,20 @@
tbody.append(tr); tbody.append(tr);
}); });
t.append(tbody); scroll.append(t); box.append(scroll); t.append(tbody); scroll.append(t); box.append(scroll);
} else if (it.tipo === 'sitio') {
box.append($('label', { html: it.txt || 'Sitio' }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const sel = $('select', { 'data-id': id, 'data-lbl': String(it.txt || 'Sitio').replace(/<[^>]+>/g, ''), style: 'max-width:420px' });
sel.append($('option', { value: '', text: it.req ? '— elegir sitio —' : '— sin sitio / no aplica —' }));
sel.append($('option', { value: '…', text: 'cargando sitios…', disabled: 'disabled' }));
cargarSitios().then(ss => {
sel.querySelector('option[value="…"]').remove();
ss.forEach(s => sel.append($('option', { value: s.id, text: s.nombre + (s.ubicacion && s.ubicacion !== 'por confirmar' ? ' — ' + s.ubicacion : '') })));
sel.append($('option', { value: 'otro', text: 'Otro / no está en la lista (anótalo en observaciones)' }));
if (sel.dataset.pend !== undefined) { sel.value = sel.dataset.pend; delete sel.dataset.pend; }
if (!ss.length) sel.append($('option', { value: '', text: '(no hay sitios dados de alta; pide a Leo que los cree)' }));
});
box.append(sel);
} else if (it.tipo === 'fecha') { } else if (it.tipo === 'fecha') {
box.append($('label', { html: it.txt })); box.append($('label', { html: it.txt }));
const hoy = new Date(); hoy.setMinutes(hoy.getMinutes() - hoy.getTimezoneOffset()); const hoy = new Date(); hoy.setMinutes(hoy.getMinutes() - hoy.getTimezoneOffset());
@ -427,7 +451,11 @@
els.forEach(el => { els.forEach(el => {
if (el.type === 'checkbox') el.checked = !!v; if (el.type === 'checkbox') el.checked = !!v;
else if (el.type === 'radio') el.checked = (el.value === v); else if (el.type === 'radio') el.checked = (el.value === v);
else el.value = v || ''; else {
el.value = v || '';
// un <select> cuyas opciones aún no llegan (sitios): guarda el valor y aplícalo al cargar
if (el.tagName === 'SELECT' && v && el.value !== v) el.dataset.pend = v;
}
}); });
} }
} }

View File

@ -34,6 +34,9 @@ Acta.render({
opciones:['Tarjeta EinarBoard','Módulo ArduSimple','Módem 4G','Unidad HBOX completa','Otro (cable, antena, gabinete…)'] }, opciones:['Tarjeta EinarBoard','Módulo ArduSimple','Módem 4G','Unidad HBOX completa','Otro (cable, antena, gabinete…)'] },
{ tipo:'radio', clave:'donde', req:true, txt:'Dónde ocurrió', { tipo:'radio', clave:'donde', req:true, txt:'Dónde ocurrió',
opciones:['En el banco (prueba o flasheo)','Durante el ensamble','En transporte o almacén','En campo, instalada','No se sabe — así se encontró'] }, opciones:['En el banco (prueba o flasheo)','Durante el ensamble','En transporte o almacén','En campo, instalada','No se sabe — así se encontró'] },
{ tipo:'sitio', clave:'sitio', guardaEn:'sitio', txt:'Sitio (si fue en un puerto o la unidad pertenece a uno)',
si:v=>/campo|transporte|No se sabe/.test(String(v.donde||'')),
ayuda:'Así el daño aparece en la página del sitio. Si fue en el banco o en el ensamble, no aplica.' },
{ tipo:'texto', clave:'quien_detecto', rows:'1', txt:'Quién lo detectó y cómo', ph:'Daniel, al medir continuidad antes de energizar' }, { tipo:'texto', clave:'quien_detecto', rows:'1', txt:'Quién lo detectó y cómo', ph:'Daniel, al medir continuidad antes de energizar' },
] }, ] },

View File

@ -48,6 +48,8 @@ Acta.render({
+ 'Sin correo contestado y horario acordado, no subas: avisa y reagenda.' }, + 'Sin correo contestado y horario acordado, no subas: avisa y reagenda.' },
{ tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha', { tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha',
ayuda:'Hoy por omisión; cámbiala si capturas una visita de otro día.' }, ayuda:'Hoy por omisión; cámbiala si capturas una visita de otro día.' },
{ tipo:'sitio', clave:'sitio', guardaEn:'sitio', txt:'Sitio (puerto o terminal)',
ayuda:'Elige el puerto donde está la máquina; la visita aparece en la página del sitio.' },
{ tipo:'texto', clave:'maquina_id', req:true, guardaEn:'maquina_id', rows:'1', { tipo:'texto', clave:'maquina_id', req:true, guardaEn:'maquina_id', rows:'1',
txt:'Máquina (como la nombra ICAVE)', txt:'Máquina (como la nombra ICAVE)',
ph:'FL-JUN-060 · RT-ZPMC-20 · HS-WES-62' }, ph:'FL-JUN-060 · RT-ZPMC-20 · HS-WES-62' },

View File

@ -32,6 +32,8 @@ Acta.render({
{ tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha' }, { tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha' },
{ tipo:'radio', clave:'destino', req:true, guardaEn:'clasificacion', txt:'Destino', { tipo:'radio', clave:'destino', req:true, guardaEn:'clasificacion', txt:'Destino',
opciones:['ICAVE (Veracruz)','TIMSA','EIT','LCT','Taller Veracruz','Querétaro (regreso)','Otro'] }, opciones:['ICAVE (Veracruz)','TIMSA','EIT','LCT','Taller Veracruz','Querétaro (regreso)','Otro'] },
{ tipo:'sitio', clave:'sitio', guardaEn:'sitio', txt:'Sitio de instalación (si va a un puerto)',
ayuda:'Elige el sitio para que la unidad aparezca en su página (Sitios). Déjalo vacío si regresa al taller o a Querétaro.' },
{ tipo:'texto', clave:'maquina_destino', rows:'1', guardaEn:'maquina_id', txt:'Máquina destino (si se sabe)', ph:'FL-JUN-060' }, { tipo:'texto', clave:'maquina_destino', rows:'1', guardaEn:'maquina_id', txt:'Máquina destino (si se sabe)', ph:'FL-JUN-060' },
{ tipo:'radio', clave:'medio', req:true, txt:'Medio de envío', { tipo:'radio', clave:'medio', req:true, txt:'Medio de envío',
opciones:['Paquetería (guía abajo)','La lleva alguien de Helgen','La recoge el cliente'] }, opciones:['Paquetería (guía abajo)','La lleva alguien de Helgen','La recoge el cliente'] },

View File

@ -0,0 +1,124 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de instalación en campo — HBOX</title>
<link rel="stylesheet" href="acta.css">
<style>
details.como{background:#eef3f8;border:1px solid #c9dcec;border-radius:10px;padding:8px 14px;margin:0 0 12px;font-size:14px;color:#1b3b57}
details.como summary{cursor:pointer;font-weight:700}
details.como ol{margin:8px 0 4px;padding-left:20px} details.como li{margin:3px 0}
</style>
</head>
<body>
<div id="acta"></div>
<script src="flota.js"></script>
<script src="series.js"></script>
<script src="acta.js"></script>
<script>
const fix = v => String(v.rtk_fix || '').startsWith('Sí');
const sinFix = v => String(v.rtk_fix || '').startsWith('No');
const conEth = v => String(v.ethernet || '').startsWith('Sí');
const ign = v => String(v.ignicion || '');
const reporta = v => String(v.monitor || '').startsWith('Sí');
Acta.render({
tipo: 'instalacion-campo',
repetible: true,
tecnicoDesdeUsuario: true,
nombres: { alan: 'Alán Basurto', daniel: 'Daniel Martínez', leo: 'Leo Rodríguez' },
unidades: window.SERIES,
etiquetaSerie: 'Unidad HBOX que se instala',
titulo: 'Acta de instalación en campo',
subtitulo: 'Una por unidad y por vehículo. Se llena en la máquina, al terminar de instalar; no de memoria después.',
textoOK: 'INSTALADA Y REPORTANDO',
intro: '<details class="como" open><summary>Cómo se llena</summary><ol>'
+ '<li>Elige el <b>sitio</b> y la <b>unidad</b> (la etiqueta de la caja, <code>HBOX5-Exx-Axxx-Rxxxx</code>). Si la unidad trae aviso de daño, no la instales.</li>'
+ '<li>Anota el vehículo <b>como lo nombra el puerto</b> (FL-JUN-060, RT-ZPMC-20): así lo buscan después.</li>'
+ '<li>Marca cada punto del checklist <b>después</b> de hacerlo, no antes. Mide el voltaje con la máquina encendida.</li>'
+ '<li>Espera el <b>RTK FIX</b> con la antena ya fija en su lugar definitivo y anota cuánto tardó.</li>'
+ '<li>Confirma con alguien que vea el monitor que la posición aparece. Sin confirmación, el resultado es FALLA.</li>'
+ '<li>Fotos: gabinete montado, antena, interior con cables sujetos. Si no subieron por señal, reintenta antes de irte.</li>'
+ '<li>Resultado: <b>INSTALADA Y REPORTANDO</b> solo si todo quedó. Si no, FALLA y explica qué faltó; se vuelve con otra acta.</li>'
+ '</ol></details>',
secciones: [
{ titulo: '0 · Dónde y en qué',
items: [
{ tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha de la instalación' },
{ tipo:'sitio', clave:'sitio', req:true, guardaEn:'sitio', txt:'Sitio (puerto o terminal)',
ayuda:'Si el sitio no está en la lista, elige «Otro» y escríbelo en observaciones; Leo lo da de alta.' },
{ tipo:'texto', clave:'maquina_id', req:true, guardaEn:'maquina_id', rows:'1', txt:'Vehículo o equipo (ID como lo nombra el puerto)', ph:'FL-JUN-060 · RT-ZPMC-20 · HS-WES-62' },
{ tipo:'radio', clave:'tipo_vehiculo', req:true, guardaEn:'clasificacion', txt:'Tipo de vehículo o equipo',
opciones:['RTG','Montacargas','Tractoplana','Grúa','Camión / pick-up','Equipo fijo','Otro'] },
{ tipo:'texto', clave:'placas', rows:'1', txt:'Placas o número económico (si aplica)' },
{ tipo:'texto', clave:'acompanante', rows:'1', txt:'Quién autorizó o acompañó (operador, supervisor)', ph:'Abraham (ICAVE), 10:30' },
] },
{ titulo: '1 · Montaje del gabinete',
items: [
{ tipo:'radio', clave:'montaje', req:true, txt:'Gabinete montado',
opciones:['Fijo, con los 4 puntos y sin vibración','Fijo, con menos puntos (explica)','Provisional (explica)'] },
{ tipo:'texto', clave:'montaje_donde', rows:'1', txt:'Dónde quedó (cabina, chasis, poste…)', ph:'Lado derecho de la cabina, bajo el asiento' },
{ tipo:'check', clave:'prensaestopas', req:true, txt:'Prensaestopas apretados y cables con bajada de agua (goteo hacia afuera).' },
{ tipo:'check', clave:'tapa', req:true, txt:'Tapa cerrada con sello y tornillos en cruz; etiqueta de la unidad a la vista.' },
{ tipo:'foto', clave:'f_gabinete', req:true, etiqueta:'gabinete', txt:'Gabinete montado, de lejos (que se vea dónde quedó).' },
] },
{ titulo: '2 · Alimentación e ignición',
sub:'Máquina encendida. Multímetro en DC.',
items: [
{ tipo:'medida', clave:'v_24', req:true, txt:'Voltaje en el GX16 (pines 1-2), máquina encendida', esperado:'22 – 28 V', u:'V',
aviso:'Fuera de 20–30 V no conectes la unidad: revisa de dónde tomaste la alimentación.' },
{ tipo:'radio', clave:'toma', req:true, txt:'De dónde se tomó la alimentación',
opciones:['Batería directa con fusible','Llave de encendido (switched)','Tablero / caja de fusibles del vehículo','Otro (explica)'] },
{ tipo:'check', clave:'fusible', req:true, txt:'Fusible en línea instalado en el positivo, cerca de la toma.' },
{ tipo:'radio', clave:'ignicion', req:true, txt:'Señal de ignición (pin 3)',
opciones:['Conectada y probada: sube con la llave, baja al apagar','Conectada, no se pudo probar','No conectada (explica)'] },
{ tipo:'medida', clave:'v_ign', si:v=>ign(v).startsWith('Conectada y probada'), txt:'Voltaje de ignición con llave', esperado:'≈ voltaje de batería', u:'V' },
{ tipo:'check', clave:'led_ok', req:true, txt:'Al energizar: LED de estado enciende y la unidad arranca (ventilador o LED de panel).' },
] },
{ titulo: '3 · Antenas y cables',
items: [
{ tipo:'radio', clave:'ant_gnss_pos', req:true, txt:'Antena GNSS: posición',
opciones:['Techo de la cabina, cielo libre','Mástil o soporte propio','Otra (explica); cielo parcialmente tapado'] },
{ tipo:'check', clave:'ant_gnss_cable', req:true, txt:'Cable GNSS sin dobleces cerrados, sujeto cada ~30 cm, SMA apretado con la mano más 1/4 de vuelta.' },
{ tipo:'check', clave:'ant_lte', req:true, txt:'2 antenas LTE instaladas, separadas entre sí y lejos de la GNSS.' },
{ tipo:'radio', clave:'ethernet', req:true, txt:'Cable Ethernet (solo si la unidad lo usa)',
opciones:['No aplica','Sí — conectado y probado','Sí — conectado, no se pudo probar','No cupo en la caja (reporta en el tablero)'] },
{ tipo:'texto', clave:'ethernet_a', si:conEth, rows:'1', txt:'A qué equipo se conectó el Ethernet' },
{ tipo:'foto', clave:'f_antena', req:true, etiqueta:'antena', txt:'Antena GNSS en su lugar definitivo.' },
{ tipo:'foto', clave:'f_interior', req:true, etiqueta:'interior', txt:'Interior del gabinete con cables sujetos, antes de cerrar.' },
] },
{ titulo: '4 · RTK, 4G y confirmación en el monitor',
sub:'Con la antena ya fija. Si tienes laptop, usa la consola; si no, el LED de estado y el monitor.',
items: [
{ tipo:'radio', clave:'rtk_fix', req:true, txt:'RTK FIX en sitio',
opciones:['Sí — FIX (fix=4)','Solo FLOAT (fix=5) tras 15 min','No — sin fix (explica)'] },
{ tipo:'medida', clave:'t_fix', si:fix, req:true, txt:'Tiempo hasta FIX desde que encendió', esperado:'< 10 min con cielo libre', u:'min' },
{ tipo:'medida', clave:'sats', si:v=>!sinFix(v), txt:'Satélites en uso', esperado:'≥ 10', u:'sat' },
{ tipo:'medida', clave:'rssi', req:true, txt:'Señal 4G (RSSI en dBm o CSQ del módem)', esperado:'mejor que −95 dBm · CSQ ≥ 10', ph:'-87 dBm · CSQ 14', modo:'text' },
{ tipo:'radio', clave:'red', req:true, txt:'Módem', opciones:['Registrado en la red (LED lento o fijo)','Registra y se cae','No registra'] },
{ tipo:'radio', clave:'monitor', req:true, txt:'Posición confirmada en el monitor',
opciones:['Sí — alguien con acceso la vio en el mapa','No — nadie pudo confirmar','No — no aparece'] },
{ tipo:'texto', clave:'monitor_quien', si:reporta, rows:'1', txt:'Quién lo confirmó y a qué hora', ph:'Daniel, 11:42, posición correcta en el patio 4' },
{ tipo:'archivo', clave:'log', etiqueta:'instalacion', txt:'Captura de consola (opcional)' },
] },
{ titulo: '5 · Cierre',
items: [
{ tipo:'radio', clave:'accion', req:true, guardaEn:'accion', txt:'Cómo quedó',
opciones:['Instalada y reportando','Instalada, sin reportar todavía (explica)','No se pudo instalar (explica)'],
aviso:'Solo «Instalada y reportando» cuenta como instalación terminada en el tablero y en la página del sitio.' },
{ tipo:'check', clave:'operador_informado', txt:'Operador o supervisor informado: no desconectar ni abrir el gabinete; a quién llamar.' },
{ tipo:'check', clave:'registro_sitio', txt:'Vehículo y unidad anotados en la hoja de contacto que se queda en el sitio.' },
{ tipo:'texto', clave:'observaciones', rows:'3', txt:'Observaciones (qué faltó, qué cambió respecto al plan)' },
] },
]
});
</script>
</body>
</html>

View File

@ -38,6 +38,8 @@ Acta.render({
{ tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha' }, { tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha' },
{ tipo:'radio', clave:'donde', req:true, guardaEn:'clasificacion', txt:'Dónde se recibe', { tipo:'radio', clave:'donde', req:true, guardaEn:'clasificacion', txt:'Dónde se recibe',
opciones:['Taller Veracruz','Oficina ICAVE','Otra terminal','Querétaro'] }, opciones:['Taller Veracruz','Oficina ICAVE','Otra terminal','Querétaro'] },
{ tipo:'sitio', clave:'sitio', guardaEn:'sitio', txt:'Sitio al que va la unidad (si se sabe)',
ayuda:'Con esto la recepción aparece en la página del sitio. Si todavía no se sabe, déjalo vacío.' },
{ tipo:'texto', clave:'guia', rows:'1', txt:'Guía o quién la entregó' }, { tipo:'texto', clave:'guia', rows:'1', txt:'Guía o quién la entregó' },
{ tipo:'texto', clave:'maquina_destino', rows:'1', guardaEn:'maquina_id', txt:'Máquina destino', ph:'FL-JUN-060' }, { tipo:'texto', clave:'maquina_destino', rows:'1', guardaEn:'maquina_id', txt:'Máquina destino', ph:'FL-JUN-060' },
] }, ] },

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<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.5.6)</title> <title>Acta de prueba — EinarBoard V4 (fw 0.6.0)</title>
<link rel="stylesheet" href="acta.css"> <link rel="stylesheet" href="acta.css">
</head> </head>
<body> <body>

View File

@ -21,7 +21,7 @@ author: Helgen Technologies
The complete KiCad design history of the HBOX watchdog board, versions v3 through v6. The complete KiCad design history of the HBOX watchdog board, versions v3 through v6.
- **Active design: `hbox-watchdog-v6.*`** — this is the only version under development. - **Active designs (Oct-2026):** `hbox-watchdog-v5.*` (V5.6, Michelle's layout), `hbox-watchdog-v57.*` (V5.7, Leo → Michelle) and `hbox-watchdog-v6.*` (v6). `v3-PROD`, `v4` and the tag `v5.5-as-fabbed` are historical: do not edit.
- `hbox-watchdog-v3/v4/v5.*` are frozen history. Do not modify them. - `hbox-watchdog-v3/v4/v5.*` are frozen history. Do not modify them.
- Files ending in `-PROD` are snapshots of boards that were actually fabricated. Never touch these. - Files ending in `-PROD` are snapshots of boards that were actually fabricated. Never touch these.
- Symbol and footprint libraries (`Drogheda-v6.kicad_sym`, `Drogheda-v6.pretty/`, `sym-lib-table`, `fp-lib-table`) live inside the repo with project-relative paths, so a clone is self-contained — no external library setup needed. - Symbol and footprint libraries (`Drogheda-v6.kicad_sym`, `Drogheda-v6.pretty/`, `sym-lib-table`, `fp-lib-table`) live inside the repo with project-relative paths, so a clone is self-contained — no external library setup needed.

View File

@ -18,7 +18,7 @@ Contacto: Leo Rodríguez — leo@helgen.tech
El historial completo de diseño en KiCad de la tarjeta HBOX watchdog, versiones v3 a v6. El historial completo de diseño en KiCad de la tarjeta HBOX watchdog, versiones v3 a v6.
- **Diseño activo: `hbox-watchdog-v6.*`** — es la única versión en desarrollo. - **Diseños activos (oct-2026):** `hbox-watchdog-v5.*` (V5.6, layout de Michelle), `hbox-watchdog-v57.*` (V5.7, Leo → Michelle) y `hbox-watchdog-v6.*` (v6). `v3-PROD`, `v4` y el tag `v5.5-as-fabbed` son históricos: no se editan.
- `hbox-watchdog-v3/v4/v5.*` son historial congelado. No los modifiques. - `hbox-watchdog-v3/v4/v5.*` son historial congelado. No los modifiques.
- Los archivos terminados en `-PROD` son instantáneas de tarjetas que ya se fabricaron. No los toques nunca. - Los archivos terminados en `-PROD` son instantáneas de tarjetas que ya se fabricaron. No los toques nunca.
- Las bibliotecas de símbolos y huellas (`Drogheda-v6.kicad_sym`, `Drogheda-v6.pretty/`, `sym-lib-table`, `fp-lib-table`) viven dentro del repositorio con rutas relativas al proyecto, así que el clon es autocontenido — no hay que configurar bibliotecas externas. - Las bibliotecas de símbolos y huellas (`Drogheda-v6.kicad_sym`, `Drogheda-v6.pretty/`, `sym-lib-table`, `fp-lib-table`) viven dentro del repositorio con rutas relativas al proyecto, así que el clon es autocontenido — no hay que configurar bibliotecas externas.

View File

@ -26,6 +26,10 @@ Acceso: usuario y contraseña te los da Leo; cámbiala la primera vez en [Mi cue
- [Campo](campo/index.md): unidad en la máquina, envíos, recepciones y daños. - [Campo](campo/index.md): unidad en la máquina, envíos, recepciones y daños.
- [Tarjetas y hardware](hardware/index.md): pinout, hoja de datos, manuales por tarjeta. - [Tarjetas y hardware](hardware/index.md): pinout, hoja de datos, manuales por tarjeta.
- [Descargas](descargas/index.md): firmware por tarjeta y SHA-256. - [Descargas](descargas/index.md): firmware por tarjeta y SHA-256.
- [Tableros](tableros/index.md): pendientes del equipo (kanban), estado por versión de tarjeta, plan 570 vs. avance.
- [Inventario](inventario/index.md): existencias y movimientos de tarjetas, módulos, gabinetes y packs.
- [Sitios](sitios/index.md): unidades instaladas por puerto y actas de instalación.
- [Historia del proyecto](historia/index.md): jun → oct 2026, con ligas a cada documento.
## Reglas que se rompen seguido ## Reglas que se rompen seguido

View File

@ -4,16 +4,79 @@ title: "Tarjetas y hardware"
# Tarjetas y hardware # Tarjetas y hardware
Estado, firmware y cómo reconocer cada tarjeta: [Manuales por tarjeta](../manuales/index.md). Estado de cada versión de la EinarBoard al **13-oct-2026**, con sus documentos vigentes, los históricos y el siguiente paso. ¿No sabes qué tarjeta tienes? [Manuales por tarjeta](../manuales/index.md) te la identifica por serigrafía, conectores y LEDs. Estado vivo (tareas, decisiones, responsables): [Tablero de ingeniería](../tableros/ingenieria.html).
| Tarjeta | Documentos | | Tarjeta | Estado | Pedido | Proyecto KiCad | Firmware |
|---|---|---|---|---|
| <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> | En layout — pasada 2 (Michelle). **No se pide todavía.** | 113 | `hbox-watchdog-v5` | 0.7.0 (`board_v56.h`), pendiente |
| <span class="badge-v57">V5.7</span> | En diseño — Leo coloca la fuente a bordo. | 327 | `hbox-watchdog-v57` (convive con v5) | 0.7.x |
| <span class="badge-v6">v6</span> | En diseño — esquemático verificado; PCB sin corregir. | 130 + reemplazos | `hbox-watchdog-v6` | 0.7.x (`board_v6.h`) |
| <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` |
!!! warning "Reglas que aplican a toda revisión"
- **DFM del LTC4040 (U6, QFN 4×5, 8-oct):** pasta del pad expuesto ≤ 50 %; vías del EP rellenas y tapadas (JLC *Epoxy Filled & Capped*) o fuera de las ventanas de pasta; pedir X-ray/AOI en U6; revisar U6 con lupa en cada tarjeta antes de energizar. Aplica a V5.6, V5.7 y v6.
- **Nada de corriente por capas internas** (In1 e In2 son de 0.5 oz): `/BAT_PLUS`, `/MAIN5V`, `/SYS5V` y los nodos de conmutación van por F.Cu/B.Cu a ≥ 1.5 mm. Hallazgo de la auditoría del 13-oct.
- El firmware se elige por tarjeta, nunca por número de versión: [Descargas](../descargas/index.md#firmware-del-hbox-que-binario-va-en-que-tarjeta).
## V5.5 — en campo { #v55 }
**Estado.** 15 tarjetas fabricadas el 22-ago-2026 (JLCPCB), en ensamble, taller y puertos. Es la tarjeta que hoy tienen los técnicos en la mano. Diseño congelado (`v5.5-as-fabbed`); **no se corrige ni se vuelve a fabricar**: sus defectos se arreglan en la V5.6.
| | Documentos |
|---|---| |---|---|
| <span class="badge-v3">V3</span> | [Manual](../manuales/manual-tarjeta-V3.md) · [Pinout](../montaje/pinout-arneses.md) · [BOM](../montaje/bom-split.md) · [Ensamble y QC](../montaje/guia-ensamble-qc.md) · [Guía de ensamble (PDF)](../montaje/assembly-guide.md) | | Vigentes | [Manual de la tarjeta V5 (V5.5 y V5.6)](../manuales/manual-tarjeta-V5.md) · [Pinout V5.6](v56-pinout.md) (sección «Diferencias V5.5 → V5.6» al final) · [Hoja de datos V5.6](v56-datasheet.md) (valores comunes) |
| <span class="badge-v4">V4</span> | [Manual](../manuales/manual-tarjeta-V4.md) · [Preparación y prueba](../procedimientos/v4-prueba-fw-0.5.6.md) | | Procedimientos | [Prueba y flasheo](../procedimientos/v5-prueba-y-flasheo.md) · [Módem 4G en batería](../procedimientos/prueba-bateria-modem.md) · [Ethernet y puertos serie](../procedimientos/v5-prueba-ethernet-serie.md) |
| <span class="badge-v55">V5.5</span> | [Manual V5](../manuales/manual-tarjeta-V5.md) | | Históricos | Auditoría EMC del 22-ago (`03-Architecture-Hardware/2026-08-22_Drogheda_EinarBoard-v5_EMC-Audit_v1.0.md`, repositorio): `/SW_BAT` 0.3 mm y `/BAT_PLUS` finos → corregidos en la V5.6 |
| <span class="badge-v56">V5.6</span> | [Manual V5 §9](../manuales/manual-tarjeta-V5.md) · [Hoja de datos](v56-datasheet.md) · [Pinout](v56-pinout.md) · [Layout](v56-layout-instrucciones.md) · [ECO](v56-eco.md) · [Cambios al esquemático](v56-cambios-esquematico.md) · [**Layout pasada 2 — correcciones antes del pedido (13-oct)**](v56-layout-pasada-2.md) · [Layout pasada 1 (12-oct)](v56-layout-pasada-1.md) · [Auditoría del layout 13-oct (inglés)](v56-auditoria-layout-13oct.md) | | Lo que no hace | Ciclo de 10 min en batería (el módem no arranca en frío sobre el boost). Regla de campo: respaldo continuo, nunca ciclos de etapa en batería. |
| <span class="badge-v57">V5.7</span> | [**ECO — fuente 24 V → 5 V a bordo**](v57-eco-fuente.md) · [Guía de layout (Leo → Michelle)](v57-layout-guia.md) — esquemático listo; PCB en trabajo. Pedido después del banco de V5.6. |
| <span class="badge-v6">v6</span> | [Manual](../manuales/manual-tarjeta-v6.md) · [**Próximos pasos para ordenar**](v6-proximos-pasos.md) · [Plan de corrección y estado](v6-plan-correccion.md) · [Cambios — resumen de 3 pasadas](v6-cambios-resumen.md) · [Auditoría completa](v6-auditoria-completa.md) · [Cambios al esquemático](v6-cambios-esquematico.md) · [Revisión vs. lecciones V5.5](v6-revision-lecciones-v55.md) · [Mapa de pines](v6-pinmap.md) · [Auditoría pre-fab](v6-auditoria-prefab.md) ([inglés](v6-prefab-audit.md)) · [Plan de cobre](EinarBoard-v6_Plan-de-Cobre_ES.html) ([inglés](EinarBoard-v6_Copper-Plan_EN.html)) | **Siguiente paso.** Ninguno de diseño. En taller: [diagnóstico](../procedimientos/diagnostico-banco.md) y [reflasheo](../procedimientos/reflasheo-unidades.md) con el binario V5 vigente.
## V5.6 — en layout → pedido de 113 { #v56 }
**Estado.** Esquemático cerrado (ECO 27/28-sep + acelerómetro U26 el 8-oct − sonda P12 el 9-oct). Layout de Michelle del 12-oct auditado el 13-oct: **NO LISTA** — `/BAT_PLUS` por In1 a 0.5 mm, y MAIN5V/SYS5V/SW3 por capas internas heredados de la V5.5. Está en **pasada 2**. Lleva Mean Well SD-15B-05 externa (igual que V5.5).
| | Documentos |
|---|---|
| Vigentes | [ECO V5.6 — requisitos R1–R5](v56-eco.md) · [Cambios al esquemático V5.5 → V5.6](v56-cambios-esquematico.md) · [Reglas de layout esquemático → PCB](v56-layout-instrucciones.md) · [**▶ Pasada 2 — correcciones antes del pedido (13-oct)**](v56-layout-pasada-2.md) · [Auditoría del layout 13-oct (inglés)](v56-auditoria-layout-13oct.md) |
| Referencia | [Hoja de datos V5.6](v56-datasheet.md) · [Pinout V5.6](v56-pinout.md) — generados de la netlist del 28-sep: **todavía listan P12 y no traen U26**; se regeneran al cerrar la pasada 2 |
| Históricos | [Layout pasada 1 (12-oct)](v56-layout-pasada-1.md) — superada por la pasada 2 |
| Firmware | 0.7.0 con `board_v56.h` (control del módem por MODEM_EN/RST, paquete LFP-HT). Ningún 0.6.x enciende su módem. |
**Siguiente paso.** Michelle termina la pasada 2 → Claude revisa pad a pad + Gerber/BOM/CPL → paquete de fabricación a cotización (113 pzas) con las reglas DFM del LTC4040. Después: regenerar hoja de datos y pinout; firmware 0.7.0.
## V5.7 — fuente 24 V → 5 V a bordo → pedido de 327 { #v57 }
**Estado.** Decidida el 12-oct, esquemático aplicado el 13-oct (ERC 0, netlist verificada): V5.6 + bloque de entrada de la v6 (U25 TPS54360B, 5.32 V fijos), fusible F2 SMD 3 A, fuera el buck del ventilador U2 y la entrada P2, JP1 puenteado. Proyecto KiCad separado `hbox-watchdog-v57`; el PCB arranca del layout V5.6 de Michelle (`d71098d`). El hueco del SD-15B queda para el paquete 26650 LFP.
| | Documentos |
|---|---|
| Vigentes | [ECO V5.7 — fuente a bordo (v0.2)](v57-eco-fuente.md) · [**▶ Guía de layout V5.7 (Leo)**](v57-layout-guia.md) |
| Históricos | ECO v0.1 del 12-oct (`03-Architecture-Hardware/2026-10-12_Drogheda_EinarBoard-V5.7_ECO-Fuente-a-bordo_ES_v0.1.md`, repositorio) — superada por v0.2 |
| Hereda de V5.6 | Todo lo demás: [ECO V5.6](v56-eco.md), [cambios al esquemático](v56-cambios-esquematico.md), acelerómetro, sin P12. Hoja de datos y pinout propios: pendientes. |
**Siguiente paso.** Leo coloca y rutea U25/F2 en la zona reservada (x 170–197, y 42–72 y alrededor de P1) → Michelle replica ahí las correcciones de la pasada 2 de V5.6 y cierra → auditoría pad a pad → paquete de fabricación (327 pzas). Se pide después del banco de la V5.6.
## v6 — GNSS y 4G a bordo → pedido de 130 + reemplazos { #v6 }
**Estado.** Diseño nuevo: ZED-F9P y módem A7670SA soldados, sin zócalos; fuente de 5 V a bordo. Esquemático **terminado y verificado** el 28-sep (4 pasadas, ERC 0, `d58a89ba…`). El **PCB no tiene ninguna corrección**: si se ordenara hoy saldría con los 11 bloqueantes de la auditoría. Decisión: **no habrá v6.1** — lo que se encuentre se corrige antes del pedido.
| | Documentos |
|---|---|
| Vigentes | [**▶ Próximos pasos para ordenar (29-sep)**](v6-proximos-pasos.md) · [Plan de corrección y lista de liberación](v6-plan-correccion.md) · [Esquemático final — resumen de 3 pasadas](v6-cambios-resumen.md) · [Mapa de pines MCU para `board_v6.h`](v6-pinmap.md) · [Plan de cobre y colocación EMC](EinarBoard-v6_Plan-de-Cobre_ES.html) ([inglés](EinarBoard-v6_Copper-Plan_EN.html)) · [Manual v6 (marcador)](../manuales/manual-tarjeta-v6.md) |
| Detalle (28-sep) | [Auditoría completa](v6-auditoria-completa.md) ([inglés](v6-full-audit.md)) · [Pasada 1 — cambios al esquemático](v6-cambios-esquematico.md) · [Pasada 2 — puertos (inglés)](v6-puertos-sin-nueva-version.md) · [Pasada 3 — revisión final (inglés)](v6-revision-final.md) |
| Históricos | [Revisión contra lecciones V5.5](v6-revision-lecciones-v55.md) — superada por la auditoría completa · [Auditoría pre-fab 25-sep](v6-auditoria-prefab.md) ([inglés](v6-prefab-audit.md)) — superada por la del 28-sep |
| Cantidad | La auditoría de septiembre hablaba de **5 prototipos**; el plan del 13-oct son **130 unidades y todos los reemplazos futuros**. |
**Siguiente paso.** Michelle ejecuta *Update PCB from Schematic* y el layout de las correcciones (orden y tiempos en [Próximos pasos](v6-proximos-pasos.md)); aplicar la regla DFM del LTC4040 y quitar P7/P8 (como P12 en V5.6); auditoría pad a pad; paquete de fabricación (130 pzas).
## V4 y V3 — tarjetas anteriores aún en campo
| Tarjeta | Estado | Documentos |
|---|---|---|
| <span class="badge-v4">V4</span> | Blackpill en zócalo + Ethernet y puertos serie; mismas redes que la V5. En prueba con 0.6.0; casos sin NMEA en oct (H0016, H0011, H0024). | [Manual V4](../manuales/manual-tarjeta-V4.md) · [Preparación y prueba con 0.6.0](../procedimientos/v4-prueba-fw-0.5.6.md) · [Riel SW_5V y arranque del F9P](../procedimientos/v4-prueba-riel-sw5v-f9p.md) |
| <span class="badge-v3">V3</span> | EinarBoard 1.0 (jul-2026, 10 pzas); 3 unidades en campo (RTK-003/006/014). | [Manual V3](../manuales/manual-tarjeta-V3.md) · [Pinout V3](../montaje/pinout-arneses.md) · históricos: [BOM V3](../montaje/bom-split.md) · [bring-up](../procedimientos/einarboard-1.0-bringup.md) |
## Por necesidad ## Por necesidad
@ -23,8 +86,10 @@ Estado, firmware y cómo reconocer cada tarjeta: [Manuales por tarjeta](../manua
| Pin, puente o LED de V5.5/V5.6 | [Pinout V5.6](v56-pinout.md) · [Manual V5](../manuales/manual-tarjeta-V5.md) | | Pin, puente o LED de V5.5/V5.6 | [Pinout V5.6](v56-pinout.md) · [Manual V5](../manuales/manual-tarjeta-V5.md) |
| Límites eléctricos, consumos, rieles | [Hoja de datos V5.6](v56-datasheet.md) | | Límites eléctricos, consumos, rieles | [Hoja de datos V5.6](v56-datasheet.md) |
| Qué cambió de V5.5 a V5.6 | [ECO V5.6](v56-eco.md) · [Cambios al esquemático](v56-cambios-esquematico.md) | | Qué cambió de V5.5 a V5.6 | [ECO V5.6](v56-eco.md) · [Cambios al esquemático](v56-cambios-esquematico.md) |
| Rutear la PCB V5.6 | [Instrucciones de layout](v56-layout-instrucciones.md) · [Pasada 2 — correcciones (13-oct)](v56-layout-pasada-2.md) | | Qué debe corregir Michelle hoy | [Pasada 2 V5.6](v56-layout-pasada-2.md) · [Guía V5.7](v57-layout-guia.md) |
| Qué trae la V5.7 y cómo colocar la fuente | [ECO V5.7](v57-eco-fuente.md) · [Guía de layout V5.7](v57-layout-guia.md) | | Qué trae la V5.7 y por qué | [ECO V5.7](v57-eco-fuente.md) |
| Arneses de una V3 | [Pinout V3](../montaje/pinout-arneses.md) · [BOM V3](../montaje/bom-split.md) | | Qué falta para pedir la v6 | [Próximos pasos](v6-proximos-pasos.md) · [Plan de corrección](v6-plan-correccion.md) |
| Cuántas de cada versión y cuándo | [Tablero de producción](../tableros/produccion.html) |
| El binario correcto | [Descargas](../descargas/index.md) | | El binario correcto | [Descargas](../descargas/index.md) |
| KiCad o el repositorio | [Guía Git](../git/guia-git-es.md) | | KiCad o el repositorio | [Guía Git](../git/guia-git-es.md) |
| Cómo llegamos aquí | [Historia del proyecto](../historia/index.md) |

View File

@ -10,6 +10,10 @@ author: Helgen Technologies
# EinarBoard V5.6 (HBOX V5) — Hoja de datos de ingeniería # EinarBoard V5.6 (HBOX V5) — Hoja de datos de ingeniería
!!! warning "Generado de la netlist del 28-sep-2026"
No incluye el acelerómetro **U26** (agregado el 8-oct) ni la eliminación de **P12/R42/C24** (9-oct). Se regenera cuando se congele la V5.6 (pasada 2 de layout). Los valores eléctricos siguen vigentes.
!!! note "Alcance y fuentes" !!! note "Alcance y fuentes"
Evolución de la V5.5 fabricada (STM32F411 a bordo + zócalos simpleRTK2B y 4G NTRIP Master). No es la v6 (GNSS y 4G soldados, `hbox-watchdog-v6`, en diseño); "copiado de v6" se refiere solo al origen de un valor de componente. Fuentes: netlist `kicad/_to_delete/v56-work-20260928/p5ntc-after.xml` (SHA-256 `45d3f034…8e42a7`, exportada de `kicad/hbox-watchdog-v5.kicad_sch` el 2026-09-28), `2026-09-28_Drogheda_EinarBoard-V5.6_Schematic-Change-Report_v1.0.md` (§1, §9, §10), `2026-09-27_Drogheda_EinarBoard-V5.6_ECO_ES_v1.0.md`, `04-Source-Code/firmware/hbox_v5_bringup/board_v5.h`, `hbox_v5_bringup.ino` 0.6.0 y `README.md`. Lo no verificado va marcado `<!-- VERIFICAR -->`. Evolución de la V5.5 fabricada (STM32F411 a bordo + zócalos simpleRTK2B y 4G NTRIP Master). No es la v6 (GNSS y 4G soldados, `hbox-watchdog-v6`, en diseño); "copiado de v6" se refiere solo al origen de un valor de componente. Fuentes: netlist `kicad/_to_delete/v56-work-20260928/p5ntc-after.xml` (SHA-256 `45d3f034…8e42a7`, exportada de `kicad/hbox-watchdog-v5.kicad_sch` el 2026-09-28), `2026-09-28_Drogheda_EinarBoard-V5.6_Schematic-Change-Report_v1.0.md` (§1, §9, §10), `2026-09-27_Drogheda_EinarBoard-V5.6_ECO_ES_v1.0.md`, `04-Source-Code/firmware/hbox_v5_bringup/board_v5.h`, `hbox_v5_bringup.ino` 0.6.0 y `README.md`. Lo no verificado va marcado `<!-- VERIFICAR -->`.

View File

@ -1,5 +1,9 @@
# Instrucciones para Michelle — V5.6 layout final (acelerómetro, sin P12, pendientes) · 12-oct-2026 # Instrucciones para Michelle — V5.6 layout final (acelerómetro, sin P12, pendientes) · 12-oct-2026
!!! note "Documento histórico — superado por la pasada 2 (13-oct-2026)"
Estas fueron las instrucciones de la **pasada 1** del layout V5.6 (resultado: commit `d71098d`). La auditoría del 13-oct la declaró **NO LISTA**; las correcciones vigentes están en [Layout — pasada 2](v56-layout-pasada-2.md) y el detalle en la [auditoría del 13-oct](v56-auditoria-layout-13oct.md).
Repositorio: Gitea `Helgen-Services/HBox_PCB`, rama `main`, commit `7ceba18` (09-oct). El esquemático ya trae los cambios; el PCB no. Leo cierra KiCad en su Mac antes de que empieces y no toca `hbox-watchdog-v5.*` hasta que hagas push. Repositorio: Gitea `Helgen-Services/HBox_PCB`, rama `main`, commit `7ceba18` (09-oct). El esquemático ya trae los cambios; el PCB no. Leo cierra KiCad en su Mac antes de que empieces y no toca `hbox-watchdog-v5.*` hasta que hagas push.
## 0. Antes de empezar ## 0. Antes de empezar

View File

@ -8,6 +8,10 @@ tags: [project-drogheda, hardware, harness, pinout, einarboard, v5.6]
# HBOX EinarBoard V5.6 — Pinout de arneses y conectores # HBOX EinarBoard V5.6 — Pinout de arneses y conectores
!!! warning "Generado de la netlist del 28-sep-2026"
No incluye el acelerómetro **U26** (agregado el 8-oct) ni la eliminación de **P12/R42/C24** (9-oct). Se regenera cuando se congele la V5.6 (pasada 2 de layout). Los valores eléctricos siguen vigentes.
!!! note "EinarBoard V5.6 (`hbox-watchdog-v5`), lote en pedido" !!! note "EinarBoard V5.6 (`hbox-watchdog-v5`), lote en pedido"
Esta tarjeta es la evolución de la V5.5 fabricada (STM32F411 a bordo + zócalos simpleRTK2B y 4G NTRIP Master). **No es la v6** (GNSS y 4G soldados a bordo, `hbox-watchdog-v6`, en diseño): la v6 tiene su propia sección en el sitio. Cuando este documento dice «copiado de v6» se refiere solo al origen de un valor de componente. Esta tarjeta es la evolución de la V5.5 fabricada (STM32F411 a bordo + zócalos simpleRTK2B y 4G NTRIP Master). **No es la v6** (GNSS y 4G soldados a bordo, `hbox-watchdog-v6`, en diseño): la v6 tiene su propia sección en el sitio. Cuando este documento dice «copiado de v6» se refiere solo al origen de un valor de componente.

View File

@ -11,6 +11,10 @@ audience: Leo, Michelle (layout), compras
# EinarBoard v6 — Próximos pasos para ordenar (29-sep-2026) # EinarBoard v6 — Próximos pasos para ordenar (29-sep-2026)
!!! note "Cantidad (13-oct-2026)"
El pedido de la v6 ya no son 5 prototipos: son **130 unidades** del pedido de 570 (113 V5.6 · 327 V5.7 · 130 v6) más todos los reemplazos futuros. El orden de los pasos no cambia; sí conviene un lote piloto antes de las 130.
!!! warning "EinarBoard v6 (`hbox-watchdog-v6`): en diseño, no se pide" !!! warning "EinarBoard v6 (`hbox-watchdog-v6`): en diseño, no se pide"
Diseño nuevo con ZED-F9P y módem A7670SA soldados a bordo. **No es la V5.6** que se manda a fabricar esta semana (`hbox-watchdog-v5`, zócalos para simpleRTK2B y 4G NTRIP Master). Los hallazgos marcados «Impacto en V5.6» ya se aplicaron a la V5.6 y están en su informe de cambios. Diseño nuevo con ZED-F9P y módem A7670SA soldados a bordo. **No es la V5.6** que se manda a fabricar esta semana (`hbox-watchdog-v5`, zócalos para simpleRTK2B y 4G NTRIP Master). Los hallazgos marcados «Impacto en V5.6» ya se aplicaron a la V5.6 y están en su informe de cambios.

View File

@ -8,6 +8,10 @@ author: Helgen Technologies
# EinarBoard v6 — Revisión contra las lecciones de la V5.5 (26–27 sep 2026) # EinarBoard v6 — Revisión contra las lecciones de la V5.5 (26–27 sep 2026)
!!! note "Documento histórico — superado por la auditoría completa v6 (28-sep-2026)"
Revisión de solo lectura hecha la mañana del 28-sep. La [auditoría completa v6](v6-auditoria-completa.md) del mismo día la profundiza y supera; lo que se aplicó está en el [resumen de las 3 pasadas](v6-cambios-resumen.md) y la lista de liberación en el [plan de corrección](v6-plan-correccion.md).
!!! warning "EinarBoard v6 (`hbox-watchdog-v6`): en diseño, no se pide" !!! warning "EinarBoard v6 (`hbox-watchdog-v6`): en diseño, no se pide"
Diseño nuevo con ZED-F9P y módem A7670SA soldados a bordo. **No es la V5.6** que se manda a fabricar esta semana (`hbox-watchdog-v5`, zócalos para simpleRTK2B y 4G NTRIP Master). Los hallazgos marcados «Impacto en V5.6» ya se aplicaron a la V5.6 y están en su informe de cambios. Diseño nuevo con ZED-F9P y módem A7670SA soldados a bordo. **No es la V5.6** que se manda a fabricar esta semana (`hbox-watchdog-v5`, zócalos para simpleRTK2B y 4G NTRIP Master). Los hallazgos marcados «Impacto en V5.6» ya se aplicaron a la V5.6 y están en su informe de cambios.

130
docs/historia/index.md Normal file
View File

@ -0,0 +1,130 @@
---
title: "Historia del proyecto"
project: Project Drogheda
date: 2026-10-13
version: "1.0"
tags: [project-drogheda, hbox, historia, cronologia, decisiones]
author: Helgen Technologies
---
# Historia del proyecto — jun → oct 2026
Cronología del HBOX RTK y de la tarjeta EinarBoard, armada **solo** con documentos fechados (nombre `AAAA-MM-DD_…` en el repositorio Drogheda o fecha en la cabecera de la página) y con las decisiones registradas en las notas del proyecto. Cuando el documento está en este sitio, la fila lo enlaza; si solo vive en el repositorio, va su nombre de archivo en `código`. Lo que ya no aplica va marcado **superado**.
Cómo leerla: cada mes abre con un resumen de 3–5 líneas; después, una fila por evento o documento. Para el estado de hoy, ve a [Tarjetas y hardware](../hardware/index.md) y a los [Tableros](../tableros/index.md).
| Tarjeta | Qué es |
|---|---|
| <span class="badge-v3">V3</span> | EinarBoard 1.0 — Blackpill en zócalo (jul-2026, 10 pzas) |
| <span class="badge-v4">V4</span> | EinarBoard 2.0 — Blackpill + Ethernet y puertos serie (ago-2026) |
| <span class="badge-v55">V5.5</span> | EinarBoard 4.0 / HBOX v5 — STM32F411 soldado (22-ago-2026, 15 pzas) |
| <span class="badge-v56">V5.6</span> | V5.5 + ECO (módem controlado por el MCU, LFP-HT, acelerómetro) — en layout |
| <span class="badge-v57">V5.7</span> | V5.6 + fuente 24 V → 5 V a bordo — en diseño |
| <span class="badge-v6">v6</span> | GNSS ZED-F9P y módem A7670SA soldados — en diseño |
---
## Junio 2026 — las fallas de campo y el primer watchdog
Las unidades de primera generación (Blackpill + PowerBoost, firmware v1) instaladas en ICAVE fallan: muerte casi simultánea de la flota y desconexiones crónicas en Wialon. El cliente escala el 17-jun. Se retiran las unidades a Veracruz, se diagnostican a distancia y nace el proyecto de una tarjeta portadora con **watchdog** propio: primero en protoboard sobre el Blackpill, con la máquina de estados, el sensado y el lazo ventilador/temperatura.
| Fecha | Qué pasó / qué se decidió | Documento | Tarjeta |
|---|---|---|---|
| 17-jun | Escalación del cliente por las fallas en ICAVE (5 unidades: RTG, montacargas, camión eléctrico, 2 reachstackers). | `06-Testing-Validation/RCA-2026-06-field-failures.md` (RCA, hipótesis ordenadas) | v1 |
| 19-jun | Todas las unidades fuera de las máquinas; 4 en mano en Veracruz (3 muertas, 1 a medias). Protocolo de diagnóstico remoto por niveles. | [Protocolo de diagnóstico remoto](../diagnostico/protocolo-diagnostico-remoto.md) — **histórico** | v1 |
| 19-jun | BOM eléctrico del gabinete: el HBOX vive dentro de un gabinete IP67 con fuente DIN de 24 V (ICAVE, TIMSA, EIT, LCT). | `03-Architecture-Hardware/cabinet-electrical-bom.md` | — |
| 20-jun | Análisis de logs de banco y hallazgos GNSS; traspaso de ingeniería y principios del proyecto (*ethos*). | `06-Testing-Validation/2026-06-20_Drogheda_Bench-Log-Analysis_v1.0.md` · `…GNSS-Findings-Summary.md` · `00-Project-Management/ethos.md` | — |
| 21-jun | Diseño de la tarjeta portadora v3 (watchdog + ventilador inteligente) y guía KiCad → JLCPCB. | `03-Architecture-Hardware/watchdog-pcb-v3-design.md` · `…watchdog-pcb-v3-kicad-guide.md` | V3 |
| 22-jun | Lista de compras del prototipo en protoboard; justificación de cada conexión del watchdog. | [Lista de compras del protoboard](../procedimientos/historico-protoboard.md) — **histórico** · `…watchdog-connections-rationale.md` | V3 (proto) |
| 24/25-jun | Bring-up del prototipo en protoboard; especificación del esquemático de producción (Δ contra el prototipo). | [Bring-up del prototipo v3](../procedimientos/historico-proto-bench-v3.md) — **histórico** · `…watchdog-production-schematic-delta.md` | V3 (proto) |
## Julio 2026 — EinarBoard 1.0 (V3) se fabrica
Se congela el estado eléctrico del watchdog, se rutea la V3 y pasa su auditoría pre-fabricación. El 8-jul se coloca el pedido en JLCPCB (10 PCB + 10 PCBA). Llegan las tarjetas y se ejecuta el *runbook* de bring-up. A fin de mes se define la 1.1 (RS-485 + RS-232 por SC16IS752 y Ethernet W5500) y se revisa el firmware v2.3 como candidato de producción. La V3 sigue hoy en 3 unidades de campo (RTK-003/006/014).
| Fecha | Qué pasó / qué se decidió | Documento | Tarjeta |
|---|---|---|---|
| 2-jul | Estado eléctrico canónico del watchdog v3. | `03-Architecture-Hardware/watchdog-electrical-design-state.md` | V3 |
| 3-jul | Ruteo de la V3 PROD al 98 %; lista de pendientes a fab-ready. | `…watchdog-pcb-v3-routing-status.md` | V3 |
| 4-jul | Pinout de arneses y conectores de la V3 (netlist). | [Pinout V3](../montaje/pinout-arneses.md) — vigente solo para las V3 en campo | V3 |
| 5-jul | BOM dividido para el pedido PCBA; módulo 4G con USB, reset y LED; serigrafía de zócalos. | [BOM V3 dividido](../montaje/bom-split.md) — **histórico** · `…4G-USB-Reset-LED-addition.md` · `…Socket-Pinout-Silkscreen.md` | V3 |
| 6/7-jul | Auditoría pre-fab de la EinarBoard 1.0: 4 hallazgos HIGH corregidos y re-verificados; guía de pedido JLCPCB y lista LCSC. | `…2026-07-06_Drogheda_EinarBoard-1.0-PreFab-Audit_v1.0.md` · `…JLCPCB-Order-Guide.md` · `…LCSC-search-list.md` | V3 |
| 8-jul | Pedido JLCPCB `W2026070815023249`: 10 PCB + 10 PCBA. *Runbook* de bring-up (J1/J2 soldados a mano). Alternativas de módem 4G. | [EinarBoard 1.0 — bring-up](../procedimientos/einarboard-1.0-bringup.md) — **histórico** · `…2026-07-08_Drogheda_4G-Modem-Alternatives_v1.0.md` | V3 |
| 24-jul | Brief de la EinarBoard 1.1: RS-485 + RS-232 (SC16IS752), Ethernet W5500 en SPI2, LED RGB. Revisión del firmware v2.3 como producción. | `…2026-07-24_Drogheda_EinarBoard-1.1_Design-Brief_v1.0.md` · `…firmware-review-v2.3-EinarBoard.md` | V3 → V4 |
## Agosto 2026 — V4, el MCU a bordo (V5.5) y el arranque de la v6
Se fabrica la V4 (Blackpill + Ethernet/serie). El 8-ago se decide la telemetría en banda (Modo B, `$PHLG`). El 12-ago se integra el STM32F411RET6 a la tarjeta (EinarBoard 3.0 → V5): el 22-ago se piden **15 pzas de la V5.5**; su auditoría EMC deja dos críticos para la siguiente corrida (`/SW_BAT` 0.3 mm, `/BAT_PLUS` 0.3–0.5 mm). El 24-ago arranca el diseño de la **v6** con GNSS y 4G soldados. El KiCad pasa a submódulo git con espejo en Gitea.
| Fecha | Qué pasó / qué se decidió | Documento | Tarjeta |
|---|---|---|---|
| 2/3-ago | Pedido de la V4 preparado en JLCPCB (lote Y5). | [Manual V4](../manuales/manual-tarjeta-V4.md) (ficha de fabricación) | V4 |
| 8-ago | Decisión de telemetría: `$PHLG` íntegra en banda por Modo B (firmware v3.1). | `…2026-08-08_Drogheda_Firmware-v3.1_ModoB-PHLG_Design-Brief_v1.0.md` | V3/V4 |
| 12/14-ago | Brief de la EinarBoard 3.0: STM32F411RET6 soldado, DFU; mapa de colocación de capacitores. | `…2026-08-12_Drogheda_EinarBoard-3.0_MCU-Integration_Design-Brief_v1.0.md` · `…2026-08-14_…Capacitor-Placement-Map_v1.0.md` | V5 |
| 18-ago | Prueba de banco de la política de ventilador por potencia (FW v3.0.3). | [FW v3.0.3 — ventilador](../procedimientos/fan-policy-bench-test.md) — **histórico** | V3/V4 |
| 20-ago | Correcciones aplicadas a la V5.5 (Q1/Q2/Q4/Q10, DzG1, U13 SOIC angosto, EP de los MP1584) y pre-auditoría. | `…2026-08-20_Drogheda_EinarBoard-v5_Fixes-Applied_v1.0.md` · `…Pre-Audit-Checks_v1.0.md` | V5.5 |
| 22-ago | **V5.5 ordenada: 15 pzas** (JLCPCB). Auditoría EMC: `/SW_BAT` y `/BAT_PLUS` finos = críticos para la segunda corrida. | `…2026-08-22_Drogheda_EinarBoard-v5_EMC-Audit_v1.0.md` | V5.5 |
| 24-ago | Brief de la **v6** (ZED-F9P + A7670SA a bordo); Fase 1 (cliente NTRIP en el STM32) y Fase 2 (delta de esquemático). | `…2026-08-24_Drogheda_EinarBoard-v6_GNSS-4G-Integration_Design-Brief_v1.0.md` · `…Phase-1_NTRIP-Firmware-Spec_v1.0.md` · `…Phase-2_Schematic-Delta_v1.0.md` | v6 |
| 27-ago | KiCad → submódulo git + espejo Gitea (HBox_PCB). | `…2026-08-27_Drogheda_KiCad-Submodule-Migration_v1.0.md` | — |
## Septiembre 2026 — llegan las V5.5, nace el sitio y se decide la V5.6
El mes más denso. Se estudia el respaldo de bajo consumo y la química del paquete a 45 °C (9/10-sep). El 15-sep llegan las V5.5: procedimientos en español para técnicos y **este sitio**; del 17 al 19 se arma el sistema de actas y salen las primeras unidades (V5-006 se destruye por un cruce en el GX16). El 24-sep se fija el despliegue GPS-RTK por puerto. Del 26 al 29 se decide y documenta la **V5.6** (ECO, esquemático, hoja de datos, pinout, instrucciones de layout), se audita a fondo la **v6** ("no habrá v6.1") y se publican los manuales por tarjeta. El firmware pasa de 0.5.x a 0.6.0 CAMPO con gobernador de carga.
| Fecha | Qué pasó / qué se decidió | Documento | Tarjeta |
|---|---|---|---|
| 5-sep | Plan de cobre y colocación EMC de la v6 (leyes de EMC, tabla de proximidad). | [Plan de cobre v6](../hardware/EinarBoard-v6_Plan-de-Cobre_ES.html) · [inglés](../hardware/EinarBoard-v6_Copper-Plan_EN.html) | v6 |
| 9-sep | Estudios: fuente de 5 V a bordo vs. Mean Well (costo), respaldo con ciclo de trabajo de bajo consumo, química de celda a 45 °C. | `…2026-09-09_Drogheda_5V-Source-OnBoard-vs-MeanWell_Cost_v1.0.md` · `…Backup-LowPower-DutyCycle_Design-Brief_v1.0.md` · `…Battery-Chemistry-HighTemp_v1.0.md` | V5/v6 |
| 10-sep | Procedimiento de evaluación del respaldo (tres medidas y una decisión); consulta a Vectro sobre PSM/eDRX. | `…2026-09-10_Drogheda_Backup-Power_Evaluation-Procedure_v1.0.md` · `…Consulta-Vectro_PSM-eDRX_v1.0.md` | V5 |
| 15-sep | **Llegan las V5.5.** Procedimiento de prueba y flasheo por tarjeta; guías Git para colaboradores; se publica **drogheda.helgen.online**. | [Prueba y flasheo](../procedimientos/v5-prueba-y-flasheo.md) · [Guía Git](../git/guia-git-es.md) | V5.5 |
| 17-sep | Procedimientos de módulos e integración y de ensamble; sistema de actas con número de unidad `HBOX5-Exx-Axxx-Rxxxx`. Banco de pruebas ambiental (HALT) aprobado para construir, con lista de compras. | [Módulos e integración](../procedimientos/v5-modulos-integracion.md) · [Ensamble](../procedimientos/v5-ensamble.md) · `…2026-09-17_Drogheda_HALT-Rig_Thermal-Vibration-Impact_v1.1.md` · `…HALT-Rig_Lista-Compras_ES_v1.0.md` | V5.5 |
| 18-sep | Prueba final de unidad (FW 0.4.0); diagnóstico en campo, diagnóstico en banco y reflasheo. Primeras unidades V5 embarcadas con 0.4.0 / 0.5.0 / 0.5.1. **V5-006 destruida** por un cruce en el GX16. | [Prueba final](../procedimientos/v5-prueba-final-unidad.md) · [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) · [Diagnóstico en banco](../procedimientos/diagnostico-banco.md) · [Reflasheo](../procedimientos/reflasheo-unidades.md) | V5.5 |
| 19-sep | Ciclo de vida de una unidad (qué acta en qué momento); índice de actas; guía de ensamble y QC (borrador V3). | [Ciclo de vida](../procedimientos/ciclo-de-vida.md) · [Actas](../actas/index.md) · [Guía de ensamble y QC](../montaje/guia-ensamble-qc.md) — **superada** por el ensamble V5 | V5.5 / V3 |
| 19/21-sep | **Regla de nombres del firmware:** un binario por tarjeta (`hbox-tarjetaV3_4.0.0`, `hbox-tarjetaV4_x.y.z`, `hbox-v5-bringup_x.y.z`); la versión no indica la tarjeta. | [Descargas](../descargas/index.md#firmware-del-hbox-que-binario-va-en-que-tarjeta) | todas |
| 24-sep | Prueba de banco del reporte cada 10 min en batería (FW 0.5.2). **Despliegue GPS-RTK:** unidades RTK en los puertos a mediados de noviembre, instalación terminada a mediados de diciembre; gabinetes y bases RTK desde la semana del 28-sep en ICAVE, EIT, LZC y TIMSA. | `…2026-09-24_Drogheda_HBOX-FW-0.5.2_Prueba-Banco-Bateria_ES_v1.0.md` · notas del proyecto (24-sep) | V4/V5 |
| 24-sep | Lote planeado: **30 unidades (15 V4 + 15 V5)** — **superado** por el plan de 570 unidades del 13-oct. | notas del proyecto (24-sep) | V4/V5 |
| 25-sep | Primera auditoría pre-fabricación de la v6 (5 prototipos previstos). | [Auditoría pre-fab v6](../hardware/v6-auditoria-prefab.md) · [inglés](../hardware/v6-prefab-audit.md) — **superada** por la del 28-sep | v6 |
| 26-sep | Auditoría del mapa de pines V4/V5 contra FW 0.5.4. **Decisión V5.6:** 500 ArduSimple ya pedidos → la arquitectura con zócalos se queda; nada de rework en tarjetas existentes. Hallazgo: el módem no arranca en batería (MP1584 en UVLO sobre el riel del boost). | `…2026-09-26_Drogheda_HBOX-V4-V5_Pinmap-Audit-FW-0.5.4_v1.0.md` · notas del proyecto (26-sep) | V4/V5.5 |
| 27-sep | **ECO V5.6**: ciclo de 10 min en batería, módem controlado por el MCU (AP64350, MODEM_EN/RST/LED), LFP-HT (JP2 2-3, R55 5.6 k), RS1 10 mΩ, TMP102 U18 en 0x49, lista EMC. README del firmware. **0.6.0 CAMPO** (gobernador de carga); 0.5.8 retirada. | [ECO V5.6](../hardware/v56-eco.md) · [README firmware](../descargas/hbox-v5-bringup-README.md) | V5.6 |
| 28-sep | Informe de cambios al esquemático V5.6 (58 diferencias, todas previstas). **v6:** auditoría completa, plan de corrección, cambios al esquemático en 3 pasadas, revisión contra lecciones V5.5, mapa de pines; **"no habrá v6.1"**. H0008: el cargador sí carga (vigilancia nocturna). | [Cambios V5.5 → V5.6](../hardware/v56-cambios-esquematico.md) · [Auditoría v6](../hardware/v6-auditoria-completa.md) · [Plan de corrección](../hardware/v6-plan-correccion.md) · [Resumen 3 pasadas](../hardware/v6-cambios-resumen.md) · [Lecciones V5.5](../hardware/v6-revision-lecciones-v55.md) · [Mapa de pines](../hardware/v6-pinmap.md) · `06-Testing-Validation/2026-09-28_H0008_charging-overnight-log.md` | V5.6 / v6 |
| 29-sep | Hoja de datos, pinout e instrucciones de layout V5.6; manuales por tarjeta (V3, V4, V5, v6); manual del cliente v2.0 y ficha técnica (PDF); configuración de unidad, prueba Ethernet/serie, checklist de puesta en marcha; guía del ingeniero; próximos pasos para ordenar la v6. **0.6.1 retirada** (su `$PHLG` no pasaba por el módem). | [Hoja de datos](../hardware/v56-datasheet.md) · [Pinout](../hardware/v56-pinout.md) · [Layout](../hardware/v56-layout-instrucciones.md) · [Manuales](../manuales/index.md) · [Manual del cliente](../manuales/manual-cliente.md) · [Configuración](../procedimientos/v5-configuracion-unidad.md) · [Ethernet y serie](../procedimientos/v5-prueba-ethernet-serie.md) · [Checklist](../procedimientos/v5-checklist-puesta-en-marcha.md) · [Guía del ingeniero](../guia-ingeniero.md) · [Próximos pasos v6](../hardware/v6-proximos-pasos.md) | V5.6 / v6 |
| 30-sep | Revisión del modo batería y del layout V5.6 (U4 junto a L6, DzG1 corregido); 0.6.2 a prueba de banco. | notas del proyecto (30-sep → 1-oct) | V5.6 |
## Octubre 2026 — 0.6.3, acelerómetro, V5.7 y el plan de 570
Firmware 0.6.3 a banco (1-oct). Se revisa el bus I²C de V5.6/v6 (5-oct), se diagnostica la V4 sin NMEA (riel SW_5V, 6/7-oct) y se escribe la regla DFM del LTC4040 (8-oct) tras las bolas de soldadura en U6. La V5.6 recibe el acelerómetro U26 (8-oct) y pierde la sonda P12 (9-oct). El 12-oct Michelle hace el layout; el 13-oct la auditoría lo declara **NO LISTA** y se abre la pasada 2. Ese mismo día nace la **V5.7** (fuente a bordo) y se fija el **pedido de 570 HBOX**: 113 V5.6 · 327 V5.7 · 130 v6.
| Fecha | Qué pasó / qué se decidió | Documento | Tarjeta |
|---|---|---|---|
| 1-oct | **0.6.3 en prueba de banco** (sustituye a 0.6.2): el módem vuelve solo tras un respaldo corto con paquete bajo; motivo de cada reset al servidor. Notas de versión, descargas v2.3, procedimiento de módem en batería v1.7. | [Notas de versión](../descargas/notas-de-version.md) · [Descargas](../descargas/index.md) · [Módem en batería](../procedimientos/prueba-bateria-modem.md) | V4/V5 |
| 5-oct | Revisión del bus I²C de V5.6 y v6. Falla del cargador en H0013 (V5.5): pausa NTC después de C8. | documentos del Proyecto Drogheda (5-oct), no publicados en el sitio | V5.6 / v6 |
| 6/7-oct | V4 sin NMEA (H0016, H0011, H0024): procedimiento de banco del riel SW_5V y arranque del F9P, con mapa de puntos de medición. | [V4 — riel SW_5V y F9P](../procedimientos/v4-prueba-riel-sw5v-f9p.md) | V4 |
| 8-oct | **Regla DFM LTC4040 (U6, QFN 4×5):** bolas de soldadura recurrentes en V5.5 y V4 → pasta del EP ≤ 50 %, vías rellenas o fuera de las ventanas, X-ray/AOI en U6, lupa antes de energizar; aplica a toda revisión. **Acelerómetro U26 SC7A20TR** (I²C 0x18, INT1 → PB9) entra a la V5.6. | notas del proyecto (8-oct) | V5.6 / v6 |
| 9-oct | **P12 (sonda NTC) sale de la V5.6** con R42/C24: redundante y se necesita el espacio; sin temperatura de celda en batería (aceptado). Aplica a P7/P8 de la v6. | notas del proyecto (9-oct) | V5.6 / v6 |
| 12-oct | Instrucciones a Michelle para el layout final V5.6 (U26, sin P12). Borrador v0.1 de la ECO V5.7. | [Layout pasada 1](../hardware/v56-layout-pasada-1.md) — **superada** por la pasada 2 · `…2026-10-12_Drogheda_EinarBoard-V5.7_ECO-Fuente-a-bordo_ES_v0.1.md` — **superado** por v0.2 | V5.6 / V5.7 |
| 13-oct | **Auditoría del layout de Michelle (`d71098d`): NO LISTA** — `/BAT_PLUS` por In1 a 0.5 mm (bloqueante); 18 piezas movidas (decisión: se quedan); MAIN5V/SYS5V/SW3 por capas internas desde V5.5. Pasada 2 con las correcciones. | [Auditoría 13-oct (inglés)](../hardware/v56-auditoria-layout-13oct.md) · [Layout pasada 2](../hardware/v56-layout-pasada-2.md) | V5.6 |
| 13-oct | **V5.7 = V5.6 + fuente 24 V → 5 V a bordo** (U25 TPS54360B del bloque v6), sin SD-15B: esquemático aplicado (ERC 0), F2 fusible SMD 3 A, fuera U2 y P2, JP1 puenteado; proyecto KiCad separado `hbox-watchdog-v57`. Leo coloca la fuente; Michelle corrige V5.6 y después toma V5.7. | [ECO V5.7 v0.2](../hardware/v57-eco-fuente.md) · [Guía de layout V5.7](../hardware/v57-layout-guia.md) | V5.7 |
| 13-oct | **Pedido de 570 HBOX:** 113 × V5.6 (con SD-15B) · 327 × V5.7 · 130 × v6 (+ reemplazos futuros en v6). Paquetes de fabricación V5.6/V5.7/v6 para cotizar. Caja plástica del HBOX: el cable Ethernet no cabe (cambio por definir). Sitios: ICAVE, LZC, EIT, TIMSA. | notas del proyecto (13-oct) · [Tablero de producción](../tableros/produccion.html) · [Kanban](../tableros/kanban.html) | V5.6 / V5.7 / v6 |
| 13-oct | Reorganización de este sitio por propósito; tableros, inventario y sitios. | esta página | — |
---
## Qué quedó superado (resumen)
| Lo que decía | Desde | Lo sustituye |
|---|---|---|
| Lote de 30 unidades (15 V4 + 15 V5) para agosto/septiembre | 24-sep | Pedido de 570 HBOX (113 V5.6 · 327 V5.7 · 130 v6), 13-oct |
| v6: "5 prototipos en JLCPCB" | 25/29-sep | v6 = 130 unidades + todos los reemplazos futuros, 13-oct |
| Auditoría pre-fabricación v6 del 25-sep | 25-sep | Auditoría completa y plan de corrección del 28-sep |
| Revisión v6 contra lecciones V5.5 | 28-sep | Auditoría completa del mismo día (la profundiza y supera) |
| Layout V5.6 pasada 1 (12-oct) | 12-oct | Pasada 2 (13-oct), tras la auditoría NO LISTA |
| ECO V5.7 v0.1 (borrador) | 12-oct | ECO V5.7 v0.2, esquemático aplicado (13-oct) |
| V5.6 con sonda NTC P12 | 9-oct | P12/R42/C24 eliminados; acelerómetro U26 añadido (8-oct) |
| Firmware 0.5.8 CAMPO · 0.6.1 · 0.6.2 | 27-sep · 29-sep · 1-oct | 0.6.0 CAMPO vigente; 0.6.3 en prueba de banco |
| Guía de ensamble y QC (borrador V3) · BOM V3 · bring-up EinarBoard 1.0 · protocolo remoto v1 | jul–sep | Procedimientos V5 de Producción, Taller y Campo |
## Historial de revisiones
| Versión | Fecha | Cambios |
|---|---|---|
| 1.0 | 2026-10-13 | Primera versión, jun → oct 2026, con la reorganización del sitio. |

View File

@ -6,131 +6,49 @@ hide:
# HBOX — Documentación técnica # HBOX — Documentación técnica
Sitio interno de Helgen para producir, probar, instalar y diagnosticar el rastreador **HBOX RTK** (tarjeta EinarBoard). Todo trabajo deja un **acta**; toda unidad tiene un **expediente**. Reglas y acceso: [Guía del ingeniero](guia-ingeniero.md).
<div class="buscar-aviso" markdown> <div class="buscar-aviso" markdown>
La lupa busca por unidad (H00xx), conector (P15, J10), evento (MODEM_DARK) o procedimiento. La lupa busca por unidad (H00xx), serie (HBOX5-E07-A010-R0006), conector (P15, J10), evento (MODEM_DARK) o procedimiento.
</div> </div>
## Tareas ## ¿Qué necesitas hacer?
<div class="grid cards" markdown> | Quiero… | Abre | Acta |
|---|---|---|
| **Probar una tarjeta nueva** (V5.5 / V5.6 desnuda) | [Prueba y flasheo](procedimientos/v5-prueba-y-flasheo.md) | [Tarjeta V5](formularios/v5.html) |
| **Armar una unidad** (módulos → ensamble → prueba final → configuración) | [Producción](produccion/index.md) | [Ensamble](formularios/ensamble.html) |
| **Diagnosticar** una unidad que falla en la máquina | [Diagnóstico en campo](diagnostico/procedimiento-diagnostico-campo.md) | [Diagnóstico en campo](formularios/diagnostico.html) |
| **Diagnosticar** una unidad en la mesa | [Diagnóstico en banco](procedimientos/diagnostico-banco.md) | [Banco](formularios/banco.html) |
| **Instalar en sitio** (ICAVE, LZC, EIT, TIMSA) | [Checklist de puesta en marcha](procedimientos/v5-checklist-puesta-en-marcha.md) | [Instalación en campo](formularios/instalacion.html) |
| **Ver los tableros** (kanban, ingeniería, producción, actas) | [Tableros](tableros/index.md) | — |
| **Inventario** (existencias, entradas y salidas) | [Inventario](inventario/inventario.html) | — |
| **Pedir una tarjeta o saber qué versión va** | [Tarjetas y hardware](hardware/index.md) | — |
| **Descargar firmware** (por tarjeta, con SHA-256) | [Descargas](descargas/index.md) | [Reflasheo](formularios/reflasheo.html) |
| Reflashear una unidad en taller | [Reflasheo de unidades](procedimientos/reflasheo-unidades.md) | [Reflasheo](formularios/reflasheo.html) |
| Enviar, recibir o reportar un daño | [Ciclo de vida](procedimientos/ciclo-de-vida.md) | [Envío](formularios/envio.html) · [Recepción](formularios/recepcion.html) · [Daño](formularios/dano.html) |
| Ver todo lo que le pasó a una unidad | [Expediente de una unidad](unidad.html) | — |
| Saber qué tarjeta tengo en la mano | [Manuales por tarjeta](manuales/index.md) | — |
| Entender cómo llegamos aquí | [Historia del proyecto](historia/index.md) | — |
- ### Llegaron tarjetas nuevas V5.6 ## Tarjetas — estado al 13-oct-2026
--- | Tarjeta | Estado | Pedido | Firmware | Dónde leer |
|---|---|---|---|---|
| <span class="badge-v55">V5.5</span> | **En campo.** 15 pzas fabricadas el 22-ago; en ensamble, taller y puertos. No se fabrica más. | — | 0.6.0 CAMPO · 0.6.3 en prueba de banco | [Manual V5](manuales/manual-tarjeta-V5.md) |
| <span class="badge-v56">V5.6</span> | **En layout** (pasada 2 de Michelle: BAT_PLUS, MAIN5V, SYS5V, SW3). Con Mean Well SD-15B externa. | **113** unidades | 0.7.0 (`board_v56.h`), pendiente | [V5.6](hardware/index.md#v56) |
| <span class="badge-v57">V5.7</span> | **En diseño.** V5.6 + fuente 24 V → 5 V a bordo (U25). Esquemático ERC 0; Leo coloca la fuente, Michelle cierra. | **327** unidades | 0.7.x | [V5.7](hardware/index.md#v57) |
| <span class="badge-v6">v6</span> | **En diseño.** GNSS ZED-F9P y módem A7670SA soldados. Esquemático verificado; PCB sin corregir. | **130** unidades + reemplazos | 0.7.x (`board_v6.h`) | [v6](hardware/index.md#v6) |
| <span class="badge-v4">V4</span> | En prueba y campo (Blackpill + Ethernet/serie). Sin producción nueva. | — | 0.6.0 CAMPO · 0.6.3 en prueba | [Manual V4](manuales/manual-tarjeta-V4.md) |
| <span class="badge-v3">V3</span> | 3 unidades en campo (RTK-003/006/014). Registro congelado. | — | 4.0.0 | [Manual V3](manuales/manual-tarjeta-V3.md) |
| <span class="badge-fw">firmware</span> | El binario se elige **por tarjeta**, nunca por número de versión. 0.6.3 en banco desde el 1-oct, no para campo. | | | [Descargas](descargas/index.md#firmware-del-hbox-que-binario-va-en-que-tarjeta) · [notas de versión](descargas/notas-de-version.md) |
Inspección, flasheo con el binario vigente y autoprueba, tarjeta por tarjeta. Pedido total: **570 HBOX** (113 V5.6 + 327 V5.7 + 130 v6). Avance real contra el plan: [Tablero de producción](tableros/produccion.html).
[**Acta de tarjeta V5**](formularios/v5.html){ .md-button .md-button--primary } ## Hoy — 13 de octubre de 2026
[Procedimiento: prueba y flasheo](procedimientos/v5-prueba-y-flasheo.md)
- ### Armar y configurar una unidad - **V5.6 no se pide todavía.** La auditoría del layout de Michelle (commit `d71098d`) salió **NO LISTA**: `/BAT_PLUS` pasa por In1 a 0.5 mm. Correcciones en la [pasada 2](hardware/v56-layout-pasada-2.md); la fila de conectores se queda como está.
- **V5.7 arrancó.** Esquemático con la fuente a bordo aplicado (ERC 0, F2 fusible SMD 3 A, fuera U2 y P2). Leo coloca y rutea la fuente con la [guía de layout](hardware/v57-layout-guia.md); después Michelle replica ahí las correcciones de V5.6.
- **Plan de 570 unidades** (113 / 327 / 130) y paquetes de fabricación V5.6, V5.7 y v6 para cotizar. Pendiente: la caja plástica del HBOX, donde el cable Ethernet no cabe. Tareas y responsables en el [Kanban](tableros/kanban.html).
--- Fuente: repositorio Drogheda (`hbox-docs` se publica desde él). Contacto: leo@helgen.tech
Módulos RTK/4G, ensamble en gabinete, prueba final y parámetros de la unidad.
[Acta de ensamble](formularios/ensamble.html){ .md-button .md-button--primary }
[Ensamble](procedimientos/v5-ensamble.md) · [Configuración](procedimientos/v5-configuracion-unidad.md) · [Flujo completo](produccion/index.md)
- ### El cliente usa Ethernet o puertos serie
---
Prueba de banco con el binario `-eth`, cable directo a la laptop.
[Acta: Ethernet y serie](formularios/ethernet-serie.html){ .md-button .md-button--primary }
[Procedimiento](procedimientos/v5-prueba-ethernet-serie.md)
- ### Una unidad falla en la máquina
---
Diagnóstico en sitio con el gabinete montado. No reinicies nada antes de capturar la evidencia.
[Acta de diagnóstico en campo](formularios/diagnostico.html){ .md-button .md-button--primary }
[Procedimiento](diagnostico/procedimiento-diagnostico-campo.md)
- ### Una unidad está en la mesa
---
Diagnóstico en banco: continuidad, rampa de corriente, rieles y capturas por USB.
[Acta de diagnóstico en banco](formularios/banco.html){ .md-button .md-button--primary }
[Procedimiento](procedimientos/diagnostico-banco.md)
- ### Reflashear una unidad
---
Solo en taller y solo después de subir la captura del diagnóstico.
[Acta de reflasheo](formularios/reflasheo.html){ .md-button .md-button--primary }
[Procedimiento](procedimientos/reflasheo-unidades.md)
- ### Instalar en el puerto
---
Checklist de puesta en marcha de una página: imprimir y llevar, una hoja por unidad.
[Checklist de puesta en marcha](procedimientos/v5-checklist-puesta-en-marcha.md){ .md-button .md-button--primary }
[Manual del cliente](manuales/manual-cliente.md)
- ### Enviar, recibir o reportar un daño
---
Qué salió en la caja, en qué estado llegó y qué se dañó. La serie queda marcada.
[Acta de envío](formularios/envio.html){ .md-button .md-button--primary }
[Recepción](formularios/recepcion.html) · [Daño](formularios/dano.html) · [Ciclo de vida](procedimientos/ciclo-de-vida.md)
- ### Consultar pinout u hoja de datos
---
Conectores, puentes, LEDs y límites eléctricos de cada tarjeta.
[**Pinout V5.6**](hardware/v56-pinout.md){ .md-button .md-button--primary }
[Hoja de datos V5.6](hardware/v56-datasheet.md) · [Todas las tarjetas](hardware/index.md)
- ### Descargar firmware
---
Binario por tarjeta (nunca por número de versión), SHA-256, parámetros 4G y ZED-F9P.
[Descargas](descargas/index.md){ .md-button .md-button--primary }
[README bring-up v5](descargas/hbox-v5-bringup-README.md)
- ### Todo sobre una unidad
---
Expediente de un número de serie: cada acta, foto y log en una línea de tiempo.
[Abrir expediente](unidad.html){ .md-button .md-button--primary }
[Tablero de producción](tablero.html) · [Tablero de campo](diagnostico/tablero-campo.html)
- ### Primera vez en el sitio
---
Reglas de trabajo, acceso y cómo se llenan las actas.
[Guía del ingeniero](guia-ingeniero.md){ .md-button .md-button--primary }
[Índice de actas](actas/index.md)
</div>
## Estado
| Tarjeta / firmware | Estado |
|---|---|
| <span class="badge-v55">V5.5</span> | 15 unidades en campo · fw 0.6.0 |
| <span class="badge-v56">V5.6</span> | Esquemático cerrado · PCB en layout · pedido pendiente |
| <span class="badge-v6">v6</span> | En diseño (GNSS + 4G a bordo) — no pedir |
| <span class="badge-fw">firmware 0.6.3</span> | En prueba de banco desde el 1-oct (sustituye a 0.6.2), no para campo ([descargas](descargas/index.md#firmware-del-hbox-que-binario-va-en-que-tarjeta)); en campo sigue 0.6.0; 0.6.1 retirada el 29-sep (su telemetría no pasaba por el módem) · [notas de versión](descargas/notas-de-version.md#063-en-prueba) |
Reglas de trabajo: [Guía del ingeniero](guia-ingeniero.md). Fuente: repositorio Drogheda. Contacto: leo@helgen.tech

58
docs/inventario/index.md Normal file
View File

@ -0,0 +1,58 @@
---
title: "Inventario"
date: 2026-10-13
tags: [project-drogheda, hbox, inventario, produccion, compras]
---
# Inventario de materiales
Qué hay en el taller para producir e instalar HBOX: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. No es una lista de BOM: aquí solo entra lo que se cuenta a mano.
**[Abrir el inventario →](inventario.html)**
## Qué hay aquí
| Página | Para qué |
|---|---|
| [Inventario — existencias y movimientos](inventario.html) | Tabla de existencias por ítem con mínimo y alerta; alta de ítem; movimiento de **entrada** (compra, devolución) o **salida** (ensamble, envío, baja) con motivo y liga a acta/unidad; exportar CSV |
| [Tablero de producción](../tableros/produccion.html) | Lo que el inventario tiene que cubrir: 113 V5.6 · 327 V5.7 · 130 v6 |
| [Kanban — pista Compras](../tableros/kanban.html) | Pedidos en curso (SD-15B-05 ×113, paquetes LFP, gabinetes, cajas HBOX, antenas) |
## Qué se registra y qué no
| Sí | No |
|---|---|
| Tarjetas EinarBoard por versión (V5.5, V5.6, V5.7, v6) | Resistencias, capacitores y componentes de la BOM normal |
| Módulos: ArduSimple simpleRTK2B Pro, 4G NTRIP Master | Herramienta y consumibles del banco |
| Gabinetes IP67, cajas plásticas HBOX, fuentes SD-15B, packs LFP | Unidades ya ensambladas (esas viven en el [tablero](../tablero.html) por su acta) |
| Antenas GNSS y LTE, cable Ethernet, conectores GX16, fusibles DIN | — |
| Componentes críticos de entrega larga: LTC4040, TPS54360B, SRP1265A | — |
## Quién registra y cuándo
| Qué pasó | Quién | Movimiento |
|---|---|---|
| Llega material (paquetería, Aldair, Mouser) | Quien recibe (Jesús o Leo) | **Entrada**, con la orden o guía como referencia, el mismo día |
| Se arma una unidad | Quien ensambla, al cerrar el acta de ensamble | **Salida** de cada pieza, con el número de unidad `HBOX5-Exx-Axxx-Rxxxx` como referencia |
| Sale un kit al puerto | Quien llena el acta de envío | **Salida** de lo que va en la caja (fuente, antenas, gabinete) |
| El conteo no cuadra | Jesús, al contar | **Ajuste** con el conteo físico y el motivo («conteo mensual», «se encontró en el otro anaquel») |
| Pieza dañada o de baja | Quien llena el acta de daño | **Salida** con el motivo y la serie |
El stock es la **suma de los movimientos**; nadie lo escribe a mano. Cada movimiento queda con usuario, fecha, cantidad, motivo y referencia, y se puede ver desde el código del ítem.
## Mínimos y alertas
- El **mínimo** lo fija quien compra (Leo), pensando en el tiempo de entrega: chips y antenas RTK tardan ~4 semanas; LTC4040 y la fuente a bordo son de entrega larga.
- Para el pedido de 570, la fuente Mean Well SD-15B-05 tiene mínimo **113** (solo la usa V5.6).
- Por debajo del mínimo el ítem se marca en rojo aquí y en el [tablero de producción](../tableros/produccion.html). La alerta no manda correos: hay que mirar el tablero.
- Mínimo 0 = sin alerta (consumibles que no vale la pena vigilar).
## Conteo inicial (pendiente del 13-oct)
Los ítems ya existen con stock 0. Jesús cuenta y registra **un ajuste por ítem** con motivo «conteo inicial». Mientras no se haga, el tablero mostrará alertas que no son reales.
## Para ingeniería
- Datos en `/srv/projects/drogheda/inventario/{items,movimientos}/` (un JSON por registro + `_indice.csv`).
- API: `GET /api/inventario`, `POST /api/inventario/movimiento`, `GET /api/inventario/movimientos?item_id=`, `GET /api/inventario/export.csv`. Ejemplos en `backend/README.md`.
- Nada se borra. Un ítem que ya no aplica se marca «(obsoleto)» en la descripción y con mínimo 0.

View File

@ -0,0 +1,241 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inventario de materiales — HBOX</title>
<link rel="stylesheet" href="../tableros/tablero.css">
<style>
table.t td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
table.t th.num{text-align:right}
table.t tr.alerta td.num.stock{color:var(--falla);font-weight:700}
table.t td .cod{font-weight:700;cursor:pointer;color:var(--naranja)}
table.t td .cat{font-size:11px;color:var(--tinta2);display:block}
table.t td button{padding:5px 9px;font-size:12.5px;margin:1px 2px 1px 0}
.acciones{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 12px}
.acciones .cnt{color:var(--tinta2);font-size:13px;margin-left:auto}
.mov-fila{display:grid;grid-template-columns:120px 70px 70px 1fr;gap:6px 10px;padding:6px 0;border-bottom:1px dashed var(--linea);font-size:13px;align-items:baseline}
.mov-fila .d{font-variant-numeric:tabular-nums}
.mov-fila .pos{color:var(--ok);font-weight:700} .mov-fila .neg{color:var(--falla);font-weight:700}
@media(max-width:560px){.mov-fila{grid-template-columns:1fr 1fr}}
.ayuda{font-size:12.5px;color:var(--tinta2);margin-top:3px}
.mini{font-size:12.5px;color:var(--tinta2)}
@media(max-width:760px){#cont table.t{min-width:780px}} /* en teléfono: desliza de lado, no aplastes columnas */
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Inventario de materiales</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="../tableros/produccion.html">Producción</a>
<a href="../tableros/kanban.html">Pendientes</a>
<a href="./">Política de inventario</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>El <b>stock no se escribe a mano</b>: es la suma de los movimientos. Llegó material → <b>Entrada</b>. Se usó en un ensamble o se envió → <b>Salida</b> con la unidad o el acta como referencia.</li>
<li>Contaste y no cuadra → <b>Ajuste</b> con el conteo físico; el sistema calcula la diferencia y la deja registrada con tu nombre.</li>
<li>Toca el <b>código</b> para ver los movimientos de ese ítem. Cada uno dice quién, cuándo, cuánto y por qué.</li>
<li><b>Alerta</b> (rojo) = stock por debajo del mínimo. El mínimo lo pone quien compra; el tablero de producción lo repite.</li>
<li><b>Nuevo ítem</b> solo para cosas que se cuentan: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. Nada de resistencias.</li>
<li><b>Exportar CSV</b> baja la tabla para Excel. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.</li>
</ol>
</details>
<div class="acciones">
<select id="fCat"><option value="">Todas las categorías</option></select>
<select id="fVer"><option value="">Aplica a: todas</option></select>
<label style="margin:0;display:flex;gap:6px;align-items:center;font-weight:500"><input type="checkbox" id="fAlerta" style="width:auto"> solo alertas</label>
<input type="search" id="fBuscar" placeholder="Buscar código o descripción…" style="flex:1;min-width:150px">
<button id="bMov">+ Movimiento</button>
<button id="bNuevo" class="sec">+ Nuevo ítem</button>
<a href="/api/inventario/export.csv" class="sec" style="padding:8px 12px;border:1px solid var(--linea);border-radius:9px;text-decoration:none;font-size:14px;font-weight:600;background:var(--tarjeta);color:var(--tinta2)">⬇ Exportar CSV</a>
<span class="cnt" id="cnt"></span>
</div>
<div class="msg" id="aviso"></div>
<div id="cont" class="tblwrap"></div>
</div>
<dialog id="dlg"></dialog>
<script>
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const $ = id => document.getElementById(id);
const CAT = { tarjeta: 'Tarjeta', modulo: 'Módulo', gabinete: 'Gabinete', caja: 'Caja', fuente: 'Fuente', bateria: 'Batería', antena: 'Antena', cable: 'Cable', conector: 'Conector', 'componente-critico': 'Componente crítico', otro: 'Otro' };
const VERS = ['V5.5', 'V5.6', 'V5.7', 'v6'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
let ITEMS = [], ADMIN = false, USER = '';
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
async function api(url, opts) { const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {})); let j = null; try { j = await r.json(); } catch (e) {} if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; }
const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
function campo(l, i, ayuda) { const d = el('div'); d.append(el('label', { text: l }), i); if (ayuda) d.append(el('div', { class: 'ayuda', text: ayuda })); return d; }
Object.entries(CAT).forEach(([k, v]) => $('fCat').append(el('option', { value: k, text: v })));
VERS.forEach(v => $('fVer').append(el('option', { value: v, text: 'Aplica a: ' + v })));
function visibles() {
const cat = $('fCat').value, ver = $('fVer').value, solo = $('fAlerta').checked, q = $('fBuscar').value.trim().toLowerCase();
return ITEMS.filter(i => (!cat || i.categoria === cat) && (!ver || (i.aplica_a || []).includes(ver)) && (!solo || i.alerta)
&& (!q || (i.codigo + ' ' + i.descripcion + ' ' + (i.proveedor || '') + ' ' + (i.ubicacion || '')).toLowerCase().includes(q)));
}
function pintar() {
const cont = $('cont'); cont.innerHTML = '';
const vis = visibles();
$('cnt').textContent = vis.length + ' ítem(s)' + (vis.length !== ITEMS.length ? ' de ' + ITEMS.length : '') + ' · ' + vis.filter(i => i.alerta).length + ' en alerta';
if (!vis.length) { cont.append(el('div', { class: 'vacio', text: ITEMS.length ? 'Nada coincide con el filtro.' : 'Todavía no hay ítems. Crea el primero con «Nuevo ítem» o corre la siembra.' })); return; }
const t = el('table', { class: 't' });
t.innerHTML = '<tr><th>Código</th><th>Descripción</th><th>Aplica a</th><th class="num">Stock</th><th class="num">Mínimo</th><th>Ubicación</th><th>Proveedor</th><th></th></tr>';
vis.forEach(i => {
const tr = el('tr', { class: i.alerta ? 'alerta' : '' });
const cod = el('span', { class: 'cod', text: i.codigo, title: 'Ver movimientos' }); cod.addEventListener('click', () => verMovimientos(i));
tr.append(el('td', null, cod, el('span', { class: 'cat', text: CAT[i.categoria] || i.categoria })));
tr.append(el('td', null, document.createTextNode(i.descripcion), i.notas ? el('div', { class: 'mini', text: i.notas.length > 90 ? i.notas.slice(0, 89) + '…' : i.notas }) : null));
const ap = el('td'); (i.aplica_a || []).forEach(v => ap.append(el('span', { class: BADGE[v] || 'pill pend', text: v, style: 'margin-right:3px' }))); tr.append(ap);
tr.append(el('td', { class: 'num stock', html: esc(i.stock) + ' <span class="mini">' + esc(i.unidad) + '</span>' + (i.alerta ? ' <span class="pill falla">alerta</span>' : '') }));
tr.append(el('td', { class: 'num', text: i.minimo || '—' }));
tr.append(el('td', { text: i.ubicacion || '—' }));
tr.append(el('td', { html: esc(i.proveedor || '—') + (i.codigo_proveedor ? '<div class="mini">' + esc(i.codigo_proveedor) + '</div>' : '') }));
const acc = el('td', { style: 'white-space:nowrap' });
const bm = el('button', { text: 'Movimiento' }); bm.addEventListener('click', () => movimiento(i));
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(i));
acc.append(bm, be); tr.append(acc);
t.append(tr);
});
cont.append(t);
}
async function cargar() {
$('meta').textContent = 'Actualizando…';
try {
const j = await api('/api/inventario');
ITEMS = j.items || []; ADMIN = !!j.admin; USER = j.user || '';
pintar();
$('meta').textContent = ITEMS.length + ' ítems · ' + (j.alertas || 0) + ' en alerta · actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + (USER ? ' · ' + USER : '');
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el inventario: ' + e.message })); $('meta').textContent = 'Error'; }
}
['fCat', 'fVer', 'fAlerta'].forEach(id => $(id).addEventListener('change', pintar));
$('fBuscar').addEventListener('input', pintar);
// ---------- diálogos ----------
function abrirDialogo(titulo, cuerpoFn, botones) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const m = el('div', { class: 'msg' });
cuerpoFn(cuerpo, m); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: botones ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
(botones || []).forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function selItems(sel) {
const s = el('select'); s.append(el('option', { value: '', text: '— elegir ítem —' }));
ITEMS.forEach(i => s.append(el('option', { value: i.id, text: i.codigo + ' — ' + i.descripcion + ' (' + i.stock + ' ' + i.unidad + ')' })));
if (sel) s.value = sel.id; return s;
}
function movimiento(item) {
abrirDialogo('Registrar movimiento', (c) => {
const fItem = selItems(item);
const fTipo = el('select'); [['entrada', 'Entrada — llegó material (+)'], ['salida', 'Salida — se usó o se envió (−)'], ['ajuste', 'Ajuste — conteo físico (fija el stock)']].forEach(([v, t]) => fTipo.append(el('option', { value: v, text: t })));
const fCant = el('input', { type: 'number', min: '0', step: '1', inputmode: 'numeric', placeholder: '0' });
const fMot = el('input', { type: 'text', maxlength: '200', placeholder: 'llegó pedido Mouser · ensamble · envío a ICAVE · conteo físico' });
const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'HBOX5-E07-A010-R0006 · OC-0042 · acta de envío' });
const ayudaCant = el('div', { class: 'ayuda', text: 'Cantidad que entra.' });
fTipo.addEventListener('change', () => { ayudaCant.textContent = fTipo.value === 'entrada' ? 'Cantidad que entra.' : fTipo.value === 'salida' ? 'Cantidad que sale. No puede dejar el stock en negativo.' : 'Lo que contaste físicamente: el stock quedará exactamente en ese número.'; });
c.append(campo('Ítem', fItem), campo('Tipo', fTipo));
const g = el('div', { class: 'dos-col' }); const cc = campo('Cantidad', fCant); cc.append(ayudaCant); g.append(cc, campo('Referencia (unidad, acta u orden)', fRef)); c.append(g);
c.append(campo('Motivo (obligatorio)', fMot));
c._leer = () => ({ item_id: fItem.value, tipo: fTipo.value, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() });
setTimeout(() => (item ? fCant : fItem).focus(), 50);
}, [['Registrar', async (m) => {
const d = dlg.querySelector('.dcuerpo')._leer();
if (!d.item_id) throw new Error('Elige el ítem.');
if (d.cantidad == null || isNaN(d.cantidad)) throw new Error('Escribe la cantidad.');
if (!d.motivo) throw new Error('Escribe el motivo.');
const j = await api('/api/inventario/movimiento', json('POST', d));
dlg.close(); mensaje('Movimiento registrado: ' + j.item.codigo + ' ahora tiene ' + j.item.stock + ' ' + j.item.unidad + '.', true); cargar();
}]]);
}
function formItem(c, i) {
i = i || {};
const f = {};
f.codigo = el('input', { type: 'text', maxlength: '40', value: i.codigo || '', placeholder: 'EB-V56 · SD-15B-05', style: 'text-transform:uppercase' });
f.descripcion = el('input', { type: 'text', maxlength: '200', value: i.descripcion || '' });
f.categoria = el('select'); Object.entries(CAT).forEach(([k, v]) => f.categoria.append(el('option', { value: k, text: v }))); f.categoria.value = i.categoria || 'otro';
f.unidad = el('select'); [['pza', 'pza (piezas)'], ['m', 'm (metros)']].forEach(([v, t]) => f.unidad.append(el('option', { value: v, text: t }))); f.unidad.value = i.unidad || 'pza';
f.aplica = el('div', { class: 'lista-chk', style: 'max-height:none;display:flex;gap:14px;flex-wrap:wrap' });
VERS.forEach(v => { const l = el('label', { style: 'margin:0' }); const ck = el('input', { type: 'checkbox', value: v }); ck.checked = (i.aplica_a || []).includes(v); l.append(ck, document.createTextNode(v)); f.aplica.append(l); });
f.minimo = el('input', { type: 'number', min: '0', step: '1', value: i.minimo || 0 });
f.ubicacion = el('input', { type: 'text', maxlength: '120', value: i.ubicacion || '', placeholder: 'Anaquel A2 · taller Veracruz' });
f.proveedor = el('input', { type: 'text', maxlength: '120', value: i.proveedor || '' });
f.codigo_proveedor = el('input', { type: 'text', maxlength: '60', value: i.codigo_proveedor || '', placeholder: 'LCSC C2057152 · Mouser 709-SD15B05' });
f.notas = el('textarea', { rows: '2' }); f.notas.value = i.notas || '';
const g = el('div', { class: 'dos-col' });
g.append(campo('Código (único)', f.codigo), campo('Categoría', f.categoria));
c.append(g, campo('Descripción', f.descripcion), campo('Aplica a (versión de tarjeta)', f.aplica));
const g2 = el('div', { class: 'dos-col' }); g2.append(campo('Unidad', f.unidad), campo('Mínimo (0 = sin alerta)', f.minimo), campo('Ubicación', f.ubicacion), campo('Proveedor', f.proveedor), campo('Código del proveedor', f.codigo_proveedor)); c.append(g2);
if (!i.id) { f.stock_inicial = el('input', { type: 'number', min: '0', step: '1', value: '0' }); c.append(campo('Stock inicial (queda registrado como entrada)', f.stock_inicial)); }
c.append(campo('Notas', f.notas));
return () => {
const d = { codigo: f.codigo.value.trim().toUpperCase(), descripcion: f.descripcion.value.trim(), categoria: f.categoria.value, unidad: f.unidad.value,
aplica_a: [...f.aplica.querySelectorAll('input:checked')].map(x => x.value), minimo: +f.minimo.value || 0, ubicacion: f.ubicacion.value.trim(),
proveedor: f.proveedor.value.trim(), codigo_proveedor: f.codigo_proveedor.value.trim(), notas: f.notas.value.trim() };
if (f.stock_inicial) d.stock_inicial = +f.stock_inicial.value || 0;
return d;
};
}
function nuevo() {
let leer;
abrirDialogo('Nuevo ítem', c => { leer = formItem(c); }, [['Crear ítem', async () => {
const d = leer(); if (!d.codigo) throw new Error('Escribe el código.'); if (!d.descripcion) throw new Error('Escribe la descripción.');
await api('/api/inventario', json('POST', d)); dlg.close(); mensaje('Ítem ' + d.codigo + ' creado.', true); cargar();
}]]);
}
function editar(i) {
let leer;
abrirDialogo('Editar ' + i.codigo, c => {
leer = formItem(c, i);
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · stock actual <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b> (se cambia con movimientos, no aquí)' }));
(i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
c.append(h);
}, [['Guardar', async () => {
const d = leer(); const cambios = {};
Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(i[k] == null ? (Array.isArray(d[k]) ? [] : (typeof d[k] === 'number' ? 0 : '')) : i[k])) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/inventario/item?id=' + encodeURIComponent(i.id), json('PATCH', cambios));
dlg.close(); mensaje('Ítem guardado.', true); cargar();
}]]);
}
async function verMovimientos(i) {
abrirDialogo('Movimientos de ' + i.codigo, async c => {
c.append(el('p', { class: 'sub', html: esc(i.descripcion) + ' · stock <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b>' + (i.minimo ? ' · mínimo ' + esc(i.minimo) : '') }));
const lista = el('div', { text: 'Cargando…' }); c.append(lista);
const b = el('button', { text: '+ Movimiento', style: 'margin-top:10px' }); b.addEventListener('click', () => movimiento(i)); c.append(b);
try {
const j = await api('/api/inventario/movimientos?item_id=' + encodeURIComponent(i.id));
lista.innerHTML = '';
if (!j.total) { lista.append(el('div', { class: 'vacio', text: 'Sin movimientos. El stock es 0 hasta que registres una entrada o un ajuste.' })); return; }
lista.append(el('div', { class: 'mov-fila', style: 'font-weight:700;color:var(--tinta2);font-size:11.5px;text-transform:uppercase' }, el('span', { text: 'Cuándo · quién' }), el('span', { text: 'Tipo' }), el('span', { text: 'Δ · stock' }), el('span', { text: 'Motivo · referencia' })));
j.movimientos.forEach(m => {
lista.append(el('div', { class: 'mov-fila' },
el('span', { html: esc(fmt(m.cuando)) + '<br><b>' + esc(m.quien) + '</b>' }),
el('span', { text: m.tipo + (m.tipo === 'ajuste' ? ' → ' + m.cantidad : '') }),
el('span', { class: 'd', html: '<span class="' + (m.delta >= 0 ? 'pos' : 'neg') + '">' + (m.delta >= 0 ? '+' : '') + esc(m.delta) + '</span> · ' + esc(m.stock_despues) }),
el('span', { html: esc(m.motivo) + (m.referencia ? '<br><span class="mini">ref. ' + esc(m.referencia) + '</span>' : '') })));
});
} catch (e) { lista.textContent = 'No se pudieron leer los movimientos: ' + e.message; }
});
}
$('bMov').addEventListener('click', () => movimiento(null));
$('bNuevo').addEventListener('click', nuevo);
cargar();
</script>
</body>
</html>

View File

@ -11,12 +11,13 @@ author: Helgen Technologies
Cada manual cubre cómo reconocer la tarjeta, conectores, puentes, botones, LEDs, flasheo y limitaciones. El binario lleva la tarjeta en el nombre; reglas en [Descargas](../descargas/index.md#firmware-del-hbox-que-binario-va-en-que-tarjeta); qué cambia entre versiones, en las [notas de versión](../descargas/notas-de-version.md). Cada manual cubre cómo reconocer la tarjeta, conectores, puentes, botones, LEDs, flasheo y limitaciones. El binario lleva la tarjeta en el nombre; reglas en [Descargas](../descargas/index.md#firmware-del-hbox-que-binario-va-en-que-tarjeta); qué cambia entre versiones, en las [notas de versión](../descargas/notas-de-version.md).
| Tarjeta | Nombre | Estado (sep-2026) | Firmware | Proyecto KiCad | Manual | | Tarjeta | Nombre | Estado (oct-2026) | Firmware | Proyecto KiCad | Manual |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| V3 | EinarBoard 1.0 (Blackpill en zócalo) | 10 pzas jul-2026; 3 unidades en campo (RTK-003/006/014); registro congelado | `hbox-tarjetaV3_4.0.0.bin` | `hbox-watchdog-v3-PROD` | [Manual V3](manual-tarjeta-V3.md) | | V3 | EinarBoard 1.0 (Blackpill en zócalo) | 10 pzas jul-2026; 3 unidades en campo (RTK-003/006/014); registro congelado | `hbox-tarjetaV3_4.0.0.bin` | `hbox-watchdog-v3-PROD` | [Manual V3](manual-tarjeta-V3.md) |
| V4 | EinarBoard 2.0 (Blackpill + Ethernet + serie) | Fabricada ago-2026; en prueba con 0.6.0 | `hbox-tarjetaV4_0.6.0.bin` | `hbox-watchdog-v4` | [Manual V4](manual-tarjeta-V4.md) | | V4 | EinarBoard 2.0 (Blackpill + Ethernet + serie) | Fabricada ago-2026; en prueba con 0.6.0 | `hbox-tarjetaV4_0.6.0.bin` | `hbox-watchdog-v4` | [Manual V4](manual-tarjeta-V4.md) |
| V5.5 | EinarBoard 4.0 (serigrafía) / HBOX v5, MCU soldado | 15 pzas 22-ago-2026; en ensamble y campo | `hbox-v5-bringup_0.6.0.bin` | `hbox-watchdog-v5` (tag `v5.5-as-fabbed`) | [Manual V5](manual-tarjeta-V5.md) | | V5.5 | EinarBoard 4.0 (serigrafía) / HBOX v5, MCU soldado | 15 pzas 22-ago-2026; en ensamble y campo | `hbox-v5-bringup_0.6.0.bin` | `hbox-watchdog-v5` (tag `v5.5-as-fabbed`) | [Manual V5](manual-tarjeta-V5.md) |
| V5.6 | HBOX v5 + ECO 27/28-sep (módem controlado por MCU, LFP, sensores) | Esquemático cerrado; PCB en layout; pedido pendiente | 0.7.0 (0.6.x no enciende su módem: no maneja `MODEM_EN`) | `hbox-watchdog-v5` | [Manual V5](manual-tarjeta-V5.md) §9 | | V5.6 | HBOX v5 + ECO 27/28-sep (módem controlado por MCU, LFP, sensores) + acelerómetro U26, sin P12 | Layout en pasada 2 (Michelle, 13-oct); pedido de **113** con fuente SD-15B | 0.7.0 (0.6.x no enciende su módem: no maneja `MODEM_EN`) | `hbox-watchdog-v5` | [Manual V5](manual-tarjeta-V5.md) §9 |
| V5.7 | V5.6 + fuente 24 V → 5 V a bordo (U25 TPS54360B, fusible F2); sin SD-15B ni P2 | En diseño: esquemático listo (13-oct), layout en curso (Leo → Michelle); pedido de **327** | 0.7.x (igual que V5.6; sin política «ventilador solo con 24 V») | `hbox-watchdog-v57` | [Manual V5](manual-tarjeta-V5.md) §9 + [ECO V5.7](../hardware/v57-eco-fuente.md) |
| v6 | GNSS + 4G a bordo | En diseño; no pedir | — | `hbox-watchdog-v6` | [Manual v6](manual-tarjeta-v6.md) | | v6 | GNSS + 4G a bordo | En diseño; no pedir | — | `hbox-watchdog-v6` | [Manual v6](manual-tarjeta-v6.md) |
Documentos de ingeniería: [Tarjetas y hardware](../hardware/index.md). Documentos de ingeniería: [Tarjetas y hardware](../hardware/index.md).

View File

@ -8,6 +8,10 @@ author: Helgen Technologies
# Guía de ensamble del gabinete (PDF) # Guía de ensamble del gabinete (PDF)
!!! warning "Documento histórico — archivo equivocado"
El PDF 0007-1000 publicado aquí es la guía de ensamble de un brazo robótico, no del gabinete HBOX (detectado el 17-sep-2026). Se conserva solo como registro hasta sustituirlo por la guía real del gabinete. Para armar una unidad V5 usa [Ensamble de la unidad](../procedimientos/v5-ensamble.md).
[0007-1000_Assembly_Guide.pdf](0007-1000_Assembly_Guide.pdf){:target="_blank"} (≈ 9 MB). Descárgala en la tablet o laptop del banco antes de empezar. Orden de trabajo y reglas de calidad: [guía de ensamble y QC](guia-ensamble-qc.md). [0007-1000_Assembly_Guide.pdf](0007-1000_Assembly_Guide.pdf){:target="_blank"} (≈ 9 MB). Descárgala en la tablet o laptop del banco antes de empezar. Orden de trabajo y reglas de calidad: [guía de ensamble y QC](guia-ensamble-qc.md).
## Historial de revisiones ## Historial de revisiones

View File

@ -9,6 +9,10 @@ version: 0.9.2
# Guía de ensamble y control de calidad — HBOX # Guía de ensamble y control de calidad — HBOX
!!! note "Documento histórico — superado por el ensamble V5 (17-sep-2026)"
Borrador escrito para la V3 a partir del BOM de ensamble y el pinout de arneses; nunca se validó. Para V5.5/V5.6 el procedimiento vigente es [Ensamble de la unidad](../procedimientos/v5-ensamble.md) con su [acta](../formularios/ensamble.html).
!!! abstract "Para quién · Cuándo · Tiempo · Necesitas" !!! abstract "Para quién · Cuándo · Tiempo · Necesitas"
- Estado: borrador V3 basado en el BOM de ensamble y el pinout de arneses; ingeniería debe validarlo. Para V5.5/V5.6: [Ensamble de la unidad](../procedimientos/v5-ensamble.md) y [pinout V5.6](../hardware/v56-pinout.md). - Estado: borrador V3 basado en el BOM de ensamble y el pinout de arneses; ingeniería debe validarlo. Para V5.5/V5.6: [Ensamble de la unidad](../procedimientos/v5-ensamble.md) y [pinout V5.6](../hardware/v56-pinout.md).
- Para quién: técnicos de banco. - Para quién: técnicos de banco.

View File

@ -1,19 +1,45 @@
--- ---
title: "Producción — lote V5.6" title: "Producción"
--- ---
# Producción — lote V5.6 # Producción
| Paso | Procedimiento | Acta | De la tarjeta desnuda a la unidad lista para embarcar. Una unidad a la vez; cada paso deja su acta y la unidad no avanza si falla un paso. Plan y avance real: [Tablero de producción](../tableros/produccion.html). Existencias: [Inventario](../inventario/inventario.html).
|---|---|---|
| 1 | [Prueba y flasheo de tarjetas](../procedimientos/v5-prueba-y-flasheo.md) | [Tarjeta V5](../formularios/v5.html) |
| 2 | [Módulos RTK y 4G e integración](../procedimientos/v5-modulos-integracion.md) | [ArduSimple](../formularios/ardusimple.html) · [Módem 4G](../formularios/modem4g.html) · [Integración](../formularios/integracion.html) |
| 3 | [Ensamble de la unidad](../procedimientos/v5-ensamble.md) | [Ensamble](../formularios/ensamble.html) |
| 4 | [Prueba final antes de embarcar](../procedimientos/v5-prueba-final-unidad.md) | En el acta de ensamble |
| 5 | [Configuración de la unidad](../procedimientos/v5-configuracion-unidad.md) | [Ensamble](../formularios/ensamble.html) · [Ethernet y serie](../formularios/ethernet-serie.html) si aplica |
| 6 | [Checklist de puesta en marcha en el puerto](../procedimientos/v5-checklist-puesta-en-marcha.md) | Impreso, al expediente |
- [Tablero de producción](../tablero.html): qué tarjeta, módulo y unidad pasó cada paso. ## Plan de 570 unidades (13-oct-2026)
- [Expediente de una unidad](../unidad.html)
- [Índice de actas](../actas/index.md) | Versión | Cantidad | Fuente de 5 V | Tarjeta | Estado del diseño |
- [Descargas](../descargas/index.md): firmware vigente y SHA-256. |---|---|---|---|---|
| <span class="badge-v56">V5.6</span> | **113** | Mean Well SD-15B-05 externa (×113) | `hbox-watchdog-v5` | En layout — pasada 2; no se pide todavía |
| <span class="badge-v57">V5.7</span> | **327** | A bordo (U25) | `hbox-watchdog-v57` | En diseño — se pide después del banco de V5.6 |
| <span class="badge-v6">v6</span> | **130** + reemplazos | A bordo (U25) | `hbox-watchdog-v6` | En diseño — PCB sin corregir |
Hoy se producen unidades con las **15 tarjetas V5.5** y se prueban las **V4**. Los procedimientos de abajo valen para V5.5 y V5.6 (la V5.6 cambia el firmware a 0.7.0 y el paquete a LFP-HT; se marcará en cada página cuando llegue el lote). Paquetes de fabricación V5.6/V5.7/v6: los prepara ingeniería ([Tarjetas y hardware](../hardware/index.md)).
## Pipeline — paso, procedimiento, acta
| Paso | Qué entra → qué sale | Procedimiento | Acta |
|---|---|---|---|
| 1 | Tarjeta desnuda V5-NN → tarjeta aprobada y flasheada | [Prueba y flasheo de tarjetas](../procedimientos/v5-prueba-y-flasheo.md) | [Tarjeta V5](../formularios/v5.html) |
| 2 | simpleRTK2B ARD-NNN y módem RTK-NNNN → pareja integrada con RTK FIX | [Módulos RTK y 4G e integración](../procedimientos/v5-modulos-integracion.md) | [ArduSimple](../formularios/ardusimple.html) · [Módem 4G](../formularios/modem4g.html) · [Integración](../formularios/integracion.html) |
| 3 | Tarjeta + pareja → unidad cerrada `HBOX5-Exx-Axxx-Rxxxx`, energizada y reportando | [Ensamble de la unidad](../procedimientos/v5-ensamble.md) | [Ensamble](../formularios/ensamble.html) |
| 4 | Unidad ensamblada → unidad probada (P1–P6) | [Prueba final antes de embarcar](../procedimientos/v5-prueba-final-unidad.md) | En el acta de ensamble |
| 5 | Unidad probada → unidad configurada (4G, F9P, ID, Ethernet/serie si aplica) | [Configuración de la unidad](../procedimientos/v5-configuracion-unidad.md) | [Ensamble](../formularios/ensamble.html) · [Ethernet y serie](../formularios/ethernet-serie.html) si aplica |
| 6 | Unidad configurada → unidad en caja, rumbo al puerto | [Ciclo de vida §6](../procedimientos/ciclo-de-vida.md#6-envio) | [Envío](../formularios/envio.html) |
| 7 | Unidad en el puerto → instalada y reportando | [Checklist de puesta en marcha](../procedimientos/v5-checklist-puesta-en-marcha.md) (en **Campo**) | [Instalación en campo](../formularios/instalacion.html) |
Antes de montar módulos revisa **U6 (LTC4040) con lupa**: bolas de soldadura o puentes son el defecto recurrente de V5.5 y V4 (regla DFM del 8-oct). Una tarjeta con U6 dudosa no se energiza; va al [acta de tarjeta](../formularios/v5.html) como falla con foto.
## Tarjetas V4
La V4 no entra al pipeline de unidades V5; se prepara y prueba en [Taller](../taller/index.md): [Preparación y prueba con 0.6.0](../procedimientos/v4-prueba-fw-0.5.6.md) · [acta V4](../formularios/v4.html).
## Dónde ver el avance
- [Tablero de producción](../tableros/produccion.html): plan 113 / 327 / 130 contra las unidades reales por etapa; bloqueos.
- [Estado de actas](../tablero.html): qué tarjeta, módulo y unidad pasó cada paso (series V5-NN, ARD-NNN, RTK-NNNN).
- [Expediente de una unidad](../unidad.html): la historia completa de una serie.
- [Inventario](../inventario/index.md): tarjetas, módulos, gabinetes, cajas, paquetes, fuentes; mínimos y alertas.
- [Kanban](../tableros/kanban.html): tareas de la pista Producción y Compras.
- [Descargas](../descargas/index.md): firmware vigente por tarjeta y SHA-256. Hoy: 0.6.0 CAMPO; 0.6.3 solo banco.
- [Índice de actas](../actas/index.md): cómo se llenan y qué registra cada una.

64
docs/sitios/index.md Normal file
View File

@ -0,0 +1,64 @@
---
title: "Sitios"
date: 2026-10-13
tags: [project-drogheda, hbox, sitios, campo, instalacion]
---
# Sitios de instalación
Un sitio es un puerto o terminal donde hay unidades HBOX instaladas (hoy: ICAVE, LZC, EIT, TIMSA). Cada sitio tiene su página con contactos, unidades, instalaciones, diagnósticos y daños, armada a partir de las actas.
<div id="lista-sitios" class="buscar-aviso" markdown="0">Cargando sitios…</div>
<script>
(function(){
var caja=document.getElementById('lista-sitios');
fetch('/api/sitios?todos=1',{cache:'no-store'}).then(function(r){return r.json();}).then(function(j){
var ss=j.sitios||[]; if(!ss.length){caja.innerHTML='Todavía no hay sitios dados de alta. '+(j.admin?'<a href="sitio.html?nuevo=1">Crear el primero</a>.':'Pide a Leo que los cree.');return;}
var h='<ul style="margin:0 0 .4em">';
ss.forEach(function(s){var ub=s.ubicacion&&s.ubicacion!=='por confirmar'?' — '+s.ubicacion:' — ubicación por confirmar';
h+='<li><a href="sitio.html?id='+encodeURIComponent(s.id)+'"><b>'+s.nombre+'</b></a>'+(s.cliente?' · '+s.cliente:'')+ub+(s.activo===false?' <em>(inactivo)</em>':'')+'</li>';});
h+='</ul>'+(j.admin?'<a class="md-button md-button--primary" href="sitio.html?nuevo=1">+ Nuevo sitio</a>':'<small>Para dar de alta un sitio, pide a Leo (administrador).</small>');
caja.innerHTML=h;
}).catch(function(){caja.innerHTML='No se pudo leer la lista de sitios (¿backend apagado?). Entra directo: <a href="sitio.html?id=icave">ICAVE</a> · <a href="sitio.html?id=lzc">LZC</a> · <a href="sitio.html?id=eit">EIT</a> · <a href="sitio.html?id=timsa">TIMSA</a>';});
})();
</script>
## Sitios actuales
| Sitio | Puerto | Operador | Página |
|---|---|---|---|
| **ICAVE** | Veracruz | Hutchison Ports | [Abrir](sitio.html?id=icave) |
| **LZC** | Lázaro Cárdenas | Hutchison Ports | [Abrir](sitio.html?id=lzc) |
| **EIT** | Ensenada | Hutchison Ports | [Abrir](sitio.html?id=eit) |
| **TIMSA** | Manzanillo | Hutchison Ports | [Abrir](sitio.html?id=timsa) |
En notas antiguas Lázaro Cárdenas aparece como «LCT»; el nombre oficial en el sitio es **LZC**.
## Cómo se relaciona con las actas
- La **acta de instalación en campo** ([abrir](../formularios/instalacion.html)) registra sitio, unidad, vehículo, antena, alimentación, RTK FIX y fotos. Es la que da de alta la unidad en el sitio.
- Las actas de **envío**, **recepción** y **diagnóstico** llevan un campo *sitio* desde oct-2026, para que el expediente muestre dónde estaba la unidad en cada momento.
- Procedimiento de instalación: [Checklist de puesta en marcha](../procedimientos/v5-checklist-puesta-en-marcha.md). Procedimiento de falla: [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md).
## Cómo se usa
1. Abre el sitio para ver **qué unidades están ahí**, en qué máquina y desde cuándo. Toca una serie para ir a su expediente.
2. Las unidades aparecen solas cuando un acta trae el campo **Sitio**: [instalación](../formularios/instalacion.html), [envío](../formularios/envio.html), [recepción](../formularios/recepcion.html), [diagnóstico](../formularios/diagnostico.html) y [daño](../formularios/dano.html). Elige siempre el sitio en el formulario.
3. Las actas anteriores al 13-oct no traen sitio: se buscan por el nombre del puerto y se marcan «por texto». Si una falta, no se corrige el acta; se registra la siguiente con el sitio correcto.
4. **Contactos y ubicación** los edita el administrador desde la página del sitio («Editar sitio»). Los datos «por confirmar» son los que faltan.
## Dar de alta un sitio (administrador)
1. En la página de cualquier sitio, elige **+ Nuevo sitio…** en el selector, o entra a [`sitio.html?nuevo=1`](sitio.html?nuevo=1).
2. Id corto en minúsculas (`icave`, `lzc`), nombre como lo dice el cliente, cliente, ubicación.
3. **Alias**: los nombres con los que ese sitio aparece en actas viejas (por ejemplo `LCT` para LZC), para que la búsqueda por texto las encuentre.
4. Contactos: nombre, rol, teléfono, correo. Guarda. El sitio aparece de inmediato en los formularios.
Un sitio no se borra: se marca **inactivo** y deja de salir en los formularios, pero su historial sigue visible.
## Relacionados
- [Acta de instalación en campo](../formularios/instalacion.html) — una por unidad y vehículo.
- [Tablero de campo](../diagnostico/tablero-campo.html) — diagnósticos por unidad.
- [Expediente de una unidad](../unidad.html) — producción → envío → instalación → campo.
- [Pendientes de campo](../tableros/kanban.html?pista=campo)

204
docs/sitios/sitio.html Normal file
View File

@ -0,0 +1,204 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sitio — HBOX</title>
<link rel="stylesheet" href="../tableros/tablero.css">
<style>
.ficha{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:14px}
.ficha .c{background:var(--tarjeta);border:1px solid var(--linea);border-radius:10px;padding:10px 12px}
.ficha .l{font-size:11px;color:var(--tinta2);text-transform:uppercase;letter-spacing:.05em}
.ficha .v{font-weight:600;font-size:15px}
.contacto{padding:6px 0;border-top:1px dashed var(--linea);font-size:14px}
.contacto:first-of-type{border-top:0}
.contacto .r{color:var(--tinta2);font-size:12.5px}
.linea{position:relative;padding-left:18px;border-left:2px solid var(--linea);margin-left:6px}
.ev{position:relative;padding:9px 0 9px 4px;border-bottom:1px solid var(--linea);font-size:14px}
.ev:last-child{border-bottom:0}
.ev::before{content:"";position:absolute;left:-25px;top:15px;width:11px;height:11px;border-radius:50%;background:var(--pend);border:2px solid var(--papel)}
.ev.instalacion-campo::before{background:var(--ok)} .ev.diagnostico-campo::before{background:var(--naranja)}
.ev.dano::before{background:var(--falla)} .ev.envio::before,.ev.recepcion::before{background:var(--info)}
.ev .f{color:var(--tinta2);font-size:12.5px}
.sitio-sel{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.contactos-ed .fila{display:grid;grid-template-columns:1.2fr 1fr 1fr 1.2fr auto;gap:6px;margin-bottom:6px}
@media(max-width:700px){.contactos-ed .fila{grid-template-columns:1fr 1fr}}
.contactos-ed .fila button{padding:6px 9px;font-size:13px}
.por-texto{font-size:11px;color:var(--tinta2);border:1px dashed var(--linea);border-radius:99px;padding:0 7px}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1 id="titulo">Sitio</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="./">Sitios</a>
<a href="../formularios/instalacion.html">Nueva instalación</a>
<a href="../formularios/diagnostico.html">Diagnóstico</a>
<a href="../tableros/kanban.html?pista=campo">Pendientes de campo</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Esta página junta todo lo que pasó en un sitio: unidades instaladas, envíos, recepciones, diagnósticos y daños. Sale de las actas; aquí no se captura nada de campo.</li>
<li>Para que una unidad aparezca, el acta debe traer el <b>sitio</b> elegido en el formulario (instalación, envío, recepción, diagnóstico, daño). Las actas viejas sin sitio se buscan por el nombre del puerto y se marcan «por texto».</li>
<li>Toca una serie para abrir su expediente completo. Rojo = unidad con marca de daño o baja.</li>
<li>Contactos y ubicación los edita solo el administrador (botón «Editar sitio»). Si falta un dato, díselo a Leo.</li>
<li>Para dar de alta otro sitio: <a href="./">lista de sitios</a> → «Nuevo sitio» (administrador).</li>
</ol>
</details>
<div class="sitio-sel"><label style="margin:0">Sitio</label><select id="sel"></select><button id="bEditar" class="sec" style="display:none">Editar sitio</button></div>
<div class="msg" id="aviso"></div>
<div id="cont"></div>
</div>
<dialog id="dlg"></dialog>
<script>
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const $ = id => document.getElementById(id);
const NOMBRE = { 'instalacion-campo': 'Instalación', 'diagnostico-campo': 'Diagnóstico en campo', 'envio': '📦 Envío', 'recepcion': '📦 Recepción', 'dano': '⚡ Daño o incidente' };
let SITIOS = [], ADMIN = false, ACTUAL = null, DATOS = null;
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
async function api(url, opts) { const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {})); let j = null; try { j = await r.json(); } catch (e) {} if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; }
const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
function campo(l, i) { const d = el('div'); d.append(el('label', { text: l }), i); return d; }
function render(j) {
const s = j.sitio, cont = $('cont'); cont.innerHTML = '';
$('titulo').textContent = s.nombre + (s.cliente ? ' — ' + s.cliente : '');
$('meta').textContent = (s.ubicacion || 'ubicación por confirmar') + ' · ' + j.total_unidades + ' unidad(es) · ' + j.total_actas + ' acta(s)' + (s.activo === false ? ' · INACTIVO' : '');
document.title = s.nombre + ' — sitio HBOX';
const ficha = el('div', { class: 'ficha' });
const c = (l, v) => ficha.append(el('div', { class: 'c' }, el('div', { class: 'l', text: l }), el('div', { class: 'v', html: v })));
c('Cliente', esc(s.cliente || '—')); c('Ubicación', esc(s.ubicacion || 'por confirmar'));
c('Unidades instaladas', String(j.unidades.filter(u => u.instalada).length) + ' <span style="font-weight:400;color:var(--tinta2);font-size:12.5px">con acta de instalación OK</span>');
c('Con daño o baja', '<span style="color:' + (j.danos.length ? 'var(--falla)' : 'inherit') + '">' + j.danos.length + '</span>');
cont.append(ficha);
if (s.notas) cont.append(el('p', { class: 'sub', style: 'margin:-6px 0 12px', text: s.notas }));
// contactos
const b0 = el('section', { class: 'bloque' });
b0.append(el('h2', { text: 'Contactos' }));
if (!(s.contactos || []).length) b0.append(el('div', { class: 'vacio', text: 'Sin contactos registrados. Pide a Leo que los agregue.' }));
(s.contactos || []).forEach(k => b0.append(el('div', { class: 'contacto', html: '<b>' + esc(k.nombre) + '</b>' + (k.rol ? ' <span class="r">· ' + esc(k.rol) + '</span>' : '') + (k.telefono ? ' · <a href="tel:' + esc(k.telefono) + '">' + esc(k.telefono) + '</a>' : '') + (k.correo ? ' · <a href="mailto:' + esc(k.correo) + '">' + esc(k.correo) + '</a>' : '') })));
cont.append(b0);
// unidades
const b1 = el('section', { class: 'bloque' });
b1.append(el('h2', { text: 'Unidades en este sitio' }), el('p', { class: 'sub', text: 'Toda serie con alguna acta que apunte a este sitio. «Instalada» = fecha de la última acta de instalación aprobada.' }));
if (!j.unidades.length) b1.append(el('div', { class: 'vacio', text: 'Todavía no hay unidades con actas en este sitio. Llena un acta de instalación o de envío con este sitio.' }));
else {
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Unidad</th><th>Máquina</th><th>Instalada</th><th>Última acta</th><th>Actas</th><th>Estado</th></tr>';
j.unidades.forEach(u => {
const tr = el('tr');
tr.innerHTML = '<td><a href="../unidad.html?serie=' + encodeURIComponent(u.serie) + '"><b>' + esc(u.serie) + '</b></a>' + (u.coincidencia === 'texto' ? ' <span class="por-texto" title="el acta no trae el sitio; coincide por el nombre del puerto">por texto</span>' : '') + '</td>'
+ '<td>' + esc(u.maquina || '—') + '</td><td>' + (u.instalada ? '<span class="pill ok">' + esc(u.instalada) + '</span>' : '<span class="pill pend">sin acta</span>') + '</td>'
+ '<td>' + esc(NOMBRE[(u.ultima || {}).tipo] || (u.ultima || {}).tipo || '—') + (u.ultima && u.ultima.fecha ? ' · ' + esc(u.ultima.fecha) : '') + (u.ultima && u.ultima.accion ? '<div style="font-size:12px;color:var(--tinta2)">' + esc(u.ultima.accion) + '</div>' : '') + '</td>'
+ '<td>' + u.actas + '</td><td>' + (u.marca ? '<span class="pill ' + (u.marca === 'BAJA' ? 'falla' : 'aviso') + '">' + (u.marca === 'BAJA' ? 'BAJA' : 'DAÑADA') + '</span>' : '<span class="pill ok">sin marca</span>') + '</td>';
t.append(tr);
});
b1.append(el('div', { class: 'tblwrap' }, t));
}
cont.append(b1);
// daños abiertos
if (j.danos.length) {
const b2 = el('section', { class: 'bloque' });
b2.append(el('h2', { text: 'Daños abiertos' }), el('p', { class: 'sub', text: 'Unidades de este sitio con marca vigente. Se levanta con un acta de daño «Reparada».' }));
j.danos.forEach(u => { const a = u.marca_acta || {}; b2.append(el('div', { class: 'ev dano', style: 'padding-left:0', html: '<b><a href="../unidad.html?serie=' + encodeURIComponent(u.serie) + '">' + esc(u.serie) + '</a></b> · ' + esc(a.clasificacion || '') + (a.resumen ? ' — ' + esc(a.resumen) : '') + '<div class="f">' + esc(a.fecha || fmt(a.recibido)) + ' · ' + esc(a.tecnico || '') + (a.accion ? ' · ' + esc(a.accion) : '') + '</div>' })); });
cont.append(b2);
}
// actas
const b3 = el('section', { class: 'bloque' });
b3.append(el('h2', { text: 'Actas de instalación y campo' }), el('p', { class: 'sub', text: 'De la más reciente a la más vieja: instalaciones, diagnósticos, envíos, recepciones y daños.' }));
if (!j.actas.length) b3.append(el('div', { class: 'vacio', text: 'Sin actas.' }));
else {
const lt = el('div', { class: 'linea' });
j.actas.forEach(a => {
const r = String(a.resultado || '');
lt.append(el('div', { class: 'ev ' + a.tipo, html: '<b>' + esc(NOMBRE[a.tipo] || a.tipo) + '</b> — <a href="../unidad.html?serie=' + encodeURIComponent(a.serie) + '">' + esc(a.serie) + '</a>'
+ (a.maquina ? ' · ' + esc(a.maquina) : '') + ' <span class="pill ' + (r === 'FALLA' ? 'falla' : r ? 'ok' : 'pend') + '">' + esc(r || '—') + '</span>'
+ (a.coincidencia === 'texto' ? ' <span class="por-texto">por texto</span>' : '')
+ '<div class="f">' + esc(a.fecha || '') + ' · recibida ' + esc(fmt(a.recibido)) + ' · ' + esc(a.tecnico || '—') + (a.fotos ? ' · ' + a.fotos + ' foto(s)' : '') + '</div>'
+ (a.accion ? '<div>→ ' + esc(a.accion) + '</div>' : '') + (a.motivo ? '<div>Motivo: ' + esc(a.motivo) + '</div>' : '') + (a.resumen ? '<div>' + esc(a.resumen) + '</div>' : '') }));
});
b3.append(lt);
}
cont.append(b3);
}
async function cargar(id) {
$('cont').innerHTML = '<p class="vacio">Cargando…</p>';
try { DATOS = await api('/api/sitio/unidades?id=' + encodeURIComponent(id)); ACTUAL = DATOS.sitio; render(DATOS); $('bEditar').style.display = ADMIN ? '' : 'none'; }
catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer el sitio: ' + e.message })); $('meta').textContent = 'Error'; }
}
// ---------- alta / edición (admin) ----------
function formSitio(c, s) {
s = s || {}; const f = {};
f.id = el('input', { type: 'text', maxlength: '30', value: s.id || '', placeholder: 'icave (minúsculas, sin espacios)' }); if (s.id) f.id.disabled = true;
f.nombre = el('input', { type: 'text', maxlength: '80', value: s.nombre || '', placeholder: 'ICAVE' });
f.cliente = el('input', { type: 'text', maxlength: '120', value: s.cliente || '', placeholder: 'Hutchison Ports' });
f.ubicacion = el('input', { type: 'text', maxlength: '200', value: s.ubicacion || '', placeholder: 'Veracruz, Ver.' });
f.alias = el('input', { type: 'text', value: (s.alias || []).join(', '), placeholder: 'LCT, Lázaro Cárdenas (nombres con los que aparece en actas viejas)' });
f.notas = el('textarea', { rows: '2' }); f.notas.value = s.notas || '';
f.activo = el('select'); [['true', 'Activo'], ['false', 'Inactivo (ya no se muestra en los formularios)']].forEach(([v, t]) => f.activo.append(el('option', { value: v, text: t }))); f.activo.value = s.activo === false ? 'false' : 'true';
const g = el('div', { class: 'dos-col' }); g.append(campo('Id (slug)', f.id), campo('Nombre', f.nombre), campo('Cliente', f.cliente), campo('Ubicación', f.ubicacion)); c.append(g);
c.append(campo('Alias (separados por coma)', f.alias));
const ced = el('div', { class: 'contactos-ed' });
function fila(k) { const r = el('div', { class: 'fila' }); r.append(el('input', { type: 'text', value: k.nombre || '', placeholder: 'Nombre' }), el('input', { type: 'text', value: k.rol || '', placeholder: 'Rol' }), el('input', { type: 'text', value: k.telefono || '', placeholder: 'Teléfono' }), el('input', { type: 'text', value: k.correo || '', placeholder: 'Correo' })); const q = el('button', { type: 'button', class: 'sec', text: '✕' }); q.addEventListener('click', () => r.remove()); r.append(q); ced.append(r); }
(s.contactos || []).forEach(fila);
const add = el('button', { type: 'button', class: 'sec', text: '+ contacto', style: 'padding:6px 10px;font-size:13px' }); add.addEventListener('click', () => fila({}));
c.append(campo('Contactos', ced), add, campo('Notas', f.notas), campo('Estado', f.activo));
return () => ({ id: f.id.value.trim().toLowerCase(), nombre: f.nombre.value.trim(), cliente: f.cliente.value.trim(), ubicacion: f.ubicacion.value.trim(),
alias: f.alias.value.split(',').map(x => x.trim()).filter(Boolean), notas: f.notas.value.trim(), activo: f.activo.value === 'true',
contactos: [...ced.querySelectorAll('.fila')].map(r => ({ nombre: r.children[0].value.trim(), rol: r.children[1].value.trim(), telefono: r.children[2].value.trim(), correo: r.children[3].value.trim() })).filter(k => k.nombre) });
}
function dialogo(titulo, s) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo })); const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const leer = formSitio(cuerpo, s);
const m = el('div', { class: 'msg' }); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
const guardar = el('button', { type: 'button', text: s ? 'Guardar' : 'Crear sitio' });
guardar.addEventListener('click', async () => {
const d = leer();
if (!d.nombre) { m.className = 'msg err'; m.textContent = 'Escribe el nombre.'; return; }
guardar.disabled = true;
try {
if (s) { delete d.id; await api('/api/sitio?id=' + encodeURIComponent(s.id), json('PATCH', d)); dlg.close(); mensaje('Sitio guardado.', true); await cargarLista(); cargar(s.id); }
else { const j = await api('/api/sitios', json('POST', d)); dlg.close(); mensaje('Sitio ' + j.sitio.nombre + ' creado.', true); await cargarLista(); history.replaceState(null, '', '?id=' + encodeURIComponent(j.sitio.id)); $('sel').value = j.sitio.id; cargar(j.sitio.id); }
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
guardar.disabled = false;
});
pie.append(cancelar, guardar);
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
$('bEditar').addEventListener('click', () => ACTUAL && dialogo('Editar sitio ' + ACTUAL.nombre, ACTUAL));
async function cargarLista() {
const j = await api('/api/sitios?todos=1'); SITIOS = j.sitios || []; ADMIN = !!j.admin;
const sel = $('sel'); sel.innerHTML = ''; sel.append(el('option', { value: '', text: '— elegir sitio —' }));
SITIOS.forEach(s => sel.append(el('option', { value: s.id, text: s.nombre + (s.activo === false ? ' (inactivo)' : '') })));
if (ADMIN) sel.append(el('option', { value: '__nuevo__', text: '+ Nuevo sitio…' }));
}
$('sel').addEventListener('change', () => { const v = $('sel').value; if (v === '__nuevo__') { $('sel').value = ACTUAL ? ACTUAL.id : ''; dialogo('Nuevo sitio', null); } else if (v) { history.replaceState(null, '', '?id=' + encodeURIComponent(v)); cargar(v); } });
(async () => {
try { await cargarLista(); } catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer la lista de sitios: ' + e.message })); return; }
const q = new URLSearchParams(location.search);
if (q.get('nuevo') === '1') { if (ADMIN) dialogo('Nuevo sitio', null); else mensaje('Solo el administrador da de alta sitios. Pide a Leo que lo cree.', false); }
const id = q.get('id') || (SITIOS[0] || {}).id;
if (id) { $('sel').value = id; cargar(id); } else { $('meta').textContent = 'No hay sitios dados de alta.'; $('cont').innerHTML = '<p class="vacio">Pide a Leo que cree el primer sitio.</p>'; }
})();
</script>
</body>
</html>

35
docs/tableros/index.md Normal file
View File

@ -0,0 +1,35 @@
---
title: "Tableros"
---
# Tableros
Las páginas que abres para saber **dónde está el proyecto hoy**: qué tareas hay y de quién son, en qué estado va cada versión de tarjeta, cuántas unidades llevamos contra el plan de 570 y qué pasó con cada serie. Se alimentan solas de las actas y de los registros del servidor (`/api/…`); no hay nada que copiar a mano.
## ¿Qué quiero ver?
| Quiero… | Tablero | Qué muestra |
|---|---|---|
| Las **tareas** del equipo y quién las tiene | [Kanban](kanban.html) | Columnas *Por hacer · En curso · Bloqueado · Hecho*; pistas Ingeniería / Producción / Mecánica / Compras / Campo; responsable, fecha límite, ligas a documentos y actas; historial de movimientos |
| **Dónde está el hardware** (V5.5 → V5.6 → V5.7 → v6) | [Ingeniería](ingenieria.html) | Una tarjeta por versión con su estado, pendientes con responsable y fecha, últimas decisiones y filosofía de diseño; las tareas de la pista Ingeniería |
| **Cuántas unidades** llevamos y cuántas faltan | [Producción](produccion.html) | Plan 113 V5.6 · 327 V5.7 · 130 v6 contra las unidades reales por etapa (tarjeta → módulos → ensamble → envío → instalación); embudo por paso; bloqueos (caja plástica / cable Ethernet) |
| Qué **tarjeta, módulo o unidad** pasó cada acta | [Estado de actas](../tablero.html) | Series V5-NN, ARD-NNN, RTK-NNNN, V4-NN; parejas integradas; unidades ensambladas. Pendiente = liga al acta |
| Las **visitas de diagnóstico** en los puertos | [Tablero de campo](../diagnostico/tablero-campo.html) | Todas las actas de diagnóstico en campo, con fotos |
| **Todo sobre una serie** | [Expediente de una unidad](../unidad.html) | Cada acta, foto y log de una unidad en una línea de tiempo, de la tarjeta al sitio |
## Cómo se usan
1. Las tareas se mueven arrastrando entre columnas. Cualquier usuario autenticado crea y mueve; **borrar y reabrir** es de administradores (`ACTAS_ADMINS`).
2. Cada tarjeta del Kanban lleva **pista**, **responsable** (leo, michelle, aldair, jesús, alan, daniel, claude), **fecha límite** y ligas. Si una tarea depende de otra, escríbelo en las notas y déjala en *Bloqueado*.
3. Los tableros de Ingeniería y Producción son **vistas** del mismo Kanban filtradas por pista, más sus bloques fijos (versiones, cantidades).
4. Las cantidades del plan de producción (versión → objetivo → fecha) las edita un administrador en el propio tablero; los conteos reales salen de las actas.
5. El Kanban sustituye al *Task Tracker* de Notion, que queda como histórico (sus tareas se importaron una vez).
## Relacionados
- [Detalle por tarjeta y módulo](../tablero.html) — qué serie pasó cada acta del lote actual.
- [Inventario](../inventario/index.md) — stock, mínimos y movimientos.
- [Sitios](../sitios/index.md) — unidades instaladas por puerto.
- [Expediente de una unidad](../unidad.html) — todo lo registrado para una serie.
- [Historia del proyecto](../historia/index.md) — jun → oct 2026 con ligas.

View File

@ -0,0 +1,118 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de ingeniería — EinarBoard</title>
<link rel="stylesheet" href="tablero.css">
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de ingeniería — ¿dónde está el hardware?</h1><div class="meta" id="meta">V5.5 · V5.6 · V5.7 · v6 — estado al 13-oct-2026</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="kanban.html">Pendientes</a>
<a class="activo" href="ingenieria.html">Ingeniería</a>
<a href="produccion.html">Producción</a>
<a href="../hardware/">Tarjetas y hardware</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Arriba, una tarjeta por versión de EinarBoard: estado, siguiente paso, responsable y sus documentos. Es texto fijo: cuando cambie una decisión, pide a Leo o a Claude que lo actualice aquí.</li>
<li>En medio, los <b>pendientes de la pista Ingeniería</b>: son los mismos del <a href="kanban.html">tablero de pendientes</a>, filtrados. Puedes moverlos desde aquí.</li>
<li>Abajo, las últimas decisiones con fecha y la filosofía de diseño. Antes de proponer un cambio, revisa que no contradiga una decisión ya tomada.</li>
<li>Para una tarjeta nueva o un cambio de pedido, primero el pendiente, luego el trabajo: así queda el rastro de quién y cuándo.</li>
</ol>
</details>
<div class="versiones" id="versiones"></div>
<section class="bloque">
<h2>Pendientes de ingeniería</h2>
<p class="sub">Pista Ingeniería del tablero. Arrastra o usa «Mover a…»; el historial anota quién lo hizo. <a href="kanban.html">Ver todas las pistas →</a></p>
<div id="kb"></div>
</section>
<section class="bloque">
<h2>Últimas decisiones</h2>
<p class="sub">Lo decidido manda hasta que otra decisión con fecha posterior lo cambie. Fuente: notas del proyecto; detalle en la sección de hardware.</p>
<ul class="decisiones">
<li><b>13-oct</b> — Pedido de <b>570 HBOX</b>: 113 × V5.6 (con SD-15B), 327 × V5.7, 130 × v6; todos los reemplazos futuros en v6. Paquetes de fabricación V5.6 / V5.7 / v6 a <b>Aldair</b> (fabricación/ensamble, rol por confirmar). Caja plástica HBOX: el cable Ethernet no cabe — cambio por definir (pista Mecánica).</li>
<li><b>13-oct</b> — Auditoría del layout V5.6 de Michelle (d71098d): <b>NO LISTA</b> — /BAT_PLUS por In1 (bloqueante), MAIN5V/SYS5V/SW3 por capas internas; las 18 piezas movidas se quedan (opción A). Esquemático V5.7 aplicado con ERC 0 (F2 SMD 3 A, Q10/D10 reusados, fuera U2 y P2, JP1 puenteado). Reparto: <b>Leo</b> coloca la fuente en V5.7; <b>Michelle</b> corrige V5.6 y después toma V5.7. <a href="../hardware/v56-auditoria-layout-13oct/">Auditoría</a> · <a href="../hardware/v56-layout-pasada-2/">Pasada 2</a></li>
<li><b>12-oct</b> — <b>V5.7 = V5.6 + fuente conmutada 24 V → 5 V a bordo</b> (U25 TPS54360B, bloque de la v6), sin SD-15B externa. Proyecto KiCad separado <code>hbox-watchdog-v57</code>; V5.6 y V5.7 conviven. <a href="../hardware/v57-eco-fuente/">ECO V5.7</a></li>
<li><b>09-oct</b> — <b>P12 (sonda NTC) eliminada</b> de V5.6 con R42/C24: redundante y se necesita el espacio. Sin lectura de temperatura de celda en batería (aceptado). Aplica a P7/P8 de la v6.</li>
<li><b>08-oct</b> — Acelerómetro <b>U26 SC7A20TR</b> (I²C 0x18, INT1 → PB9) añadido a V5.6. <b>Regla DFM LTC4040 (U6)</b>: pasta del EP ≤ 50 %, vías tapadas (epoxy filled &amp; capped) o fuera de las ventanas de pasta, X-ray/AOI en U6, lupa en cada tarjeta antes de energizar. Vale para toda revisión, incluida la v6.</li>
<li><b>05-oct</b> — Revisión del bus I²C V5.6 / v6 (direcciones, pull-ups, TMP102 U18 en 0x49).</li>
<li><b>30-sep → 1-oct</b> — V5.6: revisión de modo batería y layout (U4 junto a L6, DzG1 corregido); estado hacia producción.</li>
<li><b>28-sep</b> — v6 auditada con las lecciones de V5.5 y plan de corrección. <b>«No habrá v6.1»</b>: lo que falte se corrige antes de pedir. <a href="../hardware/v6-plan-correccion/">Plan de corrección</a> · <a href="../hardware/v6-proximos-pasos/">Próximos pasos</a></li>
<li><b>27-sep</b> — Requisito V5.6: ciclo de 10 min en batería y mejor manejo térmico. ECO V5.6: LFP de alta temperatura, JP2 2-3, R55 5.6 k → flotación 3.50 V, RS1 10 mΩ, TMP102 U18 0x49. Regla de campo V5.5/V4 con 0.6.0: respaldo continuo en batería, nunca ciclos de etapa en batería. <a href="../hardware/v56-eco/">ECO V5.6</a></li>
<li><b>26-sep</b> — Decisión V5.6: los 500 ArduSimple ya pedidos se quedan (arquitectura con zócalos); nada de rework en tarjetas existentes; buck del módem AP64350 desde SYS5V, MODEM_EN/RST/LED al MCU, lista EMC. Hallazgo: el módem no arrancaba en batería porque el MP1584 (4.5 V mín.) caía en UVLO sobre el riel de 4.6–4.79 V del boost.</li>
<li><b>19/21-sep</b> — Un solo firmware por tarjeta; la versión del firmware no indica la tarjeta: V3 → <code>blackpillrtkcode_v4</code>, V4 → <code>hbox-tarjetaV4_x.y.z.bin</code>, V5 → <code>hbox-v5-bringup_x.y.z.bin</code>. V5.6 necesita 0.7.0 (<code>board_v56.h</code>).</li>
<li><b>22-ago</b> — V5.5 ordenada, 15 pzas (JLC). Auditoría EMC: /SW_BAT 0.3 mm y /BAT_PLUS 0.3–0.5 mm = críticos para la siguiente corrida (origen de la regla «nada de potencia por capas internas»).</li>
</ul>
</section>
<section class="bloque">
<h2>Filosofía de diseño</h2>
<p class="sub">Cinco reglas que explican casi todas las decisiones anteriores. Detalle y documentos en <a href="../hardware/">Tarjetas y hardware</a>.</p>
<ul class="filosofia">
<li><b>Primero la seguridad en campo.</b> La unidad vive en un puerto a más de 45 °C y en batería: respaldo continuo, nunca ciclos de etapa en batería; se mide antes de energizar; una tarjeta con marca de daño no entra a un ensamble.</li>
<li><b>Una etapa probada vale más que una nueva.</b> Los zócalos para ArduSimple y módem se quedan (500 ya pedidos); nada de rework en tarjetas existentes; la fuente a bordo de V5.7 reutiliza el bloque ya auditado de la v6.</li>
<li><b>Redundancia solo cuando un modo de falla la justifica.</b> Por eso salió P12 (NTC redundante, estorbaba) y entró el acelerómetro U26 (detecta golpes y movimiento, que sí nos han fallado).</li>
<li><b>Las reglas de fabricación son órdenes permanentes.</b> LTC4040: pasta ≤ 50 %, vías tapadas, X-ray; ninguna red de potencia por capas internas (lección de V5.5); lupa en U6 antes de energizar. Se aplican a toda revisión, sin excepciones por prisa.</li>
<li><b>Cambios con prueba y rastro.</b> Esquemático por etiquetas y ediciones con script verificadas contra la netlist (ERC 0, paridad 100 %); git en dos pasos (submódulo KiCad y luego Drogheda); una tarjeta por proyecto KiCad (V5.7 separado); «no habrá v6.1»: se corrige antes de pedir, no después.</li>
</ul>
</section>
</div>
<script src="kanban.js"></script>
<script>
const VERSIONES = [
{ v: 'V5.5', cls: 'v55', badge: 'badge-v55', estado: 'En campo — 15 pzas (lote 22-ago), fw 0.6.x',
siguiente: 'Mantener la flota en 0.6.x con respaldo continuo en batería. Sin rework: lo aprendido va a V5.6. Inspeccionar U6 con lupa en cada tarjeta.',
quien: 'Jesús (pruebas) · Alan y Daniel (campo)',
docs: [['Manual V5', '../manuales/manual-tarjeta-V5/'], ['Lecciones V5.5 → v6', '../hardware/v6-revision-lecciones-v55/'], ['Acta de tarjeta V5', '../formularios/v5.html']] },
{ v: 'V5.6', cls: 'v56', badge: 'badge-v56', estado: 'Layout en pasada 2 — NO LISTA para pedir (BAT_PLUS por In1)',
siguiente: 'Michelle corrige la pasada 2 → Claude revisa pad a pad y genera Gerber/BOM/CPL → paquete a Aldair. Pedido: 113 (con SD-15B). Firmware 0.7.0.',
quien: 'Michelle (layout) · Claude (auditoría) · Leo (pedido)',
docs: [['▶ Pasada 2 — correcciones', '../hardware/v56-layout-pasada-2/'], ['Auditoría 13-oct (inglés)', '../hardware/v56-auditoria-layout-13oct/'], ['ECO V5.6', '../hardware/v56-eco/'], ['Hoja de datos', '../hardware/v56-datasheet/'], ['Pinout', '../hardware/v56-pinout/']] },
{ v: 'V5.7', cls: 'v57', badge: 'badge-v57', estado: 'Esquemático aplicado (ERC 0); PCB retomado de V5.6 d71098d',
siguiente: 'Leo coloca y rutea la fuente a bordo (U25, F2) en la zona acordada → Michelle replica las correcciones de V5.6 y cierra → auditoría → pedido 327.',
quien: 'Leo (fuente) · Michelle (cierre) · Claude (auditoría)',
docs: [['▶ Guía de layout V5.7', '../hardware/v57-layout-guia/'], ['ECO V5.7 — fuente a bordo', '../hardware/v57-eco-fuente/']] },
{ v: 'v6', cls: 'v6', badge: 'badge-v6', estado: 'Auditada (28-sep) con plan de corrección — no pedir todavía',
siguiente: 'Cierre para pedido según «Próximos pasos»: aplicar a P7/P8 la decisión de P12, regla DFM U6, plan de cobre EMC. Pedido 130 + todos los reemplazos. No habrá v6.1.',
quien: 'Leo · Claude',
docs: [['▶ Próximos pasos para ordenar', '../hardware/v6-proximos-pasos/'], ['Plan de corrección', '../hardware/v6-plan-correccion/'], ['Manual v6', '../manuales/manual-tarjeta-v6/'], ['Plan de cobre', '../hardware/EinarBoard-v6_Plan-de-Cobre_ES.html']] },
];
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const cont = document.getElementById('versiones');
const contadores = {};
VERSIONES.forEach(x => {
const c = el('div', { class: 'ver ' + x.cls });
c.append(el('h3', null, el('span', { text: 'EinarBoard ' + x.v }), el('span', { class: x.badge, text: x.v })));
c.append(el('div', { class: 'est', text: x.estado }));
c.append(el('div', { class: 'sig', html: '<b>Siguiente:</b> ' + x.siguiente }));
c.append(el('div', { class: 'quien', text: '👤 ' + x.quien }));
const d = el('div', { class: 'docs' }); x.docs.forEach(([t, h]) => d.append(el('a', { href: h, text: t }))); c.append(d);
const cnt = el('div', { class: 'cnt', text: '…' }); contadores[x.v] = cnt; c.append(cnt);
cont.append(c);
});
Kanban.montar(document.getElementById('kb'), {
pista: 'ingenieria', filtros: true,
onCargado: tareas => {
VERSIONES.forEach(x => {
const mios = tareas.filter(t => t.version_tarjeta === x.v && t.estado !== 'hecho');
const bloq = mios.filter(t => t.estado === 'bloqueado').length, venc = mios.filter(t => t.vencida).length;
const a = el('a', { href: 'kanban.html?pista=ingenieria', text: mios.length + ' pendiente(s) abierto(s)' + (bloq ? ' · ' + bloq + ' bloqueado(s)' : '') + (venc ? ' · ' + venc + ' vencido(s)' : '') });
contadores[x.v].innerHTML = ''; contadores[x.v].append(a);
});
}
});
</script>
</body>
</html>

53
docs/tableros/kanban.html Normal file
View File

@ -0,0 +1,53 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de pendientes (kanban) — HBOX</title>
<link rel="stylesheet" href="tablero.css">
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de pendientes</h1><div class="meta" id="meta">Ingeniería · Producción · Mecánica · Compras · Campo</div></div>
<nav>
<a href="../">← Documentación</a>
<a class="activo" href="kanban.html">Pendientes</a>
<a href="ingenieria.html">Ingeniería</a>
<a href="produccion.html">Producción</a>
<a href="../inventario/inventario.html">Inventario</a>
<a href="../sitios/">Sitios</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Cada tarjeta del tablero es un <b>pendiente</b> (no confundir con la tarjeta EinarBoard). Léelo antes de empezar el trabajo.</li>
<li>Arrastra el pendiente a la columna que corresponda; en el teléfono usa <b>Mover a…</b>. El tablero anota quién lo movió y cuándo.</li>
<li>Toca el título para ver el detalle: descripción, enlaces, de qué depende y su historial. Ahí mismo edita y guarda.</li>
<li><b>Nuevo pendiente</b>: título corto, pista, responsable y fecha límite. Si depende de otro, márcalo en «Bloqueado por».</li>
<li>Columna <b>Bloqueado</b>: solo si no puedes avanzar. Escribe en la descripción qué falta y quién lo destraba.</li>
<li>Al terminar, muévelo a <b>Hecho</b>. No lo borres: solo el administrador borra, y el historial sirve de bitácora.</li>
<li>Rojo = vencido. Filtra por pista, responsable o versión de tarjeta; los filtros no cambian nada en el servidor.</li>
</ol>
</details>
<div id="kb"></div>
</div>
<script src="kanban.js"></script>
<script>
Kanban.montar(document.getElementById('kb'), {
filtros: true,
onCargado: (tareas, meta) => {
const abiertas = tareas.filter(t => t.estado !== 'hecho').length;
const venc = tareas.filter(t => t.vencida).length;
const bloq = tareas.filter(t => t.estado === 'bloqueado').length;
document.getElementById('meta').textContent = abiertas + ' abiertos · ' + bloq + ' bloqueados · ' + venc + ' vencidos'
+ ' · actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })
+ (meta.user ? ' · ' + meta.user + (meta.admin ? ' (admin)' : '') : '');
}
});
</script>
</body>
</html>

284
docs/tableros/kanban.js Normal file
View File

@ -0,0 +1,284 @@
/* Tablero kanban HBOX — módulo compartido (kanban.html, ingenieria.html, produccion.html).
Vocabulario: la tarjeta del tablero se llama "pendiente" en pantalla para no confundirla con la
tarjeta EinarBoard. Columnas fijas: Por hacer · En curso · Bloqueado · Hecho.
Uso: const kb = Kanban.montar(document.getElementById('kb'), {
pista: 'ingenieria', // opcional: fija la pista (oculta su filtro y la precarga en "Nuevo")
filtros: true, // barra de filtros (responsable / versión / buscar / pista)
compacto: false, // tarjetas más pequeñas (tableros que solo muestran un extracto)
onCargado: (tareas, meta) => {} // callback después de cada carga
});
kb.recargar();
Servidor: GET /api/tareas · POST /api/tareas · PATCH /api/tarea?id= · POST /api/tareas/orden ·
DELETE /api/tarea?id= (solo admin). Cada cambio guarda quién y cuándo en el historial. */
(function () {
const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo' };
const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']];
const VERSIONES = ['—', 'V5.5', 'V5.6', 'V5.7', 'v6'];
const RESPONSABLES = ['leo', 'michelle', 'aldair', 'jesús', 'alan', 'daniel', 'claude'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const hoyISO = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); };
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const fechaCorta = f => { if (!f) return ''; const [y, m, d] = f.split('-'); return d + '-' + ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'][+m - 1] + (y !== hoyISO().slice(0, 4) ? '-' + y.slice(2) : ''); };
async function api(url, opts) {
const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {}));
let j = null; try { j = await r.json(); } catch (e) {}
if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status));
return j;
}
const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
function montar(root, opts) {
opts = opts || {};
let tareas = [], admin = false, user = '', porId = {};
const filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes' };
root.classList.add('kanban-root');
// ---------- barra de filtros ----------
const barra = el('div', { class: 'kb-barra' });
const selPista = el('select', { title: 'Pista' }); selPista.append(el('option', { value: '', text: 'Todas las pistas' }));
Object.entries(PISTAS).forEach(([k, v]) => selPista.append(el('option', { value: k, text: v })));
const selResp = el('select', { title: 'Responsable' }); selResp.append(el('option', { value: '', text: 'Todos los responsables' }));
const selVer = el('select', { title: 'Versión de tarjeta' }); selVer.append(el('option', { value: '', text: 'Todas las versiones' }));
VERSIONES.filter(v => v !== '—').forEach(v => selVer.append(el('option', { value: v, text: v })));
const selHechas = el('select', { title: 'Hechas' });
[['recientes', 'Hechas: últimos 14 días'], ['todas', 'Hechas: todas'], ['ninguna', 'Hechas: ocultar']].forEach(([v, t]) => selHechas.append(el('option', { value: v, text: t })));
const buscar = el('input', { type: 'search', placeholder: 'Buscar…', style: 'min-width:140px;flex:1' });
const btnNuevo = el('button', { text: '+ Nuevo pendiente' });
const cnt = el('span', { class: 'cnt' });
if (opts.filtros !== false) {
if (!opts.pista) barra.append(selPista);
barra.append(selResp, selVer, selHechas, buscar, btnNuevo, cnt);
root.append(barra);
}
const aviso = el('div', { class: 'msg' }); root.append(aviso);
function mensaje(txt, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = txt; clearTimeout(aviso._t); aviso._t = setTimeout(() => { aviso.className = 'msg'; }, bien ? 3500 : 8000); }
// ---------- columnas ----------
const kb = el('div', { class: 'kb' + (opts.compacto ? ' compacto' : '') });
if (!('draggable' in document.createElement('div')) || matchMedia('(pointer:coarse)').matches) kb.classList.add('movil');
const cols = {};
ESTADOS.forEach(([k, nombre]) => {
const c = el('div', { class: 'col e-' + k, 'data-estado': k });
c.append(el('h3', null, el('span', { text: nombre }), el('span', { class: 'n', text: '0' })));
c.addEventListener('dragover', e => { e.preventDefault(); c.classList.add('sobre'); });
c.addEventListener('dragleave', () => c.classList.remove('sobre'));
c.addEventListener('drop', e => {
e.preventDefault(); c.classList.remove('sobre');
const id = e.dataTransfer.getData('text/plain'); if (!id) return;
// posición: antes de la primera tarjeta cuyo centro queda debajo del cursor
const cards = [...c.querySelectorAll('.card')].filter(x => x.dataset.id !== id);
let idx = cards.length;
for (let i = 0; i < cards.length; i++) { const r = cards[i].getBoundingClientRect(); if (e.clientY < r.top + r.height / 2) { idx = i; break; } }
mover(id, k, idx);
});
cols[k] = c; kb.append(c);
});
root.append(kb);
// ---------- filtrado y pintado ----------
function visibles() {
const q = filtro.buscar.trim().toLowerCase();
const corte = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
return tareas.filter(t => {
if (filtro.pista && t.pista !== filtro.pista) return false;
if (filtro.responsable && !String(t.responsable || '').toLowerCase().includes(filtro.responsable)) return false;
if (filtro.version && t.version_tarjeta !== filtro.version) return false;
if (t.estado === 'hecho') {
if (filtro.hechas === 'ninguna') return false;
if (filtro.hechas === 'recientes' && (t.modificado_en || '').slice(0, 10) < corte) return false;
}
if (q) { const txt = [t.titulo, t.descripcion, t.responsable, (t.etiquetas || []).join(' '), t.id].join(' ').toLowerCase(); if (!txt.includes(q)) return false; }
return true;
});
}
function bloqueosAbiertos(t) { return (t.bloqueada_por || []).map(id => porId[id]).filter(b => b && b.estado !== 'hecho'); }
function tarjeta(t) {
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : ''), draggable: 'true', 'data-id': t.id });
c.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', t.id); e.dataTransfer.effectAllowed = 'move'; c.classList.add('arrastrando'); });
c.addEventListener('dragend', () => c.classList.remove('arrastrando'));
const tit = el('div', { class: 'tit', text: t.titulo }); tit.addEventListener('click', () => abrir(t)); c.append(tit);
const meta = el('div', { class: 'meta' });
if (!opts.pista) meta.append(el('span', { class: 'chip p-' + t.pista, text: PISTAS[t.pista] || t.pista }));
if (t.version_tarjeta && t.version_tarjeta !== '—') meta.append(el('span', { class: BADGE[t.version_tarjeta] || 'chip', text: t.version_tarjeta }));
if (t.responsable) meta.append(el('span', { text: '👤 ' + t.responsable }));
if (t.fecha_limite) meta.append(el('span', { class: 'fecha' + (t.vencida ? ' mal' : ''), text: (t.vencida ? '⚠ venció ' : '📅 ') + fechaCorta(t.fecha_limite) }));
(t.etiquetas || []).slice(0, 3).forEach(x => meta.append(el('span', { class: 'chip', text: x })));
c.append(meta);
const bl = bloqueosAbiertos(t);
if (bl.length) c.append(el('div', { class: 'bloq', text: '⛓ espera: ' + bl.map(b => b.titulo.length > 38 ? b.titulo.slice(0, 37) + '…' : b.titulo).join(' · ') }));
if ((t.enlaces || []).length) {
const e = el('div', { class: 'enl' });
t.enlaces.slice(0, 3).forEach(x => e.append(el('a', { href: x.url, text: '🔗 ' + (x.titulo || x.url) })));
c.append(e);
}
// "Mover a…": teléfonos y quien no arrastra
const mv = el('div', { class: 'mover' });
const sel = el('select');
sel.append(el('option', { value: '', text: 'Mover a…' }));
ESTADOS.filter(([k]) => k !== t.estado).forEach(([k, n]) => sel.append(el('option', { value: k, text: n })));
sel.addEventListener('change', () => { if (sel.value) mover(t.id, sel.value, 0); });
mv.append(sel); c.append(mv);
return c;
}
function pintar() {
porId = {}; tareas.forEach(t => porId[t.id] = t);
const vis = visibles();
ESTADOS.forEach(([k]) => {
const c = cols[k]; c.querySelectorAll('.card').forEach(x => x.remove());
const lst = vis.filter(t => t.estado === k).sort((a, b) => (a.orden - b.orden) || (a.creado_en < b.creado_en ? -1 : 1));
c.querySelector('.n').textContent = lst.length;
lst.forEach(t => c.append(tarjeta(t)));
});
// responsables presentes en los datos (más los conocidos)
selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove());
const set = new Set(RESPONSABLES);
tareas.forEach(t => String(t.responsable || '').split(/[,/]+/).map(s => s.trim().toLowerCase()).filter(Boolean).forEach(s => set.add(s)));
[...set].sort().forEach(r => selResp.append(el('option', { value: r, text: r })));
selResp.value = filtro.responsable;
const venc = vis.filter(t => t.vencida).length;
cnt.textContent = vis.length + ' pendiente(s)' + (venc ? ' · ' + venc + ' vencido(s)' : '') + (vis.length !== tareas.length ? ' · de ' + tareas.length : '');
}
async function recargar() {
try {
const j = await api('/api/tareas');
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
pintar();
if (opts.onCargado) opts.onCargado(tareas, { admin, user });
} catch (e) { mensaje('No se pudo cargar el tablero: ' + e.message, false); }
}
// ---------- mover (arrastre o "Mover a…") ----------
async function mover(id, estado, idx) {
const t = porId[id]; if (!t) return;
const mismaCol = t.estado === estado;
const columna = tareas.filter(x => x.estado === estado && x.id !== id).sort((a, b) => a.orden - b.orden);
columna.splice(Math.max(0, Math.min(idx, columna.length)), 0, t);
const ids = columna.map(x => x.id);
// pintado optimista
const prev = { estado: t.estado, orden: t.orden };
t.estado = estado; columna.forEach((x, i) => x.orden = i); pintar();
try {
if (!mismaCol) await api('/api/tarea?id=' + encodeURIComponent(id), json('PATCH', { estado, orden: ids.indexOf(id) }));
await api('/api/tareas/orden', json('POST', { estado, ids }));
if (!mismaCol) mensaje('«' + t.titulo.slice(0, 40) + (t.titulo.length > 40 ? '…' : '') + '» → ' + ESTADOS.find(e => e[0] === estado)[1], true);
await recargar();
} catch (e) { t.estado = prev.estado; t.orden = prev.orden; pintar(); mensaje('No se pudo mover: ' + e.message, false); }
}
// ---------- diálogo: alta y detalle ----------
const dlg = el('dialog');
document.body.append(dlg);
function campo(lbl, input) { const d = el('div'); d.append(el('label', { text: lbl }), input); return d; }
function abrir(t) {
const nuevo = !t;
t = t || { titulo: '', descripcion: '', pista: opts.pista || 'produccion', estado: 'por-hacer', responsable: '', fecha_limite: null, enlaces: [], bloqueada_por: [], etiquetas: [], version_tarjeta: '—' };
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' });
cab.append(el('h2', { text: nuevo ? 'Nuevo pendiente' : 'Pendiente ' + t.id }));
const cerrar = el('button', { type: 'button', text: '✕', title: 'Cerrar' }); cerrar.addEventListener('click', () => dlg.close()); cab.append(cerrar);
dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const fTit = el('input', { type: 'text', value: t.titulo, maxlength: '200', placeholder: 'Qué hay que hacer, en una línea' });
const fDesc = el('textarea', { rows: '3', placeholder: 'Detalle, criterio de terminado, notas' }); fDesc.value = t.descripcion || '';
const fPista = el('select'); Object.entries(PISTAS).forEach(([k, v]) => fPista.append(el('option', { value: k, text: v }))); fPista.value = t.pista;
const fEst = el('select'); ESTADOS.forEach(([k, v]) => fEst.append(el('option', { value: k, text: v }))); fEst.value = t.estado;
const fResp = el('input', { type: 'text', value: t.responsable || '', list: 'kb-resp', placeholder: 'leo, michelle, aldair, jesús…' });
let dl = document.getElementById('kb-resp'); if (!dl) { dl = el('datalist', { id: 'kb-resp' }); document.body.append(dl); }
dl.innerHTML = ''; RESPONSABLES.forEach(r => dl.append(el('option', { value: r })));
const fFecha = el('input', { type: 'date', value: t.fecha_limite || '' });
const fVer = el('select'); VERSIONES.forEach(v => fVer.append(el('option', { value: v, text: v === '—' ? '— (no aplica)' : v }))); fVer.value = t.version_tarjeta || '—';
const fEtq = el('input', { type: 'text', value: (t.etiquetas || []).join(', '), placeholder: 'layout, pedido 570, bloqueante' });
cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc));
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista), campo('Estado', fEst), campo('Responsable', fResp), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
// enlaces
const enlBox = el('div', { class: 'enlaces-ed' });
function filaEnlace(e) {
const f = el('div', { class: 'fila' });
const a = el('input', { type: 'text', value: e.titulo || '', placeholder: 'Título' });
const b = el('input', { type: 'text', value: e.url || '', placeholder: '/hardware/… o https://…' });
const q = el('button', { type: 'button', class: 'sec', text: '✕' }); q.addEventListener('click', () => f.remove());
f.append(a, b, q); enlBox.append(f);
}
(t.enlaces || []).forEach(filaEnlace);
const addEnl = el('button', { type: 'button', class: 'sec', text: '+ enlace', style: 'padding:6px 10px;font-size:13px' }); addEnl.addEventListener('click', () => filaEnlace({}));
cuerpo.append(campo('Enlaces (documentos, actas, expediente de unidad)', enlBox), addEnl);
// bloqueada por (otros pendientes abiertos)
const lst = el('div', { class: 'lista-chk' });
const otros = tareas.filter(x => x.id !== t.id && (x.estado !== 'hecho' || (t.bloqueada_por || []).includes(x.id)));
if (!otros.length) lst.append(el('span', { class: 'vacio', text: 'No hay otros pendientes.' }));
otros.forEach(x => { const l = el('label'); const ck = el('input', { type: 'checkbox', value: x.id }); ck.checked = (t.bloqueada_por || []).includes(x.id); l.append(ck, document.createTextNode(x.titulo + ' (' + (PISTAS[x.pista] || x.pista) + ')')); lst.append(l); });
cuerpo.append(campo('Bloqueado por (espera a que terminen)', lst));
if (!nuevo) {
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(t.creado_por) + ' el ' + esc(fmt(t.creado_en)) + ' · última modificación ' + esc(t.modificado_por) + ' el ' + esc(fmt(t.modificado_en)) }));
(t.historial || []).slice().reverse().forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
cuerpo.append(h);
}
const m = el('div', { class: 'msg' }); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
if (!nuevo && admin) {
const del = el('button', { type: 'button', class: 'peligro izq', text: 'Borrar' });
del.addEventListener('click', async () => {
if (!confirm('¿Borrar el pendiente «' + t.titulo + '»? No se puede deshacer. Si ya no aplica, mejor muévelo a Hecho.')) return;
try { await api('/api/tarea?id=' + encodeURIComponent(t.id), { method: 'DELETE' }); dlg.close(); mensaje('Pendiente borrado.', true); recargar(); }
catch (e) { m.className = 'msg err'; m.textContent = e.message; }
});
pie.append(del);
} else if (!nuevo) pie.append(el('span', { class: 'izq vacio', text: 'Solo el administrador borra; si ya no aplica, muévelo a Hecho.' }));
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
const guardar = el('button', { type: 'button', text: nuevo ? 'Crear pendiente' : 'Guardar cambios' });
guardar.addEventListener('click', async () => {
const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: fEst.value, responsable: fResp.value.trim(),
fecha_limite: fFecha.value || null, version_tarjeta: fVer.value, etiquetas: fEtq.value.split(',').map(s => s.trim()).filter(Boolean),
enlaces: [...enlBox.querySelectorAll('.fila')].map(f => ({ titulo: f.children[0].value.trim(), url: f.children[1].value.trim() })).filter(e => e.url),
bloqueada_por: [...lst.querySelectorAll('input:checked')].map(c => c.value) };
if (!d.titulo) { m.className = 'msg err'; m.textContent = 'Escribe el título.'; return; }
guardar.disabled = true;
try {
if (nuevo) await api('/api/tareas', json('POST', d));
else {
const cambios = {}; Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(t[k] == null ? (Array.isArray(d[k]) ? [] : (k === 'fecha_limite' ? null : '')) : t[k])) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios));
}
dlg.close(); mensaje(nuevo ? 'Pendiente creado.' : 'Cambios guardados.', true); recargar();
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
guardar.disabled = false;
});
pie.append(cancelar, guardar);
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
setTimeout(() => fTit.focus(), 50);
}
// ---------- eventos de filtros ----------
selPista.addEventListener('change', () => { filtro.pista = selPista.value; pintar(); });
selResp.addEventListener('change', () => { filtro.responsable = selResp.value; pintar(); });
selVer.addEventListener('change', () => { filtro.version = selVer.value; pintar(); });
selHechas.addEventListener('change', () => { filtro.hechas = selHechas.value; pintar(); });
buscar.addEventListener('input', () => { filtro.buscar = buscar.value; pintar(); });
btnNuevo.addEventListener('click', () => abrir(null));
const q = new URLSearchParams(location.search);
if (q.get('pista') && !opts.pista) { selPista.value = q.get('pista'); filtro.pista = q.get('pista'); }
if (q.get('responsable')) { filtro.responsable = q.get('responsable').toLowerCase(); }
if (q.get('id')) setTimeout(() => { const t = porId[q.get('id')]; if (t) abrir(t); }, 600);
recargar();
return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; } };
}
window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES };
})();

View File

@ -0,0 +1,234 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de producción — pedido 570</title>
<link rel="stylesheet" href="tablero.css">
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de producción — pedido de 570 HBOX</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="kanban.html">Pendientes</a>
<a href="ingenieria.html">Ingeniería</a>
<a class="activo" href="produccion.html">Producción</a>
<a href="../tablero.html">Detalle por tarjeta</a>
<a href="../inventario/inventario.html">Inventario</a>
<button id="refrescar" style="padding:6px 12px">Actualizar</button>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Arriba: el <b>plan</b> (cuántas unidades de cada versión y para cuándo) contra lo <b>real</b>, que sale solo de las actas enviadas. Si una unidad no tiene acta, aquí no existe.</li>
<li>El <b>embudo</b> cuenta series distintas por etapa: tarjeta probada → módulos → integración → ensamble → prueba final → configuración → envío → instalación. Toca una etapa para ir a su acta.</li>
<li><b>Bloqueos</b>: pendientes en Bloqueado de las pistas Producción, Mecánica y Compras. Resuélvelos en el <a href="kanban.html">tablero de pendientes</a>.</li>
<li><b>Alertas de inventario</b>: ítems por debajo del mínimo. Registra las entradas al llegar el material.</li>
<li>Solo el administrador edita el plan (botón «Editar plan»). Las fechas son objetivo, no promesa.</li>
</ol>
</details>
<div id="cont"></div>
</div>
<dialog id="dlgPlan"></dialog>
<script>
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const esPrueba = s => String(s || '').toUpperCase().includes('PRUEBA');
const esUnidad = s => /^HBOX/i.test(String(s || ''));
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
const FORM = { 'prueba-v5': '../formularios/v5.html', 'prueba-v4': '../formularios/v4.html', 'modulo-ard': '../formularios/ardusimple.html', 'modulo-4g': '../formularios/modem4g.html',
'integracion': '../formularios/integracion.html', 'ensamble': '../formularios/ensamble.html', 'reflasheo': '../formularios/reflasheo.html',
'prueba-ethernet-serie': '../formularios/ethernet-serie.html', 'envio': '../formularios/envio.html', 'instalacion-campo': '../formularios/instalacion.html' };
const PISTAS = { produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', ingenieria: 'Ingeniería', campo: 'Campo' };
const cont = document.getElementById('cont'), meta = document.getElementById('meta');
let PLAN = null, ADMIN = false;
async function api(url, opts) {
const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {}));
let j = null; try { j = await r.json(); } catch (e) {}
if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status));
return j;
}
function seriesEtapa(et, actas) {
const s = new Set();
actas.forEach(a => {
if (!et.tipos.includes(a.tipo) || esPrueba(a.serie)) return;
if (et.ok && a.resultado === 'FALLA') return;
if (et.solo_unidades && !esUnidad(a.serie)) return;
s.add(a.serie);
});
return s;
}
function render(plan, actas, series, danos, bloqueos, inv) {
cont.innerHTML = '';
const reales = actas.filter(a => !esPrueba(a.serie));
const ens = reales.filter(a => a.tipo === 'ensamble' && a.resultado !== 'FALLA');
const unidades = new Set(ens.map(a => a.serie));
const enviadas = new Set(reales.filter(a => a.tipo === 'envio' && esUnidad(a.serie)).map(a => a.serie));
const instaladas = new Set(reales.filter(a => a.tipo === 'instalacion-campo' && a.resultado !== 'FALLA').map(a => a.serie));
const total = plan.versiones.reduce((n, v) => n + (v.objetivo || 0), 0);
// ---- KPIs
const kp = el('div', { class: 'kpis' });
const kpi = (n, l, cls) => kp.append(el('div', { class: 'kpi ' + (cls || '') }, el('div', { class: 'n', text: String(n) }), el('div', { class: 'l', text: l })));
kpi(plan.pedido_total || total, 'Pedido total');
kpi(unidades.size, 'Unidades HBOX ensambladas (acta OK)', 'bien');
kpi(enviadas.size, 'Unidades enviadas');
kpi(instaladas.size, 'Instaladas en sitio');
kpi(danos.length, 'Series dañadas o de baja', danos.length ? 'mal' : '');
kpi(bloqueos.length, 'Bloqueos abiertos', bloqueos.length ? 'mal' : '');
kpi(inv.length, 'Alertas de inventario', inv.length ? 'mal' : '');
cont.append(kp);
// ---- plan vs real por versión
const b1 = el('section', { class: 'bloque' });
const cab = el('div', { style: 'display:flex;justify-content:space-between;gap:10px;align-items:baseline;flex-wrap:wrap' });
cab.append(el('h2', { text: 'Plan por versión vs. real' }));
if (ADMIN) { const b = el('button', { class: 'sec', text: 'Editar plan', style: 'padding:6px 12px' }); b.addEventListener('click', editarPlan); cab.append(b); }
b1.append(cab);
b1.append(el('p', { class: 'sub', html: esc(plan.nota || '') + (plan.actualizado_en ? ' · plan actualizado por <b>' + esc(plan.actualizado_por) + '</b> el ' + esc(fmt(plan.actualizado_en)) : (plan._default ? ' · <b>plan por omisión</b>: aún no se ha guardado (siembra o «Editar plan»)' : '')) }));
const grid = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px' });
plan.versiones.forEach(v => {
const pref = (v.serie_prefijo || '').toUpperCase();
const hechas = pref ? ens.filter(a => String((a.componentes || {}).einar || '').toUpperCase().startsWith(pref + '-')).length : 0;
const pct = v.objetivo ? Math.min(100, Math.round(100 * hechas / v.objetivo)) : 0;
const c = el('div', { class: 'etapa', style: 'text-align:left' });
c.append(el('div', { style: 'display:flex;justify-content:space-between;align-items:center' }, el('b', { text: 'EinarBoard ' + v.version }), el('span', { class: BADGE[v.version] || 'pill pend', text: v.version })));
c.append(el('div', { class: 'n', style: 'font-size:22px;margin-top:4px', html: hechas + ' <span style="font-size:13px;color:var(--tinta2)">/ ' + v.objetivo + ' unidades</span>' }));
const pr = el('div', { class: 'prog' }); pr.append(el('span', { style: 'width:' + pct + '%' })); c.append(pr);
c.append(el('div', { class: 'obj', html: '<span>' + pct + ' %</span><span>' + (v.fecha ? 'objetivo ' + esc(v.fecha) : 'sin fecha objetivo') + '</span>' }));
if (v.nota) c.append(el('div', { class: 'l', style: 'text-align:left;margin-top:5px', text: v.nota }));
if (pref) c.append(el('div', { class: 'f', style: 'text-align:left', text: 'cuenta ensambles OK cuya tarjeta sea ' + pref + '-nn' }));
grid.append(c);
});
// lote V5.5 actual (fuera del pedido)
const v55 = ens.filter(a => /^V5-\d/i.test(String((a.componentes || {}).einar || ''))).length;
const c55 = el('div', { class: 'etapa', style: 'text-align:left;opacity:.9' });
c55.append(el('div', { style: 'display:flex;justify-content:space-between;align-items:center' }, el('b', { text: 'Lote actual V5.5' }), el('span', { class: 'badge-v55', text: 'V5.5' })));
c55.append(el('div', { class: 'n', style: 'font-size:22px;margin-top:4px', html: v55 + ' <span style="font-size:13px;color:var(--tinta2)">unidades ensambladas</span>' }));
c55.append(el('div', { class: 'l', style: 'text-align:left;margin-top:5px', text: 'Tarjetas V5-nn (15 fabricadas el 22-ago). No forman parte del pedido de 570; se cuentan aparte.' }));
grid.append(c55);
b1.append(grid);
if (total !== (plan.pedido_total || total)) b1.append(el('p', { class: 'sub', style: 'margin-top:8px;color:var(--falla)', text: '⚠ La suma de objetivos (' + total + ') no coincide con el pedido total (' + plan.pedido_total + ').' }));
cont.append(b1);
// ---- embudo
const b2 = el('section', { class: 'bloque' });
b2.append(el('h2', { text: 'Embudo de producción (series distintas con acta)' }), el('p', { class: 'sub', text: 'Cada etapa cuenta las series que ya tienen acta de ese tipo. Las tres primeras son por pieza (tarjeta, módulo, pareja); de ensamble en adelante, por unidad HBOX. Toca una etapa para abrir su acta.' }));
const emb = el('div', { class: 'embudo' });
(plan.etapas || []).forEach(et => {
const s = seriesEtapa(et, reales);
const a = el('a', { class: 'etapa' + (s.size ? '' : ' sin'), href: FORM[et.tipos[0]] || '../actas/', style: 'text-decoration:none;color:inherit' });
a.append(el('div', { class: 'n', text: String(s.size) }), el('div', { class: 'l', text: et.nombre }));
a.append(el('div', { class: 'f', text: et.nota || ('acta: ' + et.tipos.join(', ')) }));
emb.append(a);
});
b2.append(emb);
cont.append(b2);
// ---- bloqueos + inventario
const dos = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px' });
const b3 = el('section', { class: 'bloque bloqueos', style: 'margin:0' });
b3.append(el('h2', { text: 'Bloqueos (Producción · Mecánica · Compras)' }), el('p', { class: 'sub', text: 'Pendientes en la columna Bloqueado. Quien lo destrabe, que lo mueva.' }));
if (!bloqueos.length) b3.append(el('div', { class: 'vacio', text: 'Sin bloqueos registrados.' }));
bloqueos.forEach(t => {
const c = el('div', { class: 'card p-' + t.pista });
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span>' + (t.responsable ? '<span>👤 ' + esc(t.responsable) + '</span>' : '<span>sin responsable</span>') + (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">📅 ' + esc(t.fecha_limite) + '</span>' : '') }));
if (t.descripcion) c.append(el('div', { style: 'font-size:13px;color:var(--tinta2);margin-top:4px', text: t.descripcion.length > 160 ? t.descripcion.slice(0, 159) + '…' : t.descripcion }));
b3.append(c);
});
dos.append(b3);
const b4 = el('section', { class: 'bloque', style: 'margin:0' });
b4.append(el('h2', { text: 'Alertas de inventario' }), el('p', { class: 'sub', html: 'Ítems con stock por debajo del mínimo. <a href="../inventario/inventario.html">Abrir inventario →</a>' }));
if (!inv.length) b4.append(el('div', { class: 'vacio', text: 'Sin alertas: todo por encima del mínimo (o sin mínimo definido).' }));
else {
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Código</th><th>Descripción</th><th>Stock</th><th>Mínimo</th></tr>';
inv.forEach(i => { const tr = el('tr'); tr.innerHTML = '<td><b>' + esc(i.codigo) + '</b></td><td>' + esc(i.descripcion) + '</td><td style="color:var(--falla);font-weight:700">' + esc(i.stock) + ' ' + esc(i.unidad) + '</td><td>' + esc(i.minimo) + '</td>'; t.append(tr); });
b4.append(el('div', { class: 'tblwrap' }, t));
}
dos.append(b4);
cont.append(dos);
// ---- daños
if (danos.length) {
const b5 = el('section', { class: 'bloque' });
b5.append(el('h2', { text: 'Series con marca de daño o baja' }), el('p', { class: 'sub', text: 'Lo último registrado en un acta de daño manda. «Reparada» levanta la marca.' }));
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Serie</th><th>Marca</th><th>Qué pasó</th><th>Disposición</th><th>Fecha</th></tr>';
danos.forEach(d => { const a = d.acta || {}; const tr = el('tr'); tr.innerHTML = '<td><a href="../unidad.html?serie=' + encodeURIComponent(d.serie) + '"><b>' + esc(d.serie) + '</b></a></td><td><span class="pill ' + (d.marca === 'BAJA' ? 'falla' : 'aviso') + '">' + esc(d.marca === 'BAJA' ? 'BAJA' : 'DAÑADA') + '</span></td><td>' + esc(a.clasificacion || '') + (a.resumen ? ' — ' + esc(a.resumen) : '') + '</td><td>' + esc(a.accion || '') + '</td><td>' + esc(a.fecha || fmt(a.recibido)) + '</td>'; t.append(tr); });
b5.append(el('div', { class: 'tblwrap' }, t));
cont.append(b5);
}
}
// ---- edición del plan (solo admin)
function editarPlan() {
const dlg = document.getElementById('dlgPlan');
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: 'Editar plan de producción' }));
const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const fTot = el('input', { type: 'number', min: '0', value: PLAN.pedido_total || 0 });
const fNota = el('input', { type: 'text', value: PLAN.nota || '', maxlength: '500' });
const d1 = el('div', { class: 'dos-col' }); d1.append(campo('Pedido total (unidades)', fTot), campo('Nota', fNota)); cuerpo.append(d1);
cuerpo.append(el('label', { text: 'Versiones: objetivo, nota, prefijo de serie de la tarjeta (para contar ensambles) y fecha objetivo' }));
const filas = el('div');
function fila(v) {
const f = el('div', { style: 'display:grid;grid-template-columns:70px 80px 1fr 70px 140px auto;gap:6px;margin-bottom:6px;align-items:center' });
f.append(el('input', { type: 'text', value: v.version || '', placeholder: 'V5.6', maxlength: '10' }),
el('input', { type: 'number', min: '0', value: v.objetivo || 0 }),
el('input', { type: 'text', value: v.nota || '', placeholder: 'nota' }),
el('input', { type: 'text', value: v.serie_prefijo || '', placeholder: 'V56', maxlength: '10' }),
el('input', { type: 'date', value: v.fecha || '' }));
const q = el('button', { type: 'button', class: 'sec', text: '✕', style: 'padding:6px 9px' }); q.addEventListener('click', () => f.remove()); f.append(q);
filas.append(f);
}
(PLAN.versiones || []).forEach(fila); cuerpo.append(filas);
const add = el('button', { type: 'button', class: 'sec', text: '+ versión', style: 'padding:6px 10px;font-size:13px' }); add.addEventListener('click', () => fila({})); cuerpo.append(add);
cuerpo.append(el('p', { class: 'sub', style: 'margin-top:10px', text: 'El embudo (etapas) se conserva como está; se edita por API si hace falta.' }));
const m = el('div', { class: 'msg' }); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
const guardar = el('button', { type: 'button', text: 'Guardar plan' });
guardar.addEventListener('click', async () => {
const versiones = [...filas.children].map(f => ({ version: f.children[0].value.trim(), objetivo: +f.children[1].value || 0, nota: f.children[2].value.trim(), serie_prefijo: f.children[3].value.trim(), fecha: f.children[4].value || null })).filter(v => v.version);
if (!versiones.length) { m.className = 'msg err'; m.textContent = 'Deja al menos una versión.'; return; }
guardar.disabled = true;
try {
await api('/api/plan', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pedido_total: +fTot.value || 0, nota: fNota.value.trim(), versiones }) });
dlg.close(); cargar();
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
guardar.disabled = false;
});
pie.append(cancelar, guardar);
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function campo(l, i) { const d = el('div'); d.append(el('label', { text: l }), i); return d; }
async function cargar() {
meta.textContent = 'Actualizando…';
try {
const [p, a, s, d, t, i] = await Promise.all([
api('/api/plan'), api('/api/actas'), api('/api/series').catch(() => ({ series: [] })), api('/api/danos').catch(() => ({ series: [] })),
api('/api/tareas?estado=bloqueado').catch(() => ({ tareas: [] })), api('/api/inventario?alertas=1').catch(() => ({ items: [] }))]);
PLAN = p.plan; ADMIN = !!p.admin;
const bloqueos = (t.tareas || []).filter(x => ['produccion', 'mecanica', 'compras'].includes(x.pista));
render(PLAN, a.actas || [], s.series || [], d.series || [], bloqueos, i.items || []);
meta.textContent = 'Actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + ' · ' + (a.total || 0) + ' actas · ' + (s.series || []).length + ' series con registro';
} catch (e) { cont.innerHTML = ''; cont.append(el('div', { class: 'err', text: 'No se pudo cargar el tablero: ' + e.message })); meta.textContent = 'Error'; }
}
document.getElementById('refrescar').addEventListener('click', cargar);
cargar();
setInterval(cargar, 60000);
</script>
</body>
</html>

141
docs/tableros/tablero.css Normal file
View File

@ -0,0 +1,141 @@
/* Tableros HBOX (kanban, ingeniería, producción) — misma paleta que tablero.html y acta.css */
:root{
--naranja:#e8590c; --naranja-suave:#fff1e8; --tinta:#1d1c19; --tinta2:#5a564d; --linea:#e6e1d6;
--papel:#faf8f4; --tarjeta:#fff; --superf:#fcfcfb;
--ok:#2f9e44; --ok-bg:#ebfbee; --falla:#e03131; --falla-bg:#fff5f5;
--pend:#adb5bd; --pend-bg:#f4f2ee; --info:#1971c2; --info-bg:#e7f1fb;
--aviso:#f08c00; --aviso-bg:#fff9db; --morado:#7048e8; --morado-bg:#f3f0ff;
}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){
--papel:#191b1e;--tinta:#e6e1d6;--tinta2:#a09a8d;--linea:#33363b;--tarjeta:#1f2226;--superf:#1b1d20;
--naranja-suave:#2e2218;--ok:#69db7c;--ok-bg:#1b2a1f;--falla:#ff8787;--falla-bg:#2c1d1d;--pend:#5c636a;--pend-bg:#26282c;
--info:#74c0fc;--info-bg:#182734;--aviso:#ffc078;--aviso-bg:#2e2712;--morado:#b197fc;--morado-bg:#241d33;}}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
.wrap{max-width:1240px;margin:0 auto;padding:16px 16px 70px}
header.top{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;
background:var(--tinta);color:#fff;padding:14px 16px;border-radius:12px;margin-bottom:14px}
header.top h1{margin:0;font-size:20px}
header.top .meta{color:#d7d2c8;font-size:13px}
header.top nav{display:flex;flex-wrap:wrap;gap:4px 14px;align-items:center}
header.top nav a{color:#fff;font-size:13px;text-decoration:none;opacity:.85}
header.top nav a.activo{opacity:1;font-weight:700;border-bottom:2px solid var(--naranja)}
button{background:var(--naranja);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
button:disabled{opacity:.5;cursor:not-allowed}
button.sec{background:var(--tarjeta);color:var(--tinta2);border:1px solid var(--linea)}
button.sec:hover{border-color:var(--naranja);color:var(--naranja)}
button.peligro{background:var(--tarjeta);color:var(--falla);border:1px solid var(--falla)}
select,input,textarea{padding:9px 11px;border:1px solid var(--linea);border-radius:9px;font-size:15px;background:var(--tarjeta);color:var(--tinta);max-width:100%}
input:focus,select:focus,textarea:focus{outline:2px solid var(--naranja);border-color:var(--naranja)}
label{display:block;font-weight:600;font-size:13px;margin:10px 0 4px;color:var(--tinta2)}
section.bloque{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:16px;margin:14px 0}
section.bloque h2{margin:0 0 4px;font-size:17px}
section.bloque .sub{color:var(--tinta2);font-size:13.5px;margin:0 0 10px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:14px}
.kpi{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:12px 14px}
.kpi .n{font-size:28px;font-weight:700;line-height:1}
.kpi .l{color:var(--tinta2);font-size:12.5px;margin-top:4px}
.kpi.mal .n{color:var(--falla)} .kpi.bien .n{color:var(--ok)}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px;margin:10px 0}
.vacio{color:var(--tinta2);font-style:italic;font-size:14px}
.pill{display:inline-block;font-size:11.5px;font-weight:700;padding:2px 9px;border-radius:99px;white-space:nowrap;vertical-align:middle}
.pill.ok{background:var(--ok-bg);color:var(--ok)} .pill.falla{background:var(--falla-bg);color:var(--falla)}
.pill.info{background:var(--info-bg);color:var(--info)} .pill.pend{background:var(--pend-bg);color:var(--tinta2)}
.pill.aviso{background:var(--aviso-bg);color:#7a4a00}
table.t{border-collapse:collapse;width:100%;font-size:13.5px}
table.t th{text-align:left;color:var(--tinta2);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;padding:6px 8px 6px 0;border-bottom:1px solid var(--tinta2)}
table.t td{padding:7px 8px 7px 0;border-bottom:1px solid var(--linea);vertical-align:top}
.tblwrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
a{color:var(--naranja)}
/* ---- "Cómo se usa": bloque plegable al inicio de cada página ---- */
details.como{background:var(--info-bg);border:1px solid #c9dcec;border-radius:10px;padding:8px 14px;margin:0 0 14px;font-size:14px}
details.como summary{cursor:pointer;font-weight:700;color:var(--info)}
details.como ol{margin:8px 0 4px;padding-left:20px} details.como li{margin:3px 0}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]) details.como{border-color:#2d4760}}
/* ---- Kanban ---- */
.kb-barra{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 12px}
.kb-barra select,.kb-barra input{padding:8px 10px;font-size:14px}
.kb-barra .cnt{color:var(--tinta2);font-size:13px;margin-left:auto}
.kb{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:start}
@media(max-width:980px){.kb{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:560px){.kb{grid-template-columns:1fr}}
.col{background:var(--pend-bg);border:1px solid var(--linea);border-radius:12px;padding:8px;min-height:120px}
.col.sobre{outline:2px dashed var(--naranja);outline-offset:-4px;background:var(--naranja-suave)}
.col h3{margin:2px 4px 8px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--tinta2);display:flex;justify-content:space-between}
.col h3 .n{background:var(--tarjeta);border:1px solid var(--linea);border-radius:99px;padding:0 8px;font-size:12px}
.col.e-bloqueado h3{color:var(--falla)} .col.e-hecho h3{color:var(--ok)} .col.e-en-curso h3{color:var(--info)}
.card{background:var(--tarjeta);border:1px solid var(--linea);border-left:4px solid var(--pend);border-radius:10px;padding:9px 10px;margin:0 0 8px;cursor:grab;font-size:14px;position:relative}
.card:active{cursor:grabbing}
.card.arrastrando{opacity:.45}
.card.p-ingenieria{border-left-color:var(--morado)} .card.p-produccion{border-left-color:var(--ok)}
.card.p-mecanica{border-left-color:var(--aviso)} .card.p-compras{border-left-color:var(--info)} .card.p-campo{border-left-color:var(--naranja)}
.card.vencida{box-shadow:inset 0 0 0 1px var(--falla)}
.card .tit{font-weight:700;line-height:1.3;margin-bottom:5px;cursor:pointer}
.card .tit:hover{color:var(--naranja)}
.card .meta{display:flex;flex-wrap:wrap;gap:4px 8px;font-size:12px;color:var(--tinta2);align-items:center}
.card .chip{display:inline-block;font-size:11px;font-weight:700;padding:1px 7px;border-radius:99px;background:var(--pend-bg);color:var(--tinta2)}
.card .chip.p-ingenieria{background:var(--morado-bg);color:var(--morado)} .card .chip.p-produccion{background:var(--ok-bg);color:var(--ok)}
.card .chip.p-mecanica{background:var(--aviso-bg);color:#7a4a00} .card .chip.p-compras{background:var(--info-bg);color:var(--info)}
.card .chip.p-campo{background:var(--naranja-suave);color:var(--naranja)}
.card .fecha.mal{color:var(--falla);font-weight:700}
.card .bloq{margin-top:5px;font-size:12px;color:var(--falla)}
.card .enl{margin-top:5px;display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px}
.card .mover{margin-top:7px;display:flex;gap:6px;align-items:center}
.card .mover select{padding:5px 7px;font-size:12.5px;flex:1}
@media(min-width:981px){.card .mover{display:none} .kb.movil .card .mover{display:flex}}
.badge-v3,.badge-v4,.badge-v55,.badge-v56,.badge-v57,.badge-v6{display:inline-block;padding:0 7px;border-radius:999px;font-size:11px;font-weight:700;line-height:1.7;white-space:nowrap}
.badge-v56{background:#2C3C5B;color:#fff} .badge-v55{background:#17A08C;color:#fff}
.badge-v57{background:#e3f5e8;color:#1c5c2e;border:1px solid #8fd1a3} .badge-v6{background:#fff3cd;color:#7a5200;border:1px solid #f0c36d}
.badge-v4{background:#EDEFF4;color:#2C3C5B;border:1px solid #c9d0e0} .badge-v3{background:#e9e9e9;color:#555;border:1px solid #ccc}
/* ---- diálogo de detalle / alta ---- */
dialog{border:1px solid var(--linea);border-radius:14px;padding:0;max-width:720px;width:calc(100vw - 24px);background:var(--tarjeta);color:var(--tinta)}
dialog::backdrop{background:rgba(0,0,0,.45)}
dialog .dcab{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--linea);background:var(--tinta);color:#fff;border-radius:14px 14px 0 0}
dialog .dcab h2{margin:0;font-size:17px}
dialog .dcab button{background:transparent;color:#fff;font-size:20px;padding:2px 8px}
dialog .dcuerpo{padding:6px 18px 14px;max-height:calc(100vh - 160px);overflow:auto}
dialog .dpie{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--linea)}
dialog .dpie .izq{margin-right:auto}
.dos-col{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}
@media(max-width:560px){.dos-col{grid-template-columns:1fr}}
dialog input,dialog select,dialog textarea{width:100%}
.enlaces-ed .fila{display:grid;grid-template-columns:1fr 1.4fr auto;gap:6px;margin-bottom:6px}
.enlaces-ed .fila button{padding:6px 9px;font-size:13px}
.lista-chk{max-height:140px;overflow:auto;border:1px solid var(--linea);border-radius:9px;padding:6px 10px;font-size:13.5px}
.lista-chk label{display:flex;gap:8px;align-items:flex-start;font-weight:500;margin:4px 0;color:var(--tinta)}
.lista-chk input{width:auto;margin-top:3px}
.hist{font-size:13px;color:var(--tinta2);max-height:180px;overflow:auto;border-top:1px solid var(--linea);margin-top:12px;padding-top:8px}
.hist div{padding:3px 0;border-bottom:1px dashed var(--linea)}
.hist b{color:var(--tinta)}
.msg{display:none;padding:10px 12px;border-radius:9px;margin-top:10px;font-weight:600;font-size:14px}
.msg.ok{display:block;background:var(--ok-bg);color:var(--ok);border:1px solid var(--ok)}
.msg.err{display:block;background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla)}
/* ---- tablero de ingeniería: tira de versiones ---- */
.versiones{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px;margin-bottom:14px}
.ver{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:14px 16px;border-top:4px solid var(--pend)}
.ver.v55{border-top-color:#17A08C} .ver.v56{border-top-color:#2C3C5B} .ver.v57{border-top-color:#1c5c2e} .ver.v6{border-top-color:#c99a1a}
.ver h3{margin:0 0 4px;font-size:17px;display:flex;justify-content:space-between;align-items:center;gap:8px}
.ver .est{font-weight:700;font-size:13.5px}
.ver .sig{font-size:13.5px;margin:6px 0} .ver .sig b{color:var(--naranja)}
.ver .quien{font-size:12.5px;color:var(--tinta2)}
.ver .docs{margin-top:8px;display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12.5px}
.ver .cnt{font-size:12.5px;color:var(--tinta2);margin-top:6px}
.decisiones li{margin:5px 0;font-size:14px} .decisiones b{color:var(--tinta)}
.filosofia li{margin:6px 0;font-size:14px}
/* ---- tablero de producción ---- */
.prog{height:14px;border-radius:7px;background:var(--pend-bg);overflow:hidden;margin:6px 0 4px;display:flex}
.prog span{display:block;height:100%;background:var(--ok)}
.prog span.pl{background:var(--info)}
.obj{display:flex;justify-content:space-between;font-size:13px;color:var(--tinta2)}
.embudo{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px}
.etapa{background:var(--superf);border:1px solid var(--linea);border-radius:10px;padding:10px 10px;text-align:center}
.etapa .n{font-size:26px;font-weight:700;line-height:1}
.etapa .l{font-size:12px;color:var(--tinta2);margin-top:3px;line-height:1.25}
.etapa .f{font-size:11px;color:var(--tinta2);margin-top:3px;opacity:.8}
.etapa.sin .n{color:var(--pend)}
.bloqueos .card{cursor:default}

View File

@ -4,16 +4,31 @@ title: "Taller"
# Taller # Taller
Unidad o tarjeta en la mesa. Antes de tocarla, abre su [expediente](../unidad.html). Reglas de banco: [diagnóstico en banco](../procedimientos/diagnostico-banco.md#reglas-de-banco). Unidad o tarjeta **en la mesa**: llegó del campo, falló en producción o hay que validar un firmware. Antes de tocarla, abre su [expediente](../unidad.html) y lee las actas anteriores. Reglas de banco (límite de corriente, 5 V ≥ 5.2 V o paquete conectado, baterías hinchadas): [diagnóstico en banco](../procedimientos/diagnostico-banco.md#reglas-de-banco).
| Trabajo | Cuándo | Procedimiento | Acta | ## Qué trabajo, qué procedimiento, qué acta
|---|---|---|---|
| Diagnóstico en banco | Llegó del campo o falló en producción | [Diagnóstico en banco](../procedimientos/diagnostico-banco.md) | [Banco](../formularios/banco.html) |
| Reflasheo | Después de subir la captura del diagnóstico | [Reflasheo de unidades](../procedimientos/reflasheo-unidades.md) | [Reflasheo y configuración](../formularios/reflasheo.html) |
| Módem 4G en batería | Validar firmware o unidad que no reporta en batería | [Módem 4G en batería](../procedimientos/prueba-bateria-modem.md) | [Módem en batería](../formularios/prueba-bateria.html) |
| Ethernet y puertos serie | El cliente usa Ethernet, RS-485 o RS-232 | [Ethernet y puertos serie](../procedimientos/v5-prueba-ethernet-serie.md) | [Ethernet y serie](../formularios/ethernet-serie.html) |
| Tarjeta V4 | Preparación y prueba con 0.6.0 | [Preparación y prueba V4](../procedimientos/v4-prueba-fw-0.5.6.md) | [Prueba V4](../formularios/v4.html) |
| Tarjeta V4 sin NMEA | El módem registra (`$GPTXT`) pero no hay posición; `nmea=0`; NO RTK tenue | [Riel SW_5V y arranque del F9P](../procedimientos/v4-prueba-riel-sw5v-f9p.md) | [Prueba V4](../formularios/v4.html) |
- [Manual V5](../manuales/manual-tarjeta-V5.md) · [Manual V4](../manuales/manual-tarjeta-V4.md) · [Pinout V5.6](../hardware/v56-pinout.md) | Trabajo | Cuándo | Tarjetas | Procedimiento | Acta |
- [Tablero de producción](../tablero.html) |---|---|---|---|---|
| Diagnóstico en banco | Llegó del campo (después del [diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md)) o falló en producción | V3 · V4 · V5.5 | [Diagnóstico en banco](../procedimientos/diagnostico-banco.md) | [Banco](../formularios/banco.html) |
| Reflasheo | Solo después de subir la captura del diagnóstico. Es una prueba, no una reparación | V3 · V4 · V5.5 | [Reflasheo de unidades](../procedimientos/reflasheo-unidades.md) | [Reflasheo y configuración](../formularios/reflasheo.html) |
| Módem 4G en batería | Validar un firmware nuevo en una unidad completa, o unidad que no reporta en batería | V5.5 (V4: ver abajo) | [Módem 4G en batería](../procedimientos/prueba-bateria-modem.md) | [Módem en batería](../formularios/prueba-bateria.html) |
| Ethernet y puertos serie | El cliente usa Ethernet, RS-485 o RS-232; obligatoria en al menos una V5.5 antes de pedir la V5.6 | V5.5 · V5.6 | [Ethernet y puertos serie](../procedimientos/v5-prueba-ethernet-serie.md) | [Ethernet y serie](../formularios/ethernet-serie.html) |
| Tarjeta V4 — preparación y prueba | Una vez por tarjeta V4 antes de darla por buena con 0.6.0 (puente de reset del módem, cable sin 5 V, 24 V, batería) | V4 | [Preparación y prueba V4](../procedimientos/v4-prueba-fw-0.5.6.md) | [Prueba V4](../formularios/v4.html) |
| Tarjeta V4 sin NMEA | El módem registra (`$GPTXT`) pero no hay posición; `nmea=0`; LED **NO RTK** tenue | V4 | [Riel SW_5V y arranque del F9P](../procedimientos/v4-prueba-riel-sw5v-f9p.md) | [Prueba V4](../formularios/v4.html) |
## Qué firmware cargar
Por tarjeta, nunca por número de versión. **0.6.0** es el de campo para V4 y V5; **0.6.3** está en prueba de banco desde el 1-oct y no se instala en campo; **4.0.0** para V3. Binarios y SHA-256 en [Descargas](../descargas/index.md); qué cambió en cada versión, en las [notas de versión](../descargas/notas-de-version.md).
## Qué revisar en toda tarjeta que llega
- **U6 (LTC4040) con lupa** antes de energizar: bolas de soldadura y puentes bajo el QFN son el defecto recurrente de V5.5 y V4 (regla DFM del 8-oct). Foto al acta.
- Batería hinchada, caliente o con olor: desconéctala primero y trabaja sin ella.
- Cruce en el arnés GX16: así se destruyó la V5-006 (18-sep). Mide antes de conectar.
## Referencias de la mesa
- [Manual V5](../manuales/manual-tarjeta-V5.md) · [Manual V4](../manuales/manual-tarjeta-V4.md) · [Manual V3](../manuales/manual-tarjeta-V3.md) · [Pinout V5.6](../hardware/v56-pinout.md) · [Hoja de datos V5.6](../hardware/v56-datasheet.md)
- [Estado de actas](../tablero.html) · [Expediente de una unidad](../unidad.html) · [Kanban](../tableros/kanban.html) (pista Producción)
- Procedimientos anteriores (V3, protoboard, fan v3.0.3): [Referencia e histórico](../historia/index.md)

View File

@ -61,6 +61,18 @@ table.det td{padding:5px 8px 5px 0;border-bottom:1px solid var(--linea);vertical
table.det td.k{color:var(--tinta2);width:46%} table.det td.k{color:var(--tinta2);width:46%}
.vacio{color:var(--tinta2);font-style:italic;font-size:14px} .vacio{color:var(--tinta2);font-style:italic;font-size:14px}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px} .err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px}
/* cadenas de trazabilidad: producción → campo */
.cadena{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px}
.cadena .paso{flex:1 1 118px;min-width:118px;border:1px solid var(--linea);border-radius:9px;padding:7px 9px;background:var(--superf);font-size:12.5px;position:relative}
.cadena .paso .n{font-weight:700;font-size:13px}
.cadena .paso .e{margin-top:2px}
.cadena .paso .d{color:var(--tinta2);font-size:11.5px;margin-top:2px;line-height:1.3}
.cadena .paso.ok{border-color:var(--ok);background:var(--ok-bg)} .cadena .paso.ok .e{color:var(--ok)}
.cadena .paso.falla{border-color:var(--falla);background:var(--falla-bg)} .cadena .paso.falla .e{color:var(--falla)}
.cadena .paso.info{border-color:var(--info);background:var(--info-bg)} .cadena .paso.info .e{color:var(--info)}
.cadena .paso.pend .e{color:var(--tinta2)}
.cadena .paso a{color:inherit}
.cad-t{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--tinta2);margin:8px 0 0}
@media print{header.top{background:#fff;color:#000} .buscar,button{display:none}} @media print{header.top{background:#fff;color:#000} .buscar,button{display:none}}
</style> </style>
</head> </head>
@ -164,6 +176,9 @@ function render(j){
.forEach(([n,l])=>kp.append(el('div',{class:'kpi'},el('div',{class:'n',text:String(n)}),el('div',{class:'l',text:l})))); .forEach(([n,l])=>kp.append(el('div',{class:'kpi'},el('div',{class:'n',text:String(n)}),el('div',{class:'l',text:l}))));
cont.append(kp); cont.append(kp);
// ---- trazabilidad: cadena de producción y cadena de campo ----
cont.append(trazabilidad(j));
// ---- línea de tiempo ---- // ---- línea de tiempo ----
const b=el('section',{class:'bloque'}); const b=el('section',{class:'bloque'});
b.append(el('h2',{text:'Historial'}), el('p',{class:'sub',text:'Todo lo registrado para esta serie, de lo más reciente a lo más viejo.'})); b.append(el('h2',{text:'Historial'}), el('p',{class:'sub',text:'Todo lo registrado para esta serie, de lo más reciente a lo más viejo.'}));
@ -232,6 +247,84 @@ function render(j){
} }
} }
// ---------- trazabilidad ----------
// Cadena de producción (tarjeta → módulos → integración → ensamble → configuración) y cadena de
// campo (envío → recepción → instalación → diagnósticos → daños), con fecha y sitio. Para una unidad
// HBOX, las actas de sus módulos viven en OTRAS series (componentes del ensamble): se consultan aparte.
let SITIOS = null;
async function nombreSitio(id){
if(!id) return '';
if(!SITIOS){ try{ SITIOS = ((await (await fetch('/api/sitios?todos=1')).json()).sitios)||[]; }catch(e){ SITIOS=[]; } }
const s = SITIOS.find(x=>x.id===id); return s ? s.nombre : id;
}
function paso(nombre, cls, estado, detalle){
const p=el('div',{class:'paso '+cls}); p.append(el('div',{class:'n',text:nombre}), el('div',{class:'e',text:estado}));
if(detalle) p.append(el('div',{class:'d',html:detalle}));
return p;
}
function ultima(actas, tipos){ return actas.filter(a=>tipos.includes(a.tipo))[0]||null; } // ya vienen de reciente a vieja
function pasoActa(nombre, a, textoOK, textoPend){
if(!a) return paso(nombre,'pend',textoPend||'sin acta');
const falla = a.resultado==='FALLA';
return paso(nombre, falla?'falla':'ok', falla?'✕ FALLA':(textoOK||'✓ '+(a.resultado||'OK')), (a.fecha||fecha(a.recibido).slice(0,10))+(a.tecnico?' · '+a.tecnico:''));
}
function trazabilidad(j){
const sec=el('section',{class:'bloque'});
sec.append(el('h2',{text:'Trazabilidad'}), el('p',{class:'sub',text:'De la tarjeta al puerto: cada paso con su acta, fecha y sitio. Gris = todavía no hay acta.'}));
const A=j.actas, esHbox=/^HBOX/i.test(j.serie);
// --- producción
sec.append(el('div',{class:'cad-t',text:'Producción'}));
const prod=el('div',{class:'cadena'}); sec.append(prod);
const ens=ultima(A,['ensamble']);
let comp=(ens&&ens.componentes)||{};
// sin acta de ensamble, los módulos se deducen del número de unidad: HBOX5-E02-A002-R0002 → V5-02 · ARD-002 · RTK-0002
const mSer=!comp.einar && j.serie.match(/^HBOX5-E(\d+)-A(\d+)-R(\d+)$/i);
if(mSer) comp={einar:'V5-'+mSer[1].padStart(2,'0'), ard:'ARD-'+mSer[2].padStart(3,'0'), modem:'RTK-'+mSer[3].padStart(4,'0')};
if(esHbox){
// módulos: se consultan por su serie
const m=[['Tarjeta EinarBoard','prueba-v5',comp.einar],['ArduSimple','modulo-ard',comp.ard],['Módem 4G','modulo-4g',comp.modem]];
const ph={}; m.forEach(([n,,s])=>{ const p=paso(n,'pend',s?'consultando…':'sin ensamble', s?'<a href="?serie='+encodeURIComponent(s)+'">'+s+'</a>':''); ph[n]=p; prod.append(p); });
const integ=paso('Integración','pend',comp.ard&&comp.modem?'consultando…':'—'); prod.append(integ);
(async()=>{
for(const [n,tipo,s] of m){
if(!s) continue;
let txt='sin acta', cls='pend';
try{ const r=await (await fetch(`/api/acta?tipo=${tipo}&serie=${encodeURIComponent(s)}`)).json();
if(r.exists){ cls=r.acta.resultado==='FALLA'?'falla':'ok'; txt=cls==='ok'?'✓ aprobada':'✕ FALLA'; ph[n].querySelector('.d').innerHTML+=' · '+(r.acta.fecha||fecha(r.acta._recibido).slice(0,10)); } }catch(e){}
ph[n].className='paso '+cls; ph[n].querySelector('.e').textContent=txt;
}
if(comp.ard&&comp.modem){
const si='INT-A'+(String(comp.ard).match(/(\d+)\s*$/)||[,''])[1]+'-R'+(String(comp.modem).match(/(\d+)\s*$/)||[,''])[1];
let txt='sin acta', cls='pend';
try{ const r=await (await fetch(`/api/acta?tipo=integracion&serie=${encodeURIComponent(si)}`)).json();
if(r.exists){ cls=r.acta.resultado==='FALLA'?'falla':'ok'; txt=cls==='ok'?'✓ aprobada':'✕ FALLA'; integ.append(el('div',{class:'d',html:'<a href="?serie='+encodeURIComponent(si)+'">'+si+'</a> · '+(r.acta.fecha||fecha(r.acta._recibido).slice(0,10))})); } }catch(e){}
integ.className='paso '+cls; integ.querySelector('.e').textContent=txt;
}
})();
prod.append(pasoActa('Ensamble',ens,'✓ unidad lista'));
prod.append(pasoActa('Configuración / pruebas',ultima(A,['reflasheo','prueba-ethernet-serie','prueba-bateria-modem']),null,'sin acta'));
} else {
// pieza suelta: su propia acta de prueba, y en qué unidad quedó
prod.append(pasoActa('Acta de la pieza',ultima(A,['prueba-v5','prueba-v4','modulo-ard','modulo-4g'])));
const integ=j.relacionadas.find(r=>r.tipo==='integracion'), ensR=j.relacionadas.find(r=>r.tipo==='ensamble');
prod.append(integ?paso('Integración',integ.resultado==='FALLA'?'falla':'ok',integ.resultado==='FALLA'?'✕ FALLA':'✓ en pareja','<a href="?serie='+encodeURIComponent(integ.serie)+'">'+integ.serie+'</a>'):paso('Integración','pend','sin acta'));
prod.append(ensR?paso('Ensamble',ensR.resultado==='FALLA'?'falla':'info',ensR.resultado==='FALLA'?'✕ FALLA':'▣ montada en unidad','<a href="?serie='+encodeURIComponent(ensR.serie)+'">'+ensR.serie+'</a>'):paso('Ensamble','pend','libre (no está en una unidad)'));
prod.append(pasoActa('Banco / reflasheo',ultima(A,['diagnostico-banco','reflasheo']),null,'—'));
}
// --- campo
sec.append(el('div',{class:'cad-t',text:'Campo'}));
const campo=el('div',{class:'cadena'}); sec.append(campo);
const env=ultima(A,['envio']), rec=ultima(A,['recepcion']), ins=A.filter(a=>a.tipo==='instalacion-campo'&&a.resultado!=='FALLA')[0]||ultima(A,['instalacion-campo']);
const diags=A.filter(a=>a.tipo==='diagnostico-campo'), danos=A.filter(a=>a.tipo==='dano');
const conSitio=async(p,a)=>{ if(a&&a.sitio){ const n=await nombreSitio(a.sitio); p.querySelector('.d').innerHTML+=' · <a href="sitios/sitio.html?id='+encodeURIComponent(a.sitio)+'">'+n+'</a>'; } };
const pEnv=env?paso('Envío',env.accion&&/detenida/i.test(env.accion)?'info':'ok',env.accion||'✓ enviada',(env.fecha||'')+(env.clasificacion?' · '+env.clasificacion:'')):paso('Envío','pend','sin acta'); campo.append(pEnv); conSitio(pEnv,env);
const pRec=rec?paso('Recepción',rec.resultado==='FALLA'?'falla':'ok',rec.accion||rec.resultado||'✓',(rec.fecha||'')+(rec.clasificacion?' · '+rec.clasificacion:'')):paso('Recepción','pend','sin acta'); campo.append(pRec); conSitio(pRec,rec);
const pIns=ins?paso('Instalación',ins.resultado==='FALLA'?'falla':'ok',ins.accion||(ins.resultado==='FALLA'?'✕ FALLA':'✓ instalada'),(ins.fecha||'')+(ins.maquina?' · '+ins.maquina:'')):paso('Instalación','pend','sin acta','<a href="formularios/instalacion.html">llenar acta</a>'); campo.append(pIns); conSitio(pIns,ins);
const pDg=diags.length?paso('Diagnósticos',diags[0].resultado==='FALLA'?'falla':'info',diags.length+' visita(s)',(diags[0].fecha||'')+(diags[0].accion?' · '+diags[0].accion:'')):paso('Diagnósticos','pend','ninguno'); campo.append(pDg); conSitio(pDg,diags[0]);
const pDn=j.marca?paso('Daños',j.marca==='BAJA'?'falla':'falla',j.marca==='BAJA'?'⛔ BAJA':'⚠ DAÑADA',(j.marca_acta&&j.marca_acta.fecha)||''):(danos.length?paso('Daños','ok','reparada / sin marca',danos.length+' acta(s)'):paso('Daños','ok','sin daños')); campo.append(pDn); conSitio(pDn,danos[0]);
return sec;
}
async function cargar(serie){ async function cargar(serie){
cont.innerHTML='<p class="vacio">Cargando…</p>'; cont.innerHTML='<p class="vacio">Cargando…</p>';
try{ try{

View File

@ -56,10 +56,24 @@ markdown_extensions:
permalink: true permalink: true
nav: nav:
- "🏠 Inicio": index.md - "🏠 Inicio":
- "🧭 Guía del ingeniero": guia-ingeniero.md - index.md
- "Guía del ingeniero": guia-ingeniero.md
- "⚙ Cuenta":
- "Mi cuenta (cambiar contraseña)": cuenta.html
- "Administración (Leo)": admin.html
- "Salir": salir.html
- "🏭 Producción — lote V5.6": - "📋 Tableros":
- tableros/index.md
- "Kanban — tareas del equipo": tableros/kanban.html
- "Ingeniería — estado por versión de tarjeta": tableros/ingenieria.html
- "Producción — plan 570 vs. avance real": tableros/produccion.html
- "Estado de actas (tarjetas, módulos, unidades)": tablero.html
- "Tablero de campo (visitas de diagnóstico)": diagnostico/tablero-campo.html
- "Expediente de una unidad": unidad.html
- "🏭 Producción":
- produccion/index.md - produccion/index.md
- "1 · Prueba y flasheo de tarjetas": - "1 · Prueba y flasheo de tarjetas":
- procedimientos/v5-prueba-y-flasheo.md - procedimientos/v5-prueba-y-flasheo.md
@ -74,8 +88,9 @@ nav:
- "Acta de ensamble": formularios/ensamble.html - "Acta de ensamble": formularios/ensamble.html
- "4 · Prueba final antes de embarcar": procedimientos/v5-prueba-final-unidad.md - "4 · Prueba final antes de embarcar": procedimientos/v5-prueba-final-unidad.md
- "5 · Configuración de la unidad": procedimientos/v5-configuracion-unidad.md - "5 · Configuración de la unidad": procedimientos/v5-configuracion-unidad.md
- "6 · Checklist de puesta en marcha": procedimientos/v5-checklist-puesta-en-marcha.md - "📦 Inventario":
- "Tablero de producción": tablero.html - inventario/index.md
- "Inventario — existencias y movimientos": inventario/inventario.html
- "🔬 Taller": - "🔬 Taller":
- taller/index.md - taller/index.md
@ -94,86 +109,86 @@ nav:
- "V4 — preparación y prueba con 0.6.0": - "V4 — preparación y prueba con 0.6.0":
- procedimientos/v4-prueba-fw-0.5.6.md - procedimientos/v4-prueba-fw-0.5.6.md
- "Acta de prueba V4": formularios/v4.html - "Acta de prueba V4": formularios/v4.html
- "V4 — riel SW_5V y arranque del F9P (sin NMEA)": - "V4 — riel SW_5V y arranque del F9P (sin NMEA)": procedimientos/v4-prueba-riel-sw5v-f9p.md
- procedimientos/v4-prueba-riel-sw5v-f9p.md
- "Acta de prueba V4": formularios/v4.html
- "🚜 Campo": - "🚜 Campo":
- campo/index.md - campo/index.md
- "Diagnóstico en campo": - "Diagnóstico en campo":
- diagnostico/procedimiento-diagnostico-campo.md - diagnostico/procedimiento-diagnostico-campo.md
- "Acta de diagnóstico en campo": formularios/diagnostico.html - "Acta de diagnóstico en campo": formularios/diagnostico.html
- "Tablero de campo": diagnostico/tablero-campo.html - "Instalación en el puerto":
- "Puesta en marcha en el puerto": procedimientos/v5-checklist-puesta-en-marcha.md - "Checklist de puesta en marcha (imprimir)": procedimientos/v5-checklist-puesta-en-marcha.md
- "Acta de instalación en campo": formularios/instalacion.html
- "Envío, recepción y daños": - "Envío, recepción y daños":
- procedimientos/ciclo-de-vida.md - procedimientos/ciclo-de-vida.md
- "Acta de envío": formularios/envio.html - "Acta de envío": formularios/envio.html
- "Acta de recepción e inspección": formularios/recepcion.html - "Acta de recepción e inspección": formularios/recepcion.html
- "Acta de daño o incidente": formularios/dano.html - "Acta de daño o incidente": formularios/dano.html
- "Expediente de una unidad": unidad.html - "📍 Sitios":
- sitios/index.md
- "Sitio — unidades instaladas y actas": sitios/sitio.html
- "Actas — índice y cómo se usan": actas/index.md - "Actas — índice y cómo se usan": actas/index.md
- "🔩 Tarjetas y hardware": - "🔩 Tarjetas y hardware":
- hardware/index.md - hardware/index.md
- "Manuales por tarjeta": manuales/index.md - "Manuales por tarjeta": manuales/index.md
- "V5.6 — lote en pedido": - "V5.5 en campo · V5.6 en layout → pedido 113":
- "Manual de la tarjeta V5 (V5.5 y V5.6)": manuales/manual-tarjeta-V5.md - "Manual de la tarjeta V5 (V5.5 y V5.6)": manuales/manual-tarjeta-V5.md
- "Hoja de datos V5.6": hardware/v56-datasheet.md - "Hoja de datos V5.6 (1-oct)": hardware/v56-datasheet.md
- "Pinout de conectores V5.6": hardware/v56-pinout.md - "Pinout de conectores V5.6 (29-sep)": hardware/v56-pinout.md
- "Instrucciones de layout (esquemático → PCB)": hardware/v56-layout-instrucciones.md - "ECO V5.6 — requisitos R1–R5 (27-sep)": hardware/v56-eco.md
- "ECO V5.6": hardware/v56-eco.md - "Cambios al esquemático V5.5 → V5.6 (28-sep)": hardware/v56-cambios-esquematico.md
- "Cambios al esquemático V5.5 → V5.6": hardware/v56-cambios-esquematico.md - "Reglas de layout: esquemático → PCB (29-sep)": hardware/v56-layout-instrucciones.md
- "Layout final — pasada 1 (Michelle, 12-oct)": hardware/v56-layout-pasada-1.md - "▶ Layout pasada 2 — correcciones antes del pedido (Michelle, 13-oct)": hardware/v56-layout-pasada-2.md
- "▶ Layout — pasada 2: correcciones antes del pedido (Michelle, 13-oct)": hardware/v56-layout-pasada-2.md
- "Auditoría del layout 13-oct — NO LISTA (inglés)": hardware/v56-auditoria-layout-13oct.md - "Auditoría del layout 13-oct — NO LISTA (inglés)": hardware/v56-auditoria-layout-13oct.md
- "V5.7 — fuente 24 V → 5 V a bordo (en diseño)": - "V5.7 fuente a bordo → pedido 327 (en diseño)":
- "ECO V5.7 — fuente a bordo (v0.2, esquemático aplicado)": hardware/v57-eco-fuente.md - "ECO V5.7 — fuente 24 V → 5 V a bordo (v0.2, 13-oct)": hardware/v57-eco-fuente.md
- "▶ Guía de layout V5.7 (Leo → Michelle)": hardware/v57-layout-guia.md - "▶ Guía de layout V5.7 — colocar la fuente (Leo, 13-oct)": hardware/v57-layout-guia.md
- "V4": - "v6 GNSS + 4G a bordo → pedido 130 (en diseño)":
- "Manual de la tarjeta V4": manuales/manual-tarjeta-V4.md - "Manual de la tarjeta v6 (marcador)": manuales/manual-tarjeta-v6.md
- "V3 — producción antigua":
- "Manual de la tarjeta V3": manuales/manual-tarjeta-V3.md
- "Pinout V3 (arneses y conectores)": montaje/pinout-arneses.md
- "BOM V3 (dividido para ensamble)": montaje/bom-split.md
- "Guía de ensamble y control de calidad": montaje/guia-ensamble-qc.md
- "Guía de ensamble (PDF)": montaje/assembly-guide.md
- "v6 — en diseño, no pedir":
- "Manual de la tarjeta v6": manuales/manual-tarjeta-v6.md
- "▶ Próximos pasos para ordenar (29-sep)": hardware/v6-proximos-pasos.md - "▶ Próximos pasos para ordenar (29-sep)": hardware/v6-proximos-pasos.md
- "✅ Plan de corrección y estado (28-sep)": hardware/v6-plan-correccion.md - "Plan de corrección y lista de liberación (28-sep)": hardware/v6-plan-correccion.md
- "Cambios de esquemático — resumen de 3 pasadas": hardware/v6-cambios-resumen.md - "Esquemático final — resumen de las 3 pasadas (28-sep)": hardware/v6-cambios-resumen.md
- "Puertos y sin nueva versión (inglés)": hardware/v6-puertos-sin-nueva-version.md - "Mapa de pines MCU para board_v6.h (28-sep)": hardware/v6-pinmap.md
- "Revisión final (inglés)": hardware/v6-revision-final.md - "Plan de cobre y colocación EMC (5-sep)": hardware/EinarBoard-v6_Plan-de-Cobre_ES.html
- "Auditoría completa v6 (inglés)": hardware/v6-full-audit.md - "Plan de cobre y colocación EMC (inglés)": hardware/EinarBoard-v6_Copper-Plan_EN.html
- "Auditoría completa v6": hardware/v6-auditoria-completa.md - "v6 — auditoría del 28-sep y sus pasadas (detalle)":
- "Cambios al esquemático v6": hardware/v6-cambios-esquematico.md - "Auditoría completa v6 (28-sep)": hardware/v6-auditoria-completa.md
- "Revisión v6 contra lecciones V5.5": hardware/v6-revision-lecciones-v55.md - "Auditoría completa v6 (inglés, original)": hardware/v6-full-audit.md
- "Mapa de pines MCU v6": hardware/v6-pinmap.md - "Pasada 1 — cambios al esquemático (28-sep)": hardware/v6-cambios-esquematico.md
- "Auditoría pre-fabricación v6": hardware/v6-auditoria-prefab.md - "Pasada 2 — puertos y sin nueva versión (inglés)": hardware/v6-puertos-sin-nueva-version.md
- "Auditoría pre-fabricación v6 (inglés)": hardware/v6-prefab-audit.md - "Pasada 3 — revisión final independiente (inglés)": hardware/v6-revision-final.md
- "Plan de cobre y colocación EMC v6": hardware/EinarBoard-v6_Plan-de-Cobre_ES.html - "Revisión v6 contra lecciones V5.5 (28-sep, superada)": hardware/v6-revision-lecciones-v55.md
- "Plan de cobre y colocación EMC v6 (inglés)": hardware/EinarBoard-v6_Copper-Plan_EN.html - "V4 y V3 — tarjetas anteriores aún en campo":
- "Manual de la tarjeta V4": manuales/manual-tarjeta-V4.md
- "Manual de la tarjeta V3": manuales/manual-tarjeta-V3.md
- "Pinout V3 (arneses y conectores, jul-2026)": montaje/pinout-arneses.md
- "📕 Manuales y descargas": - "📕 Manuales y descargas":
- "Manual del cliente (instalación y operación)": manuales/manual-cliente.md - "Manual del cliente (instalación y operación)": manuales/manual-cliente.md
- "Manual de instalación y operación (PDF)": manuales/Drogheda-Manual-Instalacion-Operacion_v2.0.pdf - "Manual de instalación y operación v2.0 (PDF)": manuales/Drogheda-Manual-Instalacion-Operacion_v2.0.pdf
- "Ficha técnica HBOX (PDF)": manuales/Drogheda-HBOX-Datasheet_v2.0.pdf - "Ficha técnica HBOX v2.0 (PDF)": manuales/Drogheda-HBOX-Datasheet_v2.0.pdf
- "Descargas": - "Descargas (firmware por tarjeta)":
- descargas/index.md - descargas/index.md
- "Notas de versión del firmware": descargas/notas-de-version.md - "Notas de versión del firmware V4 / V5": descargas/notas-de-version.md
- "README firmware bring-up v5": descargas/hbox-v5-bringup-README.md - "README firmware hbox_v5_bringup (inglés)": descargas/hbox-v5-bringup-README.md
- "Git y archivos de diseño": - "Git y archivos de diseño (KiCad)":
- "Guía Git": git/guia-git-es.md - "Guía Git para colaboradores": git/guia-git-es.md
- "Guía Git (inglés)": git/git-guide-en.md - "Guía Git (inglés)": git/git-guide-en.md
- "📚 Referencia e histórico": - "📚 Referencia e histórico":
- "Protocolo de diagnóstico remoto": diagnostico/protocolo-diagnostico-remoto.md - historia/index.md
- "EinarBoard 1.0 — puesta en marcha (histórico)": procedimientos/einarboard-1.0-bringup.md - "Procedimientos anteriores (superados)":
- "FW v3.0.3 — prueba de banco del ventilador": procedimientos/fan-policy-bench-test.md - "Protocolo de diagnóstico remoto — unidades v1 (jun-2026)": diagnostico/protocolo-diagnostico-remoto.md
- "Histórico — prototipo v3 en protoboard": procedimientos/historico-proto-bench-v3.md - "EinarBoard 1.0 — puesta en marcha (jul-2026)": procedimientos/einarboard-1.0-bringup.md
- "Histórico — lista de compras del protoboard": procedimientos/historico-protoboard.md - "FW v3.0.3 — prueba de banco del ventilador (ago-2026)": procedimientos/fan-policy-bench-test.md
- "Prototipo v3 en protoboard — bring-up (jun-2026)": procedimientos/historico-proto-bench-v3.md
- "⚙ Cuenta": - "Prototipo v3 en protoboard — lista de compras (jun-2026)": procedimientos/historico-protoboard.md
- "Mi cuenta (cambiar contraseña)": cuenta.html - "V3 — producción de jul-2026 (superado)":
- "Administración (Leo)": admin.html - "BOM V3 dividido para ensamble (jul-2026)": montaje/bom-split.md
- "Salir": salir.html - "Guía de ensamble y QC — borrador V3 (sep-2026)": montaje/guia-ensamble-qc.md
- "Guía de ensamble del gabinete 0007-1000 (PDF)": montaje/assembly-guide.md
- "Tarjetas — documentos superados":
- "V5.6 layout pasada 1 (Michelle, 12-oct) — superada por la pasada 2": hardware/v56-layout-pasada-1.md
- "v6 auditoría pre-fabricación (25-sep) — superada por la del 28-sep": hardware/v6-auditoria-prefab.md
- "v6 auditoría pre-fabricación (25-sep, inglés)": hardware/v6-prefab-audit.md

View File

@ -16,7 +16,7 @@ echo "Sitio publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)"
# El backend NO vive en git en el servidor: se copia igual que el sitio. # El backend NO vive en git en el servidor: se copia igual que el sitio.
# Sin --delete (ahí junto al app.py viven el venv y __pycache__). # Sin --delete (ahí junto al app.py viven el venv y __pycache__).
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend" BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt \ 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 "$REMOTE:$BACKEND_PATH/" backend/parche-nginx-api.py backend/actas-hbox.service "$REMOTE:$BACKEND_PATH/"
echo "Backend copiado en $REMOTE:$BACKEND_PATH" echo "Backend copiado en $REMOTE:$BACKEND_PATH"
echo echo