diff --git a/DESPLIEGUE.md b/DESPLIEGUE.md new file mode 100644 index 0000000..9a34f52 --- /dev/null +++ b/DESPLIEGUE.md @@ -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 | diff --git a/backend/app.py b/backend/app.py index 5da0df1..2f723df 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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"))) diff --git a/backend/app.py.bak b/backend/app.py.bak new file mode 100644 index 0000000..5da0df1 --- /dev/null +++ b/backend/app.py.bak @@ -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"))) diff --git a/docs/actas/index.md b/docs/actas/index.md index 1189b62..2b6590e 100644 --- a/docs/actas/index.md +++ b/docs/actas/index.md @@ -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). diff --git a/docs/diagnostico/procedimiento-diagnostico-campo.md b/docs/diagnostico/procedimiento-diagnostico-campo.md new file mode 100644 index 0000000..45a0cc2 --- /dev/null +++ b/docs/diagnostico/procedimiento-diagnostico-campo.md @@ -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//`. + El índice por tipo está en `/srv/projects/drogheda/actas/diagnostico-campo/_indice.csv`. diff --git a/docs/diagnostico/tablero-campo.html b/docs/diagnostico/tablero-campo.html new file mode 100644 index 0000000..cae2303 --- /dev/null +++ b/docs/diagnostico/tablero-campo.html @@ -0,0 +1,180 @@ + + + + + +Tablero de campo — diagnósticos HBOX + + + +
+
+

Tablero de campo — diagnósticos HBOX

Cargando…
+
+ ← Documentación + Nueva acta de diagnóstico + +
+
+
+
+ + + + diff --git a/docs/formularios/acta.css b/docs/formularios/acta.css index dea49e6..bb3813c 100644 --- a/docs/formularios/acta.css +++ b/docs/formularios/acta.css @@ -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} +} diff --git a/docs/formularios/acta.js b/docs/formularios/acta.js index e4e7b54..73469fb 100644 --- a/docs/formularios/acta.js +++ b/docs/formularios/acta.js @@ -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 -> 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 ? 'Foto obligatoria — ' : '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 = 'Modo prueba — 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 = '🆕 Primera visita registrada para esta unidad.'; return; } + hist.innerHTML = ''; + hist.append($('div', { class: 'hist-t', html: '📋 ' + j.total + ' visita(s) anterior(es) 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: + '' + f + ' · ' + (v.tecnico || '—') + + (v.clasificacion ? ' · ' + v.clasificacion + '' : '') + + (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 + ? '' + (u.maquina || '—') + ' · ' + (u.terminal || '—') + (u.nota ? '
' + 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 = '⛔ No se puede ensamblar con esta selección. Todos los módulos deben estar aprobados 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 ' + d.serie + '.' + (compuesta && !esPrueba(d.serie) ? '
Etiqueta de la unidad: ' + d.serie + '' : ''); - if (!j.prueba) { locked = true; showLocked({ tecnico: d.tecnico, resultado: d.resultado, _recibido: new Date().toISOString() }); } + if (cfg.repetible) { + msg.innerHTML = '✓ Registro enviado para ' + d.serie + '' + + (d.fotos && d.fotos.length ? ' con ' + d.fotos.length + ' foto(s)' : '') + + '.
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(); })(); } diff --git a/docs/formularios/acta.js.bak b/docs/formularios/acta.js.bak new file mode 100644 index 0000000..e4e7b54 --- /dev/null +++ b/docs/formularios/acta.js.bak @@ -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: '⚠ BORRADOR — 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 ->