Compare commits

..

No commits in common. "feat/ucepsa-odoo-shopfloor-sessions-shadow-v0.3.3" and "main" have entirely different histories.

258 changed files with 0 additions and 93762 deletions

48
.gitignore vendored
View File

@ -1,48 +0,0 @@
# --- MESAVAULT / UCEPSA runtime & secrets ---
**/.env
**/.env.*
!**/.env.example
!**/.env.*.example
# Runtime / data
**/backups/
**/logs/
**/data/
**/postgres/data/
**/grafana/data/
**/mosquitto/data/
**/mosquitto/log/
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.log
# Dumps / databases
**/*.dump
**/*.backup
**/*.sql.gz
**/*.tar
**/*.tar.gz
**/*.zip
# Certificates / keys / tokens
**/*.pem
**/*.key
**/*.crt
**/acme.json
**/id_rsa
**/id_rsa.pub
**/id_ed25519
**/id_ed25519.pub
**/passwords
# Odoo runtime
**/filestore/
**/enterprise/
**/odoo-data/
**/odoo-db-data/
# Editors
.vscode/
.idea/
*.swp

View File

@ -1 +0,0 @@
UCEPSA

View File

@ -1,67 +0,0 @@
# Secrets
.env
.env.*
**/.env
**/.env.*
!**/.env.example
# Python
**/__pycache__/
**/*.pyc
**/.venv/
**/venv/
# Logs
*.log
logs/
**/logs/
# Runtime data / volumes
data/
volumes/
postgres_data/
grafana/data/
mosquitto/data/
mosquitto/log/
minio/data/
# Backups and dumps with data
backups/
*.dump
*.backup
*.tar
*.tar.gz
*.tgz
*.zip
*.gz
*.sql.gz
# Keys / certificates
*.pem
*.key
*.crt
*.p12
id_rsa*
id_ed25519*
# Local env files
**/.env.local
**/*.env.local
# Backup / old files
*.bak
*.bak.*
*.bak_*
*_OLD
# Python cache
**/__pycache__/
**/*.pyc
# Grafana runtime data
grafana/data/
**/grafana/data/
# Audit packages
*_audit_*.tgz
*_audit_*.zip

View File

@ -1,112 +0,0 @@
# UCEPSA — MESAVAULT Edge-OEE
Repositorio del piloto productivo MESAVAULT Edge-OEE en UCEPSA.
## Estado actual
El sistema está en estado de piloto productivo activo.
Incluye:
- captura real de máquina mediante RevPi y nodos WISE;
- lectura energética mediante Eastron SDM630;
- publicación MQTT de telemetría;
- persistencia en PostgreSQL;
- integración con Odoo PROD mediante order_state;
- control automático de balizas;
- panel de operador para justificación de paros;
- dashboard Grafana con informe de paros, energía, estado máquina y capas de análisis.
## Máquinas incluidas
- CORT-00
- CORT-01
- CORT-02
## Servicios principales
En servidor mv-edge-oee:
- mv_ucepsa_mosquitto
- mv_ucepsa_postgres_hot
- mv_ucepsa_edge_oee_mqtt_pg_sink
- mv_ucepsa_odoo_order_state_publisher
- mv_ucepsa_odoo_order_state_pg_sink
- mv_ucepsa_operator_stop_console
- mv_ucepsa_grafana
En RevPi:
- mesavault-edge-oee-revpi
- mesavault-beacon-controller
## Integración Odoo
La integración con Odoo se realiza mediante un publicador externo:
tools/odoo_order_state_publisher.py
Este servicio consulta Odoo PROD y publica un mensaje MQTT retained por máquina:
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-00
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-01
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-02
La regla validada es:
progress_count = 1 -> orden cargada correctamente
Validación real realizada:
CORT-00
WH/MO/00090
[6] BOLSA BD 22X30 TPTE
state = loaded
## Panel de operador
El panel de operador permite justificar paros detectados automáticamente.
Servicio:
mv_ucepsa_operator_stop_console
Acceso:
http://17.126.1.70:8088
Acceso por máquina:
http://17.126.1.70:8088/?machine_id=CORT-00
http://17.126.1.70:8088/?machine_id=CORT-01
http://17.126.1.70:8088/?machine_id=CORT-02
## Estructura del repositorio
services/ servicios Python/Docker
tools/ herramientas de sincronización, diagnóstico y publicación
sql/ migraciones, vistas y funciones PostgreSQL
grafana/ dashboards, scripts e import/export
deploy/ucepsa-local/ docker-compose y plantillas de entorno
revpi/ código y unidades systemd de RevPi
db/schema/ export de esquema PostgreSQL sin datos
docs/ documentación técnica y runbooks
env-examples/ ejemplos de variables sin secretos
## No versionar
No deben versionarse:
- .env reales;
- .env.local;
- contraseñas;
- tokens;
- claves privadas;
- dumps PostgreSQL con datos;
- volúmenes Docker;
- logs;
- backups comprimidos;
- datos de Grafana;
- datos de PostgreSQL.
Las credenciales deben mantenerse fuera del repositorio.

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +0,0 @@
version: "3.8"
services:
oee-demo-generator:
build:
context: ./services/oee-demo-generator
container_name: edge_oee_demo_generator
env_file:
- .env
networks:
- platform_net
restart: "no"
edge-oee-mqtt-pg-sink:
build:
context: ./services/edge-oee-mqtt-pg-sink
container_name: edge_oee_mqtt_pg_sink
env_file:
- .env.sink
environment:
PYTHONUNBUFFERED: "1"
networks:
- platform_net
restart: unless-stopped
operator-stop-console:
build:
context: ./services/operator-stop-console
container_name: edge_oee_operator_stop_console
env_file:
- .env.operator
environment:
PYTHONUNBUFFERED: "1"
ports:
- "100.116.75.30:8088:8080"
networks:
- platform_net
restart: unless-stopped
odoo-workorders-sync:
build:
context: ./services/odoo-workorders-sync
container_name: edge_oee_odoo_workorders_sync
env_file:
- .env.odoo-sync
environment:
PYTHONUNBUFFERED: "1"
networks:
- platform_net
restart: unless-stopped
networks:
platform_net:
external:
name: ${PLATFORM_NET}

View File

@ -1,53 +0,0 @@
# Identidad
TENANT=ucepsa
SITE=ucepsa_onpremise
VERTICAL=edge_oee
MACHINE_ID=CORT-01
ASSET=revpi_oee_node_01
# PostgreSQL MESAVAULT
POSTGRES_DB=mesavault_ucepsa_hot
POSTGRES_USER=mesavault
# Grafana
GRAFANA_ADMIN_USER=admin
# MQTT local
MQTT_PORT=1883
# MQTT credentials for local Edge-OEE sink
MQTT_USER_REVPI=revpi_ucepsa_edge_oee_01
POSTGRES_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
# PostgreSQL writer for Odoo sync
# Odoo UCEPSA
#ODOO_URL=https://17.126.1.61
ODOO_URL=http://17.126.1.61:8069
ODOO_DB=ucepsa_prod
ODOO_USER=__SET_IN_ENV_OR_VAULTWARDEN__
ODOO_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
ODOO_WORKCENTER_CODE=CORT-01
ODOO_INSTANCE=odoo_ucepsa_prod
ODOO_SYNC_INTERVAL_SECONDS=30
ODOO_WORKORDER_LIMIT=100
ODOO_WORKORDER_STATES=waiting,ready,progress,done
# PostgreSQL writer for Odoo sync
ODOO_SYNC_PGUSER=edge_oee_odoo_sync_writer
ODOO_SYNC_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
# Operator stop console
OPERATOR_PGUSER=edge_oee_operator_writer
OPERATOR_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
OPERATOR_APP_PORT=8080
OPERATOR_HOST_PORT=8088
OPERATOR_DEFAULT_MACHINE_ID=CORT-01
OPERATOR_DEFAULT_ASSET=revpi_oee_node_01
OPERATOR_ALLOWED_MACHINES=CORT-00,CORT-01,CORT-02,CORT-03,CORT-04,CORT-05,CORT-06,CORT-08,CORT-09,CORT-10,CORT-FAS1,CORT-FAS2
# Operator console auto refresh
OPERATOR_AUTO_REFRESH_SECONDS=5
MQTT_PASSWORD_REVPI=__SET_IN_ENV_OR_VAULTWARDEN__
GRAFANA_ADMIN_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__

View File

@ -1,33 +0,0 @@
services:
mv_ucepsa_edge_oee_mqtt_pg_sink:
build:
context: ./services/edge-oee-mqtt-pg-sink
container_name: mv_ucepsa_edge_oee_mqtt_pg_sink
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
MQTT_HOST: mv_ucepsa_mosquitto
MQTT_PORT: 1883
MQTT_USER: ${MQTT_USER_REVPI}
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/revpi_oee_node_01/+
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
TENANT: ucepsa
SITE: ucepsa_onpremise
VERTICAL: edge_oee
ASSET: revpi_oee_node_01
MACHINE_ID: CORT-01
VALUE_HOUR_EUR: 36
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
- mv_ucepsa_mosquitto
networks:
- mv_ucepsa_net

View File

@ -1,35 +0,0 @@
services:
mv_ucepsa_odoo_workorders_sync:
build:
context: ./services/odoo-workorders-sync
container_name: mv_ucepsa_odoo_workorders_sync
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
ODOO_URL: ${ODOO_URL}
ODOO_DB: ${ODOO_DB}
ODOO_USER: ${ODOO_USER}
ODOO_PASSWORD: ${ODOO_PASSWORD}
ODOO_WORKCENTER_CODE: ${ODOO_WORKCENTER_CODE}
ODOO_INSTANCE: ${ODOO_INSTANCE}
ODOO_SYNC_INTERVAL_SECONDS: ${ODOO_SYNC_INTERVAL_SECONDS}
ODOO_WORKORDER_LIMIT: ${ODOO_WORKORDER_LIMIT}
ODOO_WORKORDER_STATES: ${ODOO_WORKORDER_STATES}
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${ODOO_SYNC_PGUSER}
PGPASSWORD: ${ODOO_SYNC_PGPASSWORD}
TENANT: ucepsa
SITE: ucepsa_test
MACHINE_ID: CORT-01
ASSET: revpi_oee_node_01
TARGET_CYCLE_RATE_PPM: 10
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net

View File

@ -1,28 +0,0 @@
services:
mv_ucepsa_operator_stop_console:
build:
context: ./services/operator-stop-console
container_name: mv_ucepsa_operator_stop_console
restart: unless-stopped
ports:
- "${OPERATOR_HOST_PORT:-8088}:${OPERATOR_APP_PORT:-8080}"
environment:
PYTHONUNBUFFERED: "1"
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${OPERATOR_PGUSER}
PGPASSWORD: ${OPERATOR_PGPASSWORD}
APP_PORT: ${OPERATOR_APP_PORT:-8080}
APP_TITLE: "MESAVAULT Edge-OEE - Consola de paros UCEPSA"
DEFAULT_MACHINE_ID: ${OPERATOR_DEFAULT_MACHINE_ID:-CORT-01}
DEFAULT_ASSET: ${OPERATOR_DEFAULT_ASSET:-revpi_oee_node_01}
ALLOWED_MACHINES: ${OPERATOR_ALLOWED_MACHINES:-CORT-01}
AUTO_REFRESH_SECONDS: ${OPERATOR_AUTO_REFRESH_SECONDS:-5}
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net

View File

@ -1,25 +0,0 @@
services:
order_report_api:
container_name: mv_ucepsa_order_report_api
build:
context: ./services/order-report-api
dockerfile: Dockerfile
image: mv_ucepsa_order_report_api:0.3
restart: unless-stopped
env_file:
- ./services/order-report-api/.env.local
ports:
- "8090:8090"
networks:
- ucepsa_edge_oee_net
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
ucepsa_edge_oee_net:
external: true
name: mv_ucepsa_net

View File

@ -1,28 +0,0 @@
services:
mv_ucepsa_odoo_order_state_pg_sink:
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
container_name: mv_ucepsa_odoo_order_state_pg_sink
restart: unless-stopped
env_file:
- .env
environment:
TENANT: ucepsa
SITE: ucepsa_onpremise
MQTT_HOST: mv_ucepsa_mosquitto
MQTT_PORT: 1883
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/+
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./tools:/app/tools:ro
working_dir: /app
command: python tools/order_state_pg_sink.py
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
external: true

View File

@ -1,37 +0,0 @@
services:
mv_ucepsa_odoo_order_state_publisher:
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
container_name: mv_ucepsa_odoo_order_state_publisher
restart: unless-stopped
env_file:
- .env
environment:
ODOO_DB_OVERRIDE: ucepsa_prod
SITE_OVERRIDE: ucepsa_onpremise
MQTT_HOST_OVERRIDE: mv_ucepsa_mosquitto
MQTT_USER: ${MQTT_USER_REVPI}
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
volumes:
- ./:/app:ro
working_dir: /app
command:
- python
- tools/odoo_order_state_publisher.py
- --publish
- --loop
- --interval
- "5"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
external: true
name: mv_ucepsa_net

View File

@ -1,35 +0,0 @@
services:
mv_ucepsa_shopfloor_session_probe:
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
container_name: mv_ucepsa_shopfloor_session_probe
profiles:
- diagnostic
env_file:
- .env
environment:
ODOO_DB_OVERRIDE: ucepsa_prod
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
volumes:
- ./:/app:ro
- /tmp:/probe-output
working_dir: /app
command:
- python
- tools/odoo_shopfloor_session_probe.py
- --machine
- CORT-00
- --watch
- --duration-minutes
- "30"
- --interval
- "2"
- --output
- /probe-output/odoo_shopfloor_session_probe.jsonl
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
external: true
name: mv_ucepsa_net

View File

@ -1,49 +0,0 @@
services:
mv_ucepsa_postgres_hot:
image: postgres:16-alpine
container_name: mv_ucepsa_postgres_hot
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: Europe/Madrid
volumes:
- ./postgres/data:/var/lib/postgresql/data
networks:
- mv_ucepsa_net
mv_ucepsa_mosquitto:
image: eclipse-mosquitto:2
container_name: mv_ucepsa_mosquitto
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
networks:
- mv_ucepsa_net
mv_ucepsa_grafana:
image: grafana/grafana-oss:latest
container_name: mv_ucepsa_grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
TZ: Europe/Madrid
volumes:
- ./grafana/data:/var/lib/grafana
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
name: mv_ucepsa_net

View File

@ -1,37 +0,0 @@
# Estado actual — MESAVAULT Edge-OEE UCEPSA
## VM
- Host: mv-edge-oee
- IP: 17.126.1.70
- Stack: Docker Compose
- Fecha: 2026-05-27
## Servicios activos
- PostgreSQL: mv_ucepsa_postgres_hot
- Mosquitto: mv_ucepsa_mosquitto
- Grafana: mv_ucepsa_grafana
- MQTT sink: mv_ucepsa_edge_oee_mqtt_pg_sink
- Odoo sync: mv_ucepsa_odoo_workorders_sync
- Consola de paros: mv_ucepsa_operator_stop_console
## Funcionalidad validada
- Odoo UCEPSA sincroniza órdenes de Cortadora 01.
- Grafana muestra Odoo vs máquina.
- Consola de paros permite clasificar paros.
- Loss Intelligence v0 creada mediante vistas SQL.
- RevPi validada previamente por OpenVPN, actualmente apagada.
## URLs
- Grafana: http://17.126.1.70:3000
- Consola: http://17.126.1.70:8088/?machine_id=CORT-01
## Pendiente
- Conectar RevPi físicamente o por VPN.
- Crear HTTPS / PWA para tablets.
- Preparar backup automático.
- Escalar a más cortadoras.

View File

@ -1,41 +0,0 @@
# MESAVAULT Edge-OEE UCEPSA Demo
Demo técnica y comercial de Edge-OEE para UCEPSA.
Objetivo:
Odoo Shop Floor dice qué orden está en curso.
MESAVAULT Edge-OEE muestra qué hizo realmente la máquina durante esa orden.
La demo convierte eventos de máquina en:
- pérdidas cuantificadas;
- coste económico;
- causas de paro;
- discrepancias Odoo/máquina;
- acciones recomendadas;
- justificación económica de mantenimiento, producción y calidad.
## Arquitectura
Esta vertical sigue el patrón MESAVAULT ya utilizado en otras verticales:
- platform-40 procesa;
- mv_postgres_hot conserva;
- Grafana visualiza;
- los servicios de vertical quedan en /srv/verticals;
- Gitea guarda SQL, scripts, dashboards y documentación técnica saneada.
## Estado
Fase actual: demo simulada sin hardware adicional.
Primer hito:
En Grafana se debe ver:
- una orden ficticia de Odoo;
- una línea temporal realista de máquina;
- preparación, marcha, microparos y paros;
- coste por causa;
- acción recomendada.

View File

@ -1,29 +0,0 @@
# ADR — Contexto productivo multifuente durante migraciones ERP
## Estado
Aceptado para UCEPSA en modo SHADOW.
## Problema
Una máquina puede producir legítimamente sin orden Odoo porque la planta mantiene
temporalmente actividad en el ERP anterior.
Tratar todo `RUNNING_WITHOUT_VALID_ORDER` como error o pérdida produciría falsos
positivos y dañaría la implantación.
## Decisión
MESAVAULT separa:
- verdad física: WISE/RevPi;
- contexto productivo: Odoo, legacy, manual, mantenimiento o prueba;
- pérdida: solo tras intersectar actividad, contexto confirmado y política económica.
## Consecuencias
- Odoo sigue siendo la fuente preferida.
- Legacy se acepta como excepción explícita.
- Sin contexto confirmado se genera una tarea de revisión, no un coste.
- Las balizas no cambian de semántica.
- El porcentaje Odoo/legacy/sin contexto se convierte en indicador de migración.

View File

@ -1,76 +0,0 @@
# Consultas Grafana — Shop Floor Context Health v0.3.5
## Estado por máquina
```sql
SELECT
machine_id AS "Máquina",
live_context_status AS "Estado",
live_severity AS "Severidad",
published_production_order AS "Orden publicada",
odoo_workorder_id AS "Workorder sesión",
COALESCE(
operator_name,
operator_candidate_name
) AS "Operario",
sync_health_status AS "Sync",
recommended_action AS "Acción"
FROM
mv_reports_ucepsa_prod
.li_shopfloor_machine_live_context_v1
ORDER BY machine_id;
```
## Incidencias
```sql
SELECT
observed_at AS "Hora",
severity AS "Severidad",
machine_id AS "Máquina",
incident_code AS "Código",
production_order AS "Orden",
odoo_workorder_id AS "Workorder",
operator_name AS "Operario",
detail AS "Situación",
recommended_action AS "Acción"
FROM
mv_reports_ucepsa_prod
.li_shopfloor_context_incidents_v1
ORDER BY
CASE severity
WHEN 'CRITICAL' THEN 1
WHEN 'WARNING' THEN 2
ELSE 3
END,
machine_id;
```
## Sesiones abiertas
```sql
SELECT
machine_id AS "Máquina",
production_order AS "Orden",
odoo_workorder_id AS "Workorder",
operator_name AS "Operario",
session_started_at AS "Inicio",
session_age_min AS "Minutos abierta",
machine_running AS "Máquina en marcha",
open_stop_duration_min AS "Paro abierto min",
watch_status AS "Estado",
watch_severity AS "Severidad"
FROM
mv_reports_ucepsa_prod
.li_shopfloor_open_session_watch_v1
ORDER BY machine_id;
```
## Resumen
```sql
SELECT *
FROM
mv_reports_ucepsa_prod
.li_shopfloor_context_health_summary_v1;
```

View File

@ -1,95 +0,0 @@
--- SCHEMAS ---
schema_name
-------------
mv_control
mv_elpicon
mv_hot
(3 rows)
--- TABLES ---
table_schema | table_name
--------------+-----------------------------
mv_control | asset_device_map
mv_control | drain_downlink_audit
mv_control | drain_schedule
mv_hot | aquaponics_latest
mv_hot | aquaponics_readings
mv_hot | climate_air_readings
mv_hot | climate_node_latest
mv_hot | climate_node_readings
mv_hot | drain_latest
mv_hot | drain_readings
mv_hot | edge_oee_data_quality_demo
mv_hot | edge_oee_eastron_readings
mv_hot | edge_oee_energy_demo
mv_hot | edge_oee_events_demo
mv_hot | edge_oee_loss_summary_demo
mv_hot | edge_oee_machine_stops
mv_hot | edge_oee_machine_targets
mv_hot | edge_oee_machines
mv_hot | edge_oee_node_events
mv_hot | edge_oee_orders_demo
mv_hot | edge_oee_quality_cases_demo
mv_hot | edge_oee_stop_causes
mv_hot | hydro_events
mv_hot | hydro_risk_history
mv_hot | terrace_regime_config
mv_hot | weather_alerts
mv_hot | weather_forecast_hourly
(27 rows)
--- VIEWS ---
table_schema | table_name
--------------+--------------------------------------
mv_elpicon | climate_air_latest
mv_elpicon | climate_air_readings
mv_elpicon | drain_events
mv_elpicon | drain_latest
mv_elpicon | drain_readings
mv_hot | aquaponics_dashboard
mv_hot | aquaponics_flow_dashboard
mv_hot | aquaponics_readings_clean
mv_hot | climate_air_latest
mv_hot | climate_node_dashboard
mv_hot | climate_node_latest_clean
mv_hot | climate_node_readings_clean
mv_hot | drain_events
mv_hot | edge_oee_actions_demo
mv_hot | edge_oee_data_quality_dashboard_demo
mv_hot | edge_oee_eastron_dashboard
mv_hot | edge_oee_eastron_diagnosis_latest
mv_hot | edge_oee_eastron_latest
mv_hot | edge_oee_executive_summary_demo
mv_hot | edge_oee_kpi_cards_demo
mv_hot | edge_oee_latest_machine_snapshot
mv_hot | edge_oee_live_telemetry
mv_hot | edge_oee_machine_state_latest
mv_hot | edge_oee_node_events_recent
mv_hot | edge_oee_open_stops
mv_hot | edge_oee_order_timeline_demo
mv_hot | edge_oee_pareto_demo
mv_hot | edge_oee_quality_case_demo
mv_hot | edge_oee_stop_cause_options
mv_hot | edge_oee_stop_summary_demo
mv_hot | edge_oee_stops_recent
mv_hot | hydro_combined_risk_now_v1
mv_hot | hydro_dashboard_v2
mv_hot | hydro_decision_now_v1
mv_hot | hydro_diagnosis_latest_v2
mv_hot | hydro_features_now_v1
mv_hot | hydro_weather_risk
mv_hot | terrace_exposure_v1
mv_hot | weather_local_regime_v1
(39 rows)
--- ROLES RELEVANTES ---
rolname
---------------------------
edge_oee_demo_writer
edge_oee_eastron_writer
edge_oee_mqtt_sink_writer
edge_oee_operator_writer
grafana_elpicon_ro
grafana_ucepsa_ro
(6 rows)

View File

