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)
|
POST /api/actas -> crea (409 si ya existe una real)
|
||||||
DELETE /api/acta?tipo=&serie= -> borra (solo admin)
|
DELETE /api/acta?tipo=&serie= -> borra (solo admin)
|
||||||
GET /api/actas -> listado (ingeniería); incluye 'componentes' en actas compuestas
|
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
|
Actas compuestas (integracion, ensamble): traen d["componentes"] = {clave: serie_modulo}. En los tipos
|
||||||
de TIPOS_UNICOS un módulo no puede aparecer en dos actas reales (409).
|
de TIPOS_UNICOS un módulo no puede aparecer en dos actas reales (409).
|
||||||
"""
|
"""
|
||||||
import os, re, json, csv, datetime
|
import os, re, json, csv, datetime, uuid
|
||||||
from flask import Flask, request, jsonify, abort
|
from flask import Flask, request, jsonify, abort, send_file
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
BASE = os.environ.get("ACTAS_DIR", "/srv/projects/drogheda/actas")
|
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()}
|
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_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 = 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 slug(s, n=40): return SAFE.sub("", str(s or "").strip())[:n]
|
||||||
|
|
||||||
def current_user():
|
def current_user():
|
||||||
@ -60,6 +74,18 @@ def componentes_en_uso(tipo, serie_actual, componentes):
|
|||||||
usados.append((k, v, d.get("serie")))
|
usados.append((k, v, d.get("serie")))
|
||||||
return usados
|
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):
|
def find_latest(tipo, serie):
|
||||||
"""Ruta del acta real más reciente para (tipo, serie), o None."""
|
"""Ruta del acta real más reciente para (tipo, serie), o None."""
|
||||||
carpeta = os.path.join(BASE, tipo)
|
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)
|
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
|
||||||
if not tipo or not serie:
|
if not tipo or not serie:
|
||||||
return jsonify(ok=False, error="faltan tipo/serie"), 400
|
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)
|
ruta = find_latest(tipo, serie)
|
||||||
if not ruta:
|
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:
|
with open(ruta, encoding="utf-8") as f:
|
||||||
d = json.load(f)
|
d = json.load(f)
|
||||||
d["_archivo"] = os.path.basename(ruta)
|
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)
|
return jsonify(ok=True, exists=True, acta=d)
|
||||||
|
|
||||||
@app.post("/api/actas")
|
@app.post("/api/actas")
|
||||||
@ -112,8 +150,8 @@ def crear():
|
|||||||
|
|
||||||
es_prueba = "PRUEBA" in serie.upper()
|
es_prueba = "PRUEBA" in serie.upper()
|
||||||
|
|
||||||
# un acta real solo se puede enviar una vez
|
# un acta real solo se puede enviar una vez (salvo tipos repetibles: cada visita es un evento)
|
||||||
if not es_prueba and find_latest(tipo, serie):
|
if not es_prueba and not es_repetible(tipo) and find_latest(tipo, serie):
|
||||||
return jsonify(ok=False, existe=True,
|
return jsonify(ok=False, existe=True,
|
||||||
error=f"Ya existe un acta para {serie}. Pide a Leo que la borre para rehacerla."), 409
|
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)
|
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
|
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)
|
ts = datetime.datetime.now().replace(microsecond=0)
|
||||||
iso = ts.isoformat()
|
iso = ts.isoformat()
|
||||||
d["_recibido"] = iso
|
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)
|
carpeta = os.path.join(BASE, tipo, "_pruebas") if es_prueba else os.path.join(BASE, tipo)
|
||||||
os.makedirs(carpeta, exist_ok=True)
|
os.makedirs(carpeta, exist_ok=True)
|
||||||
archivo = f"{serie}_{iso.replace(':', '-')}.json"
|
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:
|
with open(os.path.join(carpeta, archivo), "w", encoding="utf-8") as f:
|
||||||
json.dump(d, f, ensure_ascii=False, indent=2)
|
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:
|
with open(idx, "a", newline="", encoding="utf-8") as f:
|
||||||
w = csv.writer(f)
|
w = csv.writer(f)
|
||||||
if nuevo:
|
if nuevo:
|
||||||
w.writerow(["recibido", "serie", "tecnico", "resultado", "archivo"])
|
w.writerow(["recibido", "serie", "tecnico", "resultado", "clasificacion",
|
||||||
w.writerow([iso, serie, tecnico, d.get("resultado", ""), archivo])
|
"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}"
|
sub = f"{tipo}/_pruebas/{archivo}" if es_prueba else f"{tipo}/{archivo}"
|
||||||
return jsonify(ok=True, archivo=sub, prueba=es_prueba)
|
return jsonify(ok=True, archivo=sub, prueba=es_prueba)
|
||||||
@ -158,10 +209,17 @@ def borrar_acta():
|
|||||||
if not is_admin():
|
if not is_admin():
|
||||||
return jsonify(ok=False, error="Solo un administrador puede borrar actas."), 403
|
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)
|
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:
|
if not tipo or not serie:
|
||||||
return jsonify(ok=False, error="faltan tipo/serie"), 400
|
return jsonify(ok=False, error="faltan tipo/serie"), 400
|
||||||
carpeta = os.path.join(BASE, tipo)
|
carpeta = os.path.join(BASE, tipo)
|
||||||
borrados = []
|
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):
|
if os.path.isdir(carpeta):
|
||||||
pref = serie + "_"
|
pref = serie + "_"
|
||||||
for fn in list(os.listdir(carpeta)):
|
for fn in list(os.listdir(carpeta)):
|
||||||
@ -221,9 +279,105 @@ def listar():
|
|||||||
"resultado": d.get("resultado"), "recibido": d.get("_recibido")}
|
"resultado": d.get("resultado"), "recibido": d.get("_recibido")}
|
||||||
if isinstance(d.get("componentes"), dict):
|
if isinstance(d.get("componentes"), dict):
|
||||||
fila["componentes"] = d["componentes"]
|
fila["componentes"] = d["componentes"]
|
||||||
|
for k in ("clasificacion", "accion", "motivo", "maquina", "terminal", "unidad"):
|
||||||
|
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.append(fila)
|
||||||
filas.sort(key=lambda r: (r.get("recibido") or ""), reverse=True)
|
filas.sort(key=lambda r: (r.get("recibido") or ""), reverse=True)
|
||||||
return jsonify(ok=True, total=len(filas), actas=filas)
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))
|
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))
|
||||||
|
|||||||
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*.
|
**[▶ 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
|
## Cómo se usan
|
||||||
|
|
||||||
1. Abre el formulario y **elige el número de serie** (o los módulos, en integración y ensamble).
|
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{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 .rad{font-weight:500;display:flex;align-items:center;gap:6px;cursor:pointer}
|
||||||
.radios input{width:22px;height:22px;accent-color:var(--naranja)}
|
.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.
|
- 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)".
|
- Solo el administrador (leo) ve el botón "Borrar acta (reabrir)".
|
||||||
- La serie PRUEBA no se bloquea (para practicar).
|
- 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:
|
Modos de serie:
|
||||||
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
|
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
|
||||||
@ -16,6 +28,21 @@
|
|||||||
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
|
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
|
||||||
const esPrueba = s => String(s || '').toUpperCase().indexOf('PRUEBA') >= 0;
|
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) {
|
function opciones(sel, prefijo, desde, hasta, pad) {
|
||||||
sel.append($('option', { value: '', text: '— elegir —' }));
|
sel.append($('option', { value: '', text: '— elegir —' }));
|
||||||
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
|
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
|
||||||
@ -54,7 +81,26 @@
|
|||||||
const preBadge = []; // prerrequisitos -> <span>
|
const preBadge = []; // prerrequisitos -> <span>
|
||||||
const tecnico = $('input', { type: 'text', id: 'f_tecnico', placeholder: 'Nombre y apellido' });
|
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);
|
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 dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Número de serie' }), serieSel);
|
||||||
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
|
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
|
||||||
@ -81,10 +127,21 @@
|
|||||||
|
|
||||||
const banner = $('div', { class: 'msg', id: 'f_banner' });
|
const banner = $('div', { class: 'msg', id: 'f_banner' });
|
||||||
wrap.append(banner);
|
wrap.append(banner);
|
||||||
|
const hist = $('div', { class: 'hist' });
|
||||||
|
if (cfg.repetible) wrap.append(hist);
|
||||||
|
|
||||||
// selección actual de componentes -> {clave: valor}
|
// selección actual de componentes -> {clave: valor}
|
||||||
function seleccion() { const s = {}; for (const k in compSel) s[k] = compSel[k].value; return s; }
|
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() {
|
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;
|
if (!compuesta) return serieSel.value;
|
||||||
const s = seleccion();
|
const s = seleccion();
|
||||||
if (Object.values(s).some(v => !v)) return '';
|
if (Object.values(s).some(v => !v)) return '';
|
||||||
@ -94,13 +151,23 @@
|
|||||||
const key = () => 'acta:' + cfg.tipo + ':' + (serieActual() || '');
|
const key = () => 'acta:' + cfg.tipo + ':' + (serieActual() || '');
|
||||||
|
|
||||||
// ---------------- secciones ----------------
|
// ---------------- 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) => {
|
cfg.secciones.forEach((sec, si) => {
|
||||||
const s = $('section', { class: 'paso' });
|
const s = $('section', { class: 'paso' });
|
||||||
s.append($('h2', { text: sec.titulo }));
|
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) => {
|
(sec.items || []).forEach((it, ii) => {
|
||||||
const id = `s${si}_${ii}`;
|
const id = it.clave || `s${si}_${ii}`;
|
||||||
const box = $('div', { class: 'item' });
|
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.aviso) box.append($('div', { class: 'aviso', html: it.aviso }));
|
||||||
if (it.tipo === 'check') {
|
if (it.tipo === 'check') {
|
||||||
const lab = $('label', { class: 'chk' });
|
const lab = $('label', { class: 'chk' });
|
||||||
@ -127,6 +194,17 @@
|
|||||||
box.append(g);
|
box.append(g);
|
||||||
} else if (it.tipo === 'nota') {
|
} else if (it.tipo === 'nota') {
|
||||||
box.append($('div', { class: 'nota-item', html: it.txt }));
|
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);
|
s.append(box);
|
||||||
});
|
});
|
||||||
@ -169,12 +247,28 @@
|
|||||||
tecnico.disabled = !on; conforme().disabled = !on;
|
tecnico.disabled = !on; conforme().disabled = !on;
|
||||||
wrap.querySelectorAll('input[name=resultado]').forEach(el => el.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() {
|
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 = ''; });
|
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;
|
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) {
|
function fillFrom(a) {
|
||||||
|
if (Array.isArray(a.fotos)) { fotos = a.fotos.slice(); pintarFotos(); }
|
||||||
tecnico.value = a.tecnico || '';
|
tecnico.value = a.tecnico || '';
|
||||||
conforme().checked = !!a.conforme;
|
conforme().checked = !!a.conforme;
|
||||||
if (a.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${a.resultado}"]`); if (el) el.checked = true; }
|
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';
|
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) ----------------
|
// ---------------- 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 verif = {}; // clave -> 'OK' | 'FALLA' | 'SIN_ACTA' | 'PRUEBA' | 'EN_USO'
|
||||||
let usados = null; // { clave: { valor: serieUnidad } } — componentes ya asignados en otras actas de este tipo
|
let usados = null; // { clave: { valor: serieUnidad } } — componentes ya asignados en otras actas de este tipo
|
||||||
function setBadge(el, st, extra) {
|
function setBadge(el, st, extra) {
|
||||||
@ -273,6 +476,14 @@
|
|||||||
const d = { tipo: cfg.tipo, serie: serieActual(), tecnico: tecnico.value,
|
const d = { tipo: cfg.tipo, serie: serieActual(), tecnico: tecnico.value,
|
||||||
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
|
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
|
||||||
if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); }
|
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;
|
return d;
|
||||||
}
|
}
|
||||||
function guardarLocal() {
|
function guardarLocal() {
|
||||||
@ -292,26 +503,39 @@
|
|||||||
// ---------------- cargar una serie ----------------
|
// ---------------- cargar una serie ----------------
|
||||||
async function loadSerie() {
|
async function loadSerie() {
|
||||||
msg.className = 'msg'; locked = false;
|
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();
|
const s = serieActual();
|
||||||
if (compuesta) serieCalc.value = s || '';
|
if (compuesta) serieCalc.value = s || '';
|
||||||
blankFields(); showEditable();
|
blankFields(); showEditable();
|
||||||
if (compuesta) { if (usados === null) await cargarUsados(); await verificar(); }
|
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 (cfg.repetible) cargarHistorial();
|
||||||
if (esPrueba(s)) { cargarLocal(); estado.textContent = 'Modo prueba (no cuenta)'; return; }
|
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…';
|
estado.textContent = 'Consultando…';
|
||||||
let j = null;
|
let j = null;
|
||||||
try { const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(s)}`); j = await r.json(); } catch (e) {}
|
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 {
|
else {
|
||||||
cargarLocal();
|
cargarLocal(); aplicarCondiciones();
|
||||||
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
|
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
|
||||||
banner.className = 'msg err';
|
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.';
|
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);
|
serieSel.addEventListener('change', loadSerie);
|
||||||
|
serieOtra.addEventListener('change', loadSerie);
|
||||||
Object.values(compSel).forEach(sel => sel.addEventListener('change', loadSerie));
|
Object.values(compSel).forEach(sel => sel.addEventListener('change', loadSerie));
|
||||||
|
|
||||||
limpiar.addEventListener('click', () => {
|
limpiar.addEventListener('click', () => {
|
||||||
@ -344,7 +568,9 @@
|
|||||||
if (compuesta && cfg.exigirAprobados && !esPrueba(d.serie) && !todoAprobado())
|
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.');
|
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.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.');
|
if (!d.conforme) return fail('Debes confirmar que seguiste el procedimiento.');
|
||||||
btn.disabled = true; btn.textContent = 'Enviando…';
|
btn.disabled = true; btn.textContent = 'Enviando…';
|
||||||
try {
|
try {
|
||||||
@ -362,7 +588,14 @@
|
|||||||
msg.className = 'msg ok';
|
msg.className = 'msg ok';
|
||||||
msg.innerHTML = '✓ Acta enviada y registrada como <b>' + d.serie + '</b>.'
|
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>' : '');
|
+ (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'; }
|
else { btn.disabled = false; btn.textContent = 'Enviar acta firmada'; estado.textContent = 'Prueba enviada'; }
|
||||||
window.scrollTo(0, document.body.scrollHeight);
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -373,7 +606,14 @@
|
|||||||
function fail(m) { msg.className = 'msg err'; msg.textContent = m; window.scrollTo(0, document.body.scrollHeight); }
|
function fail(m) { msg.className = 'msg err'; msg.textContent = m; window.scrollTo(0, document.body.scrollHeight); }
|
||||||
|
|
||||||
(async () => {
|
(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();
|
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
|
- "Tablero de estado": tablero.html
|
||||||
- Actas y control de calidad:
|
- Actas y control de calidad:
|
||||||
- "Índice de actas": actas/index.md
|
- "Í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 prueba V5 (tarjeta)": formularios/v5.html
|
||||||
- "Acta de módulo ArduSimple (simpleRTK2B, ARD-NNN)": formularios/ardusimple.html
|
- "Acta de módulo ArduSimple (simpleRTK2B, ARD-NNN)": formularios/ardusimple.html
|
||||||
- "Acta de módem 4G (NTRIP Master, RTK-NNNN)": formularios/modem4g.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 y control de calidad": montaje/guia-ensamble-qc.md
|
||||||
- "Guía de ensamble (PDF)": montaje/assembly-guide.md
|
- "Guía de ensamble (PDF)": montaje/assembly-guide.md
|
||||||
- Diagnóstico:
|
- 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
|
- "Protocolo de diagnóstico remoto": diagnostico/protocolo-diagnostico-remoto.md
|
||||||
- Git:
|
- Git:
|
||||||
- "Guía Git (español)": git/guia-git-es.md
|
- "Guía Git (español)": git/guia-git-es.md
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user