diagnóstico en campo: acta, fotos, tablero y procedimiento
This commit is contained in:
parent
53be5d4de4
commit
455dbc4dc1
159
DESPLIEGUE.md
Normal file
159
DESPLIEGUE.md
Normal 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 |
|
||||
168
backend/app.py
168
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")))
|
||||
|
||||
229
backend/app.py.bak
Normal file
229
backend/app.py.bak
Normal 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")))
|
||||
@ -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).
|
||||
|
||||
156
docs/diagnostico/procedimiento-diagnostico-campo.md
Normal file
156
docs/diagnostico/procedimiento-diagnostico-campo.md
Normal 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`.
|
||||
180
docs/diagnostico/tablero-campo.html
Normal file
180
docs/diagnostico/tablero-campo.html
Normal 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>
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
})();
|
||||
}
|
||||
|
||||
381
docs/formularios/acta.js.bak
Normal file
381
docs/formularios/acta.js.bak
Normal 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 };
|
||||
})();
|
||||
247
docs/formularios/diagnostico.html
Normal file
247
docs/formularios/diagnostico.html
Normal 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
29
docs/formularios/flota.js
Normal 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.' },
|
||||
];
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user