@ -1,421 +0,0 @@
# Runbook — Order Report, Margin Intelligence y Cost Readiness Cockpit
## 1. Propósito
Este documento describe la capa de informes de ejecución de orden, Margin Intelligence y Cost Readiness Cockpit desarrollada para MESAVAULT Edge-OEE en UCEPSA.
El objetivo de esta capa es unir datos de Odoo, máquina, paros, energía, materia prima e ingreso comercial para generar informes técnicos y económico-industriales por orden de fabricación.
## 2. Componentes incluidos
La fase incluye:
- informe HTML por orden;
- informe PDF por orden;
- índice de órdenes;
- Margin Intelligence;
- Cost Readiness Cockpit;
- vistas SQL de ejecución de orden;
- vistas SQL de materia prima;
- vistas SQL de margen;
- vistas SQL de readiness económico;
- microservicio FastAPI;
- despliegue mediante Docker Compose.
## 3. URLs operativas
Servicio Order Report API:
```text
http://17.126.1.70:8090
```
Healthcheck:
```text
http://17.126.1.70:8090/health
```
Índice de órdenes:
```text
http://17.126.1.70:8090/orders.html
```
Cost Readiness Cockpit:
```text
http://17.126.1.70:8090/cost-readiness.html
```
Informe HTML de orden:
```text
http://17.126.1.70:8090/report.html?order_ref=WH%2FMO%2F00025
```
Informe PDF de orden:
```text
http://17.126.1.70:8090/report.pdf?order_ref=WH%2FMO%2F00025
```
## 4. Servicio Docker
Contenedor:
```text
mv_ucepsa_order_report_api
```
Compose:
```text
docker-compose.order-report.yml
```
Arranque:
```bash
cd /srv/mesavault/edge-oee-ucepsa
docker compose -f docker-compose.order-report.yml up -d --build
```
Parada:
```bash
cd /srv/mesavault/edge-oee-ucepsa
docker compose -f docker-compose.order-report.yml down
```
Comprobación:
```bash
curl http://127.0.0.1:8090/health
```
Logs:
```bash
docker logs --tail=120 mv_ucepsa_order_report_api
```
## 5. Ubicación runtime
Ruta runtime:
```text
/srv/mesavault/edge-oee-ucepsa/services/order-report-api
```
Archivos principales:
```text
services/order-report-api/Dockerfile
services/order-report-api/requirements.txt
services/order-report-api/app/main.py
services/order-report-api/app/templates/order_report.html
services/order-report-api/app/templates/orders_index.html
services/order-report-api/app/templates/cost_readiness.html
services/order-report-api/.env.local
```
El archivo `.env.local` contiene credenciales y no debe versionarse.
## 6. Ubicación en Gitea
Repositorio local:
```text
/srv/mesavault/40-clients
```
Ruta del proyecto:
```text
ucepsa/edge-oee-demo/
```
Archivos versionados relacionados:
```text
ucepsa/edge-oee-demo/services/order-report-api/
ucepsa/edge-oee-demo/deploy/ucepsa-local/docker-compose.order-report.yml
ucepsa/edge-oee-demo/sql/reports/070_order_execution_report_views.sql
ucepsa/edge-oee-demo/sql/reports/080_margin_intelligence_cost_readiness.sql
ucepsa/edge-oee-demo/docs/runbooks/080_order_report_margin_cost_readiness.md
```
## 7. Vistas SQL principales
### 7.1. Informe técnico de orden
```text
mv_reports_ucepsa_demo.order_execution_report_v1
mv_reports_ucepsa_demo.order_execution_stops_v1
mv_reports_ucepsa_demo.order_execution_stop_causes_v1
mv_reports_ucepsa_demo.order_execution_timeline_v1
mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1
mv_reports_ucepsa_demo.order_execution_data_quality_v1
mv_reports_ucepsa_demo.order_execution_report_full_v1
```
### 7.2. Margin Intelligence
```text
mv_edge_oee_ucepsa_demo.odoo_order_commercials
mv_edge_oee_ucepsa_demo.margin_cost_parameters
mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
mv_reports_ucepsa_demo.order_raw_material_costs_v1
mv_reports_ucepsa_demo.order_raw_material_costs_summary_v1
mv_reports_ucepsa_demo.order_margin_report_v1
```
### 7.3. Cost Readiness
```text
mv_reports_ucepsa_demo.cost_readiness_orders_v1
mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
mv_reports_ucepsa_demo.cost_readiness_summary_v1
```
## 8. Orden piloto validada
Orden:
```text
WH/MO/00025
```
Pedido de venta:
```text
S00011
```
Cliente:
```text
FRAJUSEMA, S.L.
```
Producto:
```text
[220365] BOLSA BD DE 60x90 cms. G80 TRANSPARENTE
```
Resultado técnico validado:
```text
Duración: 35,83 min
Tiempo en marcha: 33,48 min
Tiempo parado: 2,35 min
Energía: 0,029 kWh
Ciclos reales: 63
Paros: 2
Coste paros: 1,41 €
Calidad técnica: FIABLE
```
Resultado económico actual:
```text
Ingreso neto: 1154,80 €
Materia prima: [120727] TUBO BD DE 60 cms. G80 TRANSPARENTE
Cantidad consumida: 138 kg
Coste materia prima: pendiente
Margen estimado: no calculable todavía
Motivo: falta coste €/kg de materia prima 120727
```
## 9. Criterio de margen
El sistema no calcula margen final cuando falta coste de materia prima.
Estados relevantes:
```text
REVENUE_LINKED
NO_REVENUE_LINKED
MISSING_RAW_MATERIAL_COST_PARAMETER
PARTIAL_MARGIN_MISSING_RAW_MATERIAL_COST
ESTIMATED_MARGIN_AVAILABLE
```
La decisión operativa actual es bloquear el margen estimado hasta que UCEPSA cargue costes reales de materia prima en Odoo o valide costes manuales por kg.
## 10. Cost Readiness Cockpit
El cockpit muestra:
- órdenes con ingreso;
- órdenes sin ingreso;
- órdenes con materia prima;
- órdenes sin materia prima;
- órdenes con coste material pendiente;
- materias primas que bloquean cálculo de margen;
- clientes y pedidos afectados;
- score de readiness económico.
Bloqueo principal actual:
```text
Falta coste €/kg para 120727 - [120727] TUBO BD DE 60 cms. G80 TRANSPARENTE
```
## 11. Comandos de validación
Resumen readiness:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT *
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1;
SQL
```
Readiness por orden:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT
odoo_order_ref,
sale_order_ref,
customer_name,
product_default_code,
revenue_net_eur,
cost_readiness_score,
cost_readiness_status,
cost_readiness_description
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
ORDER BY cost_readiness_score ASC;
SQL
```
Materias primas pendientes:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT
raw_product_default_code,
raw_product_name,
affected_orders,
affected_customers,
total_consumed_qty,
raw_material_readiness_status,
recommended_action
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
ORDER BY affected_order_count DESC;
SQL
```
Informe de margen:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT
odoo_order_ref,
sale_order_ref,
customer_name,
revenue_net_eur,
raw_materials_summary,
material_cost_estimated_eur,
material_cost_source_status,
estimated_margin_eur,
estimated_margin_pct,
margin_status
FROM mv_reports_ucepsa_demo.order_margin_report_v1
WHERE odoo_order_ref = 'WH/MO/00025';
SQL
```
## 12. Cómo activar margen estimado en el futuro
Cuando UCEPSA tenga el coste real por kg del tubo `120727`, se podrá insertar en:
```text
mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
```
Plantilla:
```sql
INSERT INTO mv_edge_oee_ucepsa_demo.raw_material_cost_parameters (
tenant,
site,
raw_product_default_code,
raw_product_name,
material_cost_eur_per_kg,
source,
notes
)
VALUES (
'ucepsa',
'ucepsa_test',
'120727',
'[120727] TUBO BD DE 60 cms. G80 TRANSPARENTE',
COSTE_REAL_EUR_KG,
'MANUAL_UCEPSA',
'Coste validado por UCEPSA para Margin Intelligence'
);
```
Tras insertar ese coste, `order_margin_report_v1` empezará a calcular:
```text
material_cost_estimated_eur
estimated_margin_eur
estimated_margin_pct
```
## 13. Riesgos y advertencias
No presentar `partial_margin_after_known_costs_eur` como margen real.
No introducir costes aproximados sin fuente validada.
No mezclar órdenes de prueba con datos productivos sin señalarlas.
No usar informes bloqueados por falta de coste como base para pricing.
No interpretar `qty_planned`, `qty_done` o ciclos como unidades comerciales hasta validar conversiones.
## 14. Estado actual
Estado funcional:
```text
Order Execution Report: operativo
Informe HTML/PDF: operativo
Margin Intelligence: operativo en modo parcial
Cost Readiness Cockpit: operativo
Margen estimado final: bloqueado hasta coste de materia prima
```
Snapshot recomendado:
```text
order-report-margin-cost-readiness-v01-ok
```
## 15. Accesos rápidos
```text
Orders:
http://17.126.1.70:8090/orders.html
Cost Readiness:
http://17.126.1.70:8090/cost-readiness.html
Informe HTML WH/MO/00025:
http://17.126.1.70:8090/report.html?order_ref=WH%2FMO%2F00025
Informe PDF WH/MO/00025:
http://17.126.1.70:8090/report.pdf?order_ref=WH%2FMO%2F00025
```

View File

@ -1,120 +0,0 @@
# UCEPSA — Despliegue LossIntelligence en modo sombra hasta octubre de 2026
## Objetivo
Construir y validar entre julio y septiembre la infraestructura reusable de MESAVAULT LossIntelligence sin convertir todavía los paros técnicos en pérdidas oficiales. En octubre se realizará el kickoff y se activará el Ledger con una fecha efectiva acordada.
## Regla de seguridad funcional
La captura raw no se modifica:
```text
WISE / RevPi / MQTT / PostgreSQL
-> evento físico
-> paro raw
```
LossIntelligence trabaja encima:
```text
paro raw
-> contexto Odoo histórico
-> política de elegibilidad
-> exclusión / agrupación / candidato
-> Loss Ledger
-> acción
-> prueba
-> resultado
```
Un paro raw no equivale automáticamente a pérdida.
## Estados
- `SHADOW`: infraestructura activa, resultados solo para validación interna.
- `ACTIVE`: Ledger oficial desde `actual_go_live_at`.
- `PAUSED`: captura conservada, promoción de nuevas pérdidas detenida.
- `CLOSED`: despliegue cerrado.
Octubre es una fecha prevista, no una activación automática. La activación requiere kickoff y actualización explícita de `actual_go_live_at`.
## Parámetros iniciales
- Coste de capacidad: `36 €/h`, provisional y pendiente de aprobación.
- Coste eléctrico variable: `0,128249 €/kWh`, derivado de la factura 6.1TD de junio de 2026.
- Bolsas por golpe: `1` o `2` según referencia. Debe configurarse por `product_id`.
- Cadencia objetivo: pendiente por referencia o familia. Hasta completarla no se activa pérdida por baja cadencia.
## Política inicial de paros
| Duración | Tratamiento en sombra |
|---|---|
| `<5 s` | Se conserva en raw y se excluye de la cola humana. |
| `5 s5 min` | Se agrupa por máquina, orden, ventana de 15 min y causa. |
| `>=5 min` | Se mantiene como candidato individual. |
| Paro abierto | Solo monitorización; no entra en Ledger. |
| Sin orden Odoo válida y coherente | Excluido. |
| Antes del go-live | Sombra, nunca pérdida oficial. |
Los umbrales deben validarse con producción antes del kickoff.
## Rutina de revisión futura
La reunión diaria de 1530 minutos entre dirección y jefe de producción será el punto de revisión. La cola oficial se limitará a cinco elementos:
1. pérdidas nuevas con mayor prioridad;
2. acciones cuyo plazo vence;
3. pruebas en curso que requieren decisión;
4. bloqueos de calidad de dato;
5. resultados que deben mantenerse, adaptarse, repetirse, descartarse o escalarse.
## Orden de implantación
1. Crear rama de trabajo en Gitea.
2. Tomar backup de PostgreSQL.
3. Aplicar migraciones 093096.
4. Validar que la cola oficial está vacía en modo `SHADOW`.
5. Actualizar el sink de `order_state` para guardar campos tipados y un hash histórico completo.
6. Observar datos durante al menos dos semanas.
7. Completar bolsas por golpe de las referencias activas.
8. Validar política de microparos y causas con producción.
9. Construir dashboard de sombra y panel de calidad de dato.
10. Preparar checklist de kickoff.
11. En octubre, acordar `actual_go_live_at` y cambiar `mode` a `ACTIVE`.
## Kill switch
Para detener la promoción oficial sin tocar la captura:
```sql
UPDATE mv_loss_intelligence.deployment_config
SET mode = 'PAUSED', updated_at = now()
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS';
```
## Activación en kickoff
Ejemplo; sustituir fecha y hora por las acordadas:
```sql
UPDATE mv_loss_intelligence.deployment_config
SET
mode = 'ACTIVE',
actual_go_live_at = TIMESTAMPTZ '2026-10-05 06:00:00+02',
updated_at = now()
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS';
```
La activación no debe hacerse antes de confirmar:
- Shop Floor estable;
- órdenes correctamente cargadas por máquina;
- causas y umbrales aceptados;
- coste/hora revisado;
- productos activos con bolsas por golpe;
- responsables y reunión diaria confirmados;
- backup y rollback disponibles.

View File

@ -1,56 +0,0 @@
# UCEPSA LossIntelligence SHADOW v0.2 — elegibilidad power-aware
## Problema corregido
En UCEPSA, cuando una cortadora no tiene trabajo se corta su alimentación. El último evento puede ser `machine_stopped` y el paro raw permanece abierto o se cierra varios días después. Esa duración física no equivale a pérdida.
La v0.2 calcula tiempo elegible mediante la intersección:
```text
paro raw
∩ intervalo de orden Odoo válida
∩ isla de telemetría real
= tiempo candidato a pérdida
```
Cuando desaparece la alimentación, dejan de llegar muestras y la isla de telemetría termina. El tiempo posterior no entra en LossIntelligence.
## Umbrales iniciales
- Frecuencia nominal de captura observada: alrededor de 5 s.
- Un hueco mayor de 90 s abre una nueva isla de telemetría.
- Se toleran 30 s después de la última muestra válida.
- Menos de 5 s sigue siendo ruido.
- Entre 5 s y 5 min se agrupa.
- A partir de 5 min se muestra individualmente.
Los valores son de modo sombra y deben revisarse antes del kickoff de octubre.
## Vistas nuevas
- `li_telemetry_coverage_v1`
- `li_stop_order_overlaps_v2`
- `li_stop_eligible_slices_v2`
- `li_stop_candidates_v2`
- `li_stop_exclusion_diagnostics_v2`
- `li_stop_groups_v2`
- `li_shadow_review_queue_v2`
- `li_operator_stop_queue_v2`
- `li_shadow_summary_v2`
- `li_eligibility_comparison_v1`
- `machine_operational_state_v1`
- `li_data_quality_v2`
## Regla de interfaz
Una máquina sin telemetría y sin orden debe mostrarse como `SIN PRODUCCIÓN`, no como `NO OK`.
Estados que sí requieren atención:
- máquina en marcha sin orden válida;
- orden válida sin telemetría;
- telemetría inválida o comunicación fallida con orden activa.
## Activación
La v0.2 no cambia `deployment_config.mode`. Permanece `SHADOW` y la cola oficial v2 debe devolver cero filas.

View File

@ -1,93 +0,0 @@
# UCEPSA — Descubrimiento de sesiones Odoo Shop Floor v0.1
## Objetivo
Identificar, sin escribir en Odoo, qué registros y campos cambian cuando una operaria:
1. se identifica en Shop Floor;
2. inicia una orden en una cortadora;
3. pausa la orden;
4. la reanuda;
5. termina o abandona la orden;
6. es sustituida por otra operaria.
El resultado servirá para diseñar el publicador productivo de sesiones Shop Floor de MESAVAULT.
## Hipótesis técnica que se debe verificar
La fuente candidata principal es `mrp.workcenter.productivity`.
Una sesión activa debería aparecer como una fila con:
- `workorder_id`;
- `workcenter_id`;
- `date_start`;
- `date_end` vacío mientras sigue activa;
- `user_id` u otro campo de identidad;
- una pérdida/tipo compatible con tiempo productivo.
No se dará esta hipótesis por válida hasta observarla en UCEPSA PROD.
## Seguridad
La sonda:
- solo usa XML-RPC de lectura;
- no invoca `create`, `write`, `unlink` ni botones de Odoo;
- no publica MQTT;
- no modifica PostgreSQL;
- no altera balizas;
- no modifica Shop Floor.
## Secuencia recomendada
### 1. Descubrimiento estático
Ejecutar `--discover` y conservar la salida.
### 2. Instantánea inicial
Con CORT-00 sin una prueba en curso, ejecutar `--snapshot`.
### 3. Vigilancia
Iniciar `--watch` durante 30 minutos.
### 4. Acciones en Shop Floor
Anotar la hora local exacta de cada acción:
- T0: operaria se identifica;
- T1: abre CORT-00;
- T2: inicia la orden;
- T3: pausa;
- T4: reanuda;
- T5: finaliza;
- T6: otra operaria inicia o se incorpora, si el flujo lo permite.
Esperar al menos 2030 segundos entre acciones para distinguirlas con claridad.
### 5. Cierre
Detener la sonda o esperar a que termine y copiar:
- salida de descubrimiento;
- instantánea inicial;
- JSONL de vigilancia;
- lista manual T0T6;
- orden, producto y máquina utilizados.
## Criterios de aceptación
Se considera fuente oficial viable si se puede reconstruir de forma inequívoca:
- operaria;
- máquina;
- orden de trabajo;
- orden de fabricación;
- inicio;
- pausa/cierre;
- reanudación;
- relevo.
Debe confirmarse además que una sesión abierta tiene un estado detectable y que una pausa o fin deja una marca histórica, no solo un estado actual.

View File

@ -1,80 +0,0 @@
# UCEPSA — LossIntelligence Production Context SHADOW v0.3
## Decisión de arquitectura
Durante la migración conviven dos vías válidas:
1. Odoo Shop Floor, estándar preferido.
2. Producción legacy o manual autorizada.
`máquina en marcha + sin orden Odoo` no se convierte automáticamente en pérdida.
Primero se clasifica el contexto:
- ODOO_CONTEXT;
- LEGACY_AUTHORIZED;
- MANUAL_AUTHORIZED;
- MAINTENANCE;
- TEST_SETUP;
- UNCONFIRMED_CONTEXT;
- CONTEXT_CONFLICT.
## Protección de las balizas
Esta versión no modifica:
- `beacon_controller.py`;
- el publicador `order_state`;
- DO0 azul;
- DO1 rojo.
Azul fijo continúa significando exclusivamente orden Odoo válida.
Una producción legacy puede seguir provocando azul intermitente porque está fuera del
flujo Odoo, aunque MESAVAULT la reconozca como legacy autorizada.
## Flujo preventivo recomendado
Antes de producir por el ERP anterior, producción abre un contexto:
```bash
python tools/li_context_admin.py open \
--machine CORT-02 \
--source LEGACY_ERP \
--external-order-ref LEGACY-1234 \
--product-name "Referencia legacy" \
--authorized-by "Juan Pablo" \
--notes "Producción autorizada fuera de Odoo durante migración"
```
Al finalizar:
```bash
python tools/li_context_admin.py close \
--session-id 1 \
--closed-by "Juan Pablo"
```
## Flujo retrospectivo
Si no se abrió previamente, la vista `li_context_review_queue_v1` muestra un único
bloque físico. Tras confirmar el intervalo se crea una sesión retrospectiva.
## Seguridad SHADOW
Mientras `deployment_config.mode = SHADOW`, la herramienta fuerza:
```text
official_eligible = false
```
Por tanto, ningún contexto manual puede crear por sí solo una pérdida económica oficial.
## Qué no resuelve todavía
- La identidad activa de la operaria en el flujo personalizado de Shop Floor.
- El cierre real con declaración de producción.
- La extensión de los paros v2 para usar contexto legacy.
La siguiente subfase será `v0.3.1`: cruzar los paros con los contextos validados tras
comprobar que la apertura/cierre legacy funciona correctamente.

View File

@ -1,58 +0,0 @@
# UCEPSA — LossIntelligence Context-Aware Stops SHADOW v0.3.1
## Objetivo
Cruzar los paros físicos de WISE/RevPi con:
- cobertura válida de telemetría;
- contexto Odoo válido;
- producción legacy autorizada;
- producción manual autorizada.
## Seguridad
La migración 102 solo crea vistas paralelas.
No modifica:
- `li_stop_candidates_v2`;
- `li_stop_groups_v2`;
- `li_shadow_review_queue_v2`;
- `li_operator_stop_queue_v2`;
- `loss_ledger`;
- Odoo;
- RevPi;
- nodos WISE;
- balizas.
`official_ledger_eligible` queda forzado a `false`.
## Vistas nuevas
- `li_stop_context_slices_v1`
- `li_stop_candidates_context_v1`
- `li_stop_groups_context_v1`
- `li_shadow_review_queue_context_v1`
- `li_shadow_review_backlog_context_v1`
- `li_stop_context_diagnostics_v1`
## Comportamiento esperado durante la prueba
CORT-00:
- sesión legacy 1 abierta;
- un paro abierto se muestra como `OPEN_MONITORING`;
- al cerrar físicamente el paro, se convierte en candidato si supera el ruido.
CORT-02:
- `WH/MO/00081`;
- paros de 25 segundos deben quedar como ruido;
- paros superiores al umbral deben agruparse o mostrarse individualmente.
## Cola diaria
La cola diaria context-aware está limitada a las últimas 24 horas para evitar que
los falsos positivos históricos dominen la revisión.
El backlog histórico sigue disponible en una vista separada.

View File

@ -1,70 +0,0 @@
# UCEPSA — Hotfix historial de transiciones Odoo v0.3.2
## Causa confirmada
La tabla `mv_hot.odoo_order_state_events` tenía un índice único global:
```text
tenant + site + machine_id + payload_hash
```
Esto impedía representar una secuencia válida:
```text
A → B → A
```
El segundo `A` se descartaba aunque fuera una transición nueva en otro momento.
## Síntoma observado
CORT-02:
```text
WH/MO/00081-002
→ no_loaded_order
```
El estado `current` se actualizó correctamente, pero el evento histórico no se
insertó porque el hash de `no_loaded_order` ya existía de un hueco anterior.
## Corrección
- Se elimina la unicidad global por hash.
- El sink compara con el último evento de la máquina.
- Solo evita repeticiones consecutivas:
- A → A: no inserta;
- A → B: inserta B;
- A → B → A: inserta de nuevo A.
- CURRENT sigue refrescándose con cada mensaje.
- Se usa un advisory lock transaccional por máquina.
- Se repara la transición conocida de CORT-02 a las 15:51:03.861315589+02.
## Orden de despliegue obligatorio
1. Detener `mv_ucepsa_odoo_order_state_pg_sink`.
2. Aplicar migración 103.
3. Instalar el script corregido.
4. Arrancar el sink.
5. Ejecutar validación.
No se debe retirar el índice con el sink antiguo en funcionamiento, porque su
`ON CONFLICT` depende de ese índice.
## Alcance
No modifica:
- Odoo;
- publicador Odoo;
- MQTT;
- RevPi;
- WISE;
- balizas;
- tabla raw de paros;
- cola oficial;
- Loss Ledger.
La reparación conocida solo corrige el intervalo de WH/MO/00081-002.
Los huecos históricos anteriores que no puedan reconstruirse con evidencia deben
mantenerse con confianza reducida.

View File

@ -1,244 +0,0 @@
# UCEPSA — Odoo Shop Floor Sessions SHADOW v0.3.3
## Objetivo
Construir sesiones canónicas de producción a partir del flujo real de Odoo
Shop Floor sin pedir un segundo login a la operaria y sin alterar Odoo, balizas,
RevPi, WISE, la cola oficial ni el Loss Ledger.
## Evidencia base
### Workorder 87
```text
WH/MO/00081-001
CORT-02
11:48:31 → 13:48:58 hora local
operaria 43 / M. Angeles Martinez
85 kg contabilizados
```
La operaria está confirmada por dos filas de
`mrp.workcenter.productivity`.
### Workorder 810
```text
WH/MO/00081-002
CORT-02
14:01:56 → 15:50:59 hora local
candidata 20 / MARIA CIBÚ
76 kg candidatos
```
La persona y la cantidad quedan `PENDING_HUMAN`. El intervalo de sesión,
máquina, orden y workorder sí están confirmados.
## Alcance de v0.3.3
Crea:
- snapshot actual por workorder;
- eventos secuenciales por transición;
- sesión canónica por workorder;
- resolución de operaria con confianza;
- separación entre cantidad candidata y contabilizada;
- cola de revisión SHADOW;
- contexto paralelo `ODOO_SHOPFLOOR`;
- herramienta posterior de confirmación humana.
No cambia:
- `production_context_sessions`;
- vistas de candidatos de paro v0.3.1;
- cola oficial;
- Odoo;
- MQTT de balizas;
- RevPi;
- WISE;
- balizas.
La asociación de paros a las sesiones nuevas se hará en una versión posterior,
cuando la captura haya sido validada en operación continua.
## Reglas de operaria
1. Una única persona real en productividad:
- operaria confirmada;
- confianza alta.
2. Sin productividad y una única persona real asignada, excluyendo perfiles
técnicos:
- candidata;
- confianza media;
- confirmación humana pendiente.
3. Más de una persona real o ninguna evidencia:
- sin resolver;
- confianza baja.
`employee_id=35 / CORTADORAS` se excluye porque es el perfil técnico compartido
de la tablet.
## Reglas de cantidad
- Producción `done` y cantidad contabilizada:
`FINAL_POSTED`.
- Producción `to_close` con `qty_producing`:
`PROVISIONAL_TO_CLOSE`.
- La cantidad candidata no se transforma en cantidad confirmada sin evidencia
de stock final o confirmación humana.
## Tiempo
Los datetimes XML-RPC de Odoo se interpretan como UTC.
La sesión usa:
```text
mrp.workorder.date_start
mrp.workorder.date_finished
```
No usa `duration` como reloj de presencia.
## Cadena productiva
La clave se obtiene quitando el sufijo de backorder:
```text
WH/MO/00081-001 → ODOO:WH/MO/00081
WH/MO/00081-002 → ODOO:WH/MO/00081
```
`parent_production_id` y `child_production_ids` no se usan para los relevos,
porque en este caso representan una relación con la fabricación del tubo.
## Despliegue
### 1. Aplicar migración
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/104_ucepsa_odoo_shopfloor_sessions_shadow_v033.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
No es necesario detener servicios.
### 2. Instalar herramientas en runtime
```bash
REPO=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
RUNTIME=/srv/mesavault/edge-oee-ucepsa
install -m 0755 \
"$REPO/tools/odoo_shopfloor_session_sync.py" \
"$RUNTIME/tools/odoo_shopfloor_session_sync.py"
install -m 0755 \
"$REPO/tools/odoo_shopfloor_session_admin.py" \
"$RUNTIME/tools/odoo_shopfloor_session_admin.py"
install -m 0755 \
"$REPO/ops/manage_odoo_shopfloor_sync_v033.sh" \
"$RUNTIME/ops/manage_odoo_shopfloor_sync_v033.sh"
```
### 3. Ensayo de los dos workorders conocidos
```bash
cd /srv/mesavault/edge-oee-ucepsa
ops/manage_odoo_shopfloor_sync_v033.sh dry-run-known
```
Debe mostrar:
```text
87 → operaria 43 / HIGH / 85 kg FINAL_POSTED
810 → candidata 20 / MEDIUM / 76 kg PROVISIONAL_TO_CLOSE
```
### 4. Backfill
```bash
ops/manage_odoo_shopfloor_sync_v033.sh backfill-known
```
### 5. Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_odoo_shopfloor_sessions_shadow_v033.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Resultados obligatorios:
```text
official_rows = 0
invalid_session_intervals = 0
known_backfill_ok = true
consecutive_duplicate_event_count = 0
generated_production_context_rows = 0
official_operator_queue_rows = 0
```
### 6. Activar sincronización continua
Solo después de validar el backfill:
```bash
cd /srv/mesavault/edge-oee-ucepsa
ops/manage_odoo_shopfloor_sync_v033.sh start
ops/manage_odoo_shopfloor_sync_v033.sh status
docker logs --tail 100 mv_ucepsa_odoo_shopfloor_session_sync
```
## Confirmación posterior de Juan Pablo
No ejecutar hasta recibir confirmación.
### Confirmar operaria candidata
```bash
docker exec -i mv_ucepsa_odoo_order_state_pg_sink \
python tools/odoo_shopfloor_session_admin.py \
confirm-operator \
--workorder-id 810 \
--use-candidate \
--confirmed-by "Juan Pablo" \
--notes "Confirmado el segundo tramo de CORT-02."
```
### Confirmar cantidad
```bash
docker exec -i mv_ucepsa_odoo_order_state_pg_sink \
python tools/odoo_shopfloor_session_admin.py \
confirm-quantity \
--workorder-id 810 \
--quantity 76 \
--confirmed-by "Juan Pablo" \
--notes "Confirmados 76 kg declarados en el segundo tramo."
```
La sincronización posterior conserva las confirmaciones humanas.
## Parada y rollback operativo
Parar el sincronizador:
```bash
/srv/mesavault/edge-oee-ucepsa/ops/manage_odoo_shopfloor_sync_v033.sh stop
```
La parada no afecta Odoo, captura de máquina, MQTT ni balizas.
Las tablas nuevas pueden mantenerse para diagnóstico. No se recomienda borrarlas
durante el piloto.

