diagnóstico en campo: acta, fotos, tablero y procedimiento

This commit is contained in:
Leopoldo Rodríguez 2026-09-18 10:49:46 -06:00
parent 53be5d4de4
commit 455dbc4dc1
12 changed files with 1860 additions and 20 deletions

159
DESPLIEGUE.md Normal file
View File

@ -0,0 +1,159 @@
# Despliegue — sitio, backend de actas y usuarios
Servidor: `git.helgen.online` · dominio público: `https://drogheda.helgen.online`
## Qué vive dónde
| Parte | Ruta en el servidor | Cómo se actualiza |
|---|---|---|
| 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` |
| 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** |
| 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
versión— hay que hacer además el `git pull` + `restart` en el servidor, o el sitio nuevo hablará
con un backend viejo (las fotos darían 404 y el diagnóstico se bloquearía como si fuera un acta
de banco).
---
## A · Despliegue de esta versión (diagnóstico en campo)
Los pasos 3, 4 y 5 son **de una sola vez**; los pasos 1 y 2 son el ciclo normal de siempre.
### 1 · Desde la Mac — publicar el sitio
```bash
cd ~/Documents/Claude/Projects/Drogheda/hbox-docs
git add -A && git commit -m "diagnóstico en campo: acta, fotos, tablero y procedimiento"
git push
./scripts/deploy.sh # mkdocs build --strict + rsync a /srv/.../site
```
### 2 · En el servidor — actualizar el backend
```bash
cd /srv/projects/drogheda/hbox-docs && git pull
sudo systemctl restart actas-hbox
curl -s http://127.0.0.1:8081/api/salud # {"ok":true,"servicio":"actas-hbox"}
```
### 3 · Carpeta de fotos (una sola vez)
```bash
sudo mkdir -p /srv/projects/drogheda/adjuntos
sudo chown -R leo:leo /srv/projects/drogheda/adjuntos
```
El backend la deduce sola de `ACTAS_DIR`; si prefieres fijarla, agrega a
`/etc/systemd/system/actas-hbox.service` (y luego `daemon-reload` + `restart`):
```ini
Environment=ADJUNTOS_DIR=/srv/projects/drogheda/adjuntos
Environment=ADJUNTO_MAX_MB=8
Environment=ACTAS_REPETIBLES=diagnostico-campo,instalacion-campo
```
### 4 · nginx — subida de fotos e identidad del técnico (una sola vez)
En el bloque `server { listen 443 … }` de `drogheda.helgen.online`, el `location /api/` queda así:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Remote-User $remote_user; # ← quién es el técnico
client_max_body_size 12m; # ← sin esto, las fotos dan 413
proxy_read_timeout 120s; # ← subidas con señal mala
}
```
Las **dos líneas marcadas son nuevas y obligatorias**:
- Sin `client_max_body_size`, nginx corta la foto en 1 MB y el técnico ve *"la foto pesa
demasiado"* aunque no lo pese. (El formulario ya reduce cada foto a ~1600 px / 300-400 kB
antes de subirla; el margen es para ráfagas de varias fotos.)
- Sin `X-Remote-User`, el backend no sabe quién envió cada acta: el nombre no se precarga y —más
serio— **cualquier usuario autenticado podría mandar la cabecera a mano y hacerse pasar por
`leo` para borrar actas**. Al fijarla en nginx, el valor del cliente se sobrescribe siempre.
Revisa si ya la tienes: es fácil que no, porque no venía en el README original.
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 5 · Usuarios de los técnicos (una sola vez)
```bash
sudo htpasswd /etc/nginx/hbox-docs.htpasswd alan
sudo htpasswd /etc/nginx/hbox-docs.htpasswd daniel
# (sin -c: -c CREA el archivo de cero y borraría a leo y a luz)
sudo cut -d: -f1 /etc/nginx/hbox-docs.htpasswd # lista de usuarios
```
Administradores (quién puede borrar o reabrir actas) = variable `ACTAS_ADMINS` del servicio,
hoy `leo`. Alan y Daniel quedan como técnicos: llenan y envían, no borran.
---
## B · Verificación después de desplegar
```bash
# 1. el backend responde y sabe quién eres
curl -s -u leo:PASS https://drogheda.helgen.online/api/whoami # {"user":"leo","admin":true}
curl -s -u alan:PASS https://drogheda.helgen.online/api/whoami # {"user":"alan","admin":false}
```
Si `user` sale vacío, falta la línea `X-Remote-User` del paso 4.
```bash
# 2. la subida de fotos funciona de punta a punta
curl -s -u alan:PASS -F tipo=diagnostico-campo -F serie=CAMPO-PRUEBA \
-F foto=@cualquier-foto.jpg \
https://drogheda.helgen.online/api/adjuntos
# → {"ok":true,"fotos":[{"ruta":"diagnostico-campo/CAMPO-PRUEBA/2026…jpg",…}]}
# 3. el acta de campo NO se bloquea al segundo envío
curl -s -u alan:PASS https://drogheda.helgen.online/api/historial\?tipo=diagnostico-campo\&serie=CAMPO-PRUEBA
```
Y la prueba que de verdad cuenta, desde un teléfono:
abrir `https://drogheda.helgen.online/formularios/diagnostico.html`, elegir la unidad
**⚙ PRUEBA (no cuenta)**, tomar una foto y enviar. Las series con `PRUEBA` se guardan aparte,
en `_pruebas/`, y no ensucian el registro.
---
## C · Ciclo normal a partir de aquí
```bash
# contenido nuevo desde el repo Drogheda (lista curada dentro del script)
./scripts/sync-docs.sh
git add -A && git commit -m "…" && git push
./scripts/deploy.sh
# y SOLO si tocaste backend/app.py:
ssh leo@git.helgen.online 'cd /srv/projects/drogheda/hbox-docs && git pull && sudo systemctl restart actas-hbox'
```
## D · Respaldo
Todo lo que los técnicos generan son dos carpetas planas — cópialas y ya:
```bash
rsync -az leo@git.helgen.online:/srv/projects/drogheda/actas/ ~/respaldo-actas/
rsync -az leo@git.helgen.online:/srv/projects/drogheda/adjuntos/ ~/respaldo-fotos/
```
## E · Problemas frecuentes
| Síntoma | Causa | Arreglo |
|---|---|---|
| *"La foto pesa demasiado"* con fotos normales | falta `client_max_body_size` | paso 4 |
| El nombre del técnico no se precarga | falta `X-Remote-User` | paso 4 |
| *"Ya existe un acta para esta serie"* en diagnóstico | backend viejo | paso 2 (`git pull` + restart) |
| Las fotos se ven rotas en el tablero | falta la carpeta `adjuntos` o su permiso | paso 3 |
| `mkdocs build --strict` falla | un enlace del `nav` apunta a un archivo que no existe | revisa `mkdocs.yml` |
| El técnico no puede entrar | usuario no creado, o se usó `htpasswd -c` y borró a los demás | paso 5 |

View File

@ -18,19 +18,33 @@ Rutas:
POST /api/actas -> crea (409 si ya existe una real)
DELETE /api/acta?tipo=&serie= -> borra (solo admin)
GET /api/actas -> listado (ingeniería); incluye 'componentes' en actas compuestas
GET /api/historial?tipo=&serie= -> todas las actas de esa serie (tipos repetibles: diagnóstico de campo)
POST /api/adjuntos -> sube fotos (multipart) y devuelve sus rutas
GET /api/adjunto?ruta= -> descarga una foto guardada
DELETE /api/acta?...&archivo= -> borra UNA acta concreta (solo admin; tipos repetibles)
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).
"""
import os, re, json, csv, datetime
from flask import Flask, request, jsonify, abort
import os, re, json, csv, datetime, uuid
from flask import Flask, request, jsonify, abort, send_file
app = Flask(__name__)
BASE = os.environ.get("ACTAS_DIR", "/srv/projects/drogheda/actas")
ADJ = os.environ.get("ADJUNTOS_DIR", os.path.join(os.path.dirname(BASE.rstrip("/")) or "/srv/projects/drogheda", "adjuntos"))
ADMINS = {u.strip().lower() for u in os.environ.get("ACTAS_ADMINS", "leo").split(",") if u.strip()}
TIPOS_UNICOS = {"ensamble"} # tipos en los que un componente solo puede usarse en un acta real
# Tipos REPETIBLES: una misma serie puede tener varias actas (cada visita a campo es un evento nuevo).
TIPOS_REPETIBLES = {t.strip() for t in os.environ.get(
"ACTAS_REPETIBLES", "diagnostico-campo,instalacion-campo").split(",") if t.strip()}
# Subida de fotos
MAX_MB = int(os.environ.get("ADJUNTO_MAX_MB", "8"))
app.config["MAX_CONTENT_LENGTH"] = (MAX_MB + 2) * 1024 * 1024
EXT_OK = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
SAFE_FILE = re.compile(r"[^A-Za-z0-9_\-.]")
def slug(s, n=40): return SAFE.sub("", str(s or "").strip())[:n]
def current_user():
@ -60,6 +74,18 @@ def componentes_en_uso(tipo, serie_actual, componentes):
usados.append((k, v, d.get("serie")))
return usados
def es_repetible(tipo):
return tipo in TIPOS_REPETIBLES
def listar_serie(tipo, serie):
"""Todas las actas guardadas para (tipo, serie), de la más vieja a la más nueva."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
return []
pref = serie + "_"
return sorted(fn for fn in os.listdir(carpeta)
if fn.startswith(pref) and fn.endswith(".json"))
def find_latest(tipo, serie):
"""Ruta del acta real más reciente para (tipo, serie), o None."""
carpeta = os.path.join(BASE, tipo)
@ -84,12 +110,24 @@ def get_acta():
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
archivo = SAFE_FILE.sub("", str(request.args.get("archivo") or ""))[:120]
if archivo:
if archivo not in listar_serie(tipo, serie):
return jsonify(ok=True, exists=False), 404
with open(os.path.join(BASE, tipo, archivo), encoding="utf-8") as f:
d = json.load(f)
d["_archivo"] = archivo
return jsonify(ok=True, exists=True, acta=d)
ruta = find_latest(tipo, serie)
if not ruta:
return jsonify(ok=True, exists=False)
return jsonify(ok=True, exists=False, repetible=es_repetible(tipo))
with open(ruta, encoding="utf-8") as f:
d = json.load(f)
d["_archivo"] = os.path.basename(ruta)
# En un tipo repetible el acta previa NO bloquea: se devuelve como antecedente.
if es_repetible(tipo):
return jsonify(ok=True, exists=False, repetible=True, previa=d,
visitas=len(listar_serie(tipo, serie)))
return jsonify(ok=True, exists=True, acta=d)
@app.post("/api/actas")
@ -112,8 +150,8 @@ def crear():
es_prueba = "PRUEBA" in serie.upper()
# un acta real solo se puede enviar una vez
if not es_prueba and find_latest(tipo, serie):
# un acta real solo se puede enviar una vez (salvo tipos repetibles: cada visita es un evento)
if not es_prueba and not es_repetible(tipo) and find_latest(tipo, serie):
return jsonify(ok=False, existe=True,
error=f"Ya existe un acta para {serie}. Pide a Leo que la borre para rehacerla."), 409
@ -128,6 +166,12 @@ def crear():
det = "; ".join(f"{v} ya está en {s}" for _, v, s in usados)
return jsonify(ok=False, error=f"Módulo ya asignado: {det}. Pide a Leo que revise."), 409
fotos = d.get("fotos")
if isinstance(fotos, list):
d["fotos"] = [f for f in fotos[:60]
if isinstance(f, dict) and isinstance(f.get("ruta"), str)
and os.path.isfile(os.path.join(ADJ, f["ruta"]))]
ts = datetime.datetime.now().replace(microsecond=0)
iso = ts.isoformat()
d["_recibido"] = iso
@ -138,6 +182,10 @@ def crear():
carpeta = os.path.join(BASE, tipo, "_pruebas") if es_prueba else os.path.join(BASE, tipo)
os.makedirs(carpeta, exist_ok=True)
archivo = f"{serie}_{iso.replace(':', '-')}.json"
if es_repetible(tipo):
# cada visita es un registro propio: sufijo único para que dos envíos
# en el mismo segundo (doble toque) no se pisen
archivo = f"{serie}_{iso.replace(':', '-')}_{uuid.uuid4().hex[:6]}.json"
with open(os.path.join(carpeta, archivo), "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
@ -147,8 +195,11 @@ def crear():
with open(idx, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if nuevo:
w.writerow(["recibido", "serie", "tecnico", "resultado", "archivo"])
w.writerow([iso, serie, tecnico, d.get("resultado", ""), archivo])
w.writerow(["recibido", "serie", "tecnico", "resultado", "clasificacion",
"accion", "fotos", "archivo"])
w.writerow([iso, serie, tecnico, d.get("resultado", ""),
d.get("clasificacion", ""), d.get("accion", ""),
len(d.get("fotos") or []), archivo])
sub = f"{tipo}/_pruebas/{archivo}" if es_prueba else f"{tipo}/{archivo}"
return jsonify(ok=True, archivo=sub, prueba=es_prueba)
@ -158,10 +209,17 @@ def borrar_acta():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede borrar actas."), 403
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
archivo = SAFE_FILE.sub("", str(request.args.get("archivo") or ""))[:120]
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
carpeta = os.path.join(BASE, tipo)
borrados = []
# borrar UNA visita concreta (tipos repetibles), no toda la serie
if archivo:
existentes = listar_serie(tipo, serie)
if archivo in existentes:
os.remove(os.path.join(carpeta, archivo)); borrados.append(archivo)
return jsonify(ok=True, borrados=borrados)
if os.path.isdir(carpeta):
pref = serie + "_"
for fn in list(os.listdir(carpeta)):
@ -221,9 +279,105 @@ def listar():
"resultado": d.get("resultado"), "recibido": d.get("_recibido")}
if isinstance(d.get("componentes"), dict):
fila["componentes"] = d["componentes"]
for k in ("clasificacion", "accion", "motivo", "maquina", "terminal", "unidad"):
if d.get(k):
fila[k] = d[k]
if d.get("fotos"):
fila["fotos"] = [f.get("ruta") for f in d["fotos"] if f.get("ruta")]
fila["archivo_n"] = fn
filas.append(fila)
filas.sort(key=lambda r: (r.get("recibido") or ""), reverse=True)
return jsonify(ok=True, total=len(filas), actas=filas)
# ------------------------------------------------------------------ historial
@app.get("/api/historial")
def historial():
"""Todas las actas de una serie, de la más reciente a la más vieja (visitas a campo)."""
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
carpeta = os.path.join(BASE, tipo)
out = []
for fn in listar_serie(tipo, serie):
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
out.append({"archivo": fn, "recibido": d.get("_recibido"), "tecnico": d.get("tecnico"),
"resultado": d.get("resultado"), "clasificacion": d.get("clasificacion"),
"accion": d.get("accion"), "motivo": d.get("motivo"),
"fotos": len(d.get("fotos") or [])})
out.reverse()
return jsonify(ok=True, total=len(out), visitas=out)
# ------------------------------------------------------------------ adjuntos (fotos)
def _tipo_imagen(cab):
if cab[:3] == b"\xff\xd8\xff":
return ".jpg"
if cab[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if cab[:4] == b"RIFF" and cab[8:12] == b"WEBP":
return ".webp"
return None
@app.post("/api/adjuntos")
def subir_adjuntos():
"""Sube fotos de campo. multipart/form-data: tipo, serie, foto (1..10), etiqueta opcional."""
tipo = slug(request.form.get("tipo")); serie = slug(request.form.get("serie"), 24)
etiqueta = str(request.form.get("etiqueta", ""))[:80]
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
archivos = request.files.getlist("foto")
if not archivos:
return jsonify(ok=False, error="no llegó ninguna foto"), 400
if len(archivos) > 10:
return jsonify(ok=False, error="máximo 10 fotos por envío"), 400
carpeta = os.path.join(ADJ, tipo, serie)
os.makedirs(carpeta, exist_ok=True)
guardadas, rechazadas = [], []
for fh in archivos:
datos = fh.read(MAX_MB * 1024 * 1024 + 1)
if len(datos) > MAX_MB * 1024 * 1024:
rechazadas.append({"nombre": fh.filename, "error": f"pesa más de {MAX_MB} MB"}); continue
ext = _tipo_imagen(datos[:16])
if not ext:
rechazadas.append({"nombre": fh.filename, "error": "no es una imagen JPG/PNG/WEBP"}); continue
nom = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6] + ext
with open(os.path.join(carpeta, nom), "wb") as f:
f.write(datos)
guardadas.append({"ruta": f"{tipo}/{serie}/{nom}", "bytes": len(datos),
"etiqueta": etiqueta, "usuario": current_user(),
"subida": datetime.datetime.now().replace(microsecond=0).isoformat()})
if not guardadas:
return jsonify(ok=False, error=(rechazadas[0]["error"] if rechazadas else "no se guardó nada"),
rechazadas=rechazadas), 400
return jsonify(ok=True, fotos=guardadas, rechazadas=rechazadas)
@app.get("/api/adjunto")
def ver_adjunto():
ruta = (request.args.get("ruta") or "").strip().lstrip("/")
if not ruta or ".." in ruta:
return jsonify(ok=False, error="ruta inválida"), 400
fp = os.path.realpath(os.path.join(ADJ, ruta))
if not fp.startswith(os.path.realpath(ADJ) + os.sep) or not os.path.isfile(fp):
return jsonify(ok=False, error="no existe"), 404
return send_file(fp)
@app.errorhandler(413)
def _muy_grande(e):
return jsonify(ok=False, error=f"La foto pesa demasiado (máximo {MAX_MB} MB). "
"Vuelve a tomarla o espera a tener mejor señal."), 413
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))