View File

@ -1,57 +0,0 @@
# UCEPSA — Guard READY/PENDING Shop Floor v0.3.3.1
## Hallazgo
Durante la primera sincronización continua apareció:
```text
workorder 434
state = ready
production_state = progress
date_start = 17/07/2026
date_finished = 04/09/2026
```
Odoo puede poblar esas fechas como planificación antes de que exista una sesión
real de operaria. La v0.3.3 las interpretó como intervalo y mostró 1.182 horas
de cobertura ficticia.
## Riesgo
El modo sigue siendo SHADOW y las sesiones aún no alimentan candidatos de paro,
por lo que no hubo impacto oficial. Aun así, un workorder `READY/PENDING` no debe
aportar:
- contexto productivo;
- horas de cobertura;
- atribución individual;
- cola de revisión de sesiones reales.
## Corrección
La v0.3.3.1:
1. conserva las fechas originales de Odoo en
`mv_hot.odoo_shopfloor_workorder_current`;
2. pone `started_at` y `ended_at` de la sesión a `NULL` mientras el estado no sea
`progress` o `done`;
3. exige `OPEN` o `CLOSED` para `context_time_usable`;
4. excluye `PENDING` de la cola de revisión;
5. excluye `PENDING` del cálculo de horas;
6. mantiene `official_eligible=false`.
## Orden de despliegue
1. Detener temporalmente el sincronizador.
2. Aplicar migración 105.
3. Instalar el sincronizador v0.3.3.1.
4. Refrescar explícitamente el workorder 434.
5. Validar.
6. Arrancar de nuevo el sincronizador.
## Observación de producción del lunes
Cuando 434 u otro workorder pase de `ready` a `progress`, el sincronizador
registrará la transición. Después debe comprobarse si Odoo actualiza
`date_start` al inicio real o conserva la fecha planificada. Esa observación
determinará la regla definitiva de inicio para v0.3.4.

View File

@ -1,126 +0,0 @@
# UCEPSA — Shop Floor Stop Attribution SHADOW v0.3.4
## Objetivo
Cruzar en paralelo:
```text
paro físico
∩ telemetría válida
∩ sesión Odoo Shop Floor OPEN/CLOSED
```
La versión no reemplaza las vistas context-aware v0.3.1 y no alimenta la cola
oficial.
## Evidencia que permite avanzar
La sesión real `workorder 340 / WH/MO/00352` fue observada:
```text
Odoo date_start: 07:36:19
Primera observación MESAVAULT: 07:36:27
Operario confirmado después: Esteban Vaquerizo García
```
El inicio original se conservó aunque la identidad apareció más tarde.
CORT-00, por el contrario, registró actividad física sin workorder Odoo en
`progress`; debe continuar bloqueada como ausencia de sesión Shop Floor.
## Nuevas vistas
- `li_stop_shopfloor_slices_v1`
- `li_stop_shopfloor_candidates_v1`
- `li_shadow_review_queue_shopfloor_v1`
- `li_shopfloor_session_stop_summary_v1`
- `li_stop_shopfloor_diagnostics_v1`
## Reglas
Un tramo solo puede ser candidato cuando:
1. el paro está cerrado;
2. existe exactamente una sesión Shop Floor;
3. existe telemetría válida;
4. supera el umbral de ruido.
La atribución temporal y la atribución individual son independientes:
```text
sesión válida + operario confirmado
→ CONFIRMED_OPERATOR
sesión válida + persona candidata
→ PENDING_OPERATOR_CANDIDATE
sesión válida sin persona
→ UNRESOLVED_OPERATOR
```
Los paros pueden analizarse por sesión aunque todavía no exista atribución
individual.
## Alcance económico
`shadow_capacity_loss_eur` es exposición provisional de capacidad usando el
parámetro de 36 €/h cuando proceda. No es una pérdida aprobada.
## Garantías
- `official_ledger_eligible=false`
- no modifica Odoo;
- no modifica sesiones;
- no modifica paros raw;
- no modifica balizas;
- no modifica RevPi ni WISE;
- no sustituye v0.3.1;
- no alimenta `li_operator_stop_queue_v2`.
## Despliegue
No es necesario detener servicios.
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/106_ucepsa_shopfloor_stop_attribution_shadow_v034.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_attribution_shadow_v034.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
invalid_slice_intervals = 0
negative_slice_durations = 0
duration_overflow_count = 0
open_stops_marked_eligible = 0
cort00_attributed_rows = 0
official_candidate_rows = 0
official_operator_queue_rows = 0
```
La consulta de la sesión 340 debe devolver paros de CORT-02 atribuidos al
workorder y al operario Esteban, siempre que existan paros cerrados posteriores
a las 07:36:19.
## Siguiente observación
Cuando el workorder 340 se cierre:
- la sesión debe pasar `OPEN → CLOSED`;
- debe conservar el inicio 07:36:19;
- debe obtener un `ended_at` real;
- deben resolverse cantidad y posible continuación;
- los paros posteriores al cierre deben quedar fuera de la sesión.

View File

@ -1,94 +0,0 @@
# UCEPSA — Semántica del resumen de paros Shop Floor v0.3.4.1
## Hallazgo
La validación de v0.3.4 mostró para el workorder 340:
```text
physical_stop_count = 31
eligible_stop_count = 28
eligible_stop_minutes = 55,25
```
El diagnóstico descomponía esos 55 minutos en:
```text
CANDIDATE_GROUP 32,88 min
CANDIDATE_INDIVIDUAL 17,51 min
EXCLUDED_NOISE 0,11 min
OPEN_MONITORING 4,81 min
```
La vista de resumen sumaba todos los minutos observados en la columna
`eligible_stop_minutes`, aunque el importe económico sí excluía correctamente
el ruido y el paro abierto.
## Corrección
`eligible_stop_minutes` pasa a incluir únicamente filas con:
```text
technically_eligible = true
```
Se añaden columnas separadas:
- `observed_stop_minutes`
- `telemetry_covered_stop_minutes`
- `candidate_group_minutes`
- `candidate_individual_minutes`
- `excluded_noise_minutes`
- `open_monitoring_minutes`
- `blocked_minutes`
No se modifica la vista de candidatos, la cola SHADOW, la cola oficial ni los
datos raw.
## Resultado esperado para la sesión 340
A la hora de la validación inicial, aproximadamente:
```text
observed_stop_minutes 55,3
eligible_stop_minutes 50,4
candidate_group_minutes 32,9
candidate_individual 17,5
excluded_noise 0,1
open_monitoring 4,8
shadow_capacity_loss_eur 30,2
```
El paro abierto puede seguir creciendo hasta que la máquina vuelva a arrancar,
por lo que los valores exactos pueden variar.
## Despliegue
No es necesario detener servicios.
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/107_fix_shopfloor_stop_summary_semantics_v0341.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_summary_semantics_v0341.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
eligible_decomposition_errors = 0
summary_candidate_mismatches = 0
open_or_noise_inflation_errors = 0
official_summary_rows = 0
official_operator_queue_rows = 0
```

View File

@ -1,211 +0,0 @@
# UCEPSA — Shop Floor Context Health v0.3.5
## Objetivo
Supervisar en vivo cinco capas:
```text
máquina física
orden Odoo publicada
sesión Odoo Shop Floor
sincronizador
calidad temporal
```
Esta versión no cambia la atribución de paros v0.3.4, no modifica Odoo y no
alimenta el Ledger oficial.
## Nuevos objetos
### Política
```text
mv_loss_intelligence.odoo_shopfloor_health_policies
```
Umbrales iniciales:
```text
intervalo esperado 15 s
DELAYED a partir de 45 s
STALE a partir de 120 s
sesión OPEN obsoleta 12 h
tolerancia fecha futura 5 min
```
### Vistas
```text
li_shopfloor_sync_health_v1
li_shopfloor_session_data_quality_v1
li_context_identity_registry_v1
li_shopfloor_machine_live_context_v1
li_shopfloor_open_session_watch_v1
li_shopfloor_context_incidents_v1
li_shopfloor_context_health_summary_v1
```
## Estados vivos principales
### Producción coherente
```text
máquina en marcha
+ orden publicada
+ una sesión OPEN del mismo workorder
→ HEALTHY_OPEN_SESSION
```
### Parada dentro de una orden
```text
máquina parada
+ orden publicada
+ sesión OPEN
→ TEMPORARY_STOP_INSIDE_SESSION
```
La parada física no cierra la sesión.
### Producción sin Shop Floor
```text
máquina en marcha
+ sin orden
+ sin sesión
→ RUNNING_WITHOUT_SHOPFLOOR_SESSION
```
Acción: confirmar sistema anterior, prueba, material residual o inicio omitido.
### Orden sin sesión
```text
orden publicada
+ ninguna sesión OPEN
→ RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION
u ORDER_WITHOUT_SHOPFLOOR_SESSION
```
### Conflicto
```text
más de una sesión OPEN
o workorder publicado distinto del workorder de la sesión
→ CRITICAL
```
## Identidades con namespace
Nunca se cruza un identificador numérico desnudo.
Ejemplos:
```text
ODOO_SHOPFLOOR:WORKORDER:340
PRODUCTION_CONTEXT:LEGACY_ERP:1
```
## Incidencias
La vista de incidencias expone:
```text
severidad
código
máquina
context_key
orden
workorder
operario
detalle
acción recomendada
```
No crea tickets ni escribe en Odoo.
## Despliegue
No es necesario detener servicios.
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/108_ucepsa_shopfloor_context_health_v035.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_health_v035.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores estructurales obligatorios:
```text
live_machine_rows = 3
distinct_machine_rows = 3
pending_has_time_count = 0
open_missing_start_count = 0
open_has_end_count = 0
closed_missing_start_count = 0
closed_missing_end_count = 0
invalid_interval_count = 0
context_key_collision_count = 0
temporary_stop_wrong_severity = 0
healthy_session_wrong_severity = 0
idle_wrong_severity = 0
official_live_rows = 0
official_incident_rows = 0
official_operator_queue_rows = 0
```
`multiple_open_session_rows` debe ser cero en operación normal. Si no lo es, la
vista lo elevará como incidencia crítica.
## Lectura esperada
Mientras el workorder 340 siga abierto:
```text
CORT-02
context_key = ODOO_SHOPFLOOR:WORKORDER:340
operario = Esteban
estado = HEALTHY_OPEN_SESSION
```
Durante una parada física:
```text
estado = TEMPORARY_STOP_INSIDE_SESSION
severidad = INFO
```
CORT-00 solo aparecerá como advertencia cuando realmente esté en marcha sin
orden ni sesión.
## Grafana
La vista recomendada para una tabla operativa es:
```text
li_shopfloor_machine_live_context_v1
```
Para una lista de excepciones:
```text
li_shopfloor_context_incidents_v1
```
Para un indicador único:
```text
li_shopfloor_context_health_summary_v1
```

View File

@ -1,102 +0,0 @@
# UCEPSA — Dashboard Salud y contexto Shop Floor v0.3.6
## Decisión de despliegue
Grafana solo monta:
```text
/srv/mesavault/edge-oee-ucepsa/grafana/data
→ /var/lib/grafana
```
Los JSON del repositorio no están provisionados automáticamente. Por eso el
dashboard se versiona en Git y se despliega mediante la API local de Grafana.
No se modifica el dashboard operativo existente.
## Identidad
```text
Dashboard UID:
ucepsa-shopfloor-context-health
Carpeta UID:
ucepsa-mesavault
Carpeta:
MESAVAULT — UCEPSA
Datasource PostgreSQL:
bfnbcasbm6hhca
```
## Paneles
1. Salud general.
2. Salud del sincronizador.
3. Segundos desde la última sincronización.
4. Sesiones abiertas.
5. Incidencias WARNING.
6. Incidencias CRITICAL.
7. Estado vivo por máquina.
8. Incidencias activas.
9. Sesiones abiertas.
10. Calidad de sesiones.
## Credenciales
El script no guarda secretos en Git.
Prioridad:
1. `GRAFANA_API_TOKEN`.
2. `GRAFANA_USER` + `GRAFANA_PASSWORD`.
3. Variables `GF_SECURITY_ADMIN_USER` y `GF_SECURITY_ADMIN_PASSWORD` del
contenedor.
No se muestran contraseñas en la salida.
## Despliegue
```bash
cd /srv/mesavault/edge-oee-ucepsa
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh deploy
```
## Validación
```bash
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh validate
```
Debe devolver:
```text
status = ok
dashboard_uid = ucepsa-shopfloor-context-health
folder_uid = ucepsa-mesavault
panel_count = 10
datasource_uids = [bfnbcasbm6hhca]
```
## Exportación posterior
```bash
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh export \
> /tmp/ucepsa-shopfloor-context-health.export.json
```
## Rollback
La importación usa un UID propio y no sobrescribe otros dashboards.
Para retirarlo, usar la API:
```bash
curl -X DELETE \
-H "Authorization: Bearer $GRAFANA_API_TOKEN" \
http://127.0.0.1:3000/api/dashboards/uid/ucepsa-shopfloor-context-health
```
No se recomienda borrar la carpeta si contiene otros dashboards.

View File

@ -1,80 +0,0 @@
# UCEPSA — Limpieza controlada de dashboards Grafana v0.3.6.1
## Objetivo
Conservar exclusivamente:
```text
ucepsa-shopfloor-context-health
ucepsa-edge-oee-machine
```
El script no decide por título. Usa UID exacto para distinguir los dos
dashboards que actualmente comparten el título `MESAVAULT Edge-OEE — Máquina`.
## Seguridad
- `plan` es el modo predeterminado y no modifica Grafana.
- `apply` comprueba que los dos UIDs canónicos existen.
- Antes de borrar, exporta todos los dashboards y sus metadatos.
- La copia incluye manifiesto y SHA-256.
- La operación requiere una confirmación explícita.
- No borra carpetas automáticamente.
- No toca datasources, usuarios, alertas ni configuración de Grafana.
## Autenticación
```bash
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
```
## Plan
```bash
SCRIPT=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh
"$SCRIPT" plan
```
Revisar que solo aparezcan como `KEEP`:
```text
ucepsa-shopfloor-context-health
ucepsa-edge-oee-machine
```
## Aplicación
```bash
export GRAFANA_CLEANUP_CONFIRM='DELETE_NON_CANONICAL_DASHBOARDS'
"$SCRIPT" apply
unset GRAFANA_CLEANUP_CONFIRM
```
Las copias quedan bajo:
```text
/srv/mesavault/backups/ucepsa-grafana-cleanup/<timestamp>/
```
## Verificación
```bash
"$SCRIPT" verify
unset GRAFANA_API_TOKEN
```
## Carpetas vacías
El script devuelve `empty_folders_not_deleted`. Borrarlas después desde la
interfaz, únicamente cuando estén vacías:
```text
Dashboards → seleccionar carpeta vacía → Delete
```
No borrar una carpeta que contenga uno de los dos dashboards canónicos.

View File

@ -1,120 +0,0 @@
# UCEPSA — Presentación dashboard Shop Floor v0.3.6.2
## Objetivo
Corregir exclusivamente la presentación del dashboard:
```text
MESAVAULT — UCEPSA · Salud y contexto Shop Floor
```
No modifica PostgreSQL, Odoo, sesiones, paros, sincronizadores ni el Ledger.
## Correcciones
### Indicadores superiores
```text
Salud general
HEALTHY → verde
WARNING → naranja
CRITICAL → rojo
Sincronizador
HEALTHY → verde
DELAYED → naranja
STALE/ERROR → rojo
Advertencias
0 → verde
>= 1 → naranja
Críticas
0 → verde
>= 1 → rojo
```
Los paneles de texto se alimentan mediante códigos numéricos para evitar el
`No data` que Grafana mostraba al reducir valores de texto.
### Estado por máquina
Columnas visibles:
```text
Máquina
Marcha
Telemetría
Orden
Workorder
Operario
Estado
Severidad
```
Se eliminan de esta tabla `Sync` y `Acción`, ya disponibles en otros paneles.
Los estados técnicos se traducen a lenguaje operativo:
```text
RUNNING_WITHOUT_SHOPFLOOR_SESSION
→ En marcha sin sesión Shop Floor
TEMPORARY_STOP_INSIDE_SESSION
→ Parada dentro de sesión
IDLE_NO_ACTIVE_CONTEXT
→ Inactiva sin contexto
```
### Incidencias activas
Columnas:
```text
Hora
Severidad
Máquina
Situación
Orden
Operario
Acción
```
Los códigos técnicos quedan traducidos a frases operativas.
## Despliegue
```bash
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
SCRIPT=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v0362.sh
"$SCRIPT" deploy
"$SCRIPT" validate
unset GRAFANA_API_TOKEN
```
El despliegue reutiliza el mismo UID:
```text
ucepsa-shopfloor-context-health
```
Por tanto, actualiza el dashboard existente sin crear duplicados.
## Validación esperada
```text
status = ok
panel_count = 10
datasource_uids = [bfnbcasbm6hhca]
numeric_health_stat = true
numeric_sync_stat = true
compact_machine_table = true
friendly_incident_text = true
errors = []
```

View File

@ -1,187 +0,0 @@
# UCEPSA — Historial persistente de incidencias Shop Floor v0.3.7
## Objetivo
Convertir incidencias vivas en episodios persistentes:
```text
incidencia aparece
→ abre un episodio
incidencia continúa
→ actualiza last_seen_at
incidencia desaparece durante 45 s
→ cierra el episodio
incidencia reaparece más tarde
→ abre un episodio nuevo
```
No genera una fila cada 15 segundos.
## Alcance
La versión:
- lee la vista viva `li_shopfloor_context_incidents_v1`;
- guarda episodios SHADOW;
- permite revisión humana o técnica;
- amplía el dashboard existente;
- no crea sesiones `LEGACY_ERP`;
- no modifica Odoo;
- no atribuye pérdidas;
- no alimenta el Ledger oficial.
`first_seen_at` es la primera observación realizada por MESAVAULT después de
activar el colector. No debe interpretarse como el inicio físico exacto de una
situación que ya estuviera activa.
## Objetos
```text
shopfloor_context_incident_policies
shopfloor_context_incident_episodes
shopfloor_context_incident_reviews
shopfloor_context_incident_collector_state
li_shopfloor_context_incident_history_v1
li_shopfloor_context_incident_backlog_v1
li_shopfloor_context_incident_daily_summary_v1
li_shopfloor_context_incident_collector_health_v1
```
## Clasificaciones de revisión
```text
AUTHORIZED_LEGACY_PRODUCTION
TEST_OR_SETUP
RESIDUAL_MATERIAL
ODOO_START_OMITTED
DATA_ISSUE
NOT_RELEVANT
OTHER
```
Una revisión solo documenta lo sucedido. No abre un contexto ni convierte el
episodio en pérdida.
## Despliegue
### Migración
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/109_ucepsa_shopfloor_context_incident_history_v037.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
### Colector
Copiar las herramientas al runtime:
```bash
install -m 0644 \
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/tools/shopfloor_context_incident_collector.py \
/srv/mesavault/edge-oee-ucepsa/tools/
install -m 0644 \
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/tools/shopfloor_context_incident_admin.py \
/srv/mesavault/edge-oee-ucepsa/tools/
```
Arranque:
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_shopfloor_context_incident_collector_v037.sh
"$MANAGER" dry-run
"$MANAGER" once
"$MANAGER" start
"$MANAGER" status
```
### Validación
Esperar al menos 20 segundos:
```bash
sleep 20
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_incident_history_v037.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
last_seen_before_start = 0
end_before_start = 0
open_with_end = 0
closed_without_end = 0
official_episode_rows = 0
duplicate_open_fingerprints = 0
live_incidents_without_open_episode = 0
expired_open_episodes_not_live = 0
review_trigger_mismatches = 0
official_operator_queue_rows = 0
```
## Revisión de un episodio
Listar backlog:
```bash
"$MANAGER" admin list
```
Ver detalle:
```bash
"$MANAGER" admin show --episode-id 1
```
Ejemplo de clasificación, solo después de confirmación humana:
```bash
"$MANAGER" admin review \
--episode-id 1 \
--classification AUTHORIZED_LEGACY_PRODUCTION \
--reviewed-by "Juan Pablo" \
--notes "Producción confirmada con el sistema anterior."
```
No ejecutar esa clasificación automáticamente para CORT-00.
## Dashboard
El despliegue mantiene el mismo UID y añade tres paneles:
```text
Incidencias pendientes de revisión
Historial de las últimas 24 horas
Resumen diario de los últimos 7 días
```
```bash
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
DASH=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_incident_history_dashboard_v037.sh
"$DASH" deploy
"$DASH" validate
unset GRAFANA_API_TOKEN
```
## Workorder 340
Esta versión puede desplegarse con el workorder 340 todavía abierto. Su cierre
sigue siendo una validación independiente del ciclo de sesión Shop Floor.

View File

@ -1,186 +0,0 @@
# UCEPSA — Arbitraje canónico de contexto SHADOW v0.3.8
## Objetivo
Convertir varias fuentes posibles de contexto en una decisión única y
reproducible por tramo temporal.
```text
una fuente autoritativa
→ contexto canónico
cero fuentes autoritativas + actividad
→ UNCONFIRMED_CONTEXT
más de una fuente autoritativa
→ CONTEXT_CONFLICT
```
La versión continúa íntegramente en SHADOW.
## Fuentes autoritativas
```text
ODOO_SHOPFLOOR
LEGACY_ERP autorizado
MANUAL_AUTHORIZED
MAINTENANCE
TEST_SETUP
```
## Evidencias auxiliares
```text
ODOO_ORDER_CONTEXT
INFERRED_SHADOW
```
Una orden publicada o una inferencia nunca se promueven por sí solas a contexto
canónico.
## Beneficios operativos
### Una sola respuesta por tramo
Antes, una consulta podía usar orden Odoo, otra la sesión Shop Floor y otra el
contexto legacy. Ahora todas pueden consultar la misma clave canónica.
Ejemplo:
```text
CORT-02
ODOO_SHOPFLOOR:WORKORDER:340
```
### Evita doble contabilización
Si alguien abre un contexto legacy mientras continúa una sesión Shop Floor:
```text
Shop Floor + legacy
→ CONTEXT_CONFLICT
→ ningún paro es elegible
```
El sistema no aplica una prioridad silenciosa.
### Protege frente a falsas pérdidas
CORT-00 en marcha sin contexto explícito permanece:
```text
UNCONFIRMED_CONTEXT
```
No se inventa una orden, un operario ni una pérdida económica.
### Permite convivir durante la migración
Una orden fabricada con el sistema anterior puede usar un contexto
`LEGACY_ERP` autorizado sin obligar a fingir que pasó por Shop Floor.
### Simplifica el producto
Las capas posteriores dejan de implementar reglas diferentes para Odoo,
legacy y manual. Consultan:
```text
canonical_context_key
canonical_context_source
canonical_context_status
```
### Facilita la activación futura
La misma capa permite medir cobertura y conflictos antes de decidir si puede
alimentar el Loss Ledger oficial.
## Nuevos objetos
```text
context_arbitration_source_policies
li_canonical_context_candidates_v1
li_canonical_context_segments_v1
li_context_arbitration_conflicts_v1
li_machine_canonical_context_live_v1
li_canonical_context_coverage_v1
li_stop_canonical_context_slices_v1
li_stop_canonical_context_shadow_v1
li_canonical_context_diagnostics_v1
```
## Despliegue
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/110_ucepsa_canonical_context_arbitration_shadow_v038.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
No es necesario detener ningún servicio.
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_canonical_context_arbitration_v038.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
candidate_key_collisions = 0
invalid_segment_intervals = 0
negative_segment_durations = 0
one_candidate_without_canonical_key = 0
ambiguous_with_canonical_key = 0
supporting_promoted_to_canonical = 0
conflict_without_review = 0
live_machine_rows = 3
distinct_live_machines = 3
official_live_rows = 0
stop_duration_overflow_count = 0
open_stops_marked_eligible = 0
nonproductive_stops_marked_eligible = 0
ambiguous_stops_marked_eligible = 0
official_canonical_stop_rows = 0
official_canonical_segment_rows = 0
official_operator_queue_rows = 0
```
## Lectura esperada
Mientras la situación actual continúe:
```text
CORT-00
→ UNCONFIRMED_CONTEXT
CORT-01
→ NO_ACTIVE_CONTEXT
CORT-02
→ ODOO_SHOPFLOOR
→ ODOO_SHOPFLOOR:WORKORDER:340
```
Si las máquinas cambian durante la validación, los estados pueden variar, pero
las invariantes deben mantenerse.
## Dashboard
Se amplía el dashboard existente con:
```text
Contexto canónico vivo por máquina
Conflictos de arbitraje · últimas 24 horas
Cobertura de contexto canónico · últimos 30 días
```
El refresco queda fijado en 15 segundos.