229
backend/app.py.bak Normal file
View File

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Actas HBOX — servicio para recibir y consultar formularios firmados de los técnicos.
Reglas:
- Un acta real (serie que NO contiene PRUEBA) se puede enviar UNA sola vez por serie.
Un segundo envío se rechaza (409) hasta que un administrador la borre.
- Al seleccionar una serie ya enviada, el formulario la vuelve a cargar (llena, bloqueada).
- Solo el usuario 'leo' (administrador) puede borrar/reabrir un acta.
- Las series PRUEBA se guardan aparte (_pruebas), no cuentan y se pueden reenviar.
El usuario autenticado llega en la cabecera X-Remote-User (la fija nginx desde $remote_user;
el cliente no puede falsificarla porque nginx la sobrescribe).
Rutas:
GET /api/salud
GET /api/whoami -> {user, admin}
GET /api/acta?tipo=&serie= -> {exists, acta?}
POST /api/actas -> crea (409 si ya existe una real)
DELETE /api/acta?tipo=&serie= -> borra (solo admin)
GET /api/actas -> listado (ingeniería); incluye 'componentes' en actas compuestas
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).
"""
import os, re, json, csv, datetime
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
BASE = os.environ.get("ACTAS_DIR", "/srv/projects/drogheda/actas")
ADMINS = {u.strip().lower() for u in os.environ.get("ACTAS_ADMINS", "leo").split(",") if u.strip()}
TIPOS_UNICOS = {"ensamble"} # tipos en los que un componente solo puede usarse en un acta real
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
def slug(s, n=40): return SAFE.sub("", str(s or "").strip())[:n]
def current_user():
return (request.headers.get("X-Remote-User") or "").strip()
def is_admin():
return current_user().lower() in ADMINS
def componentes_en_uso(tipo, serie_actual, componentes):
"""Devuelve [(clave, valor, serie_otra)] para componentes ya usados por OTRA acta real del mismo tipo."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta) or not isinstance(componentes, dict):
return []
usados = []
for fn in os.listdir(carpeta):
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
if d.get("serie") == serie_actual or not isinstance(d.get("componentes"), dict):
continue
for k, v in componentes.items():
if v and d["componentes"].get(k) == v:
usados.append((k, v, d.get("serie")))
return usados
def find_latest(tipo, serie):
"""Ruta del acta real más reciente para (tipo, serie), o None."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
return None
pref = serie + "_"
cand = sorted(fn for fn in os.listdir(carpeta)
if fn.startswith(pref) and fn.endswith(".json"))
return os.path.join(carpeta, cand[-1]) if cand else None
@app.get("/api/salud")
def salud():
return jsonify(ok=True, servicio="actas-hbox")
@app.get("/api/whoami")
def whoami():
u = current_user()
return jsonify(ok=True, user=u, admin=(u.lower() in ADMINS))
@app.get("/api/acta")
def get_acta():
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
ruta = find_latest(tipo, serie)
if not ruta:
return jsonify(ok=True, exists=False)
with open(ruta, encoding="utf-8") as f:
d = json.load(f)
d["_archivo"] = os.path.basename(ruta)
return jsonify(ok=True, exists=True, acta=d)
@app.post("/api/actas")
def crear():
try:
d = request.get_json(force=True)
except Exception:
return jsonify(ok=False, error="JSON inválido"), 400
if not isinstance(d, dict):
return jsonify(ok=False, error="Se esperaba un objeto"), 400
tipo = slug(d.get("tipo")); serie = slug(d.get("serie"), 24)
tecnico = str(d.get("tecnico", "")).strip()
if not tipo or not serie:
return jsonify(ok=False, error="Faltan 'tipo' o 'serie'"), 400
if not tecnico:
return jsonify(ok=False, error="Falta el nombre del técnico"), 400
if not d.get("conforme"):
return jsonify(ok=False, error="Falta la conformidad del técnico"), 400
es_prueba = "PRUEBA" in serie.upper()
# un acta real solo se puede enviar una vez
if not es_prueba and find_latest(tipo, serie):
return jsonify(ok=False, existe=True,
error=f"Ya existe un acta para {serie}. Pide a Leo que la borre para rehacerla."), 409
# actas compuestas (ensamble): un módulo no puede pertenecer a dos unidades
comps = d.get("componentes")
if isinstance(comps, dict):
comps = {slug(k, 20): slug(v, 20) for k, v in comps.items()}
d["componentes"] = comps
if not es_prueba and tipo in TIPOS_UNICOS:
usados = componentes_en_uso(tipo, serie, comps)
if usados:
det = "; ".join(f"{v} ya está en {s}" for _, v, s in usados)
return jsonify(ok=False, error=f"Módulo ya asignado: {det}. Pide a Leo que revise."), 409
ts = datetime.datetime.now().replace(microsecond=0)
iso = ts.isoformat()
d["_recibido"] = iso
d["_usuario"] = current_user()
d["_ip"] = request.headers.get("X-Forwarded-For", request.remote_addr)
d["_prueba"] = es_prueba
carpeta = os.path.join(BASE, tipo, "_pruebas") if es_prueba else os.path.join(BASE, tipo)
os.makedirs(carpeta, exist_ok=True)
archivo = f"{serie}_{iso.replace(':', '-')}.json"
with open(os.path.join(carpeta, archivo), "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
if not es_prueba:
idx = os.path.join(carpeta, "_indice.csv")
nuevo = not os.path.exists(idx)
with open(idx, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if nuevo:
w.writerow(["recibido", "serie", "tecnico", "resultado", "archivo"])
w.writerow([iso, serie, tecnico, d.get("resultado", ""), archivo])
sub = f"{tipo}/_pruebas/{archivo}" if es_prueba else f"{tipo}/{archivo}"
return jsonify(ok=True, archivo=sub, prueba=es_prueba)
@app.delete("/api/acta")
def borrar_acta():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede borrar actas."), 403
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
carpeta = os.path.join(BASE, tipo)
borrados = []
if os.path.isdir(carpeta):
pref = serie + "_"
for fn in list(os.listdir(carpeta)):
if fn.startswith(pref) and fn.endswith(".json"):
os.remove(os.path.join(carpeta, fn)); borrados.append(fn)
idx = os.path.join(carpeta, "_indice.csv")
if os.path.exists(idx):
rows = list(csv.reader(open(idx, encoding="utf-8")))
keep = ([rows[0]] if rows else []) + [r for r in rows[1:] if not (len(r) > 1 and r[1] == serie)]
with open(idx, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(keep)
return jsonify(ok=True, borrados=borrados)
@app.delete("/api/actas")
def borrar_todas():
"""Reset admin: borra TODAS las actas reales de un tipo (opcional: también las de prueba)."""
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede reiniciar actas."), 403
tipo = slug(request.args.get("tipo"))
incl_pruebas = request.args.get("pruebas") == "1"
if not tipo:
return jsonify(ok=False, error="falta tipo"), 400
carpeta = os.path.join(BASE, tipo)
n = 0
if os.path.isdir(carpeta):
for fn in list(os.listdir(carpeta)):
fp = os.path.join(carpeta, fn)
if fn.endswith(".json"):
os.remove(fp); n += 1
elif fn == "_indice.csv":
os.remove(fp)
if incl_pruebas:
pdir = os.path.join(carpeta, "_pruebas")
if os.path.isdir(pdir):
for fn in list(os.listdir(pdir)):
if fn.endswith(".json"):
os.remove(os.path.join(pdir, fn)); n += 1
return jsonify(ok=True, borradas=n)
@app.get("/api/actas")
def listar():
filas = []
if os.path.isdir(BASE):
for tipo in sorted(os.listdir(BASE)):
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
continue
for fn in sorted(os.listdir(carpeta)):
if fn.endswith(".json"):
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
fila = {"tipo": tipo, "archivo": f"{tipo}/{fn}",
"serie": d.get("serie"), "tecnico": d.get("tecnico"),
"resultado": d.get("resultado"), "recibido": d.get("_recibido")}
if isinstance(d.get("componentes"), dict):
fila["componentes"] = d["componentes"]
filas.append(fila)
filas.sort(key=lambda r: (r.get("recibido") or ""), reverse=True)
return jsonify(ok=True, total=len(filas), actas=filas)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))

View File

@ -38,6 +38,17 @@ batería y respaldo. Procedimiento: [Ensamble de la unidad](../procedimientos/v5
**[▶ Acta de prueba — EinarBoard V4](../formularios/v4.html)** — *borrador*.
---
## Campo
**[▶ Acta de diagnóstico en campo — unidades instaladas](../formularios/diagnostico.html)**
Para revisar una unidad que falló en la máquina. A diferencia de las actas de banco, **esta se
puede llenar varias veces por unidad**: cada visita es un registro nuevo y el técnico ve las
visitas anteriores antes de empezar. Permite **subir fotos** desde el teléfono.
Procedimiento: [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) ·
Seguimiento: [Tablero de campo](../diagnostico/tablero-campo.html).
## Cómo se usan
1. Abre el formulario y **elige el número de serie** (o los módulos, en integración y ensamble).

View File

@ -0,0 +1,156 @@
# Diagnóstico de unidades HBOX en campo — procedimiento
**Para:** técnicos de campo (Alan, Daniel) · **Versión:** 1.0 · 18-sep-2026 · **Responsable:** Leo Rodríguez
El acta se llena en el teléfono, en la máquina:
**[▶ Abrir el acta de diagnóstico en campo](../formularios/diagnostico.html)**
---
## 1. Qué se espera de ti (y qué no)
**No** se espera que encuentres la causa de la falla. Eso se determina en el banco de
Querétaro, con instrumentos que no existen en el patio.
Lo que sí depende de ti, y que nadie más puede hacer, es **traer la evidencia completa del
estado en que estaba la unidad**. Esa es toda tu misión, y es más valiosa de lo que parece:
!!! danger "La regla que más se rompe y más caro sale"
**Una unidad reiniciada antes de leerla ya no se puede diagnosticar.**
El HBOX guarda en su memoria la **temperatura máxima** que ha alcanzado, **cuántas veces
se ha tenido que recuperar** y **por qué arrancó la última vez**. Todo eso se borra al
cortarle la corriente. Si llegas y lo primero que haces es desconectarlo "a ver si así
revive", destruiste el único dato que explicaba la falla — y la unidad vuelve a fallar
dentro de un mes sin que sepamos por qué.
Por eso el procedimiento dice: **no cortes la energía hasta terminar el paso 5.**
## 2. Qué llevar
| Herramienta | Para qué | ¿Obligatoria? |
|---|---|---|
| Teléfono con cámara y datos | El acta y las fotos | **Sí** |
| Multímetro | Decidir si la falla es del gabinete o del HBOX | **Sí** |
| Laptop + cable USB (micro-USB) | La captura serial: el dato más valioso de la visita | Muy recomendada |
| Desarmadores / llave del gabinete | Abrir | Sí |
| Etiquetas y plumón | Marcar la unidad que se desmonta | Sí |
| Bolsa antiestática | Transportar la unidad desmontada | Sí |
| Termómetro infrarrojo | Medir la temperatura interior del gabinete | Deseable — cuesta poco y mide la hipótesis térmica |
## 3. Seguridad
1. **Nunca** trabajes sobre un RTG o equipo energizado sin el permiso y el bloqueo (LOTO) del
terminal. Si el equipo está operando, **no subas**: agenda con el supervisor de ICAVE.
2. **Batería de litio.** Hinchada, caliente o con olor: no sigas desarmando, no la perfores,
sepárala de la electrónica, foto y aviso el mismo día. **Una batería hinchada no viaja en avión.**
3. **No abras el gabinete bajo lluvia** ni si está mojado por fuera.
4. El gabinete puede tener **24 V**: no es peligroso para la vida, pero un corto sí puede
quemar la tarjeta. Mide con la máquina apagada siempre que puedas.
## 4. Los pasos, en orden
| Paso | Qué haces | Por qué importa |
|---|---|---|
| 1 | Anotas **por qué vienes** y qué dijo el operador | La hora en que dejó de reportar acota la causa |
| 2 | Fotos con el gabinete **cerrado**: máquina, gabinete, antenas | Sol, altura, antena floja o robada se ven aquí |
| 3 | **Cuentas los destellos del LED** en 10 segundos | Es el diagnóstico gratis que la unidad te está dando |
| 4 | Abres, hueles, tocas, revisas la batería | Calor y agua dejan rastro visible |
| 5 | **Captura por USB** a 115200 baudios, 3 minutos | Saca la memoria de la unidad antes de perderla |
| 6 | *Ahora sí* puedes cortar la energía y probar | Ya está toda la evidencia guardada |
| 7 | Clasificas, decides cómo queda y firmas | Convierte tu visita en un dato comparable |
## 5. Tabla del LED — qué te está diciendo la unidad
Mira el LED **10 segundos con el reloj** y **cuenta cuántas veces prende**. No lo estimes:
5 destellos y 10 destellos en 10 segundos significan cosas **opuestas**.
| Lo que ves | Destellos en 10 s | Estado interno | Qué significa |
|---|---|---|---|
| **Apagado**, nunca prende | 0 | sin energía o MCU muerto | No le llega corriente, **o** el HBOX ya no enciende. El multímetro decide cuál. |
| **Fijo encendido** | — | `RUN` + vehículo + enlace OK | La unidad está sana. Si no la ves en el sistema, el problema está en el servidor. |
| Destello **muy cortito** cada 4 s | ~2, muy breves | `SLEEP` | Se durmió por batería baja. Lleva tiempo sin corriente de la máquina. |
| Prende 1 s / apaga 1 s (**latido**) | **5** | `RUN` **sin energía de vehículo** | El HBOX vive pero está en su batería: la máquina no le da 24 V. La falla está aguas arriba. |
| Parpadeo parejo 1 por segundo | **10** | `RUN` + enlace degradado | Las correcciones llegan a ratos. Antenas, señal, SIM. |
| **Dos destellos** juntos y pausa | ~7 | `RUN` + enlace en las últimas | Prácticamente sin correcciones. |
| Parpadeo **rápido continuo** | no se cuentan | arrancando o reiniciándose | Puede estar reiniciándose en bucle. **Solo la captura USB lo confirma.** |
## 6. Medidas esperadas
Con la punta negra en el chasis/negativo del gabinete:
| Dónde mides | Esperado | Si sale mal |
|---|---|---|
| Entrada de la máquina al gabinete | 12 V o 24 V según el equipo | La máquina no está alimentando el gabinete |
| Salida de la fuente Mean Well | **23 – 26 V** | Fuente muerta o entrada ausente |
| **GX16 pines 1-2** (lo que le llega al HBOX) | **23 – 26 V** | *Esta es la medida clave — ver abajo* |
| GX16 pin 3 (ignición), máquina encendida | 12 – 24 V | Señal de ignición perdida |
!!! tip "La medida que parte el diagnóstico en dos"
**Hay 24 V en el GX16 y el LED sigue apagado** → la falla es **del HBOX** → se desmonta y se envía.
**No hay 24 V en el GX16** → la falla es **del gabinete o de la máquina** (breaker, fusible,
fuente, cable) → el HBOX probablemente está sano; no lo desmontes.
## 7. La captura por USB
Es el paso que más nos sirve y el que más se salta. Vale la pena cargar la laptop.
```
1. Conecta el USB del Blackpill a la laptop.
2. Abre el puerto serie nuevo a 115200 baudios (PuTTY, TeraTerm o el monitor de Arduino).
3. Activa "guardar a archivo" (log to file).
4. Déjalo correr 3 minutos.
5. Presiona RESET y graba también el arranque.
6. Guarda el archivo con el nombre de la unidad.
```
Verás líneas así:
```
BOOT,1240,fw=3.0.3,reset=WDG
TLM,182000,RUN,DEGR,1,11,4210,1HZ,3.92,5.02,1,1,58,81,0,3,1,900,44,38,57,…
```
Los cuatro campos que debes anotar aunque no puedas copiar la línea completa:
| Campo | Qué es | Por qué importa |
|---|---|---|
| `tmax` | La **temperatura máxima** que ha visto la unidad | Confirma o descarta la hipótesis térmica de un plumazo |
| `rec` | Cuántas veces se ha tenido que **recuperar** sola | Un número alto = falla intermitente, no muerte súbita |
| `vbat` | Voltaje de la batería | Diferencia "batería agotada" de "batería dañada" |
| `veh` | 1 = le llega corriente de la máquina, 0 = no | Resuelve solo la mitad de los casos |
Y de la línea `BOOT`, el campo `reset=` dice **por qué arrancó la última vez**
(corte de energía, watchdog, brownout). Es la causa de muerte escrita por la propia unidad.
## 8. Catálogo de clasificaciones
Elige lo que **observaste**, no lo que supones. *No está claro* es una respuesta válida y útil;
inventar una causa nos hace perder semanas.
| Clasificación | Qué la sustenta | Qué pasa después |
|---|---|---|
| Sin corriente desde la máquina o el gabinete | 0 V en el GX16 | Se corrige en sitio (breaker, fusible, cable) |
| El HBOX no enciende aunque le llega 24 V | 24 V en el GX16 + LED apagado | Desmontar y enviar a QRO |
| Enlace 4G: no hay datos ni correcciones | LED 1 Hz o doble destello | Se revisa SIM, antenas y servidor antes de tocar hardware |
| Antena suelta, rota o faltante | Foto del conector | Se corrige en sitio |
| Batería dañada | Hinchada, caliente o con fuga | Aislar hoy; **no** enviar por avión |
| Daño por calor | Quemado, derretido, decolorado | Evidencia directa para el rediseño térmico |
| Agua adentro del gabinete | Encharcado o empañado | Se revisa sellado y prensaestopas |
| Daño mecánico o golpe | Foto | Se revisa el montaje |
| La unidad se ve sana — parece el servidor | LED fijo y captura sana | **No desmontar.** Ingeniería revisa el backend |
| No está claro — se necesita banco | Todo lo demás | Desmontar y enviar |
## 9. Qué pasa cuando envías el acta
1. Queda guardada en el servidor con tu nombre, la hora y las fotos.
2. Aparece en el **[tablero de campo](tablero-campo.html)**, que ingeniería revisa en Querétaro.
3. La siguiente persona que abra el acta de esa unidad **verá tu visita** antes de empezar.
Puedes llenar el acta **sin señal**: se guarda sola en el teléfono. Las fotos y el envío
necesitan datos — si falla, vuelve a intentar desde el mismo formulario cuando tengas señal,
no vuelvas a empezar.
!!! note "Para ingeniería"
Cada visita es un registro independiente (`ACTAS_REPETIBLES`), no se bloquea por serie.
Las fotos se guardan en `/srv/projects/drogheda/adjuntos/diagnostico-campo/<serie>/`.
El índice por tipo está en `/srv/projects/drogheda/actas/diagnostico-campo/_indice.csv`.

View File

@ -0,0 +1,180 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de campo — diagnósticos HBOX</title>
<style>
:root{
--naranja:#e8590c; --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;
}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){
--papel:#191b1e;--tinta:#e6e1d6;--tinta2:#a09a8d;--linea:#33363b;--tarjeta:#1f2226;--superf:#1b1d20;
--ok:#69db7c;--ok-bg:#1b2a1f;--falla:#ff8787;--falla-bg:#2c1d1d;--pend:#5c636a;--pend-bg:#26282c;--info:#74c0fc;--info-bg:#182734;}}
*{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}
.wrap{max-width:1020px;margin:0 auto;padding:18px 16px 70px}
header.top{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;
background:var(--tinta);color:#fff;padding:16px;border-radius:12px;margin-bottom:16px}
header.top h1{margin:0;font-size:20px}
header.top .meta{color:#d7d2c8;font-size:13px}
a.volver{color:#fff;font-size:13px;text-decoration:none;opacity:.85}
button{background:var(--naranja);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px}
.kpi{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:14px 16px}
.kpi .n{font-size:30px;font-weight:700;line-height:1}
.kpi .l{color:var(--tinta2);font-size:13px;margin-top:4px}
section.bloque{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:16px;margin:14px 0}
section.bloque h2{margin:0 0 2px;font-size:17px}
.resumen{color:var(--tinta2);font-size:13.5px;margin:0 0 12px}
.unidad{border:1px solid var(--linea);border-radius:11px;padding:12px 14px;margin:10px 0;background:var(--superf)}
.unidad .cab{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;justify-content:space-between}
.unidad .s{font-weight:700;font-size:15px}
.unidad .m{color:var(--tinta2);font-size:13px}
.pill{font-size:11.5px;font-weight:700;padding:3px 10px;border-radius:99px;white-space:nowrap}
.pill.ok{background:var(--ok-bg);color:var(--ok)}
.pill.falla{background:var(--falla-bg);color:var(--falla)}
.pill.pend{background:var(--pend-bg);color:var(--tinta2)}
.pill.info{background:var(--info-bg);color:var(--info)}
.visita{border-top:1px solid var(--linea);margin-top:10px;padding-top:10px;font-size:13.5px}
.visita .f{color:var(--tinta2);font-size:12.5px}
.visita .cl{font-weight:700}
.fotos{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px}
.fotos a{display:block;width:74px;height:74px;border-radius:8px;overflow:hidden;border:1px solid var(--linea)}
.fotos img{width:100%;height:100%;object-fit:cover;display:block;background:var(--pend-bg)}
details{margin-top:8px}
summary{cursor:pointer;color:var(--naranja);font-size:13px;font-weight:600}
table.det{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px}
table.det td{padding:5px 8px 5px 0;border-bottom:1px solid var(--linea);vertical-align:top}
table.det td.k{color:var(--tinta2);width:45%}
.vacio{color:var(--tinta2);font-size:13.5px;font-style:italic}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de campo — diagnósticos HBOX</h1><div class="meta" id="meta">Cargando…</div></div>
<div style="display:flex;gap:12px;align-items:center">
<a class="volver" href="../">← Documentación</a>
<a class="volver" href="../formularios/diagnostico.html">Nueva acta de diagnóstico</a>
<button id="refrescar">Actualizar</button>
</div>
</header>
<div id="cont"></div>
</div>
<script src="../formularios/flota.js"></script>
<script>
const TIPO = 'diagnostico-campo';
const cont = document.getElementById('cont');
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=>e.append(c)); return e;};
const fecha = s => (s||'').replace('T',' ').slice(0,16);
function pill(v){
const t = String(v.accion||'');
if (/^Instalada y reportando/.test(t)) return ['ok','Operando'];
if (/^Instalada/.test(t)) return ['falla','Instalada sin resolver'];
if (/Querétaro/.test(t)) return ['info','En camino a QRO'];
if (/taller/.test(t)) return ['info','En taller'];
if (/Reemplazada/.test(t)) return ['ok','Reemplazada'];
return ['pend', v.resultado || '—'];
}
async function detalle(tipo, serie, archivo, caja){
caja.textContent = 'Cargando…';
try{
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}&archivo=${encodeURIComponent(archivo)}`);
const j = await r.json();
if(!j.exists) throw new Error('no encontrada');
const t = el('table',{class:'det'});
const res = j.acta.respuestas || {};
Object.keys(res).forEach(k=>{
const v = res[k]; if(v.val===''||v.val===false||v.val==null) return;
t.append(el('tr',null, el('td',{class:'k',text:(v.lbl||k).replace(/<[^>]+>/g,'')}),
el('td',{text: v.val===true ? '✓' : String(v.val)})));
});
caja.innerHTML=''; caja.append(t);
}catch(e){ caja.textContent = 'No se pudo cargar el detalle.'; }
}
function render(actas){
cont.innerHTML='';
const porUnidad = {};
actas.filter(a=>a.tipo===TIPO).forEach(a=>{ (porUnidad[a.serie]=porUnidad[a.serie]||[]).push(a); });
const flota = (window.FLOTA||[]).slice();
Object.keys(porUnidad).forEach(s=>{ if(!flota.some(u=>u.serie===s)) flota.push({serie:s, etiqueta:s, maquina:'(fuera del registro de flota)', terminal:'Sin clasificar'}); });
const visitas = actas.filter(a=>a.tipo===TIPO);
const conVisita = Object.keys(porUnidad).length;
const operando = flota.filter(u=>{const v=(porUnidad[u.serie]||[])[0]; return v && pill(v)[0]==='ok';}).length;
const kp = el('div',{class:'kpis'});
[[flota.length,'Unidades en el registro'],[visitas.length,'Diagnósticos recibidos'],
[conVisita,'Unidades ya diagnosticadas'],[operando,'Reportadas como operando']]
.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);
const grupos = {};
flota.forEach(u=>{ (grupos[u.terminal||'Sin clasificar']=grupos[u.terminal||'Sin clasificar']||[]).push(u); });
Object.keys(grupos).sort().forEach(g=>{
const b = el('section',{class:'bloque'});
b.append(el('h2',{text:g}));
b.append(el('p',{class:'resumen',text:grupos[g].length+' unidad(es) registradas en esta terminal.'}));
grupos[g].forEach(u=>{
const vs = (porUnidad[u.serie]||[]);
const box = el('div',{class:'unidad'});
const cab = el('div',{class:'cab'});
cab.append(el('div',null, el('div',{class:'s',text:u.serie}), el('div',{class:'m',text:(u.maquina||'')+(u.nota?' · '+u.nota:'')})));
const [cls,txt] = vs.length ? pill(vs[0]) : ['pend','Sin diagnóstico'];
cab.append(el('span',{class:'pill '+cls,text:txt}));
box.append(cab);
if(!vs.length) box.append(el('div',{class:'visita vacio',text:'Todavía no hay ningún diagnóstico registrado para esta unidad.'}));
vs.forEach(v=>{
const d = el('div',{class:'visita'});
d.append(el('div',{class:'f',text:fecha(v.recibido)+' · '+(v.tecnico||'—')+(v.motivo?' · '+v.motivo:'')}));
if(v.clasificacion) d.append(el('div',{class:'cl',text:v.clasificacion}));
if(v.accion) d.append(el('div',{text:'→ '+v.accion}));
if(v.fotos && v.fotos.length){
const f = el('div',{class:'fotos'});
v.fotos.forEach(r=>{
const a = el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(r),target:'_blank'});
a.append(el('img',{src:'/api/adjunto?ruta='+encodeURIComponent(r),loading:'lazy'}));
f.append(a);
});
d.append(f);
}
const det = el('details');
det.append(el('summary',{text:'Ver todas las respuestas'}));
const caja = el('div'); det.append(caja);
det.addEventListener('toggle',()=>{ if(det.open && !caja.dataset.listo){ caja.dataset.listo='1'; detalle(TIPO, v.serie, v.archivo_n, caja); } });
d.append(det);
box.append(d);
});
b.append(box);
});
cont.append(b);
});
}
async function cargar(){
document.getElementById('meta').textContent = 'Cargando…';
try{
const r = await fetch('/api/actas',{cache:'no-store'});
const j = await r.json();
render(j.actas||[]);
document.getElementById('meta').textContent = 'Actualizado ' + new Date().toLocaleString('es-MX',{dateStyle:'short',timeStyle:'short'});
}catch(e){
cont.innerHTML=''; cont.append(el('div',{class:'err',text:'No se pudo leer el servidor de actas: '+e.message}));
document.getElementById('meta').textContent = 'Error';
}
}
document.getElementById('refrescar').addEventListener('click',cargar);
cargar();
</script>
</body>
</html>

View File

@ -70,3 +70,53 @@ button.limpiar:hover{border-color:var(--naranja);color:var(--naranja)}
.radios{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:4px}
.radios .rad{font-weight:500;display:flex;align-items:center;gap:6px;cursor:pointer}
.radios input{width:22px;height:22px;accent-color:var(--naranja)}
/* ================= actas de campo (diagnóstico / instalación) ================= */
/* ramas del árbol de diagnóstico: se ocultan hasta que la respuesta previa las activa */
.oculto{display:none !important}
/* la unidad elegida: máquina y terminal, para que el técnico confirme que abrió la correcta */
.ficha-unidad:not(:empty){margin-top:8px;padding:8px 10px;background:var(--naranja-suave);
border:1px solid #ffd8a8;border-radius:8px;font-size:13.5px;line-height:1.35}
/* historial de visitas anteriores a la misma unidad */
.hist{margin:0 0 14px;padding:12px 14px;background:#eef3f8;border:1px solid #c9dcec;
border-radius:10px;font-size:14px;color:#1b3b57}
.hist:empty{display:none}
.hist-t{margin-bottom:6px}
.hist-r{padding:4px 0;border-top:1px solid #d6e4f0}
/* fotos */
.item.foto .txt{display:block;font-weight:600;margin-bottom:8px}
button.btn-foto{width:100%;background:#fff;color:var(--naranja);border:2px dashed var(--naranja);
border-radius:12px;padding:16px;font-size:16px;font-weight:700;cursor:pointer}
button.btn-foto:active{background:var(--naranja-suave)}
.miniaturas{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
.miniaturas:empty{margin-top:0}
.mini{width:104px;border:1px solid var(--linea);border-radius:10px;padding:6px;background:#fff;
display:flex;flex-direction:column;gap:4px;align-items:center}
.mini img{width:90px;height:90px;object-fit:cover;border-radius:6px;background:#eee}
.mini .est{font-size:11px;font-weight:700;text-align:center;line-height:1.2}
.mini.ok{border-color:var(--ok)} .mini.ok .est{color:var(--ok)}
.mini.err{border-color:var(--falla)} .mini.err .est{color:var(--falla)}
.mini.subiendo .est{color:var(--tinta2)}
button.mini-btn{background:#fff;border:1px solid var(--linea);border-radius:6px;
font-size:11px;padding:3px 7px;cursor:pointer;color:var(--tinta2)}
/* aviso de seguridad: más fuerte que un 'aviso' normal */
.peligro{background:var(--falla-bg);border:2px solid var(--falla);color:#a01a1a;
padding:12px 14px;border-radius:10px;margin:10px 0;font-size:14px;line-height:1.45}
.peligro b{color:var(--falla)}
/* en el patio: con guantes, sol y prisa — todo más grande */
@media(max-width:560px){
body{font:17px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{padding:12px 12px 130px}
.chk input{width:30px;height:30px}
.medida input{max-width:none;flex:1 1 140px;font-size:17px;padding:13px 12px}
.resultado label{padding:16px;font-size:17px}
section.paso{padding:12px 13px}
.barra{padding:10px 12px}
button.enviar{padding:15px 18px}
}

View File

@ -2,7 +2,19 @@
- Un acta real se envía UNA vez: al elegir una serie ya enviada, se carga llena y bloqueada.
- Solo el administrador (leo) ve el botón "Borrar acta (reabrir)".
- La serie PRUEBA no se bloquea (para practicar).
Tipos de ítem: check | medida | texto | radio | nota · Envío: POST /api/actas
Tipos de ítem: check | medida | texto | radio | nota | foto · Envío: POST /api/actas
Extensiones para actas de CAMPO (diagnóstico / instalación):
(c) cfg.repetible = true → la serie NO se bloquea: cada visita es un registro nuevo,
y se muestra el historial de visitas anteriores.
cfg.unidades = [{serie, etiqueta, maquina, terminal, nota}] → lista de la flota desplegada
(+ opción "otra / no listada" con captura libre).
cfg.tecnicoDesdeUsuario = true → precarga el nombre del usuario que inició sesión.
item.clave = 'led' → nombre estable de la respuesta (para condiciones y reportes)
item.si = v => v.led==='apagado' → muestra el ítem/sección solo si se cumple
item.req = true → obligatorio para poder enviar
item.guardaEn = 'clasificacion' → además copia el valor a la raíz del acta (índice/tablero)
item.tipo = 'foto' → cámara del teléfono; sube a /api/adjuntos y guarda la ruta
Modos de serie:
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
@ -16,6 +28,21 @@
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
const esPrueba = s => String(s || '').toUpperCase().indexOf('PRUEBA') >= 0;
/* Reduce la foto antes de subirla: en el patio la señal es mala y una foto de 4 MB
no sube. 1600 px de lado mayor + JPEG 0.8 ≈ 200-400 kB y se sigue leyendo una
etiqueta o una soldadura quemada. */
async function reducir(file, max = 1600, calidad = 0.8) {
const bmp = await (window.createImageBitmap
? createImageBitmap(file, { imageOrientation: 'from-image' }).catch(() => createImageBitmap(file))
: new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }));
const w = bmp.width, h = bmp.height, e = Math.min(1, max / Math.max(w, h));
const cv = document.createElement('canvas');
cv.width = Math.round(w * e); cv.height = Math.round(h * e);
cv.getContext('2d').drawImage(bmp, 0, 0, cv.width, cv.height);
const blob = await new Promise(r => cv.toBlob(r, 'image/jpeg', calidad));
return blob || file;
}
function opciones(sel, prefijo, desde, hasta, pad) {
sel.append($('option', { value: '', text: '— elegir —' }));
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
@ -54,7 +81,26 @@
const preBadge = []; // prerrequisitos -> <span>
const tecnico = $('input', { type: 'text', id: 'f_tecnico', placeholder: 'Nombre y apellido' });
if (!compuesta) {
const flota = Array.isArray(cfg.unidades) && cfg.unidades.length > 0;
const serieOtra = $('input', { type: 'text', id: 'f_serie_otra', placeholder: 'Escribe la etiqueta de la unidad tal como viene en la caja' });
const fichaUnidad = $('div', { class: 'ficha-unidad' });
if (flota) {
serieSel.append($('option', { value: '', text: '— elegir unidad —' }));
const grupos = {};
cfg.unidades.forEach(u => { (grupos[u.terminal || 'Otras'] = grupos[u.terminal || 'Otras'] || []).push(u); });
Object.keys(grupos).forEach(g => {
const og = $('optgroup', { label: g });
grupos[g].forEach(u => og.append($('option', { value: u.serie, text: u.etiqueta || (u.serie + (u.maquina ? ' · ' + u.maquina : '')) })));
serieSel.append(og);
});
serieSel.append($('option', { value: '__OTRA__', text: '➕ Otra unidad (no está en la lista)' }));
serieSel.append($('option', { value: 'CAMPO-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
const dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Unidad' }), serieSel, serieOtra, fichaUnidad);
serieOtra.style.display = 'none'; serieOtra.style.marginTop = '8px';
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dS, dT);
} else if (!compuesta) {
opciones(serieSel, cfg.prefijo, cfg.desde || 1, (cfg.desde || 1) + (cfg.series || 15) - 1, cfg.pad || 2);
const dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Número de serie' }), serieSel);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
@ -81,10 +127,21 @@
const banner = $('div', { class: 'msg', id: 'f_banner' });
wrap.append(banner);
const hist = $('div', { class: 'hist' });
if (cfg.repetible) wrap.append(hist);
// selección actual de componentes -> {clave: valor}
function seleccion() { const s = {}; for (const k in compSel) s[k] = compSel[k].value; return s; }
function unidadSel() {
if (!flota) return null;
return (cfg.unidades || []).find(u => u.serie === serieSel.value) || null;
}
function serieActual() {
if (flota) {
if (serieSel.value === '__OTRA__')
return String(serieOtra.value || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '-').slice(0, 24);
return serieSel.value;
}
if (!compuesta) return serieSel.value;
const s = seleccion();
if (Object.values(s).some(v => !v)) return '';
@ -94,13 +151,23 @@
const key = () => 'acta:' + cfg.tipo + ':' + (serieActual() || '');
// ---------------- secciones ----------------
const condicionales = []; // [{el, fn}] — secciones o ítems que solo se muestran si se cumple algo
const obligatorios = []; // [{id, txt, esFoto}]
const guardaEn = {}; // data-id -> nombre del campo en la raíz del acta
let fotos = []; // [{ruta, etiqueta, pendiente?}]
const pendientes = []; // fotos que no lograron subir: {blob, etiqueta, li}
cfg.secciones.forEach((sec, si) => {
const s = $('section', { class: 'paso' });
s.append($('h2', { text: sec.titulo }));
if (sec.sub) s.append($('p', { class: 'sub', text: sec.sub }));
if (sec.sub) s.append($('p', { class: 'sub', html: sec.sub }));
if (typeof sec.si === 'function') condicionales.push({ el: s, fn: sec.si });
(sec.items || []).forEach((it, ii) => {
const id = `s${si}_${ii}`;
const id = it.clave || `s${si}_${ii}`;
const box = $('div', { class: 'item' });
if (typeof it.si === 'function') condicionales.push({ el: box, fn: it.si });
if (it.req) obligatorios.push({ id, txt: String(it.txt || '').replace(/<[^>]+>/g, ''), esFoto: it.tipo === 'foto' });
if (it.guardaEn) guardaEn[id] = it.guardaEn;
if (it.aviso) box.append($('div', { class: 'aviso', html: it.aviso }));
if (it.tipo === 'check') {
const lab = $('label', { class: 'chk' });
@ -127,6 +194,17 @@
box.append(g);
} else if (it.tipo === 'nota') {
box.append($('div', { class: 'nota-item', html: it.txt }));
} else if (it.tipo === 'foto') {
box.classList.add('foto');
box.append($('span', { class: 'txt', html: (it.req ? '<b>Foto obligatoria</b> — ' : 'Foto — ') + it.txt }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const inp = $('input', { type: 'file', accept: 'image/*', capture: 'environment', multiple: 'multiple', style: 'display:none' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: '📷 Tomar o elegir foto' });
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Fotos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
btn.addEventListener('click', () => inp.click());
inp.addEventListener('change', () => { subirFotos(inp.files, id, it.etiqueta || String(it.txt || '').replace(/<[^>]+>/g, '').slice(0, 60), tiras, marca); inp.value = ''; });
box.append(btn, inp, tiras, marca);
}
s.append(box);
});
@ -169,12 +247,28 @@
tecnico.disabled = !on; conforme().disabled = !on;
wrap.querySelectorAll('input[name=resultado]').forEach(el => el.disabled = !on);
}
function pintarFotos() {
wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = '');
fotos.forEach(f => {
const t = wrap.querySelector(`.miniaturas[data-fotos="${f.campo}"]`);
const marca = wrap.querySelector(`[data-id="${f.campo}"]`);
if (!t) return;
const li = $('div', { class: 'mini ok' });
li.append($('img', { src: '/api/adjunto?ruta=' + encodeURIComponent(f.ruta) }),
$('span', { class: 'est', text: '✓ guardada' }));
if (marca) li.append(quitarFoto(f, li, marca));
t.append(li);
});
}
function blankFields() {
fotos = [];
wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = '');
fields().forEach(el => { if (el.type === 'checkbox' || el.type === 'radio') el.checked = false; else el.value = ''; });
const rr = wrap.querySelector('input[name=resultado]:checked'); if (rr) rr.checked = false;
conforme().checked = false; tecnico.value = '';
conforme().checked = false; tecnico.value = nombreUsuario;
}
function fillFrom(a) {
if (Array.isArray(a.fotos)) { fotos = a.fotos.slice(); pintarFotos(); }
tecnico.value = a.tecnico || '';
conforme().checked = !!a.conforme;
if (a.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${a.resultado}"]`); if (el) el.checked = true; }
@ -206,7 +300,116 @@
estado.textContent = 'Bloqueada';
}
// ---------------- condiciones, obligatorios y fotos (actas de campo) ----------------
function valores() {
const v = {};
fields().forEach(el => {
const k = el.dataset.id;
if (el.type === 'checkbox') v[k] = el.checked;
else if (el.type === 'radio') { if (el.checked) v[k] = el.value; else if (!(k in v)) v[k] = ''; }
else if (el.type !== 'hidden') v[k] = el.value;
});
v.__fotos = fotos;
return v;
}
function aplicarCondiciones() {
if (!condicionales.length) return;
const v = valores();
condicionales.forEach(c => {
let ok = false;
try { ok = !!c.fn(v); } catch (e) { ok = false; }
c.el.classList.toggle('oculto', !ok);
});
}
function visible(el) {
for (let n = el; n && n !== document.body; n = n.parentElement)
if (n.classList && n.classList.contains('oculto')) return false;
return true;
}
function faltantes() {
const v = valores(); const falta = [];
obligatorios.forEach(o => {
const el = wrap.querySelector(`[data-id="${o.id}"]`);
if (!el || !visible(el)) return; // oculto por una condición: no aplica
if (o.esFoto) { if (!fotos.some(f => f.campo === o.id)) falta.push(o.txt); return; }
const val = v[o.id];
if (val === '' || val === false || val === undefined || val === null) falta.push(o.txt);
});
return falta;
}
function quitarFoto(f, li, marca) {
const b = $('button', { type: 'button', class: 'mini-btn', text: '✕ quitar' });
b.addEventListener('click', () => {
fotos = fotos.filter(x => x !== f); li.remove();
marca.value = fotos.filter(x => x.campo === f.campo).length || '';
guardarLocal(); aplicarCondiciones();
});
return b;
}
async function enviarFoto(blob, campo, etiqueta, li, est, marca) {
const fd = new FormData();
fd.append('tipo', cfg.tipo); fd.append('serie', serieActual());
fd.append('etiqueta', etiqueta); fd.append('foto', blob, 'foto.jpg');
try {
const r = await fetch('/api/adjuntos', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
const f = j.fotos[0]; f.campo = campo; fotos.push(f);
li.className = 'mini ok'; est.textContent = '✓ guardada';
li.append(quitarFoto(f, li, marca));
marca.value = fotos.filter(x => x.campo === campo).length;
guardarLocal(); aplicarCondiciones();
} catch (e) {
li.className = 'mini err'; est.textContent = 'no subió — ' + e.message;
const rt = $('button', { type: 'button', class: 'mini-btn', text: '⟳ reintentar' });
rt.addEventListener('click', () => {
li.className = 'mini subiendo'; est.textContent = 'subiendo…'; rt.remove();
enviarFoto(blob, campo, etiqueta, li, est, marca);
});
li.append(rt);
}
}
async function subirFotos(lista, campo, etiqueta, tiras, marca) {
if (!serieActual()) { fail('Primero elige la unidad: las fotos se guardan con su número de serie.'); return; }
for (const file of Array.from(lista).slice(0, 10)) {
const li = $('div', { class: 'mini subiendo' });
const img = $('img'); const est = $('span', { class: 'est', text: 'subiendo…' });
li.append(img, est); tiras.append(li);
let blob = file;
try { blob = await reducir(file); } catch (e) {}
try { img.src = URL.createObjectURL(blob); } catch (e) {}
await enviarFoto(blob, campo, etiqueta, li, est, marca);
}
}
// ---------------- historial de visitas (tipos repetibles) ----------------
async function cargarHistorial() {
if (!cfg.repetible) return;
const s = serieActual();
hist.innerHTML = ''; hist.className = 'hist';
if (!s) return;
if (esPrueba(s)) { hist.innerHTML = '<b>Modo prueba</b> — este registro no cuenta.'; return; }
hist.innerHTML = 'Consultando visitas anteriores…';
try {
const r = await fetch(`/api/historial?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(s)}`, { cache: 'no-store' });
const j = await r.json();
if (!j.total) { hist.innerHTML = '🆕 <b>Primera visita</b> registrada para esta unidad.'; return; }
hist.innerHTML = '';
hist.append($('div', { class: 'hist-t', html: '📋 <b>' + j.total + ' visita(s) anterior(es)</b> a esta unidad — léelas antes de empezar:' }));
j.visitas.slice(0, 6).forEach(v => {
const f = (v.recibido || '').replace('T', ' ').slice(0, 16);
hist.append($('div', { class: 'hist-r', html:
'<b>' + f + '</b> · ' + (v.tecnico || '—') +
(v.clasificacion ? ' · <b>' + v.clasificacion + '</b>' : '') +
(v.accion ? ' → ' + v.accion : '') +
(v.fotos ? ' · ' + v.fotos + ' foto(s)' : '') }));
});
} catch (e) { hist.innerHTML = 'No se pudo leer el historial (sin señal). Continúa igual.'; }
}
// ---------------- verificación de componentes / prerrequisitos (modo compuesto) ----------------
let nombreUsuario = ''; // nombre precargado del usuario (no se borra al limpiar)
let verif = {}; // clave -> 'OK' | 'FALLA' | 'SIN_ACTA' | 'PRUEBA' | 'EN_USO'
let usados = null; // { clave: { valor: serieUnidad } } — componentes ya asignados en otras actas de este tipo
function setBadge(el, st, extra) {
@ -273,6 +476,14 @@
const d = { tipo: cfg.tipo, serie: serieActual(), tecnico: tecnico.value,
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); }
if (fotos.length) d.fotos = fotos;
const v = valores();
for (const k in guardaEn) if (v[k] !== undefined && v[k] !== '') d[guardaEn[k]] = v[k];
if (flota) {
const u = unidadSel();
if (u) { d.maquina = u.maquina || ''; d.terminal = u.terminal || ''; d.unidad = u.etiqueta || ''; }
else if (serieSel.value === '__OTRA__') d.unidad = 'no listada';
}
return d;
}
function guardarLocal() {
@ -292,26 +503,39 @@
// ---------------- cargar una serie ----------------
async function loadSerie() {
msg.className = 'msg'; locked = false;
if (flota) {
serieOtra.style.display = serieSel.value === '__OTRA__' ? '' : 'none';
const u = unidadSel();
fichaUnidad.innerHTML = u
? '<b>' + (u.maquina || '—') + '</b> · ' + (u.terminal || '—') + (u.nota ? '<br>' + u.nota : '')
: '';
}
const s = serieActual();
if (compuesta) serieCalc.value = s || '';
blankFields(); showEditable();
if (compuesta) { if (usados === null) await cargarUsados(); await verificar(); }
if (!s) { estado.textContent = compuesta ? 'Elige los tres módulos' : 'Elige el número de serie'; return; }
if (esPrueba(s)) { cargarLocal(); estado.textContent = 'Modo prueba (no cuenta)'; return; }
if (cfg.repetible) cargarHistorial();
if (!s) {
estado.textContent = compuesta ? 'Elige los tres módulos' : (flota ? 'Elige la unidad' : 'Elige el número de serie');
aplicarCondiciones(); return;
}
if (esPrueba(s)) { cargarLocal(); aplicarCondiciones(); estado.textContent = 'Modo prueba (no cuenta)'; return; }
estado.textContent = 'Consultando…';
let j = null;
try { const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(s)}`); j = await r.json(); } catch (e) {}
if (j && j.exists) { locked = true; fillFrom(j.acta); showLocked(j.acta); }
if (j && j.exists) { locked = true; fillFrom(j.acta); showLocked(j.acta); aplicarCondiciones(); }
else {
cargarLocal();
cargarLocal(); aplicarCondiciones();
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
banner.className = 'msg err';
banner.innerHTML = '⛔ <b>No se puede ensamblar con esta selección.</b> Todos los módulos deben estar <b>aprobados</b> en su acta y no estar ya asignados a otra unidad. Revisa los indicadores de arriba.';
}
}
}
wrap.addEventListener('input', guardarLocal);
wrap.addEventListener('input', () => { aplicarCondiciones(); guardarLocal(); });
wrap.addEventListener('change', aplicarCondiciones);
serieSel.addEventListener('change', loadSerie);
serieOtra.addEventListener('change', loadSerie);
Object.values(compSel).forEach(sel => sel.addEventListener('change', loadSerie));
limpiar.addEventListener('click', () => {
@ -344,7 +568,9 @@
if (compuesta && cfg.exigirAprobados && !esPrueba(d.serie) && !todoAprobado())
return fail('Hay módulos sin aprobar, sin acta o ya asignados a otra unidad. No se puede enviar.');
if (!d.tecnico.trim()) return fail('Escribe tu nombre.');
if (!d.resultado) return fail('Marca el resultado final (Aprobada o Falla).');
const falta = faltantes();
if (falta.length) return fail('Faltan pasos obligatorios (' + falta.length + '): ' + falta.slice(0, 4).join(' · ') + (falta.length > 4 ? '…' : ''));
if (!d.resultado) return fail('Marca el resultado final (' + (cfg.textoOK || 'APROBADA') + ' o FALLA).');
if (!d.conforme) return fail('Debes confirmar que seguiste el procedimiento.');
btn.disabled = true; btn.textContent = 'Enviando…';
try {
@ -362,7 +588,14 @@
msg.className = 'msg ok';
msg.innerHTML = '✓ Acta enviada y registrada como <b>' + d.serie + '</b>.'
+ (compuesta && !esPrueba(d.serie) ? '<br>Etiqueta de la unidad: <b style="font-size:18px;letter-spacing:.04em">' + d.serie + '</b>' : '');
if (!j.prueba) { locked = true; showLocked({ tecnico: d.tecnico, resultado: d.resultado, _recibido: new Date().toISOString() }); }
if (cfg.repetible) {
msg.innerHTML = '✓ <b>Registro enviado</b> para <b>' + d.serie + '</b>'
+ (d.fotos && d.fotos.length ? ' con ' + d.fotos.length + ' foto(s)' : '')
+ '.<br>Ingeniería ya puede verlo. Puedes cerrar o elegir otra unidad.';
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
estado.textContent = 'Enviado'; cargarHistorial();
}
else if (!j.prueba) { locked = true; showLocked({ tecnico: d.tecnico, resultado: d.resultado, _recibido: new Date().toISOString() }); }
else { btn.disabled = false; btn.textContent = 'Enviar acta firmada'; estado.textContent = 'Prueba enviada'; }
window.scrollTo(0, document.body.scrollHeight);
} catch (e) {
@ -373,7 +606,14 @@
function fail(m) { msg.className = 'msg err'; msg.textContent = m; window.scrollTo(0, document.body.scrollHeight); }
(async () => {
try { const r = await fetch('/api/whoami'); const j = await r.json(); admin = !!j.admin; } catch (e) {}
try {
const r = await fetch('/api/whoami'); const j = await r.json();
admin = !!j.admin;
if (cfg.tecnicoDesdeUsuario && j.user) {
nombreUsuario = (cfg.nombres && cfg.nombres[j.user]) || j.user;
if (!tecnico.value) tecnico.value = nombreUsuario;
}
} catch (e) {}
loadSerie();
})();
}

View File

@ -0,0 +1,381 @@
/* Motor de actas HBOX — renderiza el formulario, sincroniza con el servidor y firma.
- Un acta real se envía UNA vez: al elegir una serie ya enviada, se carga llena y bloqueada.
- Solo el administrador (leo) ve el botón "Borrar acta (reabrir)".
- La serie PRUEBA no se bloquea (para practicar).
Tipos de ítem: check | medida | texto | radio | nota · Envío: POST /api/actas
Modos de serie:
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
(b) compuesta: cfg.componentes = [{clave, etiqueta, prefijo, desde, hasta, pad, tipoActa}]
cfg.serie(sel) → número de serie calculado, p.ej. "HBOX5-E07-R010-M006"
cfg.prerequisitos = [{etiqueta, tipo, serie(sel)}] → otras actas que deben existir y estar OK
cfg.exigirAprobados = true → no se puede enviar si algún componente/prerrequisito no está aprobado
cfg.unicoPorComponente = true → un componente no puede estar en dos actas de este tipo */
(function () {
const $ = (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=>e.append(c)); return e; };
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
const esPrueba = s => String(s || '').toUpperCase().indexOf('PRUEBA') >= 0;
function opciones(sel, prefijo, desde, hasta, pad) {
sel.append($('option', { value: '', text: '— elegir —' }));
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
for (let i = desde; i <= hasta; i++) {
const s = prefijo + '-' + String(i).padStart(pad, '0');
sel.append($('option', { value: s, text: s }));
}
}
function render(cfg) {
const root = document.getElementById('acta');
const compuesta = Array.isArray(cfg.componentes) && cfg.componentes.length > 0;
let admin = false;
const wrap = $('div', { class: 'wrap' });
const head = $('header', { class: 'top' });
head.append($('h1', { text: cfg.titulo }));
head.append($('p', { text: cfg.subtitulo || 'Helgen Technologies · llena mientras pruebas la tarjeta' }));
wrap.append(head);
const nav = $('div', { style: 'display:flex;justify-content:space-between;margin:2px 0 8px' });
nav.append($('a', { class: 'volver', href: '../actas/', text: '← Volver a Actas' }));
nav.append($('a', { class: 'volver', href: '../salir.html', text: 'Cerrar sesión' }));
wrap.append(nav);
if (cfg.borrador)
wrap.append($('div', { class: 'borrador', html: '⚠ <b>BORRADOR</b> — este formulario aún debe revisarse con ingeniería antes de usarlo como registro oficial.' }));
if (cfg.intro) wrap.append($('div', { class: 'nota-item', style: 'margin:0 0 10px', html: cfg.intro }));
// ---------------- cabecera: serie (simple o compuesta) + técnico ----------------
const datos = $('div', { class: 'datos' });
const serieSel = $('select', { id: 'f_serie' }); // modo simple
const serieCalc = $('input', { type: 'text', id: 'f_serie_calc', readonly: 'readonly', placeholder: 'se calcula al elegir los módulos' }); // modo compuesto
const compSel = {}; // clave -> <select>
const compBadge = {}; // clave -> <span> estado
const preBadge = []; // prerrequisitos -> <span>
const tecnico = $('input', { type: 'text', id: 'f_tecnico', placeholder: 'Nombre y apellido' });
if (!compuesta) {
opciones(serieSel, cfg.prefijo, cfg.desde || 1, (cfg.desde || 1) + (cfg.series || 15) - 1, cfg.pad || 2);
const dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Número de serie' }), serieSel);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dS, dT);
} else {
cfg.componentes.forEach(c => {
const sel = $('select', { id: 'f_comp_' + c.clave, 'data-comp': c.clave });
opciones(sel, c.prefijo, c.desde || 1, c.hasta || ((c.desde || 1) + (c.series || 15) - 1), c.pad || 2);
const badge = $('span', { class: 'badge pend', text: '—' });
const d = $('div'); d.append($('label', { text: c.etiqueta }), sel, badge);
datos.append(d); compSel[c.clave] = sel; compBadge[c.clave] = badge;
});
const dSer = $('div', { class: 'serie-calc' }); dSer.append($('label', { text: cfg.etiquetaSerie || 'Número de serie de la unidad (automático)' }), serieCalc);
(cfg.prerequisitos || []).forEach(p => {
const b = $('span', { class: 'badge pend', text: '—' });
const d = $('div'); d.append($('label', { text: p.etiqueta }), b); dSer.append(d); preBadge.push(b);
});
datos.append(dSer);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dT);
}
const cab = $('section', { class: 'paso' }); cab.append(datos);
wrap.append(cab);
const banner = $('div', { class: 'msg', id: 'f_banner' });
wrap.append(banner);
// selección actual de componentes -> {clave: valor}
function seleccion() { const s = {}; for (const k in compSel) s[k] = compSel[k].value; return s; }
function serieActual() {
if (!compuesta) return serieSel.value;
const s = seleccion();
if (Object.values(s).some(v => !v)) return '';
if (Object.values(s).some(esPrueba)) return (cfg.prefijoPrueba || 'PRUEBA') + '-PRUEBA';
return cfg.serie(s);
}
const key = () => 'acta:' + cfg.tipo + ':' + (serieActual() || '');
// ---------------- secciones ----------------
cfg.secciones.forEach((sec, si) => {
const s = $('section', { class: 'paso' });
s.append($('h2', { text: sec.titulo }));
if (sec.sub) s.append($('p', { class: 'sub', text: sec.sub }));
(sec.items || []).forEach((it, ii) => {
const id = `s${si}_${ii}`;
const box = $('div', { class: 'item' });
if (it.aviso) box.append($('div', { class: 'aviso', html: it.aviso }));
if (it.tipo === 'check') {
const lab = $('label', { class: 'chk' });
lab.append($('input', { type: 'checkbox', 'data-id': id, 'data-lbl': it.txt }));
lab.append($('span', { class: 'txt', html: it.txt }));
box.append(lab);
} else if (it.tipo === 'medida') {
box.classList.add('medida');
box.append($('span', { class: 'txt', text: it.txt }));
if (it.esperado) box.append($('span', { class: 'esp', html: 'Esperado: ' + it.esperado }));
box.append($('input', { type: 'text', inputmode: it.modo || 'decimal', 'data-id': id, 'data-lbl': it.txt, placeholder: it.ph || 'medido' }));
if (it.u) box.append($('span', { class: 'u', text: it.u }));
} else if (it.tipo === 'texto') {
box.append($('label', { text: it.txt }));
box.append($('textarea', { rows: it.rows || '2', 'data-id': id, 'data-lbl': it.txt, placeholder: it.ph || '' }));
} else if (it.tipo === 'radio') {
box.append($('label', { text: it.txt }));
const g = $('div', { class: 'radios' });
(it.opciones || []).forEach(o => {
const l = $('label', { class: 'rad' });
l.append($('input', { type: 'radio', name: 'r_' + id, value: o, 'data-id': id, 'data-lbl': it.txt }), document.createTextNode(' ' + o));
g.append(l);
});
box.append(g);
} else if (it.tipo === 'nota') {
box.append($('div', { class: 'nota-item', html: it.txt }));
}
s.append(box);
});
wrap.append(s);
});
// ---------------- firma ----------------
const firma = $('section', { class: 'paso firma' });
firma.append($('h2', { text: 'Cierre y firma' }));
firma.append($('p', { class: 'sub', text: 'Al enviar, quedas registrado como responsable de esta prueba.' }));
const res = $('div', { class: 'resultado' });
const rOK = $('label', { class: 'ok' }); rOK.append($('input', { type: 'radio', name: 'resultado', value: 'OK' }), document.createTextNode(cfg.textoOK || 'APROBADA'));
const rNO = $('label', { class: 'falla' }); rNO.append($('input', { type: 'radio', name: 'resultado', value: 'FALLA' }), document.createTextNode('FALLA'));
res.append(rOK, rNO);
firma.append($('label', { text: 'Resultado final' }), res);
const conf = $('label', { class: 'chk', style: 'margin-top:14px' });
conf.append($('input', { type: 'checkbox', id: 'f_conforme' }));
conf.append($('span', { class: 'txt', html: 'Confirmo que <b>seguí el procedimiento completo</b> y que los datos anotados son verídicos.' }));
firma.append(conf);
wrap.append(firma);
const msg = $('div', { class: 'msg', id: 'f_msg' });
wrap.append(msg);
root.append(wrap);
const barra = $('div', { class: 'barra' });
const estado = $('span', { class: 'estado', id: 'f_estado', text: 'Sin guardar' });
const limpiar = $('button', { class: 'limpiar', id: 'f_limpiar', text: 'Limpiar' });
const borrar = $('button', { class: 'limpiar', id: 'f_borrar', text: 'Borrar acta (reabrir)', style: 'display:none;border-color:var(--falla);color:var(--falla)' });
const btn = $('button', { class: 'enviar', id: 'f_enviar', text: 'Enviar acta firmada' });
const grp = $('div', { style: 'display:flex;gap:10px' }); grp.append(limpiar, borrar, btn);
barra.append(estado, grp);
document.body.append(barra);
// ---------------- helpers de estado ----------------
const conforme = () => document.getElementById('f_conforme');
function fields() { return wrap.querySelectorAll('[data-id]'); }
function setEnabled(on) {
fields().forEach(el => el.disabled = !on);
tecnico.disabled = !on; conforme().disabled = !on;
wrap.querySelectorAll('input[name=resultado]').forEach(el => el.disabled = !on);
}
function blankFields() {
fields().forEach(el => { if (el.type === 'checkbox' || el.type === 'radio') el.checked = false; else el.value = ''; });
const rr = wrap.querySelector('input[name=resultado]:checked'); if (rr) rr.checked = false;
conforme().checked = false; tecnico.value = '';
}
function fillFrom(a) {
tecnico.value = a.tecnico || '';
conforme().checked = !!a.conforme;
if (a.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${a.resultado}"]`); if (el) el.checked = true; }
for (const k in (a.respuestas || {})) {
const v = a.respuestas[k].val;
const els = wrap.querySelectorAll(`[data-id="${k}"]`); if (!els.length) continue;
els.forEach(el => {
if (el.type === 'checkbox') el.checked = !!v;
else if (el.type === 'radio') el.checked = (el.value === v);
else el.value = v || '';
});
}
}
function showEditable() {
setEnabled(true);
limpiar.style.display = ''; btn.style.display = ''; borrar.style.display = 'none';
wrap.classList.remove('enviada'); banner.className = 'msg';
}
function showLocked(a) {
setEnabled(false);
limpiar.style.display = 'none'; btn.style.display = 'none';
borrar.style.display = admin ? '' : 'none';
banner.className = 'msg ok';
const f = (a._recibido || '').replace('T', ' ').slice(0, 16);
banner.innerHTML = '✓ <b>Acta ya enviada</b> por <b>' + (a.tecnico || '—') + '</b>'
+ (f ? ' el ' + f : '') + '. Registro final'
+ (a.resultado ? ' — resultado <b>' + a.resultado + '</b>' : '') + '.'
+ (admin ? '' : ' Para rehacerla, pide a Leo que la borre.');
estado.textContent = 'Bloqueada';
}
// ---------------- verificación de componentes / prerrequisitos (modo compuesto) ----------------
let verif = {}; // clave -> 'OK' | 'FALLA' | 'SIN_ACTA' | 'PRUEBA' | 'EN_USO'
let usados = null; // { clave: { valor: serieUnidad } } — componentes ya asignados en otras actas de este tipo
function setBadge(el, st, extra) {
const map = { OK: ['ok', '✓ Aprobado'], FALLA: ['falla', '✕ FALLA'], SIN_ACTA: ['falla', '✕ Sin acta'],
PRUEBA: ['pend', '⚙ prueba'], EN_USO: ['falla', '⚠ ya en ' + (extra || 'otra unidad')], CONSULTANDO: ['pend', '…'] };
const m = map[st] || ['pend', '—'];
el.className = 'badge ' + m[0]; el.textContent = m[1];
}
async function estadoActa(tipo, serie) {
if (!serie) return 'SIN_ACTA';
if (esPrueba(serie)) return 'PRUEBA';
try {
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}`);
const j = await r.json();
if (!j.exists) return 'SIN_ACTA';
return j.acta && j.acta.resultado === 'FALLA' ? 'FALLA' : 'OK';
} catch (e) { return 'SIN_ACTA'; }
}
async function cargarUsados() {
if (!cfg.unicoPorComponente) { usados = {}; return; }
usados = {};
try {
const r = await fetch('/api/actas', { cache: 'no-store' }); const j = await r.json();
(j.actas || []).filter(a => a.tipo === cfg.tipo && a.componentes && !esPrueba(a.serie)).forEach(a => {
for (const k in a.componentes) { usados[k] = usados[k] || {}; usados[k][a.componentes[k]] = a.serie; }
});
} catch (e) {}
}
async function verificar() {
verif = {};
const s = seleccion();
const tareas = cfg.componentes.map(async c => {
const v = s[c.clave]; const b = compBadge[c.clave];
if (!v) { setBadge(b, null); verif[c.clave] = 'SIN_SELECCION'; return; }
setBadge(b, 'CONSULTANDO');
let st = await estadoActa(c.tipoActa, v);
if (st === 'OK' && usados && usados[c.clave] && usados[c.clave][v] && usados[c.clave][v] !== serieActual()) {
st = 'EN_USO'; setBadge(b, st, usados[c.clave][v]); verif[c.clave] = st; return;
}
setBadge(b, st); verif[c.clave] = st;
});
const pre = (cfg.prerequisitos || []).map(async (p, i) => {
const b = preBadge[i];
if (Object.values(s).some(v => !v)) { setBadge(b, null); verif['pre_' + i] = 'SIN_SELECCION'; return; }
setBadge(b, 'CONSULTANDO');
const st = await estadoActa(p.tipo, Object.values(s).some(esPrueba) ? 'PRUEBA' : p.serie(s));
setBadge(b, st); verif['pre_' + i] = st;
});
await Promise.all(tareas.concat(pre));
}
function todoAprobado() {
return Object.values(verif).every(v => v === 'OK' || v === 'PRUEBA');
}
// ---------------- autoguardado local ----------------
let t, locked = false;
function snapshot() {
const r = {};
fields().forEach(el => {
if (el.type === 'radio') { if (el.checked) r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.value }; else if (!r[el.dataset.id]) r[el.dataset.id] = { lbl: el.dataset.lbl, val: '' }; }
else r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.type === 'checkbox' ? el.checked : el.value };
});
const rr = wrap.querySelector('input[name=resultado]:checked');
const d = { tipo: cfg.tipo, serie: serieActual(), tecnico: tecnico.value,
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); }
return d;
}
function guardarLocal() {
if (locked || !serieActual()) return;
clearTimeout(t);
t = setTimeout(() => {
try { localStorage.setItem(key(), JSON.stringify(snapshot())); } catch (e) {}
estado.textContent = 'Guardado local ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}, 400);
}
function cargarLocal() {
let d; try { d = JSON.parse(localStorage.getItem(key()) || 'null'); } catch (e) {}
if (d) { fillFrom(d); estado.textContent = 'Borrador recuperado'; }
else estado.textContent = 'Nueva acta';
}
// ---------------- cargar una serie ----------------
async function loadSerie() {
msg.className = 'msg'; locked = false;
const s = serieActual();
if (compuesta) serieCalc.value = s || '';
blankFields(); showEditable();
if (compuesta) { if (usados === null) await cargarUsados(); await verificar(); }
if (!s) { estado.textContent = compuesta ? 'Elige los tres módulos' : 'Elige el número de serie'; return; }
if (esPrueba(s)) { cargarLocal(); estado.textContent = 'Modo prueba (no cuenta)'; return; }
estado.textContent = 'Consultando…';
let j = null;
try { const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(s)}`); j = await r.json(); } catch (e) {}
if (j && j.exists) { locked = true; fillFrom(j.acta); showLocked(j.acta); }
else {
cargarLocal();
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
banner.className = 'msg err';
banner.innerHTML = '⛔ <b>No se puede ensamblar con esta selección.</b> Todos los módulos deben estar <b>aprobados</b> en su acta y no estar ya asignados a otra unidad. Revisa los indicadores de arriba.';
}
}
}
wrap.addEventListener('input', guardarLocal);
serieSel.addEventListener('change', loadSerie);
Object.values(compSel).forEach(sel => sel.addEventListener('change', loadSerie));
limpiar.addEventListener('click', () => {
if (locked) return;
if (!confirm('¿Limpiar este formulario y empezar de nuevo?')) return;
try { localStorage.removeItem(key()); } catch (e) {}
blankFields(); msg.className = 'msg'; estado.textContent = 'Formulario limpio'; window.scrollTo(0, 0);
});
borrar.addEventListener('click', async () => {
if (!admin) return;
if (!confirm('¿Borrar el acta de ' + serieActual() + '? Quedará disponible para rehacerse.')) return;
borrar.disabled = true;
try {
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(serieActual())}`, { method: 'DELETE' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
locked = false; blankFields(); showEditable(); usados = null;
msg.className = 'msg ok'; msg.textContent = 'Acta borrada. Ya puede rehacerse.';
estado.textContent = 'Reabierta';
} catch (e) { msg.className = 'msg err'; msg.textContent = 'No se pudo borrar: ' + e.message; }
borrar.disabled = false;
});
// ---------------- envío ----------------
btn.addEventListener('click', async () => {
if (locked) return;
const d = snapshot();
if (!d.serie) return fail(compuesta ? 'Elige los tres módulos.' : 'Elige el número de serie.');
if (compuesta && cfg.exigirAprobados && !esPrueba(d.serie) && !todoAprobado())
return fail('Hay módulos sin aprobar, sin acta o ya asignados a otra unidad. No se puede enviar.');
if (!d.tecnico.trim()) return fail('Escribe tu nombre.');
if (!d.resultado) return fail('Marca el resultado final (Aprobada o Falla).');
if (!d.conforme) return fail('Debes confirmar que seguiste el procedimiento.');
btn.disabled = true; btn.textContent = 'Enviando…';
try {
const r = await fetch('/api/actas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) });
const j = await r.json();
if (r.status === 409) {
try { localStorage.removeItem(key()); } catch (e) {}
usados = null; await loadSerie();
msg.className = 'msg err'; msg.textContent = j.error || 'Ya existe un acta para esta serie.';
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
return;
}
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
try { localStorage.removeItem(key()); } catch (e) {}
msg.className = 'msg ok';
msg.innerHTML = '✓ Acta enviada y registrada como <b>' + d.serie + '</b>.'
+ (compuesta && !esPrueba(d.serie) ? '<br>Etiqueta de la unidad: <b style="font-size:18px;letter-spacing:.04em">' + d.serie + '</b>' : '');
if (!j.prueba) { locked = true; showLocked({ tecnico: d.tecnico, resultado: d.resultado, _recibido: new Date().toISOString() }); }
else { btn.disabled = false; btn.textContent = 'Enviar acta firmada'; estado.textContent = 'Prueba enviada'; }
window.scrollTo(0, document.body.scrollHeight);
} catch (e) {
fail('No se pudo enviar: ' + e.message + '. Revisa tu conexión (tu borrador está guardado).');
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
}
});
function fail(m) { msg.className = 'msg err'; msg.textContent = m; window.scrollTo(0, document.body.scrollHeight); }
(async () => {
try { const r = await fetch('/api/whoami'); const j = await r.json(); admin = !!j.admin; } catch (e) {}
loadSerie();
})();
}
window.Acta = { render, num };
})();

View File

@ -0,0 +1,247 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de diagnóstico en campo — HBOX</title>
<link rel="stylesheet" href="acta.css">
</head>
<body>
<div id="acta"></div>
<script src="flota.js"></script>
<script src="acta.js"></script>
<script>
const led = v => String(v.led || '');
const abrio = v => String(v.abrir || '').startsWith('S');
const conUsb = v => String(v.usb || '').startsWith('S');
Acta.render({
tipo: 'diagnostico-campo',
repetible: true,
tecnicoDesdeUsuario: true,
nombres: { alan: 'Alan', daniel: 'Daniel', leo: 'Leo Rodríguez' },
unidades: window.FLOTA,
etiquetaSerie: 'Unidad que vas a revisar',
titulo: 'Acta de diagnóstico en campo — HBOX',
subtitulo: 'Helgen Technologies · llénala EN LA MÁQUINA, mientras revisas. No la llenes de memoria después.',
textoOK: 'QUEDA OPERATIVA',
intro: '<b>Para qué sirve esta acta.</b> Nadie espera que tú encuentres la causa de la falla: eso se '
+ 'determina en el banco de Querétaro. Lo que sí depende de ti es que la evidencia llegue completa. '
+ 'Una unidad mal diagnosticada se puede volver a revisar; una unidad que reiniciaste antes de mirarla '
+ '<b>ya no se puede diagnosticar nunca</b>, porque el estado en que la encontraste se borró.',
secciones: [
{ titulo: '⚠ Reglas de oro — léelas antes de tocar la unidad',
items: [
{ tipo:'nota', txt:'<div class="peligro"><b>1 · No reinicies, no desconectes, no abras nada hasta el paso 3.</b> '
+ 'El estado en que encontraste la unidad <b>es la evidencia</b>. Cortarle la energía borra la temperatura '
+ 'máxima, la cuenta de reinicios y la causa del último arranque — justo lo que necesitamos.</div>' },
{ tipo:'nota', txt:'<div class="peligro"><b>2 · Seguridad de la máquina primero.</b> No trabajes sobre un RTG o '
+ 'un equipo energizado sin el permiso y el bloqueo (LOTO) del terminal. Si el equipo está operando, '
+ '<b>no subas</b>: agenda con el supervisor de ICAVE y regresa.</div>' },
{ tipo:'nota', txt:'<div class="peligro"><b>3 · Batería.</b> Si ves la batería <b>hinchada</b>, si <b>quema</b> al '
+ 'tacto o si huele a quemado: no sigas desarmando, no la perfores, sepárala de la electrónica, tómale foto '
+ 'y avisa de inmediato. Una batería de litio hinchada <b>no viaja en avión</b>.</div>' },
{ tipo:'nota', txt:'<div class="peligro"><b>4 · Si está lloviendo o el gabinete está mojado, no lo abras.</b> '
+ 'Anótalo y regresa en seco.</div>' },
{ tipo:'check', clave:'reglas', req:true, txt:'<b>Leí las cuatro reglas</b> y voy a seguir el procedimiento en orden.' },
] },
{ titulo: '1 · Por qué vienes a esta unidad',
sub:'Contesta con lo que te dijeron, aunque suene vago. La versión del operador vale.',
items: [
{ tipo:'radio', clave:'motivo', req:true, guardaEn:'motivo', txt:'Motivo de la visita',
opciones:['Dejó de reportar','Reporta pero con posición mala o saltos','Nunca ha reportado desde que se instaló',
'La máquina se quedó sin la unidad / se desmontó','Revisión preventiva','Otro (explícalo abajo)'] },
{ tipo:'texto', clave:'reporte', rows:'2', txt:'¿Quién avisó y qué dijo exactamente?',
ph:'Ej.: el operador del montacargas dice que ayer en la tarde dejó de verse en el sistema.' },
{ tipo:'texto', clave:'ultima_vez', txt:'¿Cuándo se vio reportando por última vez? (fecha y hora aproximadas)',
ph:'17-sep, como a las 3 de la tarde' },
{ tipo:'radio', clave:'estado_maquina', txt:'¿Cómo está la máquina ahora?',
opciones:['Trabajando normal','Encendida pero parada','Apagada','En taller / fuera de servicio','No sé'] },
] },
{ titulo: '2 · Antes de abrir nada — cómo la encontraste',
sub:'Todo este paso se hace con el gabinete CERRADO y sin cortar la energía.',
items: [
{ tipo:'foto', clave:'f_maquina', req:true, etiqueta:'maquina',
txt:'La máquina completa, de lejos, donde se vea dónde está montado el gabinete.',
ayuda:'Sirve para saber qué sol le pega y a qué altura está. Una sola foto basta.' },
{ tipo:'foto', clave:'f_gabinete', req:true, etiqueta:'gabinete-cerrado',
txt:'El gabinete cerrado, de cerca, tal como lo encontraste.' },
{ tipo:'foto', clave:'f_antenas', req:true, etiqueta:'antenas',
txt:'Las antenas (la de GPS y las dos de celular) con su cable.',
ayuda:'Acércate al conector: buscamos cables pellizcados, conectores flojos, antenas rotas o faltantes.' },
{ tipo:'check', clave:'ext_golpes', txt:'Revisé el exterior: <b>golpes, tapas flojas, tornillos faltantes, prensaestopas sueltos</b>.' },
{ tipo:'radio', clave:'ext_estado', txt:'¿El gabinete por fuera está…?',
opciones:['Bien, sin daños','Golpeado','Con agua o marcas de agua','Con la tapa mal cerrada','Falta alguna antena'] },
{ tipo:'radio', clave:'sol', txt:'¿Le pega el sol directo a esa hora?', opciones:['Sí, de lleno','Parcial','No, está en sombra'] },
{ tipo:'medida', clave:'hora_revision', txt:'Hora a la que estás haciendo esta revisión', ph:'14:30', modo:'text' },
] },
{ titulo: '3 · El LED de estado — el dato más importante de toda la visita',
sub:'La unidad te está diciendo qué le pasa con el parpadeo del LED. Todavía SIN cortarle la energía.',
items: [
{ tipo:'nota', txt:'<b>Cómo se hace bien:</b> mira el LED <b>10 segundos completos</b> con el reloj y '
+ '<b>cuenta cuántas veces prende</b>. No lo adivines: 5 veces en 10 s y 10 veces en 10 s significan '
+ 'cosas <b>opuestas</b> (una es que la máquina no le da corriente, la otra es que no tiene enlace).' },
{ tipo:'medida', clave:'destellos', txt:'¿Cuántas veces prendió el LED en 10 segundos?', ph:'5', u:'destellos' },
{ tipo:'radio', clave:'led', req:true, txt:'¿Cuál de estos patrones es? (elige el que más se parezca)',
opciones:[
'A · Apagado — no prende ni una vez en 30 segundos',
'B · Fijo encendido, sin parpadear',
'C · Un destello muy cortito cada 4 segundos',
'D · Prende 1 seg y apaga 1 seg — latido lento (5 en 10 s)',
'E · Parpadeo parejo, 1 por segundo (10 en 10 s)',
'F · Dos destellos juntos, pausa, y se repite',
'G · Parpadeo rápido continuo, no alcanzo a contarlo',
'H · No puedo ver el LED desde afuera' ] },
{ tipo:'foto', clave:'f_led', etiqueta:'led', txt:'Opcional pero muy útil: foto del LED encendido.' },
] },
{ titulo: '🔴 Lectura A — la unidad está muerta o sin corriente', si:v=>led(v).startsWith('A'),
sub:'El LED apagado significa una de dos: no le llega energía, o el HBOX ya no enciende. El multímetro lo decide.',
items: [
{ tipo:'nota', txt:'Abre el gabinete (máquina apagada) y mide <b>en este orden</b>. Si en algún punto ya no hay voltaje, ahí está el corte.' },
{ tipo:'medida', clave:'v_maquina', txt:'1) Voltaje que entrega la máquina al gabinete', esperado:'12 V o 24 V según el equipo', u:'V' },
{ tipo:'radio', clave:'breaker', txt:'2) Breaker / fusible del gabinete', opciones:['Cerrado y con continuidad','Botado (disparado)','Fusible abierto (quemado)','No pude revisarlo'] },
{ tipo:'radio', clave:'meanwell_led', txt:'3) LED verde de la fuente Mean Well', opciones:['Encendido','Apagado','No tiene / no se ve'] },
{ tipo:'medida', clave:'v_meanwell', txt:'4) Salida de la fuente Mean Well', esperado:'23 – 26 V', u:'V' },
{ tipo:'medida', clave:'v_gx16', txt:'5) En el conector GX16 del HBOX, pines 1-2', esperado:'23 – 26 V', u:'V',
aviso:'Esta es la medida que parte el diagnóstico en dos: <b>con 24 V aquí y el LED apagado, la falla es del HBOX</b>. Sin 24 V aquí, la falla es del gabinete o de la máquina.' },
{ tipo:'medida', clave:'v_ign', txt:'6) GX16 pin 3 (ignición), con la máquina encendida', esperado:'12 – 24 V con ignición', u:'V' },
{ tipo:'foto', clave:'f_interior_a', req:true, etiqueta:'interior', txt:'Interior del gabinete completo, antes de mover cables.' },
] },
{ titulo: '🟡 Lectura D — el HBOX vive, pero la máquina no le está dando corriente', si:v=>led(v).startsWith('D'),
sub:'Latido lento = el equipo está funcionando con su propia batería. Aguanta poco: por eso "dejó de reportar".',
items: [
{ tipo:'nota', txt:'La falla <b>no está en el HBOX</b>, está aguas arriba: breaker, fusible, fuente, cable o ignición.' },
{ tipo:'medida', clave:'v_maquina_d', txt:'Voltaje que entrega la máquina al gabinete', esperado:'12 V o 24 V', u:'V' },
{ tipo:'radio', clave:'breaker_d', txt:'Breaker / fusible del gabinete', opciones:['Cerrado y con continuidad','Botado (disparado)','Fusible abierto (quemado)','No pude revisarlo'] },
{ tipo:'medida', clave:'v_gx16_d', txt:'GX16 pines 1-2 (lo que llega al HBOX)', esperado:'23 – 26 V', u:'V' },
{ tipo:'texto', clave:'nota_d', txt:'¿Encontraste el punto donde se corta?', ph:'Ej.: hay 24 V a la salida de la fuente pero 0 V en el GX16 → cable o conector.' },
{ tipo:'foto', clave:'f_interior_d', req:true, etiqueta:'interior', txt:'Interior del gabinete y el punto donde se corta el voltaje.' },
] },
{ titulo: '🟠 Lectura E / F / G — el HBOX vive pero no tiene enlace', si:v=>/^[EFG]/.test(led(v)),
sub:'Parpadeo de 1 por segundo, doble destello o parpadeo rápido continuo = está buscando o perdiendo el enlace de datos.',
items: [
{ tipo:'nota', txt:'Las correcciones y los datos viajan por el <b>módem 4G</b>, no por el GPS. Revisa la cadena del celular.' },
{ tipo:'check', clave:'ant_lte', txt:'Las <b>dos antenas de celular</b> están atornilladas y firmes (no flojas, no rotas).' },
{ tipo:'check', clave:'ant_gnss', txt:'La antena de <b>GPS</b> está firme, con vista al cielo y el cable sin aplastar.' },
{ tipo:'radio', clave:'senal_tel', txt:'Con tu teléfono, parado junto a la máquina, ¿cuánta señal Telcel hay?',
opciones:['Llena','Media','Una rayita','Nada'] },
{ tipo:'texto', clave:'nota_efg', txt:'¿Algo cambió cerca? (estructura metálica nueva, contenedores apilados, el gabinete se movió)' },
{ tipo:'foto', clave:'f_ant_cerca', req:true, etiqueta:'antenas-detalle', txt:'Detalle de los conectores de antena, bien de cerca.' },
{ tipo:'nota', txt:'Si el parpadeo era <b>rápido y continuo</b> (patrón G), puede ser que la unidad se esté '
+ 'reiniciando sola una y otra vez. Eso <b>solo</b> se confirma con la captura por USB del paso 5: hazla sí o sí.' },
] },
{ titulo: '🔵 Lectura B — la unidad se ve sana', si:v=>led(v).startsWith('B'),
sub:'LED fijo = está trabajando, con corriente de la máquina y con enlace. Entonces el problema no está aquí.',
items: [
{ tipo:'nota', txt:'<b>No la desmontes.</b> Si el sistema no la ve pero el equipo está sano, lo más probable es que el '
+ 'problema esté en el servidor, en la SIM o en la plataforma. Haz la captura por USB del paso 5 y avisa a ingeniería.' },
{ tipo:'check', clave:'b_aviso', txt:'Entendido: la dejo instalada y reporto a ingeniería.' },
] },
{ titulo: '🟣 Lectura C — la unidad está dormida por batería agotada', si:v=>led(v).startsWith('C'),
sub:'Destello cortito cada 4 s = se durmió para no dañar la batería. Casi siempre es consecuencia, no causa: lleva tiempo sin corriente de la máquina.',
items: [
{ tipo:'medida', clave:'v_gx16_c', txt:'GX16 pines 1-2 (lo que llega al HBOX)', esperado:'23 – 26 V', u:'V' },
{ tipo:'medida', clave:'v_ign_c', txt:'GX16 pin 3 (ignición), máquina encendida', esperado:'12 – 24 V', u:'V' },
{ tipo:'foto', clave:'f_interior_c', req:true, etiqueta:'interior', txt:'Interior del gabinete.' },
] },
{ titulo: '⚪ No se ve el LED', si:v=>led(v).startsWith('H'),
items: [
{ tipo:'nota', txt:'Sin el LED no hay atajo: el paso 5 (captura por USB con la laptop) pasa de recomendado a <b>obligatorio</b>.' },
] },
{ titulo: '4 · Por dentro — temperatura, olor y batería',
sub:'Solo si ya abriste el gabinete y la máquina está apagada.',
items: [
{ tipo:'radio', clave:'abrir', req:true, txt:'¿Abriste el gabinete?', opciones:['Sí','No — no fue posible (explica al final)'] },
{ tipo:'radio', clave:'tacto', si:abrio, txt:'Con el <b>dorso</b> de la mano, la caja del HBOX se siente…',
opciones:['Fría o al tiempo','Tibia','Caliente pero se aguanta','Quema — no se puede tocar'] },
{ tipo:'medida', clave:'t_ir', si:abrio, txt:'Si traes termómetro infrarrojo: temperatura dentro del gabinete', esperado:'anota también la del aire afuera', u:'°C' },
{ tipo:'medida', clave:'t_amb', si:abrio, txt:'Temperatura ambiente a la sombra', u:'°C' },
{ tipo:'radio', clave:'olor', si:abrio, req:true, txt:'¿Huele a quemado o a plástico derretido?', opciones:['No','Sí, leve','Sí, fuerte'] },
{ tipo:'radio', clave:'agua', si:abrio, txt:'¿Hay agua, humedad o condensación adentro?', opciones:['No','Gotas o empañado','Encharcado'] },
{ tipo:'radio', clave:'bateria', si:abrio, req:true, txt:'La batería se ve…',
opciones:['Normal','Hinchada o abombada','Con manchas cafés o negras','Con líquido o fuga','Caliente','No tiene batería / no la vi'],
aviso:'Si marcaste hinchada, con fuga o caliente: <b>deja de desarmar</b>, sepárala de la electrónica, no la perfores y avisa hoy mismo.' },
{ tipo:'foto', clave:'f_bateria', etiqueta:'bateria',
si:v=>['Hinchada','Con manchas','Con líquido','Caliente'].some(t=>String(v.bateria||'').startsWith(t)),
req:true, txt:'Foto de la batería desde dos ángulos.' },
{ tipo:'foto', clave:'f_quemado', etiqueta:'dano', si:v=>String(v.olor||'').startsWith('Sí'),
req:true, txt:'Foto de lo que se ve quemado, oscurecido o derretido.' },
{ tipo:'check', clave:'wiggle', si:abrio, txt:'Prueba de meneo: moví <b>suavemente</b> cada conector y cable. Anoto abajo si alguno estaba flojo.' },
{ tipo:'texto', clave:'nota_interior', si:abrio, rows:'2', txt:'¿Qué encontraste flojo, quemado, mojado o fuera de lugar?' },
] },
{ titulo: '5 · Captura por USB — lo que la unidad tiene guardado',
sub:'Este paso vale más que todos los demás juntos: la unidad guarda su temperatura máxima, cuántas veces se reinició y por qué arrancó la última vez. Eso se pierde al cortarle la corriente.',
items: [
{ tipo:'nota', txt:'<b>Cómo:</b> conecta el cable USB del Blackpill a la laptop → abre el puerto serie nuevo a '
+ '<b>115200</b> baudios → <b>guarda a archivo</b> → déjalo correr <b>3 minutos</b> → después presiona RESET y '
+ 'graba también el arranque. Al final guarda el archivo y pega abajo las dos últimas líneas.' },
{ tipo:'radio', clave:'usb', req:true, txt:'¿Pudiste hacer la captura?',
opciones:['Sí, con archivo guardado','Sí, pero solo pude ver la pantalla','No traía laptop','No, la unidad no enciende','No, no hay acceso físico al USB'] },
{ tipo:'texto', clave:'linea_boot', si:conUsb, rows:'2',
txt:'Pega la línea que empieza con BOOT,', ph:'BOOT,1234,fw=3.0.3,reset=…' },
{ tipo:'texto', clave:'linea_tlm', si:conUsb, rows:'3',
txt:'Pega la última línea que empieza con TLM,', ph:'TLM,…,RUN,OK,4,14,…' },
{ tipo:'nota', si:conUsb,
txt:'Si no puedes copiar y pegar, no importa: llena a mano los cuatro datos de abajo, que son los que más nos dicen.' },
{ tipo:'medida', clave:'tlm_tmax', si:conUsb, txt:'Campo <code>tmax</code> (temperatura máxima que ha visto)', u:'°C' },
{ tipo:'medida', clave:'tlm_rec', si:conUsb, txt:'Campo <code>rec</code> (veces que se ha tenido que recuperar)' },
{ tipo:'medida', clave:'tlm_vbat', si:conUsb, txt:'Campo <code>vbat</code> (voltaje de la batería)', u:'V' },
{ tipo:'medida', clave:'tlm_veh', si:conUsb, txt:'Campo <code>veh</code> (1 = le llega corriente de la máquina, 0 = no)' },
{ tipo:'foto', clave:'f_pantalla', etiqueta:'captura-usb', si:conUsb,
txt:'Foto de la pantalla de la laptop con las líneas visibles (respaldo por si falla el copiado).' },
] },
{ titulo: '6 · Reinicio controlado — hasta ahora sí puedes cortar la energía',
sub:'Solo cuando ya terminaste los pasos 2 al 5. Antes no.',
items: [
{ tipo:'check', clave:'evidencia_lista', req:true, txt:'Ya tomé las fotos y (si se pudo) la captura por USB. <b>La evidencia está completa.</b>' },
{ tipo:'radio', clave:'reinicio', txt:'Corté la energía 30 segundos, la volví a dar y esperé 2 minutos. ¿Qué pasó?',
opciones:['Revivió y quedó con LED fijo','Revivió pero volvió a fallar','Sigue igual','No lo intenté'] },
] },
{ titulo: '7 · Cómo la dejas y qué crees que tiene',
sub:'Elige lo que observaste, no lo que supones. "No está claro" es una respuesta válida y útil.',
items: [
{ tipo:'radio', clave:'clasificacion', req:true, guardaEn:'clasificacion', txt:'Clasificación de la falla',
opciones:[
'Sin corriente desde la máquina o el gabinete',
'El HBOX no enciende aunque le llega 24 V',
'Enlace 4G: no hay datos ni correcciones',
'Antena suelta, rota o faltante',
'Batería dañada (hinchada, caliente o con fuga)',
'Daño por calor (quemado, derretido, decolorado)',
'Agua adentro del gabinete',
'Daño mecánico o golpe',
'La unidad se ve sana — el problema parece ser del servidor',
'No está claro — se necesita banco' ] },
{ tipo:'radio', clave:'accion', req:true, guardaEn:'accion', txt:'¿Cómo queda la unidad al terminar tu visita?',
opciones:[
'Instalada y reportando',
'Instalada, pero sin resolver — pendiente de ingeniería',
'Desmontada y guardada en el taller, etiquetada',
'Desmontada y enviada a Querétaro',
'Reemplazada por otra unidad (anota cuál abajo)' ] },
{ tipo:'texto', clave:'unidad_repuesto', si:v=>String(v.accion||'').startsWith('Reemplazada'),
txt:'Número de serie de la unidad que quedó instalada' },
{ tipo:'texto', clave:'observaciones', rows:'4', req:true,
txt:'Cuenta con tus palabras qué viste y qué hiciste',
ph:'Escribe como se lo contarías a un compañero. Lo que aquí escribas es lo que va a leer ingeniería.' },
{ tipo:'foto', clave:'f_final', etiqueta:'final', txt:'Foto de cómo quedó al terminar (instalada o empacada).' },
] },
]
});
</script>
</body>
</html>

29
docs/formularios/flota.js Normal file
View File

@ -0,0 +1,29 @@
/* Flota HBOX desplegada — registro único de unidades en campo.
Lo usan el acta de diagnóstico y (después) la de instalación.
PARA AGREGAR O CORREGIR UNA UNIDAD: edita este archivo y vuelve a publicar el sitio.
serie identificador corto y ESTABLE de la unidad (va en el acta y en el servidor).
Sin espacios ni acentos. Es la etiqueta física de la caja.
etiqueta lo que ve el técnico en la lista desplegable.
maquina equipo donde está montada, con el número del terminal.
terminal ICAVE · TIMSA · EIT · LCT (agrupa la lista)
estado operativa | falla | desmontada | repuesto
nota cualquier cosa que el técnico deba saber ANTES de subirse a la máquina.
⚠ Los datos de abajo están PENDIENTES DE CONFIRMAR por Leo (series y número de máquina). */
window.FLOTA = [
{ serie: 'HBOX-V3-RTG-020', etiqueta: 'RTG ZPM-020 — unidad desmontada', maquina: 'RTG ZPM 020',
terminal: 'ICAVE', estado: 'desmontada',
nota: 'Desmontada del gabinete y de la máquina; está en el escritorio del taller. Diagnóstico en banco, no en campo.' },
{ serie: 'HBOX-V3-TRACTO-01', etiqueta: 'Tractocamión / tractoplana — unidad desmontada', maquina: 'Tractoplana (confirmar número)',
terminal: 'ICAVE', estado: 'desmontada',
nota: 'Desmontada del gabinete y de la máquina; está en el escritorio del taller. Diagnóstico en banco, no en campo.' },
{ serie: 'HBOX-V3-MONTA-01', etiqueta: 'Montacargas — dejó de reportar (sigue instalada)', maquina: 'Montacargas (confirmar número)',
terminal: 'ICAVE', estado: 'falla',
nota: '⚠ SIGUE INSTALADA Y NADIE LA HA TOCADO. Es la única evidencia intacta que tenemos: sigue el procedimiento en orden y NO le cortes la energía hasta terminar el paso 5.' },
{ serie: 'HBOX-V3-RS-SAN-001', etiqueta: 'Reach Stacker RS-SAN-001', maquina: 'Reach Stacker RS-SAN-001',
terminal: 'ICAVE', estado: 'falla', nota: 'Confirmar si sigue instalada.' },
];

View File

@ -43,6 +43,7 @@ nav:
- "Tablero de estado": tablero.html
- Actas y control de calidad:
- "Índice de actas": actas/index.md
- "Acta de diagnóstico en campo": formularios/diagnostico.html
- "Acta de prueba V5 (tarjeta)": formularios/v5.html
- "Acta de módulo ArduSimple (simpleRTK2B, ARD-NNN)": formularios/ardusimple.html
- "Acta de módem 4G (NTRIP Master, RTK-NNNN)": formularios/modem4g.html
@ -64,6 +65,9 @@ nav:
- "Guía de ensamble y control de calidad": montaje/guia-ensamble-qc.md
- "Guía de ensamble (PDF)": montaje/assembly-guide.md
- Diagnóstico:
- "⚑ Diagnóstico en campo — procedimiento": diagnostico/procedimiento-diagnostico-campo.md
- "⚑ Acta de diagnóstico en campo (llenar en la máquina)": formularios/diagnostico.html
- "Tablero de campo (ingeniería)": diagnostico/tablero-campo.html
- "Protocolo de diagnóstico remoto": diagnostico/protocolo-diagnostico-remoto.md
- Git:
- "Guía Git (español)": git/guia-git-es.md