View File

@ -1,161 +0,0 @@
# UCEPSA — Readiness de rollout del contexto canónico v0.3.9
## Objetivo
Responder con números a esta pregunta:
> ¿Qué le falta a cada cortadora antes de poder usar el contexto canónico en un
> piloto controlado?
La versión no activa el Ledger oficial. Incluso una máquina en
`READY_FOR_CONTROLLED_SHADOW` conserva:
```text
ready_for_official_ledger = false
official_ledger_eligible = false
```
## Criterios iniciales
Ventana:
```text
7 días
```
Umbrales configurables:
```text
mínimo observado 8 h
contexto confirmado mínimo 90 %
contexto sin confirmar máximo 10 %
conflicto máximo 0 min
revisiones pendientes máximas 0
antigüedad máxima revisión 24 h
DQ crítica máxima 0
sync requerido HEALTHY
colector requerido HEALTHY
```
Estos valores son iniciales y pueden revisarse antes de octubre.
## Estados
```text
TECHNICAL_BLOCKED
INSUFFICIENT_OBSERVATION
REVIEW_BACKLOG
COVERAGE_NOT_READY
READY_FOR_CONTROLLED_SHADOW
```
La evaluación se detiene en el primer bloqueo operativo relevante.
## Beneficios
### Evita activar por intuición
No basta con que un dashboard se vea bien. La vista exige salud técnica,
observación suficiente, ausencia de conflictos, backlog resuelto y cobertura
mínima.
### Convierte el rollout en una lista de acciones
Ejemplos:
```text
CORT-00
REVIEW_BACKLOG
→ preguntar a Producción por el episodio pendiente
CORT-01
INSUFFICIENT_OBSERVATION
→ acumular horas reales
CORT-02
COVERAGE_NOT_READY
→ aumentar uso correcto de Shop Floor
```
### Separa readiness SHADOW de activación oficial
El sistema puede indicar que una máquina está preparada para una prueba
controlada sin permitir todavía que genere pérdidas oficiales.
### Da una evolución medible
Cada semana puede observarse:
```text
sube % confirmado
baja % sin confirmar
desaparece backlog
conflictos siguen en cero
```
## Objetos
```text
context_rollout_readiness_policies
li_canonical_context_readiness_metrics_v1
li_canonical_context_readiness_gate_v1
li_context_rollout_blockers_v1
li_context_rollout_readiness_summary_v1
```
## Despliegue
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/111_ucepsa_context_rollout_readiness_shadow_v039.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_rollout_readiness_v039.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
machine_rows = 3
distinct_machines = 3
official_metric_rows = 0
negative_observed_hours = 0
negative_confirmed_hours = 0
negative_unconfirmed_hours = 0
negative_conflict_minutes = 0
invalid_confirmed_pct = 0
invalid_unconfirmed_pct = 0
invalid_operator_pct = 0
activity_decomposition_errors = 0
ready_gate_mismatches = 0
official_gate_rows = 0
official_blocker_rows = 0
official_operator_queue_rows = 0
```
## Dashboard
Se añaden al dashboard existente:
```text
Readiness de contexto por máquina
Bloqueos de rollout
Resumen de readiness
```
El UID no cambia y el refresco permanece en 15 segundos.

View File

@ -1,186 +0,0 @@
# UCEPSA — Cola de casos raíz SHADOW v0.3.10
## Objetivo
Reducir la intervención humana a una decisión por problema real.
Ejemplo actual:
```text
episodio 7 incidencia de máquina
episodio 8 evidencia workorder 649
episodio 9 evidencia workorder 698
```
La cola humana debe presentar:
```text
1 caso raíz
CORT-00
Varias órdenes Shop Floor abiertas
Evidencias: 7, 8 y 9
```
No se elimina ninguna evidencia.
## Arquitectura
```text
episodios técnicos
→ constructor de casos raíz
→ una pregunta humana
→ revisión auditada
→ cascada de revisión a episodios asociados
```
## Agrupación inicial
`MULTIPLE_OPEN_SESSIONS` se agrupa cuando:
```text
misma máquina
misma familia
intervalos solapados o separados <= 120 s
```
Los demás episodios generan inicialmente un caso individual.
## Revisión
Clasificaciones de caso:
```text
ODOO_DOUBLE_START_ONE_VALID
ODOO_DOUBLE_START_BOTH_INVALID
ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED
AUTHORIZED_LEGACY_PRODUCTION
TEST_OR_SETUP
RESIDUAL_MATERIAL
ODOO_START_OMITTED
DATA_ISSUE
NOT_RELEVANT
OTHER
```
Una revisión de caso:
- actualiza el caso raíz;
- crea una revisión auditada para cada episodio pendiente;
- elimina la duplicación del backlog;
- no modifica Odoo;
- no crea pérdidas oficiales.
## Objetos
```text
context_review_case_policies
context_review_cases
context_review_case_evidence
context_review_case_reviews
context_review_case_builder_state
li_context_review_case_history_v1
li_context_review_case_backlog_v1
li_context_review_case_evidence_v1
li_context_review_case_builder_health_v1
li_canonical_context_readiness_metrics_v2
li_canonical_context_readiness_gate_v2
li_context_rollout_blockers_v2
li_context_rollout_readiness_summary_v2
```
## Despliegue
### Migración
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/112_ucepsa_context_root_case_queue_shadow_v0310.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
### Runtime
```bash
RUNTIME=/srv/mesavault/edge-oee-ucepsa
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
install -m 0644 \
"$TARGET/tools/context_review_case_builder.py" \
"$RUNTIME/tools/"
install -m 0644 \
"$TARGET/tools/context_review_case_admin.py" \
"$RUNTIME/tools/"
```
### Constructor
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh
"$MANAGER" dry-run
"$MANAGER" once
"$MANAGER" start
sleep 35
"$MANAGER" status
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_root_case_queue_v0310.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
evidence_link_duplicates = 0
review_episodes_without_case = 0
root_case_count_7_8_9 = 1
evidence_count_7_8_9 = 3
ready_v2_gate_mismatches = 0
official_case_rows = 0
official_gate_rows = 0
official_operator_queue_rows = 0
```
## Consola
```bash
"$MANAGER" admin list
"$MANAGER" admin show --case-id <id>
```
Cuando Juan Pablo confirme la orden real del conflicto:
```bash
"$MANAGER" admin review \
--case-id <id> \
--classification ODOO_DOUBLE_START_ONE_VALID \
--selected-order-ref "WH/MO/00706" \
--selected-workorder-id 698 \
--reviewed-by "Juan Pablo" \
--notes "Confirma que WH/MO/00706 era la orden real y WH/MO/00660 quedó abierta por error."
```
No ejecutar hasta recibir confirmación.
## Dashboard
Se mantienen los dos dashboards existentes. El dashboard de salud añade:
```text
Casos raíz pendientes
Evidencias asociadas
Historial de casos raíz
```
El panel readiness utiliza v2 y cuenta decisiones humanas, no evidencias.

View File

@ -1,160 +0,0 @@
# UCEPSA — Decisiones humanas consolidadas SHADOW v0.3.11
## Objetivo
Reducir la carga de revisión:
```text
10 episodios técnicos
→ 7 casos raíz
→ aproximadamente 3 decisiones humanas
```
No se elimina ningún intervalo, caso o evidencia.
## Agrupación inicial
### Actividad sin contexto
```text
misma máquina
+ mismo día local Europe/Madrid
+ separación <= 15 min
→ una decisión humana
```
### Conflictos de arranque Odoo
```text
MULTIPLE_OPEN_SESSIONS
+ ORDER_WITHOUT_SESSION
+ misma máquina
+ separación <= 5 min
→ una decisión humana
```
No se inventan turnos horarios porque todavía no existe un calendario de turnos
confirmado. El día local actúa como frontera conservadora.
## Casos esperados
```text
CORT-00 casos 1/2/3/4
→ una decisión sobre sistema antiguo
CORT-00 casos 5/6
→ una decisión WH/MO/00660 vs WH/MO/00706
CORT-02 casos 7/8
→ una decisión sobre actividad posterior sin sesión
```
## Revisión
La revisión de una decisión:
- registra una única respuesta humana;
- revisa únicamente los casos raíz pendientes;
- revisa únicamente los episodios pendientes;
- conserva las revisiones ya existentes;
- no modifica Odoo;
- no crea contextos retrospectivos automáticamente;
- no habilita el Ledger oficial.
## Despliegue
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
No hay servicio nuevo: la agrupación es determinista mediante vistas SQL.
## Runtime
```bash
RUNTIME=/srv/mesavault/edge-oee-ucepsa
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
install -m 0644 \
"$TARGET/tools/context_decision_group_admin.py" \
"$RUNTIME/tools/"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
root_case_assignment_duplicates = 0
recent_root_cases_without_decision_group = 0
decision_group_count_1_2_3_4 = 1
linked_case_count_1_2_3_4 = 4
decision_group_count_5_6 = 1
linked_case_count_5_6 = 2
decision_group_count_7_8 = 1
linked_case_count_7_8 = 2
decision_group_count_exceeds_root_case_count = 0
ready_v3_gate_mismatches = 0
official_decision_group_rows = 0
official_gate_rows = 0
official_operator_queue_rows = 0
```
## Consola
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
"$MANAGER" list --limit 50
"$MANAGER" show \
--decision-group-key 'DECISION:...'
```
No revisar el grupo de doble arranque hasta recibir la respuesta de Juan Pablo.
Cuando responda:
```bash
"$MANAGER" review \
--decision-group-key 'DECISION:ODOO_ORDER_START_CONFLICT:CORT-00:20260720:5' \
--classification ODOO_DOUBLE_START_ONE_VALID \
--selected-order-ref "WH/MO/00706" \
--selected-workorder-id 698 \
--reviewed-by "Juan Pablo" \
--notes "Confirma la orden real y la orden abierta por error."
```
## Dashboard
Se mantiene el mismo UID y se añaden:
```text
Decisiones humanas pendientes
Casos raíz incluidos en cada decisión
Historial de decisiones humanas
```
Readiness v3 compara:
```text
evidencias pendientes
casos raíz pendientes
decisiones humanas pendientes
```

View File

@ -1,142 +0,0 @@
# UCEPSA — Agenda diaria de gobernanza SHADOW v0.3.12
## Objetivo
Convertir la arquitectura de gobernanza en una revisión diaria de 15 minutos:
```text
datos automáticos
→ decisiones humanas consolidadas
→ agenda priorizada
→ snapshot auditable
→ revisión y seguimiento
```
Una fila de agenda equivale a una pregunta humana.
## Priorización
La severidad técnica se conserva.
La agenda añade un estado de atención independiente:
```text
0-8 h RECENT
8-24 h ATTENTION
>24 h OVERDUE
```
Estos tramos organizan la reunión. No convierten una advertencia en crítica.
## Objetos
```text
context_governance_daily_policies
context_governance_agenda_snapshots
context_governance_agenda_snapshot_items
li_context_governance_decision_evidence_pack_v1
li_context_governance_daily_agenda_v1
li_context_governance_daily_summary_v1
li_context_governance_agenda_snapshot_history_v1
```
## Despliegue
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/114_ucepsa_context_daily_governance_agenda_shadow_v0312.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Runtime
```bash
RUNTIME=/srv/mesavault/edge-oee-ucepsa
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
install -m 0644 \
"$TARGET/tools/context_daily_governance.py" \
"$RUNTIME/tools/"
```
## Consola
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh
"$MANAGER" agenda --limit 50
"$MANAGER" show \
--decision-group-key 'DECISION:...'
```
## Guardar la agenda de una reunión
```bash
"$MANAGER" snapshot \
--generated-by "Víctor Fraile" \
--notes "Revisión diaria con Dirección y Producción."
```
La salida devuelve `snapshot_id`.
## Exportar Markdown
Vista actual:
```bash
OUT=/srv/mesavault/exports/governance-review-$(date +%F).md
mkdir -p "$(dirname "$OUT")"
"$MANAGER" export-markdown \
--generated-by "MESAVAULT" \
> "$OUT"
```
Snapshot inmutable:
```bash
"$MANAGER" export-markdown \
--snapshot-id NUMERO \
> "$OUT"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_daily_governance_v0312.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
agenda_rows = decision_backlog_rows
duplicated_agenda_decisions = 0
agenda_rank_mismatches = 0
invalid_attention_status_rows = 0
snapshot_item_count_mismatches = 0
official_snapshot_rows = 0
official_snapshot_item_rows = 0
official_summary_rows = 0
official_operator_queue_rows = 0
```
## Dashboard
Se mantiene el mismo UID y se añaden:
```text
Agenda diaria de gobernanza
Paquete de evidencia por decisión
Indicadores de gobernanza diaria
```
Paneles totales esperados: 28.

View File

@ -1,195 +0,0 @@
# UCEPSA — Regresión y hardening SHADOW v0.3.13.1
## Objetivo
Proteger automáticamente los comportamientos ya validados:
```text
cierre Shop Floor
arbitraje canónico
contexto legacy
conflictos sin prioridad silenciosa
casos raíz
decisiones consolidadas
agenda diaria
snapshots
readiness
guardas oficiales
```
La suite no modifica Odoo ni datos de producción.
## Contratos principales
```text
WO 340:
sesión cerrada
100 kg finales
Esteban confirmado
sin extensión posterior al cierre
Episodio 1:
LEGACY_ERP autorizado
intervalo exacto
cobertura canónica completa
WO 649/698:
conflicto histórico conservado
sin clave canónica
Gobernanza:
7/8/9 → un caso raíz
1-4, 5-6 y 7-8 → tres decisiones
agenda = backlog
snapshot 1 coherente
SHADOW:
cero filas oficiales
```
## Objetos
```text
context_regression_policies
context_regression_runs
context_regression_results
li_context_regression_latest_run_v1
li_context_regression_latest_results_v1
li_context_regression_failures_v1
li_context_regression_run_history_v1
li_context_regression_health_v1
```
## Despliegue
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/115_ucepsa_context_regression_hardening_shadow_v0313.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Runtime
```bash
RUNTIME=/srv/mesavault/edge-oee-ucepsa
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
install -m 0644 \
"$TARGET/tools/context_regression_suite.py" \
"$RUNTIME/tools/"
```
## Ejecución previa sin persistencia
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh
"$MANAGER" dry-run \
--triggered-by "Víctor Fraile"
```
## Primera ejecución persistente
```bash
"$MANAGER" run \
--triggered-by "Víctor Fraile" \
--trigger-kind DEPLOYMENT \
--notes "Validación posterior al despliegue v0.3.13."
```
`PASS` es el resultado ideal. `WARN` es admisible si se limita a salud o
rendimiento. `FAIL` o `ERROR` requiere investigación antes de continuar.
## Consultas
```bash
"$MANAGER" show
"$MANAGER" list --limit 20
"$MANAGER" show --run-id NUMERO
```
## Exportar Markdown
```bash
OUT=/srv/mesavault/exports/context-regression-$(date +%F).md
"$MANAGER" export-markdown \
> "$OUT"
sed -n '1,260p' "$OUT"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_regression_v0313.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
run_count_mismatches = 0
duplicate_test_codes = 0
suite_complete = true
latest_fail_or_error_count = 0
critical_contract_not_passed = 0
official_run_rows = 0
official_result_rows = 0
official_health_rows = 0
official_operator_queue_rows = 0
```
## Uso operativo recomendado
Ejecutar la suite:
```text
después de cada migración
después de modificar sincronizadores o arbitraje
antes de desplegar el dashboard
antes de activar cualquier nueva fase de readiness
```
La ejecución periódica puede programarse después de acordar la hora adecuada.
No se instala ningún cron automáticamente en esta versión.
## Dashboard
Se mantiene el mismo UID y se añaden:
```text
Última ejecución de regresión
Warnings y fallos
Histórico de regresión
```
Paneles totales esperados: 31.
## Hotfix 0.3.13.1
La vista `li_context_arbitration_conflicts_v1` es derivada del estado canónico
actual. Al corregir Odoo, el conflicto desaparece correctamente de esa vista.
Los contratos `CONFLICT-001` y `CONFLICT-002` pasan a verificar:
```text
episodios persistentes 7/8/9
caso raíz 6
decisión consolidada del doble arranque
cero elegibilidad oficial
```
No dependen ya de una ventana derivada vacía y dejan de ejecutar la consulta de
aproximadamente 18 segundos.
La migración 116 solo actualiza la versión de política y el mínimo esperado de
tests a 25. La migración 115 permanece inmutable.

View File

@ -1,209 +0,0 @@
# UCEPSA — Governance Review UI SHADOW v0.3.14
## Objetivo
Dar a Juan Pablo una interfaz de trabajo, separada de Grafana, para resolver
excepciones sin interpretar SQL, hashes ni códigos técnicos.
```text
Grafana
→ supervisión y auditoría
Governance Review UI
→ revisar y resolver
```
## URL prevista
```text
http://17.126.1.70:8091
```
La aplicación incluye autenticación, sesión firmada, CSRF, roles y auditoría.
## Roles
```text
PRODUCTION
→ decisiones de Producción y conjuntas
TECHNICAL
→ decisiones técnicas y conjuntas
ADMIN
→ todas las decisiones
```
## Funciones
```text
cola por decisión humana
estado de incidencia separado de estado de revisión
línea temporal por intervalos
evidencia técnica desplegable
formularios específicos por familia
selección de uno o varios casos
revisión en cascada de casos y episodios
auditoría por usuario
historial de acciones
```
## Contextos retrospectivos
La creación es opcional y exige confirmación explícita.
Solo se permite para:
```text
AUTHORIZED_LEGACY_PRODUCTION → LEGACY_ERP
TEST_OR_SETUP → TEST_SETUP
MAINTENANCE → MAINTENANCE
```
Condiciones:
```text
caso terminado
ended_at conocido
sin solapamiento con Odoo u otro contexto autoritativo
un contexto exacto por caso
sin cubrir huecos
official_eligible = false
```
## Lo que NO hace
```text
no cambia órdenes Odoo
no cierra workorders
no cambia cantidades
no edita telemetría raw
no activa el Ledger oficial
```
## Despliegue SQL
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/117_ucepsa_governance_review_ui_shadow_v0314.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Copiar aplicación al runtime
```bash
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
RUNTIME=/srv/mesavault/edge-oee-ucepsa
rm -rf "$RUNTIME/governance-ui"
mkdir -p "$RUNTIME/governance-ui"
cp -a \
"$TARGET/governance-ui/." \
"$RUNTIME/governance-ui/"
```
## Construir imagen y crear hashes
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
"$MANAGER" build
```
Generar un hash para cada usuario:
```bash
docker run --rm -it \
--entrypoint python \
mesavault/ucepsa-governance-ui:0.3.14 \
/app/tools/generate_password_hash.py
```
Guardar únicamente los hashes en `.env`.
## Configuración
```bash
cd /srv/mesavault/edge-oee-ucepsa/governance-ui
cp .env.example .env
chmod 600 .env
nano .env
```
Generar el secreto:
```bash
python3 - <<'PY'
import secrets
print(secrets.token_urlsafe(48))
PY
```
## Arranque
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
"$MANAGER" deploy
sleep 20
"$MANAGER" status
"$MANAGER" health
```
## Validación SQL
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
ui_queue_rows = agenda_rows
invalid_ui_status_rows = 0
duplicate_ui_decisions = 0
pending_cases_without_timeline = 0
official_ui_action_rows = 0
official_ui_queue_rows = 0
official_operator_queue_rows = 0
```
## Primera prueba
1. Entrar como Víctor.
2. Abrir una decisión pendiente.
3. Comprobar la línea temporal y la evidencia.
4. No resolver todavía una decisión real.
5. Comprobar `/health`.
6. Validar que Grafana sigue en solo lectura.
La primera escritura real debe ser una respuesta confirmada por Juan Pablo.
## Activación en dos pasos
La variable inicial debe permanecer:
```text
GOVERNANCE_UI_WRITE_ENABLED=false
```
Primero se valida login, roles, cola, timeline y evidencia. Cuando Juan Pablo
acepte la interfaz:
```bash
sed -i \
's/^GOVERNANCE_UI_WRITE_ENABLED=false/GOVERNANCE_UI_WRITE_ENABLED=true/' \
/srv/mesavault/edge-oee-ucepsa/governance-ui/.env
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
"$MANAGER" restart
```

View File

@ -1,230 +0,0 @@
# UCEPSA — Governance Review UI en dos etapas SHADOW v0.3.15
## Objetivo
Separar claramente las dos responsabilidades:
```text
Producción
→ explica qué ocurrió realmente en planta.
MESAVAULT / responsable técnico
→ decide si esa explicación puede convertirse en contexto canónico.
```
Juan Pablo deja de ver y utilizar la casilla técnica de creación de contexto.
Cuando una respuesta permite proponer un contexto, la aplicación crea una
propuesta pendiente de aprobación técnica, no el contexto definitivo.
## Novedades
```text
respuesta de Producción
→ revisión de decisión, casos y episodios
→ propuesta técnica cuando procede
→ aprobación / rechazo / solicitud de aclaración
→ creación de contexto solo al aprobar
```
Incluye además:
- estados visibles en lenguaje normal;
- `En consulta` para los casos cuya respuesta todavía no se conoce;
- aclaraciones entre Producción y el revisor técnico;
- idempotencia mediante una clave única por formulario;
- auditoría unificada;
- pruebas de roles y traducciones;
- tres paneles nuevos de Grafana.
## Estados
### Decisión de Producción
```text
Pendiente
En consulta
Resuelta
```
### Propuesta de contexto
```text
Pendiente
Requiere aclaración
Aprobada
Rechazada
```
## Contextos que pueden proponerse
```text
Producción con sistema antiguo
→ LEGACY_ERP
Prueba, ajuste o preparación
→ TEST_SETUP
Mantenimiento
→ MAINTENANCE
```
La propuesta no significa que el contexto esté creado.
## Validaciones antes de aprobar
La aplicación comprueba:
```text
intervalo terminado;
inicio y fin conocidos;
máquina identificada;
sin solapamiento con una sesión Odoo;
sin solapamiento con otro contexto explícito;
un contexto exacto por caso;
official_eligible = false.
```
## Despliegue
No reiniciar la aplicación mientras Juan Pablo tenga un formulario abierto.
Esperar a que termine la acción o indicarle que recargue después del despliegue.
### Migración 118
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
Debe finalizar con:
```text
DO
COMMIT
```
### Instalar la aplicación conservando `.env`
```bash
INSTALLER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
"$INSTALLER"
```
### Construir y reiniciar
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh
"$MANAGER" build
TESTS=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh
"$TESTS"
"$MANAGER" restart
sleep 15
"$MANAGER" status
"$MANAGER" health
```
El cambio de `SESSION_SALT` cerrará las sesiones anteriores. Los usuarios
deben volver a iniciar sesión después del despliegue.
## Validación SQL
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Valores obligatorios:
```text
ui_v2_rows = agenda_rows
invalid_workflow_status_rows = 0
duplicate_open_consultations = 0
duplicate_active_proposals = 0
approved_without_context = 0
invalid_approved_contexts = 0
direct_context_creation_actions = 0
duplicate_review_requests = 0
duplicate_proposal_action_requests = 0
official_consultation_rows = 0
official_proposal_rows = 0
official_proposal_action_rows = 0
official_operator_queue_rows = 0
```
## Prueba funcional sin crear contexto
1. Entrar como Juan Pablo.
2. Abrir una decisión pendiente.
3. Comprobar que no aparece ninguna casilla para crear contexto.
4. Marcar una decisión real como `En consulta` y comprobar la cola.
5. Volver a Pendiente o resolverla cuando exista respuesta.
6. Confirmar que una respuesta `Sistema antiguo`, `Prueba` o `Mantenimiento`
crea una propuesta técnica, no un contexto.
7. Entrar como Víctor.
8. Abrir `Aprobación técnica`.
9. Validar o solicitar aclaración.
## Aprobación técnica
Al aprobar:
```text
propuesta PENDING
→ comprobación de solapamientos
→ producción_context_sessions
→ propuesta APPROVED
→ auditoría
```
Al solicitar aclaración:
```text
propuesta NEEDS_CLARIFICATION
→ aparece en Aclaraciones para Producción
→ Producción responde
→ propuesta vuelve a PENDING
```
## Idempotencia
Cada formulario lleva un UUID único. Un doble clic, reenvío o recarga no debe
crear dos revisiones ni dos contextos.
Las tablas aplican restricciones únicas sobre:
```text
review request_id
consultation request_id
proposal action request_id
proposal activa por case_id
```
## Grafana
Se mantienen los 31 paneles previos y se añaden:
```text
32 · Contextos pendientes de aprobación
33 · Decisiones en consulta
34 · Actividad de la interfaz · 30 días
```
Los paneles de decisiones utilizan `li_context_governance_ui_queue_v2`.
## Regla operativa
```text
Juan Pablo responde la realidad de planta.
Víctor valida la coherencia técnica.
Ninguna respuesta de Producción crea por sí sola un contexto autoritativo.
```

View File

@ -1,22 +0,0 @@
ODOO_URL=https://17.126.1.61
ODOO_DB=ucepsa_test_jp
ODOO_USER=mesavault_sync@local
ODOO_PASSWORD=CHANGE_ME
ODOO_WORKCENTER_CODE=CORT-01
ODOO_INSTANCE=odoo_ucepsa_test
ODOO_SYNC_INTERVAL_SECONDS=30
ODOO_WORKORDER_LIMIT=50
ODOO_WORKORDER_STATES=waiting,ready,progress,done
TENANT=ucepsa
SITE=ucepsa_test
MACHINE_ID=CORT-01
ASSET=revpi_oee_node_01
TARGET_CYCLE_RATE_PPM=10
PGHOST=mv_ucepsa_postgres_hot
PGPORT=5432
PGDATABASE=mesavault_ucepsa_hot
PGUSER=edge_oee_odoo_sync_writer
PGPASSWORD=CHANGE_ME

View File

@ -1,9 +0,0 @@
PGHOST=mv_ucepsa_postgres_hot
PGPORT=5432
PGDATABASE=mesavault_ucepsa_hot
PGUSER=edge_oee_operator_writer
PGPASSWORD=CHANGE_ME
APP_PORT=8080
APP_TITLE=MESAVAULT Edge-OEE - Consola de paros UCEPSA
DEFAULT_MACHINE_ID=CORT-01

View File

@ -1,18 +0,0 @@
MQTT_HOST=mv_ucepsa_mosquitto
MQTT_PORT=1883
MQTT_USER=revpi_ucepsa_edge_oee_01
MQTT_PASSWORD=CHANGE_ME
MQTT_TOPIC=vertical/ucepsa/ucepsa_test/edge_oee/+/+
PGHOST=mv_ucepsa_postgres_hot
PGPORT=5432
PGDATABASE=mesavault_ucepsa_hot
PGUSER=edge_oee_mqtt_sink_writer
PGPASSWORD=CHANGE_ME
TENANT=ucepsa
SITE=ucepsa_test
VERTICAL=edge_oee
ASSET=revpi_oee_node_01
MACHINE_ID=CORT-01
VALUE_HOUR_EUR=36

View File

@ -1,6 +0,0 @@
.env
.hash-venv
__pycache__
*.pyc
.pytest_cache
.git

View File

@ -1,9 +0,0 @@
GOVERNANCE_UI_TITLE=MESAVAULT · UCEPSA · Revisión de gobernanza
GOVERNANCE_UI_SESSION_SECRET=CAMBIAR_POR_UN_SECRETO_LARGO
GOVERNANCE_UI_SESSION_MAX_AGE_S=28800
GOVERNANCE_UI_COOKIE_SECURE=false
GOVERNANCE_UI_WRITE_ENABLED=false
# Generar cada password_hash con:
# python tools/generate_password_hash.py
GOVERNANCE_UI_USERS_JSON=[{"username":"juanpablo","display_name":"Juan Pablo","password_hash":"$2b$12$CAMBIAR","roles":["PRODUCTION"]},{"username":"victor","display_name":"Víctor Fraile","password_hash":"$2b$12$CAMBIAR","roles":["ADMIN","TECHNICAL","JOINT"]}]

View File

@ -1,26 +0,0 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir \
-r /app/requirements.txt
COPY app /app/app
COPY tools /app/tools
COPY tests /app/tests
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]

View File

@ -1,171 +0,0 @@
from __future__ import annotations
import secrets
from dataclasses import dataclass
from typing import FrozenSet
import bcrypt
from fastapi import HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired
from itsdangerous.url_safe import URLSafeTimedSerializer
from .settings import UserConfig, get_settings
from .policy import (
can_approve_context_roles,
can_manage_clarification_roles,
can_view_owner_roles,
)
SESSION_COOKIE = "mv_governance_session"
SESSION_SALT = "mv-governance-ui-v0315"
@dataclass(frozen=True)
class CurrentUser:
username: str
display_name: str
roles: FrozenSet[str]
csrf_token: str
def serializer() -> URLSafeTimedSerializer:
settings = get_settings()
return URLSafeTimedSerializer(
secret_key=settings.session_secret,
salt=SESSION_SALT,
)
def authenticate(
username: str,
password: str,
) -> UserConfig | None:
settings = get_settings()
user = settings.users.get(username)
if user is None:
return None
try:
valid = bcrypt.checkpw(
password.encode("utf-8"),
user.password_hash.encode("utf-8"),
)
except ValueError:
return None
return user if valid else None
def build_session_token(user: UserConfig) -> str:
payload = {
"username": user.username,
"display_name": user.display_name,
"roles": sorted(user.roles),
"csrf_token": secrets.token_urlsafe(32),
}
return serializer().dumps(payload)
def read_current_user(
request: Request,
) -> CurrentUser | None:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
settings = get_settings()
try:
payload = serializer().loads(
token,
max_age=settings.session_max_age_s,
)
except (
BadSignature,
SignatureExpired,
):
return None
username = str(payload.get("username", ""))
configured = settings.users.get(username)
if configured is None:
return None
csrf_token = str(
payload.get("csrf_token", "")
)
if not csrf_token:
return None
return CurrentUser(
username=configured.username,
display_name=configured.display_name,
roles=configured.roles,
csrf_token=csrf_token,
)
def require_user(request: Request) -> CurrentUser:
user = read_current_user(request)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Sesión no válida.",
)
return user
def verify_csrf(
user: CurrentUser,
submitted_token: str,
) -> None:
if (
not submitted_token
or not secrets.compare_digest(
user.csrf_token,
submitted_token,
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token CSRF no válido.",
)
def can_view_owner(
user: CurrentUser,
review_owner: str,
) -> bool:
return can_view_owner_roles(
user.roles,
review_owner,
)
def can_review_owner(
user: CurrentUser,
review_owner: str,
) -> bool:
return can_view_owner(
user,
review_owner,
)
def can_approve_context(
user: CurrentUser,
) -> bool:
return can_approve_context_roles(
user.roles
)
def can_manage_clarification(
user: CurrentUser,
) -> bool:
return can_manage_clarification_roles(
user.roles
)

View File

@ -1,34 +0,0 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Iterator
import psycopg2
from psycopg2.extensions import connection as Connection
from psycopg2.extras import RealDictCursor
from .settings import get_settings
@contextmanager
def get_connection() -> Iterator[Connection]:
settings = get_settings()
connection = psycopg2.connect(
host=settings.pg_host,
port=settings.pg_port,
dbname=settings.pg_database,
user=settings.pg_user,
password=settings.pg_password,
connect_timeout=10,
application_name="governance_review_ui_v0315",
)
try:
yield connection
finally:
connection.close()
def dict_cursor(connection: Connection) -> RealDictCursor:
return connection.cursor(
cursor_factory=RealDictCursor
)

View File

@ -1,68 +0,0 @@
from __future__ import annotations
LABELS = {
"RECENT": "Reciente",
"ATTENTION": "Requiere atención",
"OVERDUE": "Vencida",
"APPLIED": "Aplicada",
"ERROR": "Error",
"PRODUCTION_REVIEW": "Revisión de Producción",
"CONSULTATION": "Consulta",
"CONTEXT_PROPOSAL": "Propuesta de contexto",
"MARK_IN_CONSULTATION": "Marcada En consulta",
"CLOSE_CONSULTATION": "Consulta cerrada",
"WARNING": "Aviso",
"CRITICAL": "Crítica",
"PENDING": "Pendiente",
"REVIEWED": "Resuelta",
"DISMISSED": "Descartada",
"NOT_REQUIRED": "No requiere revisión",
"OPEN": "Activa",
"CLOSED": "Terminada",
"ACTIVE": "Activa",
"ENDED": "Terminada",
"IN_CONSULTATION": "En consulta",
"PRODUCTION": "Producción",
"TECHNICAL": "Técnico",
"JOINT": "Conjunto",
"ADMIN": "Administrador",
"AUTHORIZED_LEGACY_PRODUCTION":
"Producción con el sistema antiguo",
"ODOO_START_OMITTED":
"Producción real, pero se olvidó iniciar Odoo",
"TEST_OR_SETUP": "Prueba, ajuste o preparación",
"RESIDUAL_MATERIAL":
"Material residual o vaciado de máquina",
"MAINTENANCE": "Mantenimiento",
"NOT_PRODUCTION": "No era producción",
"NOT_RELEVANT": "No era producción",
"ODOO_DOUBLE_START_ONE_VALID":
"Una orden era válida y la otra quedó abierta por error",
"ODOO_DOUBLE_START_BOTH_INVALID":
"Ninguna de las órdenes era válida",
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED":
"Eran órdenes consecutivas y la anterior no se cerró",
"DATA_ISSUE": "Incidencia de datos o configuración",
"OTHER": "Otro",
"LEGACY_ERP": "Sistema antiguo",
"TEST_SETUP": "Prueba o ajuste",
"APPROVED": "Aprobado",
"REJECTED": "Rechazado",
"NEEDS_CLARIFICATION": "Requiere aclaración",
"CANCELLED": "Cancelado",
"APPROVE": "Aprobar contexto",
"REJECT": "Rechazar propuesta",
"REQUEST_CLARIFICATION": "Solicitar aclaración",
"CLARIFICATION_RESPONSE": "Aclaración respondida",
}
def label(value: object) -> str:
if value is None:
return ""
raw = str(value)
return LABELS.get(
raw,
raw.replace("_", " ").capitalize(),
)

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +0,0 @@
from __future__ import annotations
from typing import AbstractSet
def can_view_owner_roles(
roles: AbstractSet[str],
review_owner: str,
) -> bool:
if "ADMIN" in roles:
return True
if review_owner == "PRODUCTION":
return "PRODUCTION" in roles
if review_owner == "TECHNICAL":
return "TECHNICAL" in roles
if review_owner == "JOINT":
return bool(
roles
& {
"PRODUCTION",
"TECHNICAL",
"JOINT",
}
)
return False
def can_approve_context_roles(
roles: AbstractSet[str],
) -> bool:
return bool(
roles
& {
"TECHNICAL",
"ADMIN",
}
)
def can_manage_clarification_roles(
roles: AbstractSet[str],
) -> bool:
return bool(
roles
& {
"PRODUCTION",
"ADMIN",
}
)

View File

@ -1,597 +0,0 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from psycopg2.extras import Json, RealDictCursor
from .auth import CurrentUser
from .db import get_connection
from .settings import get_settings
@dataclass(frozen=True)
class ProposalActionInput:
submission_key: uuid.UUID
proposal_id: int
action_code: str
notes: str
class ProposalError(RuntimeError):
pass
def json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {
str(key): json_safe(item)
for key, item in value.items()
}
if isinstance(value, (list, tuple, set)):
return [
json_safe(item)
for item in value
]
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def fetch_proposal_for_update(
cursor: RealDictCursor,
proposal_id: int,
) -> dict[str, Any]:
cursor.execute(
"""
SELECT p.*
FROM
mv_loss_intelligence
.context_governance_context_proposals raw
JOIN
mv_reports_ucepsa_prod
.li_context_governance_context_proposals_v1 p
ON p.proposal_id = raw.proposal_id
WHERE raw.proposal_id = %s
FOR UPDATE OF raw
""",
(proposal_id,),
)
row = cursor.fetchone()
if not row:
raise ProposalError(
"La propuesta no existe."
)
return dict(row)
def existing_action(
cursor: RealDictCursor,
settings,
submission_key: uuid.UUID,
) -> dict[str, Any] | None:
cursor.execute(
"""
SELECT *
FROM
mv_loss_intelligence
.context_governance_context_proposal_actions
WHERE tenant = %s
AND site = %s
AND request_id = %s
""",
(
settings.tenant,
settings.site,
str(submission_key),
),
)
row = cursor.fetchone()
return dict(row) if row else None
def count_authoritative_overlaps(
cursor: RealDictCursor,
proposal: dict[str, Any],
) -> int:
cursor.execute(
"""
SELECT COUNT(*) AS overlap_count
FROM (
SELECT 1
FROM
mv_loss_intelligence
.production_context_sessions s
WHERE s.machine_id = %s
AND s.started_at < %s
AND COALESCE(
s.ended_at,
'infinity'::timestamptz
) > %s
UNION ALL
SELECT 1
FROM
mv_loss_intelligence
.odoo_shopfloor_sessions o
WHERE o.machine_id = %s
AND o.started_at IS NOT NULL
AND o.started_at < %s
AND COALESCE(
o.ended_at,
'infinity'::timestamptz
) > %s
AND o.session_status IN (
'OPEN',
'CLOSED'
)
) overlaps
""",
(
proposal["machine_id"],
proposal["ended_at"],
proposal["first_seen_at"],
proposal["machine_id"],
proposal["ended_at"],
proposal["first_seen_at"],
),
)
return int(
cursor.fetchone()["overlap_count"]
)
def insert_proposal_action(
cursor: RealDictCursor,
settings,
action: ProposalActionInput,
proposal: dict[str, Any],
user: CurrentUser,
resulting_status: str,
context_session_id: int | None,
source_ip: str | None,
user_agent: str | None,
) -> int:
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.context_governance_context_proposal_actions (
tenant,
site,
request_id,
proposal_id,
action_code,
previous_status,
resulting_status,
actor_username,
actor_display_name,
actor_roles,
notes,
context_session_id,
source_ip,
user_agent,
official_eligible
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s, false
)
RETURNING proposal_action_id
""",
(
settings.tenant,
settings.site,
str(action.submission_key),
action.proposal_id,
action.action_code,
proposal["proposal_status"],
resulting_status,
user.username,
user.display_name,
sorted(user.roles),
action.notes.strip() or None,
context_session_id,
source_ip,
user_agent,
),
)
return int(
cursor.fetchone()[
"proposal_action_id"
]
)
def approve_proposal(
action: ProposalActionInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
settings = get_settings()
with get_connection() as connection:
connection.autocommit = False
try:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT pg_advisory_xact_lock(
hashtext(%s)
)
""",
(
(
f"{settings.tenant}:"
f"{settings.site}:"
f"{action.submission_key}"
),
),
)
replay = existing_action(
cursor,
settings,
action.submission_key,
)
if replay:
connection.rollback()
return {
"idempotent_replay": True,
"proposal_action_id":
replay[
"proposal_action_id"
],
"context_session_id":
replay[
"context_session_id"
],
}
proposal = fetch_proposal_for_update(
cursor,
action.proposal_id,
)
if proposal["proposal_status"] != "PENDING":
raise ProposalError(
"Solo pueden aprobarse propuestas pendientes."
)
if (
proposal["case_status"] !=
"CLOSED"
or proposal["ended_at"] is None
):
raise ProposalError(
"El intervalo todavía no ha terminado."
)
overlap_count = count_authoritative_overlaps(
cursor,
proposal,
)
if overlap_count > 0:
raise ProposalError(
"El intervalo se solapa con otro contexto autoritativo. "
"La propuesta no puede aprobarse."
)
source_ref = (
"GOVERNANCE_PROPOSAL:"
f"{proposal['proposal_id']}"
)
cursor.execute(
"""
SELECT context_session_id
FROM
mv_loss_intelligence
.production_context_sessions
WHERE source_ref = %s
""",
(source_ref,),
)
existing_context = cursor.fetchone()
if existing_context:
context_session_id = int(
existing_context[
"context_session_id"
]
)
else:
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.production_context_sessions (
tenant,
site,
machine_id,
source_type,
source_ref,
started_at,
ended_at,
status,
authorization_status,
confidence,
official_eligible,
authorized_by,
authorized_at,
closed_by,
notes,
evidence_json
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, 'CLOSED',
'AUTHORIZED', 'MEDIUM', false,
%s, now(), %s, %s, %s
)
RETURNING context_session_id
""",
(
settings.tenant,
settings.site,
proposal["machine_id"],
proposal[
"proposed_source_type"
],
source_ref,
proposal["first_seen_at"],
proposal["ended_at"],
user.display_name,
user.display_name,
action.notes.strip()
or proposal.get(
"technical_review_notes"
)
or "Aprobación técnica desde Governance Review UI.",
Json({
"created_with":
"governance_review_ui_v0315",
"proposal_id":
proposal["proposal_id"],
"proposal_key":
proposal["proposal_key"],
"decision_group_key":
proposal[
"decision_group_key"
],
"case_id":
proposal["case_id"],
"answer_code":
proposal["answer_code"],
"proposed_by":
proposal[
"proposed_by_display_name"
],
"approved_by":
user.display_name,
"deployment_mode":
"SHADOW",
}),
),
)
context_session_id = int(
cursor.fetchone()[
"context_session_id"
]
)
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_context_proposals
SET
proposal_status = 'APPROVED',
technical_review_by_username = %s,
technical_review_by_display_name = %s,
technical_review_at = now(),
technical_review_notes = %s,
context_session_id = %s,
updated_at = now()
WHERE proposal_id = %s
""",
(
user.username,
user.display_name,
action.notes.strip() or None,
context_session_id,
action.proposal_id,
),
)
proposal_action_id = insert_proposal_action(
cursor,
settings,
action,
proposal,
user,
"APPROVED",
context_session_id,
source_ip,
user_agent,
)
connection.commit()
return {
"idempotent_replay": False,
"proposal_action_id":
proposal_action_id,
"context_session_id":
context_session_id,
}
except Exception:
connection.rollback()
raise
def change_proposal_status(
action: ProposalActionInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
settings = get_settings()
target_status = {
"REJECT": "REJECTED",
"REQUEST_CLARIFICATION":
"NEEDS_CLARIFICATION",
"CLARIFICATION_RESPONSE":
"PENDING",
}.get(action.action_code)
if target_status is None:
raise ProposalError(
"Acción de propuesta no permitida."
)
if not action.notes.strip():
raise ProposalError(
"Debe escribir una explicación."
)
with get_connection() as connection:
connection.autocommit = False
try:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT pg_advisory_xact_lock(
hashtext(%s)
)
""",
(
(
f"{settings.tenant}:"
f"{settings.site}:"
f"{action.submission_key}"
),
),
)
replay = existing_action(
cursor,
settings,
action.submission_key,
)
if replay:
connection.rollback()
return {
"idempotent_replay": True,
"proposal_action_id":
replay[
"proposal_action_id"
],
"resulting_status":
replay[
"resulting_status"
],
}
proposal = fetch_proposal_for_update(
cursor,
action.proposal_id,
)
expected_status = (
"NEEDS_CLARIFICATION"
if action.action_code ==
"CLARIFICATION_RESPONSE"
else "PENDING"
)
if proposal["proposal_status"] != expected_status:
raise ProposalError(
"La propuesta ha cambiado de estado. Recargue la página."
)
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_context_proposals
SET
proposal_status = %s,
technical_review_by_username =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_by_username
ELSE %s
END,
technical_review_by_display_name =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_by_display_name
ELSE %s
END,
technical_review_at =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_at
ELSE now()
END,
technical_review_notes =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_notes
ELSE %s
END,
updated_at = now()
WHERE proposal_id = %s
""",
(
target_status,
action.action_code,
user.username,
action.action_code,
user.display_name,
action.action_code,
action.action_code,
action.notes.strip(),
action.proposal_id,
),
)
proposal_action_id = insert_proposal_action(
cursor,
settings,
action,
proposal,
user,
target_status,
None,
source_ip,
user_agent,
)
connection.commit()
return {
"idempotent_replay": False,
"proposal_action_id":
proposal_action_id,
"resulting_status":
target_status,
}
except Exception:
connection.rollback()
raise

File diff suppressed because it is too large Load Diff

View File

@ -1,220 +0,0 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from functools import lru_cache
from typing import FrozenSet
@dataclass(frozen=True)
class UserConfig:
username: str
display_name: str
password_hash: str
roles: FrozenSet[str]
@dataclass(frozen=True)
class Settings:
app_title: str
tenant: str
site: str
pg_host: str
pg_port: int
pg_database: str
pg_user: str
pg_password: str
session_secret: str
session_max_age_s: int
cookie_secure: bool
write_enabled: bool
users: dict[str, UserConfig]
def env_first(*names: str, default: str | None = None) -> str | None:
for name in names:
value = os.getenv(name)
if value not in (None, ""):
return value
return default
def parse_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {
"1",
"true",
"yes",
"on",
}
def load_users(raw: str) -> dict[str, UserConfig]:
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(
"GOVERNANCE_UI_USERS_JSON no contiene JSON válido."
) from exc
if not isinstance(payload, list) or not payload:
raise RuntimeError(
"GOVERNANCE_UI_USERS_JSON debe ser una lista no vacía."
)
users: dict[str, UserConfig] = {}
for item in payload:
if not isinstance(item, dict):
raise RuntimeError(
"Cada usuario debe ser un objeto JSON."
)
username = str(item.get("username", "")).strip()
display_name = str(
item.get("display_name", "")
).strip()
password_hash = str(
item.get("password_hash", "")
).strip()
roles_raw = item.get("roles", [])
if (
not username
or not display_name
or not password_hash
or not isinstance(roles_raw, list)
):
raise RuntimeError(
"Cada usuario requiere username, display_name, "
"password_hash y roles."
)
roles = frozenset(
str(role).strip().upper()
for role in roles_raw
if str(role).strip()
)
allowed_roles = {
"PRODUCTION",
"TECHNICAL",
"JOINT",
"ADMIN",
}
if not roles or not roles.issubset(allowed_roles):
raise RuntimeError(
f"Roles inválidos para {username!r}: {sorted(roles)}"
)
if username in users:
raise RuntimeError(
f"Usuario duplicado: {username!r}"
)
users[username] = UserConfig(
username=username,
display_name=display_name,
password_hash=password_hash,
roles=roles,
)
return users
@lru_cache(maxsize=1)
def get_settings() -> Settings:
database = env_first(
"PGDATABASE",
"POSTGRES_DB",
)
user = env_first(
"PGUSER",
"POSTGRES_USER",
)
password = env_first(
"PGPASSWORD",
"POSTGRES_PASSWORD",
)
session_secret = env_first(
"GOVERNANCE_UI_SESSION_SECRET"
)
users_json = env_first(
"GOVERNANCE_UI_USERS_JSON"
)
missing = [
name
for name, value in (
("PGDATABASE/POSTGRES_DB", database),
("PGUSER/POSTGRES_USER", user),
("PGPASSWORD/POSTGRES_PASSWORD", password),
("GOVERNANCE_UI_SESSION_SECRET", session_secret),
("GOVERNANCE_UI_USERS_JSON", users_json),
)
if not value
]
if missing:
raise RuntimeError(
"Faltan variables: " + ", ".join(missing)
)
return Settings(
app_title=env_first(
"GOVERNANCE_UI_TITLE",
default="MESAVAULT · Revisión de gobernanza",
)
or "MESAVAULT · Revisión de gobernanza",
tenant=env_first(
"TENANT_OVERRIDE",
"TENANT",
default="ucepsa",
)
or "ucepsa",
site=env_first(
"SITE_OVERRIDE",
"SITE",
default="ucepsa_onpremise",
)
or "ucepsa_onpremise",
pg_host=env_first(
"PGHOST",
default="mv_ucepsa_postgres_hot",
)
or "mv_ucepsa_postgres_hot",
pg_port=int(
env_first(
"PGPORT",
default="5432",
)
or "5432"
),
pg_database=str(database),
pg_user=str(user),
pg_password=str(password),
session_secret=str(session_secret),
session_max_age_s=int(
env_first(
"GOVERNANCE_UI_SESSION_MAX_AGE_S",
default="28800",
)
or "28800"
),
cookie_secure=parse_bool(
env_first(
"GOVERNANCE_UI_COOKIE_SECURE",
default="false",
)
),
write_enabled=parse_bool(
env_first(
"GOVERNANCE_UI_WRITE_ENABLED",
default="false",
)
),
users=load_users(str(users_json)),
)

View File

@ -1,377 +0,0 @@
:root {
color-scheme: dark;
--bg: #0f141a;
--panel: #171e26;
--panel-2: #202936;
--text: #eef3f8;
--muted: #9eabb8;
--line: #344050;
--primary: #5ba8ff;
--success: #47c78a;
--warning: #f0a53a;
--danger: #ef6461;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, sans-serif;
background: var(--bg);
color: var(--text);
}
a { color: var(--primary); }
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 2rem;
background: #0a0f14;
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 5;
}
.brand {
color: var(--text);
font-weight: 800;
text-decoration: none;
letter-spacing: .04em;
}
.subtitle {
color: var(--muted);
margin-left: .7rem;
}
.topbar nav {
display: flex;
align-items: center;
gap: 1rem;
}
.topbar nav a { text-decoration: none; }
.user { color: var(--muted); }
.container {
width: min(1400px, calc(100% - 2rem));
margin: 2rem auto 4rem;
}
.page-heading,
.detail-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1.5rem;
}
h1, h2, h3 { margin-top: 0; }
p { line-height: 1.55; }
.summary-badge,
.machine,
.severity,
.status {
display: inline-block;
border-radius: 999px;
padding: .35rem .7rem;
background: var(--panel-2);
font-size: .88rem;
}
.machine { font-weight: 800; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 1rem;
}
.decision-card,
.decision-detail,
.login-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 1.25rem;
}
.decision-card.severity-critical {
border-left: 5px solid var(--danger);
}
.decision-card.severity-warning {
border-left: 5px solid var(--warning);
}
.card-head,
.status-pair,
.status-stack {
display: flex;
gap: .5rem;
flex-wrap: wrap;
justify-content: space-between;
}
.question { font-size: 1.08rem; }
.facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .75rem;
}
.facts div {
background: var(--panel-2);
border-radius: 10px;
padding: .7rem;
}
.facts dt {
color: var(--muted);
font-size: .8rem;
}
.facts dd { margin: .25rem 0 0; }
.button,
button {
border: 0;
border-radius: 9px;
padding: .72rem 1rem;
font: inherit;
cursor: pointer;
}
.primary {
background: var(--primary);
color: #06111f;
font-weight: 800;
text-decoration: none;
}
.link-button {
color: var(--primary);
background: transparent;
padding: 0;
}
.inline { display: inline; }
.alert {
padding: .9rem 1rem;
border-radius: 10px;
margin-bottom: 1rem;
}
.alert.error {
background: rgba(239, 100, 97, .16);
border: 1px solid var(--danger);
}
.alert.success {
background: rgba(71, 199, 138, .16);
border: 1px solid var(--success);
}
.empty-state {
text-align: center;
padding: 4rem 1rem;
color: var(--muted);
}
.login-card {
max-width: 460px;
margin: 7rem auto;
}
label {
display: block;
margin: 1rem 0;
}
input,
textarea,
select {
width: 100%;
margin-top: .4rem;
padding: .75rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #0e151d;
color: var(--text);
font: inherit;
}
input[type="checkbox"],
input[type="radio"] {
width: auto;
margin: 0;
}
fieldset {
margin: 1.25rem 0;
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem;
}
legend {
padding: 0 .5rem;
font-weight: 800;
}
.check-row,
.radio-row {
display: flex;
align-items: flex-start;
gap: .7rem;
margin: .65rem 0;
}
.help,
small { color: var(--muted); }
.timeline {
display: grid;
gap: .75rem;
}
.timeline-item {
display: grid;
grid-template-columns: 220px 1fr;
gap: 1rem;
background: var(--panel-2);
border-radius: 10px;
padding: 1rem;
border-left: 4px solid var(--warning);
}
.timeline-item.reviewed {
border-left-color: var(--success);
opacity: .82;
}
.timeline-time {
display: flex;
flex-direction: column;
gap: .35rem;
}
.question-box {
background: var(--panel-2);
border-radius: 12px;
padding: 1rem;
margin: 1rem 0 1.5rem;
}
details {
margin: 1.5rem 0;
background: var(--panel-2);
padding: 1rem;
border-radius: 10px;
}
details summary {
cursor: pointer;
font-weight: 800;
}
.evidence-list article {
border-top: 1px solid var(--line);
padding: 1rem 0;
}
.review-form {
border-top: 1px solid var(--line);
margin-top: 2rem;
padding-top: 1rem;
}
.confirmation {
background: rgba(240, 165, 58, .12);
padding: 1rem;
border-radius: 10px;
}
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
}
th, td {
padding: .75rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th { color: var(--muted); }
.back { display: inline-block; margin-bottom: 1rem; }
@media (max-width: 760px) {
.topbar,
.page-heading,
.detail-heading {
flex-direction: column;
}
.timeline-item {
grid-template-columns: 1fr;
}
.facts {
grid-template-columns: 1fr;
}
.card-grid {
grid-template-columns: 1fr;
}
}
.secondary {
background: #52616f;
color: var(--text);
font-weight: 700;
}
.danger-button {
background: var(--danger);
color: #160604;
font-weight: 800;
}
.info-box,
.consultation-box {
background: rgba(91, 168, 255, .10);
border: 1px solid rgba(91, 168, 255, .35);
border-radius: 10px;
padding: 1rem;
margin: 1rem 0;
}
.consultation-box {
background: rgba(240, 165, 58, .10);
border-color: rgba(240, 165, 58, .35);
}
.consultation-form {
border-color: rgba(240, 165, 58, .45);
}
.compact-section {
margin-bottom: 1rem;
}
.topbar nav {
flex-wrap: wrap;
}

View File

@ -1,82 +0,0 @@
{% extends "base.html" %}
{% block content %}
<a class="back" href="/approvals">← Volver a aprobación técnica</a>
{% if error %}
<div class="alert error">{{ error }}</div>
{% endif %}
<section class="decision-detail">
<div class="detail-heading">
<div>
<span class="machine">{{ proposal.machine_id }}</span>
<span class="status">{{ proposal.proposal_status|label }}</span>
<h1>Propuesta de contexto: {{ proposal.proposed_source_type|label }}</h1>
</div>
</div>
<div class="question-box">
<h2>Respuesta aportada por Producción</h2>
<p><strong>{{ proposal.answer_code|label }}</strong></p>
<p>Respondido por {{ proposal.proposed_by_display_name }} el {{ proposal.proposed_at|datetime_es }}</p>
</div>
<dl class="facts">
<div><dt>Inicio exacto</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
<div><dt>Fin exacto</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
<div><dt>Estado del intervalo</dt><dd>{{ proposal.case_status|label }}</dd></div>
<div><dt>Estado de revisión</dt><dd>{{ proposal.case_review_status|label }}</dd></div>
<div><dt>Órdenes</dt><dd>{{ proposal.production_orders|join(", ") if proposal.production_orders else "—" }}</dd></div>
<div><dt>Workorders</dt><dd>{{ proposal.odoo_workorder_ids|join(", ") if proposal.odoo_workorder_ids else "—" }}</dd></div>
</dl>
{% if proposal.last_action_notes %}
<div class="consultation-box">
<strong>Último intercambio</strong>
<p>{{ proposal.last_action_notes }}</p>
<small>{{ proposal.last_action_by }} · {{ proposal.last_action_at|datetime_es }}</small>
</div>
{% endif %}
<div class="info-box">
<strong>Validaciones al aprobar</strong>
<p>La aplicación comprobará que el intervalo ha terminado y que no se solapa con Odoo ni con otro contexto autoritativo. El contexto se creará en SHADOW y con elegibilidad oficial desactivada.</p>
</div>
{% if not write_enabled %}
<div class="alert error">La interfaz está en modo solo lectura.</div>
{% elif proposal.proposal_status == 'PENDING' %}
<form method="post" action="/approvals/{{ proposal.proposal_id }}/approve" class="review-form">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_key }}">
<label>
Observaciones técnicas
<textarea name="notes" rows="4" placeholder="Ejemplo: intervalo cerrado, sin solapamientos y coherente con la respuesta de Producción."></textarea>
</label>
<button class="primary" type="submit">Aprobar y crear contexto</button>
</form>
<form method="post" action="/approvals/{{ proposal.proposal_id }}/request_clarification" class="review-form">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_key }}">
<label>
Aclaración necesaria
<textarea name="notes" rows="4" required placeholder="Indique qué dato debe aclarar Producción."></textarea>
</label>
<button class="secondary" type="submit">Solicitar aclaración</button>
</form>
<form method="post" action="/approvals/{{ proposal.proposal_id }}/reject" class="review-form">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_key }}">
<label>
Motivo del rechazo
<textarea name="notes" rows="4" required placeholder="Explique por qué no debe crearse el contexto."></textarea>
</label>
<button class="danger-button" type="submit">Rechazar propuesta</button>
</form>
{% else %}
<div class="alert success">La propuesta ya no está pendiente: {{ proposal.proposal_status|label }}.</div>
{% endif %}
</section>
{% endblock %}

View File

@ -1,49 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Aprobación técnica de contextos</h1>
<p>Producción ya explicó qué ocurrió. Esta cola valida si la respuesta puede convertirse en contexto canónico sin crear solapamientos.</p>
</div>
<div class="summary-badge">{{ proposals|length }} propuestas</div>
</div>
{% if message %}
<div class="alert success">{{ message }}</div>
{% endif %}
{% if not proposals %}
<div class="empty-state">
<h2>No hay propuestas pendientes</h2>
<p>Las respuestas de Producción que no necesitan contexto no aparecen aquí.</p>
</div>
{% endif %}
<div class="card-grid">
{% for proposal in proposals %}
<article class="decision-card">
<div class="card-head">
<div>
<span class="machine">{{ proposal.machine_id }}</span>
<span class="status">{{ proposal.proposal_status|label }}</span>
</div>
</div>
<h2>{{ proposal.proposed_source_type|label }}</h2>
<p><strong>Respuesta de Producción:</strong> {{ proposal.answer_code|label }}</p>
<dl class="facts">
<div><dt>Inicio</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
<div><dt>Fin</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
<div><dt>Propuesto por</dt><dd>{{ proposal.proposed_by_display_name }}</dd></div>
<div><dt>Caso</dt><dd>{{ proposal.case_id }}</dd></div>
</dl>
{% if proposal.last_action_notes %}
<div class="consultation-box">
<strong>Último intercambio</strong>
<p>{{ proposal.last_action_notes }}</p>
</div>
{% endif %}
<a class="primary button" href="/approvals/{{ proposal.proposal_id }}">Revisar propuesta</a>
</article>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,41 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ settings.app_title }}</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% if user %}
<header class="topbar">
<div>
<a class="brand" href="/">MESAVAULT</a>
<span class="subtitle">Revisión de gobernanza</span>
</div>
<nav>
<a href="/">Pendientes</a>
{% if 'TECHNICAL' in user.roles or 'ADMIN' in user.roles %}
<a href="/approvals">Aprobación técnica</a>
{% endif %}
{% if 'PRODUCTION' in user.roles or 'ADMIN' in user.roles %}
<a href="/clarifications">Aclaraciones</a>
{% endif %}
<a href="/history">Historial</a>
<span class="user">{{ user.display_name }}</span>
{% if not settings.write_enabled %}
<span class="status">Solo lectura</span>
{% endif %}
<form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="link-button" type="submit">Salir</button>
</form>
</nav>
</header>
{% endif %}
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@ -1,46 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Aclaraciones solicitadas</h1>
<p>El revisor técnico necesita un dato adicional antes de aprobar o rechazar el contexto propuesto.</p>
</div>
<div class="summary-badge">{{ proposals|length }} pendientes</div>
</div>
{% if message %}
<div class="alert success">{{ message }}</div>
{% endif %}
{% if not proposals %}
<div class="empty-state">
<h2>No hay aclaraciones pendientes</h2>
</div>
{% endif %}
{% for proposal in proposals %}
<section class="decision-detail compact-section">
<div class="detail-heading">
<div>
<span class="machine">{{ proposal.machine_id }}</span>
<h2>{{ proposal.proposed_source_type|label }}</h2>
</div>
<span class="status">{{ proposal.proposal_status|label }}</span>
</div>
<p><strong>Pregunta técnica:</strong> {{ proposal.last_action_notes }}</p>
<p><strong>Intervalo:</strong> {{ proposal.first_seen_at|datetime_es }} → {{ proposal.ended_at|datetime_es }}</p>
{% if write_enabled %}
<form method="post" action="/clarifications/{{ proposal.proposal_id }}/respond">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_keys[proposal.proposal_id] }}">
<label>
Respuesta o aclaración
<textarea name="notes" rows="4" required></textarea>
</label>
<button class="primary" type="submit">Enviar aclaración</button>
</form>
{% endif %}
</section>
{% endfor %}
{% endblock %}

View File

@ -1,184 +0,0 @@
{% extends "base.html" %}
{% block content %}
<a class="back" href="/">← Volver a pendientes</a>
{% if error %}
<div class="alert error">{{ error }}</div>
{% endif %}
<section class="decision-detail">
<div class="detail-heading">
<div>
<span class="machine">{{ decision.machine_id or "GLOBAL" }}</span>
<span class="severity">{{ decision.max_severity|label }}</span>
<h1>{{ decision.decision_title }}</h1>
</div>
<div class="status-stack">
<span class="status">Incidencia: {{ decision.decision_status|label }}</span>
<span class="status">Revisión: {{ decision.review_status|label }}</span>
</div>
</div>
<div class="question-box">
<h2>Pregunta</h2>
<p>{{ decision.question_text }}</p>
</div>
{% if consultation %}
<div class="consultation-box">
<h2>En consulta</h2>
<p>{{ consultation.reason }}</p>
{% if consultation.follow_up_with %}
<p><strong>Consultar con:</strong> {{ consultation.follow_up_with }}</p>
{% endif %}
<small>Marcado por {{ consultation.created_by_display_name }} el {{ consultation.created_at|datetime_es }}</small>
{% if write_enabled %}
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/resume">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ resume_submission_key }}">
<button class="secondary" type="submit">Ya tengo la información</button>
</form>
{% endif %}
</div>
{% endif %}
<section>
<h2>Intervalos y casos</h2>
<div class="timeline">
{% for case in cases %}
<article class="timeline-item {% if case.case_review_status != 'PENDING' %}reviewed{% endif %}">
<div class="timeline-time">
<strong>{{ case.first_seen_at|datetime_es }}</strong>
<span>→ {{ case.ended_at|datetime_es }}</span>
</div>
<div>
<h3>Caso {{ case.case_id }} · {{ case.case_title }}</h3>
<p>
Incidencia: {{ case.case_status|label }}
· Revisión: {{ case.case_review_status|label }}
</p>
{% if case.case_resolution_classification %}
<p><strong>Periodo ya aclarado:</strong> {{ case.case_resolution_classification|label }}</p>
{% endif %}
{% if case.production_orders %}
<p><strong>Órdenes:</strong> {{ case.production_orders|join(", ") }}</p>
{% endif %}
</div>
</article>
{% endfor %}
</div>
</section>
<details>
<summary>Evidencia técnica</summary>
<div class="evidence-list">
{% for item in decision.episode_evidence %}
<article>
<strong>Episodio {{ item.episode_id }} · {{ item.incident_title }}</strong>
<p>{{ item.latest_detail or "Sin detalle adicional." }}</p>
<small>
{{ item.first_seen_at }} → {{ item.ended_at or "abierto" }}
{% if item.production_order %} · {{ item.production_order }}{% endif %}
{% if item.operator_name %} · {{ item.operator_name }}{% endif %}
</small>
</article>
{% endfor %}
</div>
</details>
{% if not write_enabled %}
<div class="alert error">
La interfaz está en modo solo lectura. Puede validar la información, pero todavía no registrar respuestas.
</div>
{% endif %}
{% if can_review and pending_cases %}
{% if not consultation %}
<details class="consultation-form">
<summary>No conozco todavía la respuesta</summary>
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/consultation">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ consultation_submission_key }}">
<label>
Qué información falta
<textarea name="reason" rows="3" required placeholder="Ejemplo: preguntar a la operaria del turno de tarde qué estaba haciendo la máquina."></textarea>
</label>
<label>
A quién hay que consultar
<input name="follow_up_with" placeholder="Ejemplo: María / responsable del turno anterior">
</label>
<button class="secondary" type="submit">Marcar En consulta</button>
</form>
</details>
{% endif %}
<form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ review_submission_key }}">
<fieldset>
<legend>1. Intervalos a los que se aplica la respuesta</legend>
{% for case in pending_cases %}
<label class="check-row">
<input type="checkbox" name="case_ids" value="{{ case.case_id }}" checked>
<span>
Caso {{ case.case_id }}:
{{ case.first_seen_at|datetime_es }}
→ {{ case.ended_at|datetime_es }}
</span>
</label>
{% endfor %}
<p class="help">Desmarque un intervalo cuando la causa no sea la misma. Los demás seguirán pendientes.</p>
</fieldset>
<fieldset>
<legend>2. Qué ocurrió</legend>
{% for code, text in answer_options %}
<label class="radio-row">
<input type="radio" name="answer_code" value="{{ code }}" required>
<span>{{ text }}</span>
</label>
{% endfor %}
</fieldset>
{% if decision.decision_family == "ODOO_ORDER_START_CONFLICT" %}
<fieldset>
<legend>3. Orden válida</legend>
{% for order_ref, workorder_id in order_pairs %}
<label class="radio-row">
<input type="radio" name="selected_order" value="{{ order_ref }}|{{ workorder_id }}">
<span>{{ order_ref }} · WO {{ workorder_id }}</span>
</label>
{% endfor %}
<label class="check-row">
<input type="checkbox" name="corrected_in_odoo" value="true">
<span>La incoherencia ya está corregida en Odoo</span>
</label>
</fieldset>
{% endif %}
<div class="info-box">
<strong>Qué ocurrirá después</strong>
<p>Su respuesta cerrará la pregunta de Producción. Cuando la respuesta permita crear un contexto histórico, se enviará una propuesta a validación técnica. Usted no crea ni aprueba contextos.</p>
</div>
<label>
Observaciones
<textarea name="notes" rows="5" placeholder="Explique brevemente qué ocurrió y, cuando proceda, qué se corrigió."></textarea>
</label>
<label class="check-row confirmation">
<input type="checkbox" name="confirmation" value="true" required>
<span>Confirmo que la respuesta refleja la realidad de planta para los intervalos seleccionados.</span>
</label>
<button class="primary" type="submit">Resolver selección</button>
</form>
{% elif not pending_cases %}
<div class="alert success">Esta decisión ya no tiene casos pendientes.</div>
{% elif write_enabled %}
<div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div>
{% endif %}
</section>
{% endblock %}

View File

@ -1,38 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Historial de actividad</h1>
<p>Revisiones de Producción, consultas y decisiones técnicas durante los últimos 30 días.</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Usuario</th>
<th>Decisión</th>
<th>Acción</th>
<th>Estado</th>
<th>Notas</th>
</tr>
</thead>
<tbody>
{% for activity in activities %}
<tr>
<td>{{ activity.created_at|datetime_es }}</td>
<td>{{ activity.activity_type|label }}</td>
<td>{{ activity.actor_display_name }}</td>
<td>{{ activity.decision_group_key }}</td>
<td>{{ activity.action_code|label }}</td>
<td>{{ activity.status|label }}</td>
<td>{{ activity.notes or "—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,23 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="login-card">
<h1>{{ settings.app_title }}</h1>
<p>Acceso interno para revisar y resolver excepciones operativas.</p>
{% if error %}
<div class="alert error">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<label>
Usuario
<input name="username" autocomplete="username" required>
</label>
<label>
Contraseña
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="primary" type="submit">Entrar</button>
</form>
</section>
{% endblock %}

View File

@ -1,68 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Decisiones pendientes</h1>
<p>Una tarjeta equivale a una pregunta humana. Producción explica lo ocurrido; la creación de contexto se valida después por separado.</p>
</div>
<div class="summary-badge">{{ rows|length }} pendientes</div>
</div>
{% if message %}
<div class="alert success">{{ message }}</div>
{% endif %}
{% if not rows %}
<div class="empty-state">
<h2>No hay decisiones pendientes para su rol</h2>
<p>La cola se actualizará automáticamente cuando aparezca una excepción que necesite una explicación humana.</p>
</div>
{% endif %}
<div class="card-grid">
{% for row in rows %}
<article class="decision-card severity-{{ row.max_severity|lower }}">
<div class="card-head">
<div>
<span class="machine">{{ row.machine_id or "GLOBAL" }}</span>
<span class="severity">{{ row.max_severity|label }}</span>
</div>
<div class="status-pair">
<span class="status">Incidencia: {{ row.incident_status|label }}</span>
<span class="status">Revisión: {{ row.workflow_status|label }}</span>
</div>
</div>
<h2>{{ row.decision_title }}</h2>
<p class="question">{{ row.question_text }}</p>
{% if row.workflow_status == 'IN_CONSULTATION' %}
<div class="consultation-box">
<strong>En consulta</strong>
<p>{{ row.consultation_reason }}</p>
{% if row.follow_up_with %}
<small>Consultar con: {{ row.follow_up_with }}</small>
{% endif %}
</div>
{% endif %}
<dl class="facts">
<div><dt>Inicio</dt><dd>{{ row.first_seen_at|datetime_es }}</dd></div>
<div><dt>Fin</dt><dd>{{ row.ended_at|datetime_es }}</dd></div>
<div><dt>Responsable</dt><dd>{{ row.review_owner|label }}</dd></div>
<div><dt>Casos pendientes</dt><dd>{{ row.pending_root_case_count }}</dd></div>
<div><dt>Evidencias</dt><dd>{{ row.linked_episode_count }}</dd></div>
<div><dt>Atención</dt><dd>{{ row.attention_status|label }}</dd></div>
</dl>
{% if row.production_orders %}
<p><strong>Órdenes:</strong> {{ row.production_orders|join(", ") }}</p>
{% endif %}
<a class="primary button" href="/decisions/{{ row.decision_group_key|urlencode }}">
Revisar
</a>
</article>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,8 +0,0 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.34,<1
jinja2>=3.1,<4
psycopg2-binary>=2.9,<3
python-multipart>=0.0.20,<1
itsdangerous>=2.2,<3
bcrypt>=4.1,<5
pytest>=8,<9

View File

@ -1,81 +0,0 @@
from app.labels import label
from app.policy import (
can_approve_context_roles,
can_manage_clarification_roles,
can_view_owner_roles,
)
def test_production_permissions() -> None:
roles = {"PRODUCTION"}
assert can_view_owner_roles(
roles,
"PRODUCTION",
)
assert can_view_owner_roles(
roles,
"JOINT",
)
assert not can_view_owner_roles(
roles,
"TECHNICAL",
)
assert not can_approve_context_roles(
roles
)
assert can_manage_clarification_roles(
roles
)
def test_technical_permissions() -> None:
roles = {"TECHNICAL"}
assert can_view_owner_roles(
roles,
"TECHNICAL",
)
assert can_view_owner_roles(
roles,
"JOINT",
)
assert not can_view_owner_roles(
roles,
"PRODUCTION",
)
assert can_approve_context_roles(
roles
)
assert not can_manage_clarification_roles(
roles
)
def test_admin_permissions() -> None:
roles = {"ADMIN"}
for owner in (
"PRODUCTION",
"TECHNICAL",
"JOINT",
):
assert can_view_owner_roles(
roles,
owner,
)
assert can_approve_context_roles(
roles
)
assert can_manage_clarification_roles(
roles
)
def test_human_labels() -> None:
assert label("WARNING") == "Aviso"
assert label("PENDING") == "Pendiente"
assert (
label(
"AUTHORIZED_LEGACY_PRODUCTION"
)
== "Producción con el sistema antiguo"
)
assert label("IN_CONSULTATION") == "En consulta"

View File

@ -1,33 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import getpass
import bcrypt
def main() -> int:
first = getpass.getpass("Contraseña: ")
second = getpass.getpass("Repetir contraseña: ")
if first != second:
raise SystemExit(
"Las contraseñas no coinciden."
)
if len(first) < 12:
raise SystemExit(
"Use al menos 12 caracteres."
)
password_hash = bcrypt.hashpw(
first.encode("utf-8"),
bcrypt.gensalt(rounds=12),
).decode("utf-8")
print(password_hash)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,524 +0,0 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode()
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
body = resp.read().decode()
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode(), file=sys.stderr)
raise
datasources = api("GET", "/api/datasources")
print("Datasources encontrados:")
for d in datasources:
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')} url={d.get('url')} database={d.get('database')}")
pg = next(
(
d for d in datasources
if d.get("type") in ("postgres", "grafana-postgresql-datasource")
or "postgres" in (d.get("type") or "").lower()
or "postgres" in (d.get("name") or "").lower()
or "mesavault" in (d.get("name") or "").lower()
),
None,
)
if not pg:
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT en Grafana", file=sys.stderr)
sys.exit(1)
DS = {
"type": "postgres",
"uid": pg["uid"],
}
def target(sql, ref="A", fmt="table"):
return {
"refId": ref,
"datasource": DS,
"format": fmt,
"rawQuery": True,
"rawSql": sql,
"editorMode": "code",
}
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none", mappings=None):
field_defaults = {
"unit": unit,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
],
},
}
if mappings:
field_defaults["mappings"] = mappings
return {
"id": panel_id,
"type": "stat",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql)],
"options": {
"reduceOptions": {
"values": False,
"calcs": ["lastNotNull"],
"fields": "",
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
},
"fieldConfig": {
"defaults": field_defaults,
"overrides": [],
},
}
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "timeseries",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="time_series")],
"fieldConfig": {
"defaults": {
"unit": unit,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 5,
"showPoints": "never",
},
},
"overrides": [],
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
"tooltip": {
"mode": "multi",
"sort": "none",
},
},
}
def table_panel(panel_id, title, x, y, w, h, sql):
return {
"id": panel_id,
"type": "table",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql)],
"options": {
"showHeader": True,
"cellHeight": "sm",
},
"fieldConfig": {
"defaults": {},
"overrides": [],
},
}
comm_mapping = [
{
"type": "value",
"options": {
"1": {"text": "OK", "color": "green"},
"0": {"text": "NO OK", "color": "red"},
},
}
]
run_mapping = [
{
"type": "value",
"options": {
"1": {"text": "EN MARCHA", "color": "green"},
"0": {"text": "PARADA", "color": "yellow"},
},
}
]
dashboard = {
"id": None,
"uid": DASHBOARD_UID,
"title": "MESAVAULT Edge-OEE — Máquina",
"tags": ["mesavault", "ucepsa", "edge-oee"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "5s",
"time": {
"from": "now-30m",
"to": "now",
},
"templating": {
"list": [
{
"name": "machine_id",
"type": "query",
"label": "Máquina",
"datasource": DS,
"refresh": 1,
"sort": 1,
"multi": False,
"includeAll": False,
"query": "SELECT machine_id FROM mv_reports_ucepsa_demo.machine_state_latest_multi ORDER BY machine_id",
"current": {
"text": "CORT-00",
"value": "CORT-00",
"selected": True,
},
}
]
},
"panels": [
stat_panel(
1,
"Comunicación",
0,
0,
6,
4,
"""
SELECT
CASE
WHEN age_s < 30 AND comm_ok = true AND COALESCE(sample_valid, true) = true THEN 1
ELSE 0
END AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
mappings=comm_mapping,
),
stat_panel(
2,
"Edad último dato",
6,
0,
6,
4,
"""
SELECT age_s AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="s",
),
stat_panel(
3,
"Estado máquina",
12,
0,
6,
4,
"""
SELECT
CASE WHEN machine_running = true THEN 1 ELSE 0 END AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
mappings=run_mapping,
),
stat_panel(
4,
"Ciclos/min",
18,
0,
6,
4,
"""
SELECT cycle_rate_ppm AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="cpm",
),
stat_panel(
5,
"Potencia total",
0,
4,
6,
4,
"""
SELECT power_total_kw AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="kwatt",
),
stat_panel(
6,
"Energía importada",
6,
4,
6,
4,
"""
SELECT import_kwh AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="kwatth",
),
stat_panel(
7,
"Frecuencia",
12,
4,
6,
4,
"""
SELECT frequency_hz AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="hertz",
),
stat_panel(
8,
"Pulsos acumulados",
18,
4,
6,
4,
"""
SELECT cycle_pulse_count AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="none",
),
timeseries_panel(
9,
"Potencia por fase",
0,
8,
12,
8,
"""
SELECT
ts AS "time",
active_power_l1_w AS "L1 W",
active_power_l2_w AS "L2 W",
active_power_l3_w AS "L3 W",
active_power_w AS "Total W"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="watt",
),
timeseries_panel(
10,
"Tensiones L1/L2/L3",
12,
8,
12,
8,
"""
SELECT
ts AS "time",
voltage_l1_v AS "L1 V",
voltage_l2_v AS "L2 V",
voltage_l3_v AS "L3 V"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="volt",
),
timeseries_panel(
11,
"Corrientes L1/L2/L3",
0,
16,
12,
8,
"""
SELECT
ts AS "time",
current_l1_a AS "L1 A",
current_l2_a AS "L2 A",
current_l3_a AS "L3 A"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="amp",
),
table_panel(
12,
"Última lectura trifásica",
12,
16,
12,
8,
"""
SELECT
machine_id,
round(age_s::numeric, 1) AS age_s,
comm_ok,
sample_valid,
energy_map,
machine_running,
cycle_input_state,
cycle_pulse_count,
wise_cycle_counter_raw,
round(voltage_l1_v::numeric, 2) AS v_l1,
round(voltage_l2_v::numeric, 2) AS v_l2,
round(voltage_l3_v::numeric, 2) AS v_l3,
round(current_l1_a::numeric, 3) AS i_l1,
round(current_l2_a::numeric, 3) AS i_l2,
round(current_l3_a::numeric, 3) AS i_l3,
round(power_total_kw::numeric, 4) AS kw_total,
digital_inputs
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
),
table_panel(
13,
"Odoo vs máquina",
0,
24,
12,
7,
"""
SELECT
machine_id,
order_ref,
product_name,
odoo_state,
machine_running,
telemetry_age_s,
cycle_rate_ppm,
status_check,
diagnostic_message
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo
WHERE machine_id = '$machine_id'
""",
),
table_panel(
14,
"Loss Intelligence",
12,
24,
12,
7,
"""
SELECT
priority_rank,
priority_score,
priority_level,
machine_id,
order_ref,
loss_type,
severity,
duration_min,
cost_eur,
status,
description,
recommended_action
FROM mv_reports_ucepsa_demo.loss_priority_board
WHERE machine_id = '$machine_id'
ORDER BY priority_rank
""",
),
],
}
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Create MESAVAULT Edge-OEE machine dashboard with machine selector",
}
result = api("POST", "/api/dashboards/db", payload)
print("Dashboard creado/actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -1,114 +0,0 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def walk_panels(panels):
for p in panels or []:
yield p
for child in walk_panels(p.get("panels", [])):
yield child
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
changed = 0
for panel in walk_panels(dashboard.get("panels", [])):
if panel.get("title") in ("Pareto causas de paro", "Pareto causas clasificadas"):
panel["title"] = "Pareto causas clasificadas por minutos"
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'CLASSIFIED'
AND status = 'CLOSED'
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
"""
target["format"] = "table"
panel.setdefault("fieldConfig", {}).setdefault("defaults", {})["unit"] = "min"
changed += 1
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Clean stop cause pareto panel",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print(f"Paneles modificados: {changed}")
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -1,507 +0,0 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
STOP_PANEL_IDS = set(range(300, 320))
STOP_PANEL_TITLES = {
"Informe de paros",
"Paros abiertos",
"Pendientes clasificar",
"Nº paros",
"Minutos paro",
"Coste paros",
"Duración media",
"Pareto causas de paro",
"Minutos de paro por hora",
"Paros pendientes de clasificación",
"Detalle completo de paros",
}
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def flatten_panels(panels):
result = []
for p in panels or []:
result.append(p)
if p.get("panels"):
result.extend(flatten_panels(p.get("panels")))
return result
def find_datasource_from_dashboard(dashboard):
for panel in flatten_panels(dashboard.get("panels", [])):
ds = panel.get("datasource")
if isinstance(ds, dict) and ds.get("uid"):
return {
"type": ds.get("type", "postgres"),
"uid": ds["uid"],
}
for target in panel.get("targets", []) or []:
tds = target.get("datasource")
if isinstance(tds, dict) and tds.get("uid"):
return {
"type": tds.get("type", "postgres"),
"uid": tds["uid"],
}
return None
def find_datasource_fallback():
datasources = api("GET", "/api/datasources")
for d in datasources:
if (
d.get("type") in ("postgres", "grafana-postgresql-datasource")
or "postgres" in (d.get("type") or "").lower()
or "postgres" in (d.get("name") or "").lower()
or "mesavault" in (d.get("name") or "").lower()
):
return {
"type": d.get("type", "postgres"),
"uid": d["uid"],
}
print("Datasources encontrados:")
for d in datasources:
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')}")
return None
def target(sql, ref="A", fmt="table"):
return {
"refId": ref,
"datasource": DS,
"format": fmt,
"rawQuery": True,
"rawSql": sql,
"editorMode": "code",
}
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "stat",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"options": {
"reduceOptions": {
"values": False,
"calcs": ["lastNotNull"],
"fields": "",
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
},
"fieldConfig": {
"defaults": {
"unit": unit,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
{"color": "yellow", "value": 1},
{"color": "red", "value": 5},
],
},
},
"overrides": [],
},
}
def table_panel(panel_id, title, x, y, w, h, sql):
return {
"id": panel_id,
"type": "table",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"options": {
"showHeader": True,
"cellHeight": "sm",
},
"fieldConfig": {
"defaults": {},
"overrides": [],
},
}
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "timeseries",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="time_series")],
"fieldConfig": {
"defaults": {
"unit": unit,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 5,
"showPoints": "never",
},
},
"overrides": [],
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
"tooltip": {
"mode": "multi",
"sort": "none",
},
},
}
def barchart_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "barchart",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"fieldConfig": {
"defaults": {
"unit": unit,
},
"overrides": [],
},
"options": {
"orientation": "horizontal",
"xField": "Causa",
"showValue": "always",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
},
}
def max_y(panels):
y = 0
for p in panels:
gp = p.get("gridPos") or {}
y = max(y, int(gp.get("y", 0)) + int(gp.get("h", 1)))
return y
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
DS = find_datasource_from_dashboard(dashboard) or find_datasource_fallback()
if not DS:
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT", file=sys.stderr)
sys.exit(1)
print(f"Datasource usado: type={DS.get('type')} uid={DS.get('uid')}")
existing = dashboard.get("panels", [])
filtered = []
for p in existing:
title = p.get("title")
pid = p.get("id")
if pid in STOP_PANEL_IDS or title in STOP_PANEL_TITLES:
continue
filtered.append(p)
base_y = max_y(filtered) + 1
row = {
"id": 300,
"type": "row",
"title": "Informe de paros",
"collapsed": False,
"gridPos": {"x": 0, "y": base_y, "w": 24, "h": 1},
"panels": [],
}
panels = [
row,
stat_panel(
301,
"Paros abiertos",
0,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND status = 'OPEN'
""",
),
stat_panel(
302,
"Pendientes clasificar",
4,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
""",
),
stat_panel(
303,
"Nº paros",
8,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
),
stat_panel(
304,
"Minutos paro",
12,
base_y + 1,
4,
4,
"""
SELECT COALESCE(SUM(duration_min), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="min",
),
stat_panel(
305,
"Coste paros",
16,
base_y + 1,
4,
4,
"""
SELECT COALESCE(SUM(cost_eur), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="currencyEUR",
),
stat_panel(
306,
"Duración media",
20,
base_y + 1,
4,
4,
"""
SELECT COALESCE(AVG(duration_min), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="min",
),
barchart_panel(
307,
"Pareto causas de paro",
0,
base_y + 5,
12,
8,
"""
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
COUNT(*) AS "Nº paros",
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
""",
unit="min",
),
timeseries_panel(
308,
"Minutos de paro por hora",
12,
base_y + 5,
12,
8,
"""
SELECT
date_trunc('hour', started_at) AS "time",
SUM(duration_min) AS "Minutos de paro"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
GROUP BY 1
ORDER BY 1
""",
unit="min",
),
table_panel(
309,
"Paros pendientes de clasificación",
0,
base_y + 13,
24,
6,
"""
SELECT
id AS "ID",
started_at AS "Inicio",
ROUND(duration_min::numeric, 2) AS "Min",
cost_eur AS "Coste €",
status AS "Estado",
classification_status AS "Clasificación",
cause_name AS "Causa",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
ORDER BY started_at DESC
""",
),
table_panel(
310,
"Detalle completo de paros",
0,
base_y + 19,
24,
10,
"""
SELECT
id AS "ID",
machine_id AS "Máquina",
started_at AS "Inicio",
ended_at AS "Fin",
duration_min AS "Min",
cost_eur AS "Coste €",
stop_status_label AS "Estado",
classification_status AS "Clasificación",
cause_code AS "Código causa",
cause_name AS "Causa",
start_event_name AS "Evento inicio",
end_event_name AS "Evento fin",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
ORDER BY started_at DESC
LIMIT 100
""",
),
]
dashboard["panels"] = filtered + panels
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Add stop report section to machine dashboard",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -1,151 +0,0 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def all_panels(panels):
out = []
for p in panels or []:
out.append(p)
out.extend(all_panels(p.get("panels", [])))
return out
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
changed = 0
for panel in all_panels(dashboard.get("panels", [])):
title = panel.get("title", "")
if title == "Nº paros":
panel["title"] = "Nº paros en rango"
changed += 1
elif title == "Minutos paro":
panel["title"] = "Minutos paro en rango"
changed += 1
elif title == "Coste paros":
panel["title"] = "Coste paros en rango"
changed += 1
elif title == "Duración media":
panel["title"] = "Duración media en rango"
changed += 1
elif title == "Pareto causas de paro":
panel["title"] = "Pareto causas clasificadas"
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
COUNT(*) AS "Nº paros",
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'CLASSIFIED'
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
"""
target["format"] = "table"
changed += 1
elif title == "Paros pendientes de clasificación":
# Dejamos este panel sin filtro temporal: es backlog operativo.
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
id AS "ID",
started_at AS "Inicio",
ROUND(duration_min::numeric, 2) AS "Min",
cost_eur AS "Coste €",
status AS "Estado",
classification_status AS "Clasificación",
cause_name AS "Causa",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
ORDER BY started_at DESC
"""
changed += 1
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Fix stop report pareto and clarify time-scoped panels",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print(f"Paneles modificados: {changed}")
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -1,318 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-plan}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
BACKUP_BASE="${GRAFANA_CLEANUP_BACKUP_BASE:-/srv/mesavault/backups/ucepsa-grafana-cleanup}"
KEEP_UIDS=(
"ucepsa-shopfloor-context-health"
"ucepsa-edge-oee-machine"
)
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver la URL de Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
Cárguelo sin mostrarlo ni guardarlo en Git:
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
export BACKUP_BASE
export KEEP_UIDS_JSON
KEEP_UIDS_JSON="$(
printf '%s\n' "${KEEP_UIDS[@]}" \
| python3 -c 'import json,sys; print(json.dumps([x.strip() for x in sys.stdin if x.strip()]))'
)"
python3 - "$ACTION" <<'PY'
import datetime as dt
import hashlib
import json
import os
import pathlib
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
action = sys.argv[1]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
backup_base = pathlib.Path(os.environ["BACKUP_BASE"])
keep_uids = set(json.loads(os.environ["KEEP_UIDS_JSON"]))
confirm_value = os.environ.get("GRAFANA_CLEANUP_CONFIRM", "")
required_confirm = "DELETE_NON_CANONICAL_DASHBOARDS"
def request(method, path, payload=None, allow_404=False):
url = base_url + path
data = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
url,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=20) as response:
raw = response.read()
return json.loads(raw.decode("utf-8")) if raw else None
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
if allow_404 and exc.code == 404:
return None
raise RuntimeError(
f"Grafana API {method} {path}: HTTP {exc.code}: {detail}"
) from exc
def search(kind):
encoded = urllib.parse.urlencode({
"type": kind,
"limit": 5000,
})
return request("GET", f"/api/search?{encoded}") or []
def dashboard_row(item):
return {
"uid": item.get("uid"),
"title": item.get("title"),
"folder_uid": item.get("folderUid"),
"folder_title": item.get("folderTitle") or "General",
"url": item.get("url"),
"tags": item.get("tags") or [],
}
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(f"Grafana no está saludable: {health!r}")
dashboards = [dashboard_row(x) for x in search("dash-db")]
folders = search("dash-folder")
found_uids = {row["uid"] for row in dashboards}
missing = sorted(keep_uids - found_uids)
if missing:
raise RuntimeError(
"Faltan dashboards canónicos; se aborta para no borrar nada: "
+ ", ".join(missing)
)
rows = []
for row in sorted(
dashboards,
key=lambda x: (
x["folder_title"].lower(),
x["title"].lower(),
x["uid"],
),
):
rows.append({
**row,
"decision": "KEEP" if row["uid"] in keep_uids else "DELETE",
})
print("=== PLAN DE LIMPIEZA ===")
print(json.dumps(rows, ensure_ascii=False, indent=2))
print()
print(json.dumps({
"grafana_url": base_url,
"dashboard_count": len(rows),
"keep_count": sum(r["decision"] == "KEEP" for r in rows),
"delete_count": sum(r["decision"] == "DELETE" for r in rows),
"keep_uids": sorted(keep_uids),
}, ensure_ascii=False, indent=2))
if action == "plan":
print()
print("PLAN ONLY: no se ha modificado Grafana.")
raise SystemExit(0)
if action == "apply":
if confirm_value != required_confirm:
raise RuntimeError(
"Confirmación ausente. Ejecute:\n"
f" export GRAFANA_CLEANUP_CONFIRM='{required_confirm}'"
)
timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = backup_base / timestamp
dashboard_dir = backup_dir / "dashboards"
dashboard_dir.mkdir(parents=True, exist_ok=False)
manifest = {
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"grafana_url": base_url,
"keep_uids": sorted(keep_uids),
"dashboards_before": rows,
"folders_before": folders,
}
(backup_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
for row in rows:
uid = row["uid"]
payload = request(
"GET",
f"/api/dashboards/uid/{urllib.parse.quote(uid)}",
)
safe_uid = re.sub(r"[^A-Za-z0-9._-]+", "_", uid)
(dashboard_dir / f"{safe_uid}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
checksums = []
for path in sorted(backup_dir.rglob("*.json")):
digest = hashlib.sha256(path.read_bytes()).hexdigest()
checksums.append(
f"{digest} {path.relative_to(backup_dir)}"
)
(backup_dir / "SHA256SUMS").write_text(
"\n".join(checksums) + "\n",
encoding="utf-8",
)
deleted = []
for row in rows:
if row["decision"] != "DELETE":
continue
uid = row["uid"]
result = request(
"DELETE",
f"/api/dashboards/uid/{urllib.parse.quote(uid)}",
)
deleted.append({
"uid": uid,
"title": row["title"],
"folder_title": row["folder_title"],
"response": result,
})
remaining = [dashboard_row(x) for x in search("dash-db")]
remaining_uids = {x["uid"] for x in remaining}
if remaining_uids != keep_uids:
raise RuntimeError(
"La limpieza terminó con un conjunto inesperado.\n"
f"Esperado: {sorted(keep_uids)}\n"
f"Actual: {sorted(remaining_uids)}"
)
used_folder_uids = {
x["folder_uid"]
for x in remaining
if x.get("folder_uid")
}
empty_folders = [
{
"uid": folder.get("uid"),
"title": folder.get("title"),
}
for folder in folders
if folder.get("uid")
and folder.get("uid") not in used_folder_uids
]
result = {
"status": "ok",
"backup_dir": str(backup_dir),
"deleted_count": len(deleted),
"deleted": deleted,
"remaining": remaining,
"empty_folders_not_deleted": empty_folders,
}
(backup_dir / "result.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print("\n=== RESULTADO ===")
print(json.dumps(result, ensure_ascii=False, indent=2))
raise SystemExit(0)
if action == "verify":
remaining_uids = {row["uid"] for row in dashboards}
errors = []
if remaining_uids != keep_uids:
errors.append({
"expected": sorted(keep_uids),
"actual": sorted(remaining_uids),
})
print(json.dumps({
"status": "ok" if not errors else "error",
"dashboard_count": len(dashboards),
"dashboards": dashboards,
"errors": errors,
}, ensure_ascii=False, indent=2))
raise SystemExit(1 if errors else 0)
raise SystemExit(
"Uso: cleanup_grafana_dashboards_v0361.sh {plan|apply|verify}"
)
PY

View File

@ -1,257 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${CANONICAL_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${CANONICAL_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${CANONICAL_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
(
action,
dashboard_file,
folder_uid,
dashboard_uid,
datasource_uid,
) = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT canonical context "
"arbitration SHADOW v0.3.8"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid)
),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get("datasource", {}).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 16:
errors.append(
"El dashboard no tiene 16 paneles"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
14: "li_machine_canonical_context_live_v1",
15: "li_context_arbitration_conflicts_v1",
16: "li_canonical_context_coverage_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está alineado a 15s"
)
output = {
"status": "ok" if not errors else "error",
"dashboard_uid": dashboard.get("uid"),
"folder_uid": result.get("meta", {}).get(
"folderUid"
),
"panel_count": len(
dashboard.get("panels", [])
),
"refresh": dashboard.get("refresh"),
"datasource_uids": datasource_uids,
"version": dashboard.get("version"),
"url": result.get("meta", {}).get("url"),
"canonical_panels": {
"live": 14 in panels,
"conflicts": 15 in panels,
"coverage": 16 in panels,
},
"errors": errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: "
"deploy_canonical_context_arbitration_dashboard_v038.sh "
"{deploy|validate}"
)
PY

View File

@ -1,259 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${DAILY_GOVERNANCE_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${DAILY_GOVERNANCE_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${DAILY_GOVERNANCE_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT daily governance agenda "
"SHADOW v0.3.12"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get(
"datasource",
{},
).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 28:
errors.append(
"El dashboard no tiene 28 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
26: "li_context_governance_daily_agenda_v1",
27: "li_context_governance_decision_evidence_pack_v1",
28: "li_context_governance_daily_summary_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
output = {
"status":
"ok" if not errors else "error",
"dashboard_uid":
dashboard.get("uid"),
"folder_uid":
result.get(
"meta",
{},
).get("folderUid"),
"panel_count":
len(
dashboard.get(
"panels",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"daily_governance_panels": {
"agenda": 26 in panels,
"evidence_pack": 27 in panels,
"summary": 28 in panels,
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: deploy_context_daily_governance_dashboard_v0312.sh "
"{deploy|validate}"
)
PY

View File

@ -1,275 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${DECISION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${DECISION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${DECISION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT decision groups "
"SHADOW v0.3.11"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get(
"datasource",
{},
).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 25:
errors.append(
"El dashboard no tiene 25 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
23: "li_context_decision_group_backlog_v1",
24: "li_context_decision_group_cases_v1",
25: "li_context_decision_group_history_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
readiness_sql = (
panels.get(17, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if (
"li_canonical_context_readiness_gate_v3"
not in readiness_sql
):
errors.append(
"El panel readiness no usa gate v3"
)
output = {
"status":
"ok" if not errors else "error",
"dashboard_uid":
dashboard.get("uid"),
"folder_uid":
result.get(
"meta",
{},
).get("folderUid"),
"panel_count":
len(
dashboard.get(
"panels",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"decision_group_panels": {
"backlog": 23 in panels,
"root_cases": 24 in panels,
"history": 25 in panels,
"readiness_v3":
"li_canonical_context_readiness_gate_v3"
in readiness_sql,
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: deploy_context_decision_groups_dashboard_v0311.sh "
"{deploy|validate}"
)
PY

View File

@ -1,259 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT regression hardening "
"SHADOW v0.3.13"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get(
"datasource",
{},
).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 31:
errors.append(
"El dashboard no tiene 31 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
29: "li_context_regression_latest_run_v1",
30: "li_context_regression_failures_v1",
31: "li_context_regression_run_history_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
output = {
"status":
"ok" if not errors else "error",
"dashboard_uid":
dashboard.get("uid"),
"folder_uid":
result.get(
"meta",
{},
).get("folderUid"),
"panel_count":
len(
dashboard.get(
"panels",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"regression_panels": {
"latest_run": 29 in panels,
"issues": 30 in panels,
"history": 31 in panels,
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: deploy_context_regression_dashboard_v0313.sh "
"{deploy|validate}"
)
PY

View File

@ -1,315 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${READINESS_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${READINESS_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${READINESS_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
(
action,
dashboard_file,
folder_uid,
dashboard_uid,
datasource_uid,
) = sys.argv[1:]
base_url = os.environ[
"GRAFANA_URL_RESOLVED"
].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(
raw.decode("utf-8")
)
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT context rollout "
"readiness SHADOW v0.3.9"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(
dashboard_uid
)
),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get(
"panels",
[],
)
}
datasource_uids = sorted({
panel.get(
"datasource",
{},
).get("uid")
for panel in dashboard.get(
"panels",
[],
)
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get(
"datasource",
{},
).get("uid")
})
errors = []
if len(
dashboard.get(
"panels",
[],
)
) != 19:
errors.append(
"El dashboard no tiene 19 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: "
f"{datasource_uids!r}"
)
expected = {
17:
"li_canonical_context_readiness_gate_v1",
18:
"li_context_rollout_blockers_v1",
19:
"li_context_rollout_readiness_summary_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(
panel_id,
{},
)
.get(
"targets",
[{}],
)[0]
.get(
"rawSql",
"",
)
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} "
f"no consulta {fragment}"
)
output = {
"status":
"ok" if not errors else "error",
"dashboard_uid":
dashboard.get("uid"),
"folder_uid":
result.get(
"meta",
{},
).get("folderUid"),
"panel_count":
len(
dashboard.get(
"panels",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"readiness_panels": {
"machine_gate":
17 in panels,
"blockers":
18 in panels,
"summary":
19 in panels,
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: "
"deploy_context_rollout_readiness_dashboard_v039.sh "
"{deploy|validate}"
)
PY

View File

@ -1,298 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${ROOT_CASE_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${ROOT_CASE_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${ROOT_CASE_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
(
action,
dashboard_file,
folder_uid,
dashboard_uid,
datasource_uid,
) = sys.argv[1:]
base_url = os.environ[
"GRAFANA_URL_RESOLVED"
].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT root case queue "
"SHADOW v0.3.10"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(
dashboard_uid
)
),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get(
"datasource",
{},
).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 22:
errors.append(
"El dashboard no tiene 22 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
20: "li_context_review_case_backlog_v1",
21: "li_context_review_case_evidence_v1",
22: "li_context_review_case_history_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} "
f"no consulta {fragment}"
)
readiness_sql = (
panels.get(17, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if (
"li_canonical_context_readiness_gate_v2"
not in readiness_sql
):
errors.append(
"El panel readiness no usa gate v2"
)
output = {
"status":
"ok" if not errors else "error",
"dashboard_uid":
dashboard.get("uid"),
"folder_uid":
result.get(
"meta",
{},
).get("folderUid"),
"panel_count":
len(
dashboard.get(
"panels",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"root_case_panels": {
"backlog": 20 in panels,
"evidence": 21 in panels,
"history": 22 in panels,
"readiness_v2":
"li_canonical_context_readiness_gate_v2"
in readiness_sql,
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: "
"deploy_context_root_case_dashboard_v0310.sh "
"{deploy|validate}"
)
PY

View File

@ -1,301 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT governance review UI "
"SHADOW v0.3.14"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get(
"datasource",
{},
).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 31:
errors.append(
"El dashboard no tiene 31 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
29: "li_context_regression_latest_run_v1",
30: "li_context_regression_failures_v1",
31: "li_context_regression_run_history_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
governance_expected = {
23: "li_context_governance_ui_queue_v1",
26: "li_context_governance_ui_queue_v1",
}
for panel_id, fragment in governance_expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
if (
'"Incidencia"' not in sql
or '"Revisión"' not in sql
):
errors.append(
f"Panel {panel_id} no separa Incidencia y Revisión"
)
output = {
"status":
"ok" if not errors else "error",
"dashboard_uid":
dashboard.get("uid"),
"folder_uid":
result.get(
"meta",
{},
).get("folderUid"),
"panel_count":
len(
dashboard.get(
"panels",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"regression_panels": {
"latest_run": 29 in panels,
"issues": 30 in panels,
"history": 31 in panels,
},
"governance_ui_panels": {
"pending_review": 23 in panels,
"daily_agenda": 26 in panels,
"semantic_status_split": all(
(
'"Incidencia"' in (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
and '"Revisión"' in (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
)
for panel_id in (23, 26)
),
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: deploy_governance_review_ui_dashboard_v0314.sh "
"{deploy|validate}"
)
PY

View File

@ -1,323 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT governance review UI "
"SHADOW v0.3.15"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get(
"datasource",
{},
).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 34:
errors.append(
"El dashboard no tiene 34 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
29: "li_context_regression_latest_run_v1",
30: "li_context_regression_failures_v1",
31: "li_context_regression_run_history_v1",
}
two_stage_expected = {
32: "li_context_governance_context_approval_queue_v1",
33: "li_context_governance_ui_queue_v2",
34: "li_context_governance_ui_activity_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
governance_expected = {
23: "li_context_governance_ui_queue_v2",
26: "li_context_governance_ui_queue_v2",
}
for panel_id, fragment in governance_expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
if (
'"Incidencia"' not in sql
or '"Revisión"' not in sql
):
errors.append(
f"Panel {panel_id} no separa Incidencia y Revisión"
)
for panel_id, fragment in two_stage_expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
output = {
"status":
"ok" if not errors else "error",
"dashboard_uid":
dashboard.get("uid"),
"folder_uid":
result.get(
"meta",
{},
).get("folderUid"),
"panel_count":
len(
dashboard.get(
"panels",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"regression_panels": {
"latest_run": 29 in panels,
"issues": 30 in panels,
"history": 31 in panels,
},
"governance_ui_panels": {
"pending_review": 23 in panels,
"daily_agenda": 26 in panels,
"semantic_status_split": all(
(
'"Incidencia"' in (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
and '"Revisión"' in (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
)
for panel_id in (23, 26)
),
},
"two_stage_panels": {
"approval_queue": 32 in panels,
"consultations": 33 in panels,
"activity": 34 in panels,
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: deploy_governance_review_ui_dashboard_v0315.sh "
"{deploy|validate}"
)
PY

View File

@ -1,239 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
ROOT="${SHOPFLOOR_DASHBOARD_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
REPO_ROOT="${SHOPFLOOR_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
DASHBOARD_FILE="${SHOPFLOOR_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${SHOPFLOOR_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
FOLDER_TITLE="${SHOPFLOOR_DASHBOARD_FOLDER_TITLE:-MESAVAULT — UCEPSA}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver la URL de Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
read_container_env() {
local key="$1"
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n "s/^${key}=//p" \
| head -n 1
}
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
export GRAFANA_USER_RESOLVED="${GRAFANA_USER:-$(read_container_env GF_SECURITY_ADMIN_USER)}"
export GRAFANA_PASSWORD_RESOLVED="${GRAFANA_PASSWORD:-$(read_container_env GF_SECURITY_ADMIN_PASSWORD)}"
if [[ -z "$GRAFANA_USER_RESOLVED" ]]; then
GRAFANA_USER_RESOLVED="admin"
fi
if [[ -z "$GRAFANA_PASSWORD_RESOLVED" ]]; then
cat >&2 <<'MSG'
ERROR: no se encontró una credencial de escritura de Grafana.
Defina una de estas opciones sin guardarla en Git:
export GRAFANA_API_TOKEN='...'
o:
export GRAFANA_USER='admin'
export GRAFANA_PASSWORD='...'
MSG
exit 1
fi
fi
python3 - "$ACTION" "$DASHBOARD_FILE" "$FOLDER_UID" "$FOLDER_TITLE" "$DASHBOARD_UID" <<'PY'
import base64
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
action, dashboard_file, folder_uid, folder_title, dashboard_uid = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ.get("GRAFANA_API_TOKEN")
user = os.environ.get("GRAFANA_USER_RESOLVED")
password = os.environ.get("GRAFANA_PASSWORD_RESOLVED")
def request(method, path, payload=None, allow_404=False):
url = base_url + path
body = None
headers = {"Accept": "application/json"}
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
elif user and password:
raw = f"{user}:{password}".encode("utf-8")
headers["Authorization"] = (
"Basic " + base64.b64encode(raw).decode("ascii")
)
req = urllib.request.Request(
url,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=15) as response:
data = response.read()
if not data:
return None
return json.loads(data.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
if allow_404 and exc.code == 404:
return None
raise RuntimeError(
f"Grafana API {method} {path}: HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(f"Grafana no está saludable: {health!r}")
if action in {"deploy", "validate"}:
folder = request(
"GET",
f"/api/folders/{urllib.parse.quote(folder_uid)}",
allow_404=True,
)
if folder is None and action == "deploy":
folder = request(
"POST",
"/api/folders",
{"uid": folder_uid, "title": folder_title},
)
if action == "deploy":
with open(dashboard_file, encoding="utf-8") as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": "Deploy reproducible MESAVAULT Shop Floor Context Health v0.3.6",
},
)
print(json.dumps(
{
"status": "deployed",
"grafana_url": base_url,
"folder_uid": folder_uid,
"dashboard_uid": dashboard_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
f"/api/dashboards/uid/{urllib.parse.quote(dashboard_uid)}",
)
dashboard = result["dashboard"]
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(panel.get("datasource"), dict)
and panel.get("datasource", {}).get("uid")
})
expected_datasource = "bfnbcasbm6hhca"
errors = []
if dashboard.get("uid") != dashboard_uid:
errors.append("UID de dashboard inesperado")
if result.get("meta", {}).get("folderUid") != folder_uid:
errors.append("El dashboard no está en la carpeta prevista")
if len(dashboard.get("panels", [])) != 10:
errors.append("El dashboard no tiene 10 paneles")
if datasource_uids != [expected_datasource]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
output = {
"status": "ok" if not errors else "error",
"grafana_url": base_url,
"dashboard_uid": dashboard.get("uid"),
"title": dashboard.get("title"),
"folder_uid": result.get("meta", {}).get("folderUid"),
"panel_count": len(dashboard.get("panels", [])),
"datasource_uids": datasource_uids,
"version": dashboard.get("version"),
"url": result.get("meta", {}).get("url"),
"errors": errors,
}
print(json.dumps(output, ensure_ascii=False, indent=2))
if errors:
raise SystemExit(1)
elif action == "export":
result = request(
"GET",
f"/api/dashboards/uid/{urllib.parse.quote(dashboard_uid)}",
)
print(json.dumps(
result["dashboard"],
ensure_ascii=False,
indent=2,
))
else:
raise SystemExit(
"Uso: deploy_shopfloor_context_health_dashboard_v036.sh "
"{deploy|validate|export}"
)
PY

View File

@ -1,335 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${SHOPFLOOR_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${SHOPFLOOR_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${SHOPFLOOR_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
FOLDER_TITLE="${SHOPFLOOR_DASHBOARD_FOLDER_TITLE:-MESAVAULT — UCEPSA}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver la URL de Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
Cárguelo sin mostrarlo:
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$FOLDER_TITLE" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
(
action,
dashboard_file,
folder_uid,
folder_title,
dashboard_uid,
datasource_uid,
) = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None, allow_404=False):
url = base_url + path
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
url,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
if allow_404 and exc.code == 404:
return None
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
folder = request(
"GET",
f"/api/folders/{urllib.parse.quote(folder_uid)}",
allow_404=True,
)
if action == "deploy" and folder is None:
request(
"POST",
"/api/folders",
{
"uid": folder_uid,
"title": folder_title,
},
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT Shop Floor Context Health "
"presentation fix v0.3.6.2"
),
},
)
print(json.dumps(
{
"status": "deployed",
"grafana_url": base_url,
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid)
),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get("datasource", {}).get("uid")
})
errors = []
if dashboard.get("uid") != dashboard_uid:
errors.append("UID inesperado")
if result.get("meta", {}).get("folderUid") != folder_uid:
errors.append("Carpeta inesperada")
if len(dashboard.get("panels", [])) != 10:
errors.append("El dashboard no tiene 10 paneles")
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
health_sql = (
panels.get(1, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
sync_sql = (
panels.get(2, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
machine_sql = (
panels.get(7, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
incident_sql = (
panels.get(8, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if "CASE overall_health_status" not in health_sql:
errors.append(
"Salud general no usa código numérico"
)
if "CASE sync_health_status" not in sync_sql:
errors.append(
"Sincronizador no usa código numérico"
)
for forbidden in ('AS "Sync"', 'AS "Acción"'):
if forbidden in machine_sql:
errors.append(
f"Tabla de máquinas conserva columna {forbidden}"
)
if 'AS "Situación"' not in incident_sql:
errors.append(
"Incidencias no usa situación operativa"
)
output = {
"status": "ok" if not errors else "error",
"grafana_url": base_url,
"dashboard_uid": dashboard.get("uid"),
"title": dashboard.get("title"),
"folder_uid": result.get("meta", {}).get(
"folderUid"
),
"panel_count": len(
dashboard.get("panels", [])
),
"datasource_uids": datasource_uids,
"version": dashboard.get("version"),
"url": result.get("meta", {}).get("url"),
"presentation_checks": {
"numeric_health_stat": (
"CASE overall_health_status"
in health_sql
),
"numeric_sync_stat": (
"CASE sync_health_status"
in sync_sql
),
"compact_machine_table": (
'AS "Sync"' not in machine_sql
and 'AS "Acción"' not in machine_sql
),
"friendly_incident_text": (
'AS "Situación"' in incident_sql
),
},
"errors": errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
elif action == "export":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid)
),
)
print(json.dumps(
result["dashboard"],
ensure_ascii=False,
indent=2,
))
else:
raise SystemExit(
"Uso: "
"deploy_shopfloor_context_health_dashboard_v0362.sh "
"{deploy|validate|export}"
)
PY

View File

@ -1,240 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${SHOPFLOOR_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${SHOPFLOOR_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${SHOPFLOOR_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
(
action,
dashboard_file,
folder_uid,
dashboard_uid,
datasource_uid,
) = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None):
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
base_url + path,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
return (
json.loads(raw.decode("utf-8"))
if raw
else None
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT Shop Floor incident "
"history v0.3.7"
),
},
)
print(json.dumps(
{
"status": "deployed",
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid)
),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get("datasource", {}).get("uid")
})
errors = []
if len(dashboard.get("panels", [])) != 13:
errors.append("El dashboard no tiene 13 paneles")
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
expected = {
11: "li_shopfloor_context_incident_backlog_v1",
12: "li_shopfloor_context_incident_history_v1",
13: "li_shopfloor_context_incident_daily_summary_v1",
}
for panel_id, fragment in expected.items():
sql = (
panels.get(panel_id, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if fragment not in sql:
errors.append(
f"Panel {panel_id} no consulta {fragment}"
)
output = {
"status": "ok" if not errors else "error",
"dashboard_uid": dashboard.get("uid"),
"folder_uid": result.get("meta", {}).get(
"folderUid"
),
"panel_count": len(
dashboard.get("panels", [])
),
"datasource_uids": datasource_uids,
"version": dashboard.get("version"),
"url": result.get("meta", {}).get("url"),
"history_panels": {
"backlog": 11 in panels,
"history_24h": 12 in panels,
"daily_summary": 13 in panels,
},
"errors": errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: "
"deploy_shopfloor_context_incident_history_dashboard_v037.sh "
"{deploy|validate}"
)
PY

View File

@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_ROOT="${GOVERNANCE_UI_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
SOURCE="$TARGET_ROOT/governance-ui"
DEST="$RUNTIME_ROOT/governance-ui"
TMP_ENV=""
if [[ ! -f "$SOURCE/Dockerfile" ]]; then
echo "ERROR: no existe $SOURCE/Dockerfile" >&2
exit 1
fi
if [[ -f "$DEST/.env" ]]; then
TMP_ENV="$(mktemp)"
cp "$DEST/.env" "$TMP_ENV"
chmod 600 "$TMP_ENV"
fi
rm -rf "$DEST"
mkdir -p "$DEST"
cp -a "$SOURCE/." "$DEST/"
if [[ -n "$TMP_ENV" ]]; then
cp "$TMP_ENV" "$DEST/.env"
chmod 600 "$DEST/.env"
rm -f "$TMP_ENV"
echo "Configuración .env preservada."
else
echo "AVISO: no existía .env previo; configure $DEST/.env antes de arrancar." >&2
fi
find "$DEST" -type d -name '__pycache__' -prune -exec rm -rf {} +
echo "Governance Review UI v0.3.15 instalada en $DEST"

View File

@ -1,47 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-agenda}"
ROOT="${DAILY_GOVERNANCE_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
NETWORK="${DAILY_GOVERNANCE_NETWORK:-mv_ucepsa_net}"
POSTGRES_CONTAINER="${DAILY_GOVERNANCE_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
TENANT_OVERRIDE="${DAILY_GOVERNANCE_TENANT_OVERRIDE:-ucepsa}"
SITE_OVERRIDE="${DAILY_GOVERNANCE_SITE_OVERRIDE:-ucepsa_onpremise}"
PGHOST_OVERRIDE="${DAILY_GOVERNANCE_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
cd "$ROOT"
IMAGE="$(
docker inspect \
mv_ucepsa_odoo_order_state_pg_sink \
--format '{{.Config.Image}}'
)"
PGDATABASE_OVERRIDE="${DAILY_GOVERNANCE_PGDATABASE_OVERRIDE:-$(
docker inspect "$POSTGRES_CONTAINER" \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^POSTGRES_DB=//p' \
| head -n 1
)}"
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
exit 1
fi
shift || true
docker run --rm -i \
--network "$NETWORK" \
--env-file .env \
-e "TENANT_OVERRIDE=$TENANT_OVERRIDE" \
-e "SITE_OVERRIDE=$SITE_OVERRIDE" \
-e "PGHOST=$PGHOST_OVERRIDE" \
-e "PGDATABASE=$PGDATABASE_OVERRIDE" \
-v "$ROOT:/app:ro" \
-w /app \
--entrypoint python \
"$IMAGE" \
tools/context_daily_governance.py \
"$ACTION" \
"$@"

View File

@ -1,47 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-list}"
ROOT="${CONTEXT_DECISION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
NETWORK="${CONTEXT_DECISION_NETWORK:-mv_ucepsa_net}"
POSTGRES_CONTAINER="${CONTEXT_DECISION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
TENANT_OVERRIDE="${CONTEXT_DECISION_TENANT_OVERRIDE:-ucepsa}"
SITE_OVERRIDE="${CONTEXT_DECISION_SITE_OVERRIDE:-ucepsa_onpremise}"
PGHOST_OVERRIDE="${CONTEXT_DECISION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
cd "$ROOT"
IMAGE="$(
docker inspect \
mv_ucepsa_odoo_order_state_pg_sink \
--format '{{.Config.Image}}'
)"
PGDATABASE_OVERRIDE="${CONTEXT_DECISION_PGDATABASE_OVERRIDE:-$(
docker inspect "$POSTGRES_CONTAINER" \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^POSTGRES_DB=//p' \
| head -n 1
)}"
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
exit 1
fi
shift || true
docker run --rm -i \
--network "$NETWORK" \
--env-file .env \
-e "TENANT_OVERRIDE=$TENANT_OVERRIDE" \
-e "SITE_OVERRIDE=$SITE_OVERRIDE" \
-e "PGHOST=$PGHOST_OVERRIDE" \
-e "PGDATABASE=$PGDATABASE_OVERRIDE" \
-v "$ROOT:/app:ro" \
-w /app \
--entrypoint python \
"$IMAGE" \
tools/context_decision_group_admin.py \
"$ACTION" \
"$@"

View File

@ -1,50 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-show}"
ROOT="${CONTEXT_REGRESSION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
REPO="${CONTEXT_REGRESSION_REPO_ROOT:-/srv/mesavault/40-clients}"
NETWORK="${CONTEXT_REGRESSION_NETWORK:-mv_ucepsa_net}"
POSTGRES_CONTAINER="${CONTEXT_REGRESSION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
PGHOST_OVERRIDE="${CONTEXT_REGRESSION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
cd "$ROOT"
IMAGE="$(
docker inspect \
mv_ucepsa_odoo_order_state_pg_sink \
--format '{{.Config.Image}}'
)"
PGDATABASE_OVERRIDE="${CONTEXT_REGRESSION_PGDATABASE_OVERRIDE:-$(
docker inspect "$POSTGRES_CONTAINER" \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^POSTGRES_DB=//p' \
| head -n 1
)}"
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
exit 1
fi
SOURCE_GIT_COMMIT="$(
git -C "$REPO" rev-parse HEAD 2>/dev/null \
|| printf 'unknown'
)"
shift || true
docker run --rm -i \
--network "$NETWORK" \
--env-file .env \
-e "PGHOST=$PGHOST_OVERRIDE" \
-e "PGDATABASE=$PGDATABASE_OVERRIDE" \
-e "SOURCE_GIT_COMMIT=$SOURCE_GIT_COMMIT" \
-v "$ROOT:/app:ro" \
-w /app \
--entrypoint python \
"$IMAGE" \
tools/context_regression_suite.py \
"$ACTION" \
"$@"

View File

@ -1,125 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-status}"
ROOT="${CONTEXT_CASE_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
CONTAINER="${CONTEXT_CASE_CONTAINER:-mv_ucepsa_context_review_case_builder}"
NETWORK="${CONTEXT_CASE_NETWORK:-mv_ucepsa_net}"
INTERVAL="${CONTEXT_CASE_INTERVAL_S:-15}"
POSTGRES_CONTAINER="${CONTEXT_CASE_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
TENANT_OVERRIDE="${CONTEXT_CASE_TENANT_OVERRIDE:-ucepsa}"
SITE_OVERRIDE="${CONTEXT_CASE_SITE_OVERRIDE:-ucepsa_onpremise}"
PGHOST_OVERRIDE="${CONTEXT_CASE_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
cd "$ROOT"
IMAGE="$(
docker inspect \
mv_ucepsa_odoo_order_state_pg_sink \
--format '{{.Config.Image}}'
)"
PGDATABASE_OVERRIDE="${CONTEXT_CASE_PGDATABASE_OVERRIDE:-$(
docker inspect "$POSTGRES_CONTAINER" \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^POSTGRES_DB=//p' \
| head -n 1
)}"
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
exit 1
fi
common_args=(
--network "$NETWORK"
--env-file .env
-e "TENANT_OVERRIDE=$TENANT_OVERRIDE"
-e "SITE_OVERRIDE=$SITE_OVERRIDE"
-e "PGHOST=$PGHOST_OVERRIDE"
-e "PGDATABASE=$PGDATABASE_OVERRIDE"
-v "$ROOT:/app:ro"
-w /app
--entrypoint python
)
start_builder() {
docker rm -f "$CONTAINER" \
>/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
"${common_args[@]}" \
"$IMAGE" \
tools/context_review_case_builder.py \
--watch \
--interval "$INTERVAL"
docker ps \
--format 'table {{.Names}}\t{{.Status}}' \
| grep -E "$CONTAINER|NAMES"
}
run_builder_once() {
docker run --rm -i \
"${common_args[@]}" \
"$IMAGE" \
tools/context_review_case_builder.py \
--once
}
run_builder_dry() {
docker run --rm -i \
"${common_args[@]}" \
"$IMAGE" \
tools/context_review_case_builder.py \
--once \
--dry-run
}
run_admin() {
shift
docker run --rm -i \
"${common_args[@]}" \
"$IMAGE" \
tools/context_review_case_admin.py \
"$@"
}
case "$ACTION" in
start)
start_builder
;;
stop)
docker stop "$CONTAINER"
;;
restart)
docker rm -f "$CONTAINER" \
>/dev/null 2>&1 || true
start_builder
;;
status)
docker ps -a \
--format 'table {{.Names}}\t{{.Status}}' \
| grep -E "$CONTAINER|NAMES"
;;
logs)
docker logs --tail 200 -f "$CONTAINER"
;;
once)
run_builder_once
;;
dry-run)
run_builder_dry
;;
admin)
run_admin "$@"
;;
*)
echo \
"Uso: $0 {start|stop|restart|status|logs|once|dry-run|admin ...}" \
>&2
exit 2
;;
esac

View File

@ -1,93 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-status}"
RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
APP_ROOT="${GOVERNANCE_UI_APP_ROOT:-$RUNTIME_ROOT/governance-ui}"
CONTAINER="${GOVERNANCE_UI_CONTAINER:-mv_ucepsa_governance_ui}"
IMAGE="${GOVERNANCE_UI_IMAGE:-mesavault/ucepsa-governance-ui:0.3.14}"
NETWORK="${GOVERNANCE_UI_NETWORK:-mv_ucepsa_net}"
HOST_IP="${GOVERNANCE_UI_HOST_IP:-0.0.0.0}"
HOST_PORT="${GOVERNANCE_UI_HOST_PORT:-8091}"
BASE_ENV="${GOVERNANCE_UI_BASE_ENV:-$RUNTIME_ROOT/.env}"
UI_ENV="${GOVERNANCE_UI_ENV:-$APP_ROOT/.env}"
require_build_files() {
test -f "$APP_ROOT/Dockerfile"
test -f "$APP_ROOT/requirements.txt"
}
require_runtime_files() {
require_build_files
test -f "$UI_ENV"
test -f "$BASE_ENV"
}
build_image() {
require_build_files
docker build \
-t "$IMAGE" \
"$APP_ROOT"
}
start_container() {
require_runtime_files
docker rm -f "$CONTAINER" \
>/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
--network "$NETWORK" \
--env-file "$BASE_ENV" \
--env-file "$UI_ENV" \
-e "PGHOST=mv_ucepsa_postgres_hot" \
-e "TENANT_OVERRIDE=ucepsa" \
-e "SITE_OVERRIDE=ucepsa_onpremise" \
-p "${HOST_IP}:${HOST_PORT}:8080" \
"$IMAGE"
docker ps \
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \
| grep -E "$CONTAINER|NAMES"
}
case "$ACTION" in
build)
build_image
;;
start)
start_container
;;
deploy)
build_image
start_container
;;
restart)
docker rm -f "$CONTAINER" \
>/dev/null 2>&1 || true
start_container
;;
stop)
docker stop "$CONTAINER"
;;
status)
docker ps -a \
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \
| grep -E "$CONTAINER|NAMES"
;;
logs)
docker logs --tail 200 -f "$CONTAINER"
;;
health)
curl -fsS \
"http://127.0.0.1:${HOST_PORT}/health"
echo
;;
*)
echo \
"Uso: $0 {build|start|deploy|restart|stop|status|logs|health}" \
>&2
exit 2
;;
esac

Some files were not shown because too many files have changed in this diff Show More