Compare commits
No commits in common. "feat/ucepsa-lossintelligence-context-stops-v0.3.1" and "main" have entirely different histories.
feat/uceps
...
main
48
.gitignore
vendored
48
.gitignore
vendored
@ -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
|
||||
@ -1 +0,0 @@
|
||||
UCEPSA
|
||||
67
ucepsa/edge-oee-demo/.gitignore
vendored
67
ucepsa/edge-oee-demo/.gitignore
vendored
@ -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
|
||||
@ -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
@ -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}
|
||||
@ -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__
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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 s–5 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 15–30 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 093–096.
|
||||
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.
|
||||
@ -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.
|
||||
@ -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 20–30 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 T0–T6;
|
||||
- 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.
|
||||
@ -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.
|
||||
@ -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 2–5 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.
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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}"))
|
||||
@ -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}"))
|
||||
@ -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}"))
|
||||
@ -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}"))
|
||||
@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /srv/verticals/edge-oee-ucepsa-demo
|
||||
|
||||
echo "--- Regenerando datos Edge-OEE demo ---"
|
||||
./ops/run_oee_demo_generator.sh
|
||||
|
||||
echo "--- Validando datos Edge-OEE demo ---"
|
||||
./ops/validate_edge_oee_demo.sh
|
||||
|
||||
echo "--- Demo preparada ---"
|
||||
echo "Abrir Grafana y usar rango:"
|
||||
echo "From: now/d+7h"
|
||||
echo "To: now/d+14h"
|
||||
@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /srv/verticals/edge-oee-ucepsa-demo
|
||||
docker-compose run --rm oee-demo-generator
|
||||
@ -1,193 +0,0 @@
|
||||
\pset pager off
|
||||
|
||||
\echo '================================================'
|
||||
\echo '1. VISTAS CREADAS'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_stop_context_slices_v1'
|
||||
) AS slices_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_stop_candidates_context_v1'
|
||||
) AS candidates_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_stop_groups_context_v1'
|
||||
) AS groups_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1'
|
||||
) AS shadow_queue_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1'
|
||||
) AS diagnostics_view;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '2. INVARIANTES: TODAS DEBEN DEVOLVER 0'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT COUNT(*) AS invalid_slice_intervals
|
||||
FROM mv_reports_ucepsa_prod.li_stop_context_slices_v1
|
||||
WHERE segment_to <= segment_from
|
||||
OR segment_duration_s <= 0;
|
||||
|
||||
SELECT COUNT(*) AS candidate_duration_overflow
|
||||
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
|
||||
WHERE context_segment_duration_s > raw_duration_s + 0.01
|
||||
OR eligible_duration_s > context_segment_duration_s + 0.01
|
||||
OR eligible_duration_s < 0;
|
||||
|
||||
SELECT COUNT(*) AS open_stops_marked_eligible
|
||||
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
|
||||
WHERE source_status = 'OPEN'
|
||||
AND technically_eligible = true;
|
||||
|
||||
SELECT COUNT(*) AS official_rows_in_parallel_v1
|
||||
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
|
||||
WHERE official_ledger_eligible = true;
|
||||
|
||||
SELECT COUNT(*) AS official_group_rows_in_parallel_v1
|
||||
FROM mv_reports_ucepsa_prod.li_stop_groups_context_v1
|
||||
WHERE official_ledger_eligible = true;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '3. SEGURIDAD SHADOW Y COLA OFICIAL ANTERIOR'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
mode,
|
||||
actual_go_live_at
|
||||
FROM mv_loss_intelligence.deployment_config
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
AND scope_code = 'CUTTERS'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
|
||||
SELECT COUNT(*) AS existing_official_operator_queue_rows
|
||||
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '4. POLÍTICA DE PAROS'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
policy_code,
|
||||
noise_below_s,
|
||||
aggregate_below_s,
|
||||
operator_individual_from_s,
|
||||
aggregation_window_min,
|
||||
require_valid_order,
|
||||
require_closed_event,
|
||||
require_telemetry_coverage,
|
||||
telemetry_gap_s,
|
||||
telemetry_tail_s
|
||||
FROM mv_loss_intelligence.loss_policies
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
AND policy_code = 'CUTTER_STOPS_V1'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '5. DIAGNÓSTICO ÚLTIMAS 24 HORAS'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '6. CORT-00 — SESIÓN LEGACY 1'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
source_stop_id,
|
||||
source_status,
|
||||
started_at,
|
||||
ended_at,
|
||||
ROUND(eligible_duration_s / 60.0, 2)
|
||||
AS eligible_duration_min,
|
||||
context_status,
|
||||
primary_context_source,
|
||||
primary_context_session_id,
|
||||
eligibility_status,
|
||||
technically_eligible,
|
||||
shadow_capacity_loss_eur,
|
||||
official_ledger_eligible
|
||||
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
|
||||
WHERE machine_id = 'CORT-00'
|
||||
AND primary_context_session_id = 1
|
||||
ORDER BY source_stop_id, started_at;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '7. CORT-02 — ORDEN WH/MO/00081'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
source_stop_id,
|
||||
source_status,
|
||||
started_at,
|
||||
ended_at,
|
||||
ROUND(eligible_duration_s, 2) AS eligible_duration_s,
|
||||
context_status,
|
||||
primary_context_source,
|
||||
order_ref,
|
||||
eligibility_status,
|
||||
technically_eligible,
|
||||
shadow_capacity_loss_eur,
|
||||
official_ledger_eligible
|
||||
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
|
||||
WHERE machine_id = 'CORT-02'
|
||||
AND order_ref = 'WH/MO/00081'
|
||||
ORDER BY source_stop_id, started_at;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '8. COLA SHADOW CONTEXT-AWARE ÚLTIMAS 24 HORAS'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
shadow_rank,
|
||||
source_key,
|
||||
machine_id,
|
||||
context_status,
|
||||
primary_context_source,
|
||||
primary_context_session_id,
|
||||
order_ref,
|
||||
event_count,
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_min,
|
||||
capacity_loss_eur,
|
||||
preliminary_priority,
|
||||
queue_status,
|
||||
official_ledger_eligible,
|
||||
source_stop_ids
|
||||
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1
|
||||
ORDER BY shadow_rank
|
||||
LIMIT 30;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '9. CONFLICTOS RECIENTES'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
source_stop_id,
|
||||
machine_id,
|
||||
started_at,
|
||||
ended_at,
|
||||
context_count,
|
||||
context_sources,
|
||||
eligibility_status
|
||||
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
|
||||
WHERE raw_started_at >= now() - interval '24 hours'
|
||||
AND context_count > 1
|
||||
ORDER BY machine_id, started_at;
|
||||
|
||||
\echo '================================================'
|
||||
\echo '10. CONFIRMACIÓN DE NO MODIFICACIÓN'
|
||||
\echo '================================================'
|
||||
|
||||
SELECT
|
||||
'INFO' AS status,
|
||||
'102 crea vistas paralelas; no reemplaza v2, no escribe en loss_ledger y no modifica Odoo, WISE, RevPi ni balizas.'
|
||||
AS description;
|
||||
@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
docker exec -i mv_postgres_hot sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
< /srv/verticals/edge-oee-ucepsa-demo/ops/validate_edge_oee_demo.sql
|
||||
@ -1,42 +0,0 @@
|
||||
\echo '--- KPI CARDS ---'
|
||||
SELECT *
|
||||
FROM mv_hot.edge_oee_kpi_cards_demo
|
||||
ORDER BY sort_order;
|
||||
|
||||
\echo '--- PARETO ---'
|
||||
SELECT
|
||||
ranking,
|
||||
cause,
|
||||
hours_month,
|
||||
monthly_cost_eur,
|
||||
recommended_action
|
||||
FROM mv_hot.edge_oee_pareto_demo
|
||||
ORDER BY ranking;
|
||||
|
||||
\echo '--- TIMELINE OF00481 ---'
|
||||
SELECT
|
||||
time,
|
||||
time_end,
|
||||
order_id,
|
||||
machine_name,
|
||||
source,
|
||||
state,
|
||||
detail,
|
||||
duration_min,
|
||||
cost_eur
|
||||
FROM mv_hot.edge_oee_order_timeline_demo
|
||||
WHERE order_id = 'OF00481'
|
||||
ORDER BY time, sort_order;
|
||||
|
||||
\echo '--- DATA QUALITY ---'
|
||||
SELECT *
|
||||
FROM mv_hot.edge_oee_data_quality_dashboard_demo
|
||||
ORDER BY sort_order;
|
||||
|
||||
\echo '--- ACTIONS ---'
|
||||
SELECT *
|
||||
FROM mv_hot.edge_oee_actions_demo;
|
||||
|
||||
\echo '--- QUALITY CASE ---'
|
||||
SELECT *
|
||||
FROM mv_hot.edge_oee_quality_case_demo;
|
||||
@ -1,53 +0,0 @@
|
||||
\pset pager off
|
||||
\echo '=== 1. Configuración de despliegue ==='
|
||||
SELECT tenant, site, scope_code, mode, planned_go_live_date, actual_go_live_at,
|
||||
review_owner_primary, review_owner_secondary, daily_review_limit
|
||||
FROM mv_loss_intelligence.deployment_config
|
||||
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise';
|
||||
|
||||
\echo '=== 2. Parámetros económicos ==='
|
||||
SELECT parameter_code, parameter_value, unit, confidence, includes_energy,
|
||||
valid_from, source_ref, approved_by
|
||||
FROM mv_loss_intelligence.economic_parameters
|
||||
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND is_active = true
|
||||
ORDER BY parameter_code, valid_from DESC;
|
||||
|
||||
\echo '=== 3. Calidad de dato ==='
|
||||
SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v1 ORDER BY check_code;
|
||||
|
||||
\echo '=== 4. Intervalos Odoo más recientes ==='
|
||||
SELECT machine_id, effective_from, effective_to, odoo_state,
|
||||
order_present, order_coherent, production_order, product_id, product,
|
||||
qty_production
|
||||
FROM mv_reports_ucepsa_prod.li_order_state_intervals_v1
|
||||
ORDER BY effective_from DESC
|
||||
LIMIT 20;
|
||||
|
||||
\echo '=== 5. Resumen de elegibilidad en sombra ==='
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_shadow_summary_v1
|
||||
ORDER BY machine_id, eligibility_status;
|
||||
|
||||
\echo '=== 6. Primeros candidatos de sombra ==='
|
||||
SELECT shadow_rank, machine_id, order_ref, product_name, cause_name,
|
||||
event_count, started_at, duration_min, capacity_loss_eur,
|
||||
queue_status
|
||||
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v1
|
||||
ORDER BY shadow_rank
|
||||
LIMIT 20;
|
||||
|
||||
\echo '=== 7. Cola oficial: debe estar vacía en SHADOW ==='
|
||||
SELECT COUNT(*) AS official_operator_queue_rows
|
||||
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v1;
|
||||
|
||||
\echo '=== 8. Cobertura bolsas/golpe y cadencia ==='
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_product_cycle_coverage_v1
|
||||
ORDER BY latest_stop_started_at DESC NULLS LAST;
|
||||
|
||||
\echo '=== 9. Catálogo de acciones ==='
|
||||
SELECT action_id, source_cause_code, action_type, action_candidate,
|
||||
suggested_owner, test_window, success_criteria
|
||||
FROM mv_loss_intelligence.action_catalog
|
||||
WHERE catalog_code = 'LI-CUTTER-STOPS' AND is_active = true
|
||||
ORDER BY source_cause_code, action_id;
|
||||
@ -1,73 +0,0 @@
|
||||
\pset pager off
|
||||
\echo '=== 1. Política telemetry-aware ==='
|
||||
SELECT policy_code, require_telemetry_coverage, telemetry_gap_s, telemetry_tail_s,
|
||||
noise_below_s, operator_individual_from_s
|
||||
FROM mv_loss_intelligence.loss_policies
|
||||
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND policy_code = 'CUTTER_STOPS_V1';
|
||||
|
||||
\echo '=== 2. Estado operativo power-aware ==='
|
||||
SELECT machine_id, operational_state, machine_state_label, telemetry_fresh,
|
||||
round(age_s::numeric, 1) AS age_s, machine_running,
|
||||
order_present, order_coherent, production_order
|
||||
FROM mv_reports_ucepsa_prod.machine_operational_state_v1
|
||||
ORDER BY machine_id;
|
||||
|
||||
\echo '=== 3. Islas de telemetría más recientes ==='
|
||||
SELECT machine_id, coverage_from, coverage_to, sample_count, sampled_span_s
|
||||
FROM mv_reports_ucepsa_prod.li_telemetry_coverage_v1
|
||||
ORDER BY coverage_to DESC
|
||||
LIMIT 15;
|
||||
|
||||
\echo '=== 4. Invariantes temporales: debe devolver 0 ==='
|
||||
SELECT COUNT(*) AS invalid_slice_rows
|
||||
FROM mv_reports_ucepsa_prod.li_stop_eligible_slices_v2
|
||||
WHERE eligible_from < order_overlap_from
|
||||
OR eligible_to > order_overlap_to
|
||||
OR eligible_from < telemetry_coverage_from
|
||||
OR eligible_to > telemetry_coverage_to
|
||||
OR eligible_to <= eligible_from
|
||||
OR eligible_duration_s <= 0;
|
||||
|
||||
\echo '=== 5. Duración elegible nunca supera la raw: debe devolver 0 ==='
|
||||
SELECT COUNT(*) AS duration_overflow_rows
|
||||
FROM mv_reports_ucepsa_prod.li_stop_candidates_v2
|
||||
WHERE eligible_duration_s > raw_duration_s + 0.01
|
||||
OR eligible_duration_s > order_overlap_duration_s + 0.01;
|
||||
|
||||
\echo '=== 6. Comparación v0.1 frente a v0.2 ==='
|
||||
SELECT * FROM mv_reports_ucepsa_prod.li_eligibility_comparison_v1;
|
||||
|
||||
\echo '=== 7. Resumen v0.2 ==='
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_shadow_summary_v2
|
||||
ORDER BY machine_id, eligibility_status;
|
||||
|
||||
\echo '=== 8. Primeros candidatos power-aware ==='
|
||||
SELECT shadow_rank, machine_id, order_ref, product_name, event_count,
|
||||
started_at, ended_at, duration_min, capacity_loss_eur,
|
||||
order_clipped, telemetry_clipped, crosses_order_boundary, queue_status
|
||||
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v2
|
||||
ORDER BY shadow_rank
|
||||
LIMIT 25;
|
||||
|
||||
\echo '=== 9. Diagnóstico de exclusiones ==='
|
||||
SELECT diagnostic_status, machine_id, COUNT(*) AS stop_count,
|
||||
ROUND(SUM(raw_duration_min)::numeric, 2) AS raw_minutes
|
||||
FROM mv_reports_ucepsa_prod.li_stop_exclusion_diagnostics_v2
|
||||
GROUP BY diagnostic_status, machine_id
|
||||
ORDER BY machine_id, diagnostic_status;
|
||||
|
||||
\echo '=== 10. Cola oficial v2: debe estar vacía en SHADOW ==='
|
||||
SELECT COUNT(*) AS official_operator_queue_v2_rows
|
||||
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2;
|
||||
|
||||
\echo '=== 11. Calidad de dato v2 ==='
|
||||
SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v2
|
||||
ORDER BY check_code;
|
||||
|
||||
\echo '=== 12. Candidatos v2 de más de 12 h para revisión manual ==='
|
||||
SELECT shadow_rank, machine_id, order_ref, started_at, ended_at,
|
||||
duration_min, telemetry_clipped, order_clipped
|
||||
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v2
|
||||
WHERE duration_min >= 720
|
||||
ORDER BY duration_min DESC;
|
||||
@ -1,128 +0,0 @@
|
||||
\pset pager off
|
||||
|
||||
\echo '=== 1. Política de contexto ==='
|
||||
SELECT
|
||||
policy_code,
|
||||
activity_gap_s,
|
||||
activity_tail_s,
|
||||
stale_open_context_s,
|
||||
allow_legacy_context,
|
||||
require_legacy_authorization,
|
||||
allow_inferred_for_official
|
||||
FROM mv_loss_intelligence.production_context_policies
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
ORDER BY id DESC;
|
||||
|
||||
\echo '=== 2. Seguridad SHADOW ==='
|
||||
SELECT
|
||||
mode,
|
||||
actual_go_live_at
|
||||
FROM mv_loss_intelligence.deployment_config
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
AND scope_code = 'CUTTERS';
|
||||
|
||||
SELECT count(*) AS official_context_sessions_in_shadow
|
||||
FROM mv_loss_intelligence.production_context_sessions s
|
||||
JOIN mv_loss_intelligence.deployment_config d
|
||||
ON d.tenant = s.tenant
|
||||
AND d.site = s.site
|
||||
AND d.scope_code = 'CUTTERS'
|
||||
WHERE d.mode = 'SHADOW'
|
||||
AND s.official_eligible = true;
|
||||
|
||||
\echo '=== 3. Estado actual de migración ==='
|
||||
SELECT
|
||||
machine_id,
|
||||
operational_state,
|
||||
migration_context_state,
|
||||
migration_context_label,
|
||||
telemetry_fresh,
|
||||
machine_running,
|
||||
order_present,
|
||||
order_coherent,
|
||||
production_order,
|
||||
explicit_context_source,
|
||||
explicit_order_ref,
|
||||
explicit_context_started_at
|
||||
FROM mv_reports_ucepsa_prod.machine_migration_context_state_v1
|
||||
ORDER BY machine_id;
|
||||
|
||||
\echo '=== 4. Contextos explícitos ==='
|
||||
SELECT
|
||||
context_session_id,
|
||||
machine_id,
|
||||
source_type,
|
||||
source_ref,
|
||||
external_order_ref,
|
||||
product_name,
|
||||
operator_employee_id,
|
||||
operator_name,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
authorization_status,
|
||||
confidence,
|
||||
official_eligible,
|
||||
authorized_by,
|
||||
closed_by
|
||||
FROM mv_loss_intelligence.production_context_sessions
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
ORDER BY context_session_id DESC
|
||||
LIMIT 30;
|
||||
|
||||
\echo '=== 5. Invariantes de actividad: deben devolver 0 ==='
|
||||
SELECT count(*) AS invalid_activity_rows
|
||||
FROM mv_reports_ucepsa_prod.li_machine_running_activity_v1
|
||||
WHERE activity_to <= activity_from
|
||||
OR activity_duration_s <= 0;
|
||||
|
||||
SELECT count(*) AS invalid_context_segment_rows
|
||||
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
WHERE segment_to <= segment_from
|
||||
OR segment_duration_s <= 0;
|
||||
|
||||
\echo '=== 6. Los segmentos no superan la actividad: debe devolver 0 ==='
|
||||
WITH totals AS (
|
||||
SELECT
|
||||
machine_id,
|
||||
activity_island_id,
|
||||
max(activity_duration_s) AS activity_duration_s,
|
||||
sum(segment_duration_s) AS segmented_duration_s
|
||||
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
GROUP BY machine_id, activity_island_id
|
||||
)
|
||||
SELECT count(*) AS segment_overflow_rows
|
||||
FROM totals
|
||||
WHERE segmented_duration_s > activity_duration_s + 0.01;
|
||||
|
||||
\echo '=== 7. Cobertura de contexto últimos 30 días ==='
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_production_context_coverage_v1
|
||||
ORDER BY machine_id, hours DESC;
|
||||
|
||||
\echo '=== 8. Cola de revisión de contexto ==='
|
||||
SELECT
|
||||
review_rank,
|
||||
context_review_key,
|
||||
machine_id,
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_min,
|
||||
context_status,
|
||||
allowed_decisions
|
||||
FROM mv_reports_ucepsa_prod.li_context_review_queue_v1
|
||||
ORDER BY review_rank
|
||||
LIMIT 30;
|
||||
|
||||
\echo '=== 9. Calidad de dato v3 ==='
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_data_quality_v3
|
||||
ORDER BY check_code;
|
||||
|
||||
\echo '=== 10. Las balizas no forman parte de esta migración ==='
|
||||
SELECT
|
||||
'INFO' AS status,
|
||||
'099/100 no modifican beacon_controller, order_state publisher ni lógica DO0/DO1.' AS description;
|
||||
@ -1,93 +0,0 @@
|
||||
--- /mnt/data/ucepsa_repo/40-clients/ucepsa/edge-oee-demo/tools/order_state_pg_sink.py 2026-07-16 09:11:42.000000000 +0000
|
||||
+++ /mnt/data/ucepsa_lossintelligence_patch/tools/order_state_pg_sink.py 2026-07-16 10:10:45.826794132 +0000
|
||||
@@ -67,8 +67,15 @@
|
||||
"odoo_state": payload.get("odoo_state"),
|
||||
"reason": payload.get("reason"),
|
||||
"workorder_id": payload.get("workorder_id"),
|
||||
+ "production_id": payload.get("production_id"),
|
||||
"production_order": payload.get("production_order"),
|
||||
+ "product_id": payload.get("product_id"),
|
||||
"product": payload.get("product"),
|
||||
+ "qty_production": payload.get("qty_production"),
|
||||
+ "date_start": payload.get("date_start"),
|
||||
+ "date_finished": payload.get("date_finished"),
|
||||
+ "workorder_name": payload.get("workorder_name"),
|
||||
+ "workorder_state": payload.get("workorder_state"),
|
||||
"waiting_count": diagnostics.get("waiting_count"),
|
||||
"ready_count": diagnostics.get("ready_count"),
|
||||
"progress_count": diagnostics.get("progress_count"),
|
||||
@@ -133,8 +140,15 @@
|
||||
"odoo_state": payload.get("odoo_state"),
|
||||
"reason": payload.get("reason"),
|
||||
"workorder_id": str(payload.get("workorder_id")) if payload.get("workorder_id") is not None else None,
|
||||
+ "workorder_name": payload.get("workorder_name"),
|
||||
+ "workorder_state": payload.get("workorder_state"),
|
||||
+ "production_id": to_int(payload.get("production_id")),
|
||||
"production_order": payload.get("production_order"),
|
||||
+ "product_id": to_int(payload.get("product_id")),
|
||||
"product": payload.get("product"),
|
||||
+ "qty_production": payload.get("qty_production"),
|
||||
+ "date_start": parse_ts(payload.get("date_start")),
|
||||
+ "date_finished": parse_ts(payload.get("date_finished")),
|
||||
"waiting_count": to_int(diagnostics.get("waiting_count")),
|
||||
"ready_count": to_int(diagnostics.get("ready_count")),
|
||||
"progress_count": to_int(diagnostics.get("progress_count")),
|
||||
@@ -149,7 +163,9 @@
|
||||
tenant, site, machine_id, topic, mqtt_ts, received_at,
|
||||
source, workcenter_id, workcenter_name, workcenter_display_name,
|
||||
order_present, order_coherent, odoo_state, reason,
|
||||
- workorder_id, production_order, product,
|
||||
+ workorder_id, workorder_name, workorder_state,
|
||||
+ production_id, production_order, product_id, product,
|
||||
+ qty_production, date_start, date_finished,
|
||||
waiting_count, ready_count, progress_count,
|
||||
payload_hash, payload_json
|
||||
)
|
||||
@@ -157,7 +173,9 @@
|
||||
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
|
||||
%(source)s, %(workcenter_id)s, %(workcenter_name)s, %(workcenter_display_name)s,
|
||||
%(order_present)s, %(order_coherent)s, %(odoo_state)s, %(reason)s,
|
||||
- %(workorder_id)s, %(production_order)s, %(product)s,
|
||||
+ %(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
|
||||
+ %(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
|
||||
+ %(qty_production)s, %(date_start)s, %(date_finished)s,
|
||||
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
|
||||
%(payload_hash)s, %(payload_json)s
|
||||
)
|
||||
@@ -175,8 +193,15 @@
|
||||
odoo_state = EXCLUDED.odoo_state,
|
||||
reason = EXCLUDED.reason,
|
||||
workorder_id = EXCLUDED.workorder_id,
|
||||
+ workorder_name = EXCLUDED.workorder_name,
|
||||
+ workorder_state = EXCLUDED.workorder_state,
|
||||
+ production_id = EXCLUDED.production_id,
|
||||
production_order = EXCLUDED.production_order,
|
||||
+ product_id = EXCLUDED.product_id,
|
||||
product = EXCLUDED.product,
|
||||
+ qty_production = EXCLUDED.qty_production,
|
||||
+ date_start = EXCLUDED.date_start,
|
||||
+ date_finished = EXCLUDED.date_finished,
|
||||
waiting_count = EXCLUDED.waiting_count,
|
||||
ready_count = EXCLUDED.ready_count,
|
||||
progress_count = EXCLUDED.progress_count,
|
||||
@@ -191,14 +216,18 @@
|
||||
INSERT INTO mv_hot.odoo_order_state_events (
|
||||
tenant, site, machine_id, topic, mqtt_ts, received_at,
|
||||
odoo_state, order_present, order_coherent,
|
||||
- production_order, product, reason,
|
||||
+ workorder_id, workorder_name, workorder_state,
|
||||
+ production_id, production_order, product_id, product,
|
||||
+ qty_production, date_start, date_finished, reason,
|
||||
waiting_count, ready_count, progress_count,
|
||||
payload_hash, payload_json
|
||||
)
|
||||
VALUES (
|
||||
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
|
||||
%(odoo_state)s, %(order_present)s, %(order_coherent)s,
|
||||
- %(production_order)s, %(product)s, %(reason)s,
|
||||
+ %(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
|
||||
+ %(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
|
||||
+ %(qty_production)s, %(date_start)s, %(date_finished)s, %(reason)s,
|
||||
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
|
||||
%(payload_hash)s, %(payload_json)s
|
||||
)
|
||||
@ -1,30 +0,0 @@
|
||||
MQTT_HOST=17.126.1.70
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER=revpi_ucepsa_edge_oee_01
|
||||
MQTT_PASSWORD=***REDACTED***
|
||||
MQTT_CLIENT_ID=ucepsa_edge_oee_revpi_cutters_node_01
|
||||
|
||||
TENANT=ucepsa
|
||||
SITE=ucepsa_test
|
||||
VERTICAL=edge_oee
|
||||
ASSET=revpi_oee_node_01
|
||||
|
||||
MQTT_TOPIC_TELEMETRY=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/telemetry
|
||||
MQTT_TOPIC_STATUS=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/status
|
||||
MQTT_TOPIC_EVENT=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/event
|
||||
|
||||
MODBUS_PORT=/dev/ttyRS485
|
||||
MODBUS_SLAVE=1
|
||||
MODBUS_BAUD=9600
|
||||
MODBUS_PARITY=N
|
||||
MODBUS_STOPBITS=1
|
||||
MODBUS_BYTESIZE=8
|
||||
MODBUS_TIMEOUT=1.5
|
||||
|
||||
DI_AUTO_INPUT=I_1
|
||||
DI_CYCLE_INPUT=I_2
|
||||
DI_DEBOUNCE_MS=50
|
||||
CYCLE_RATE_WINDOW_SECONDS=60
|
||||
PUBLISH_INTERVAL_SECONDS=10
|
||||
|
||||
MACHINE_ID=CORT-01
|
||||
@ -1,41 +0,0 @@
|
||||
# RevPi Edge-OEE Inventory
|
||||
|
||||
## Identificación
|
||||
|
||||
Hostname:
|
||||
|
||||
IP:
|
||||
|
||||
Modelo RevPi:
|
||||
|
||||
Sistema operativo:
|
||||
|
||||
Kernel:
|
||||
|
||||
## Red
|
||||
|
||||
Interfaz Ethernet:
|
||||
|
||||
IP:
|
||||
|
||||
Gateway:
|
||||
|
||||
DNS:
|
||||
|
||||
## Serie / RS-485
|
||||
|
||||
Dispositivos detectados:
|
||||
|
||||
- /dev/...
|
||||
|
||||
## Uso previsto
|
||||
|
||||
Captura industrial real para demo MESAVAULT Edge-OEE.
|
||||
|
||||
Primera señal objetivo:
|
||||
|
||||
- Eastron Modbus RTU
|
||||
- lectura de tensión
|
||||
- corriente
|
||||
- potencia
|
||||
- kWh
|
||||
@ -1,474 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
DO0_BLUE_COIL = 16
|
||||
DO1_RED_COIL = 17
|
||||
|
||||
TELEMETRY_TTL_S = 30
|
||||
ORDER_TTL_S = 30
|
||||
BLINK_PERIOD_S = 1.0
|
||||
OFFLINE_LOG_INTERVAL_S = 30
|
||||
|
||||
WISE_NODES_FILE = Path("wise_nodes.json")
|
||||
|
||||
|
||||
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
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return env
|
||||
|
||||
|
||||
def now_ts():
|
||||
return time.time()
|
||||
|
||||
|
||||
def load_nodes():
|
||||
data = json.loads(WISE_NODES_FILE.read_text())
|
||||
nodes = {}
|
||||
|
||||
for node in data.get("nodes", []):
|
||||
if not node.get("enabled", True):
|
||||
continue
|
||||
|
||||
machine_id = node["machine_id"]
|
||||
host = (
|
||||
node.get("wise_ip")
|
||||
or node.get("wise_host")
|
||||
or node.get("host")
|
||||
or node.get("ip")
|
||||
)
|
||||
|
||||
if not host:
|
||||
raise RuntimeError(f"Nodo sin IP: {node}")
|
||||
|
||||
nodes[machine_id] = {
|
||||
"machine_id": machine_id,
|
||||
"source_node": node.get("source_node", ""),
|
||||
"host": host,
|
||||
"port": int(node.get("wise_port", 502)),
|
||||
"unit": int(node.get("unit", 1)),
|
||||
}
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def bool_from_payload(value):
|
||||
return bool(value)
|
||||
|
||||
|
||||
def order_is_fresh(order):
|
||||
if not order:
|
||||
return False
|
||||
return (now_ts() - order.get("_rx_ts", 0)) <= ORDER_TTL_S
|
||||
|
||||
|
||||
def telemetry_is_fresh(telemetry):
|
||||
if not telemetry:
|
||||
return False
|
||||
return (now_ts() - telemetry.get("_rx_ts", 0)) <= TELEMETRY_TTL_S
|
||||
|
||||
|
||||
class WiseOutputs:
|
||||
def __init__(self, nodes):
|
||||
self.nodes = nodes
|
||||
self.clients = {}
|
||||
self.last_written = {}
|
||||
self.last_error_ts = {}
|
||||
self.offline_until = {}
|
||||
self.offline_retry_s = float(os.getenv("WISE_OFFLINE_RETRY_S", "15"))
|
||||
|
||||
def get_client(self, machine_id):
|
||||
client = self.clients.get(machine_id)
|
||||
if client is not None:
|
||||
return client
|
||||
|
||||
node = self.nodes[machine_id]
|
||||
client = ModbusTcpClient(node["host"], port=node["port"], timeout=2)
|
||||
|
||||
if not client.connect():
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"No conecta con {machine_id} {node['host']}:{node['port']}")
|
||||
|
||||
self.clients[machine_id] = client
|
||||
return client
|
||||
|
||||
def close_client(self, machine_id):
|
||||
client = self.clients.pop(machine_id, None)
|
||||
if client is not None:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def in_offline_backoff(self, machine_id):
|
||||
return now_ts() < self.offline_until.get(machine_id, 0)
|
||||
|
||||
def mark_offline(self, machine_id):
|
||||
self.offline_until[machine_id] = now_ts() + self.offline_retry_s
|
||||
self.last_written.pop(machine_id, None)
|
||||
|
||||
def write_coil(self, machine_id, address, value):
|
||||
node = self.nodes[machine_id]
|
||||
client = self.get_client(machine_id)
|
||||
|
||||
rr = client.write_coil(
|
||||
address=address,
|
||||
value=bool(value),
|
||||
device_id=node["unit"],
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
self.close_client(machine_id)
|
||||
raise RuntimeError(f"Error escribiendo {machine_id} coil {address}: {rr}")
|
||||
|
||||
def set_beacon(self, machine_id, blue_on, red_on):
|
||||
do0 = bool(blue_on)
|
||||
|
||||
# Roja con contacto NC:
|
||||
# rojo ON -> DO1 OFF
|
||||
# rojo OFF -> DO1 ON
|
||||
do1 = not bool(red_on)
|
||||
|
||||
current = (do0, do1)
|
||||
previous = self.last_written.get(machine_id)
|
||||
|
||||
if previous == current:
|
||||
return
|
||||
|
||||
self.write_coil(machine_id, DO0_BLUE_COIL, do0)
|
||||
self.write_coil(machine_id, DO1_RED_COIL, do1)
|
||||
|
||||
self.last_written[machine_id] = current
|
||||
self.offline_until.pop(machine_id, None)
|
||||
|
||||
if os.getenv("BEACON_LOG_WRITES", "0").lower() in ("1", "true", "yes", "on"):
|
||||
print(
|
||||
f"BEACON {machine_id} "
|
||||
f"blue_on={blue_on} red_on={red_on} "
|
||||
f"DO0={do0} DO1={do1}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def log_offline_error(self, machine_id, reason):
|
||||
now = now_ts()
|
||||
last = self.last_error_ts.get(machine_id, 0)
|
||||
|
||||
if now - last >= OFFLINE_LOG_INTERVAL_S:
|
||||
self.last_error_ts[machine_id] = now
|
||||
print(f"WISE_OFFLINE {machine_id} {reason}", flush=True)
|
||||
|
||||
def safe_connected_only(self):
|
||||
for machine_id in list(self.clients.keys()):
|
||||
try:
|
||||
self.set_beacon(machine_id, blue_on=False, red_on=True)
|
||||
except Exception as e:
|
||||
print(f"ERROR dejando safe {machine_id}: {e}", flush=True)
|
||||
self.close_client(machine_id)
|
||||
|
||||
def close_all(self):
|
||||
for machine_id in list(self.clients.keys()):
|
||||
self.close_client(machine_id)
|
||||
|
||||
|
||||
def mode_to_bool(mode, blink_on):
|
||||
if mode == "ON":
|
||||
return True
|
||||
if mode == "OFF":
|
||||
return False
|
||||
if mode == "BLINK":
|
||||
return blink_on
|
||||
return False
|
||||
|
||||
|
||||
def decide_modes(machine_id, telemetry, order):
|
||||
"""
|
||||
Devuelve:
|
||||
blue_mode, red_mode, reason, should_write
|
||||
|
||||
should_write=False significa:
|
||||
máquina probablemente sin corriente y sin orden cargada.
|
||||
No intentamos escribir en WISE para evitar ruido.
|
||||
"""
|
||||
|
||||
tel_fresh = telemetry_is_fresh(telemetry)
|
||||
ord_fresh = order_is_fresh(order)
|
||||
|
||||
order_present = False
|
||||
order_coherent = False
|
||||
force_machine_fault = False
|
||||
|
||||
if ord_fresh:
|
||||
order_present = bool_from_payload(order.get("order_present", False))
|
||||
order_coherent = bool_from_payload(order.get("order_coherent", False))
|
||||
force_machine_fault = bool_from_payload(order.get("force_machine_fault", False))
|
||||
|
||||
order_valid_loaded = order_present and order_coherent
|
||||
|
||||
if not tel_fresh and not order_present:
|
||||
return "OFF", "OFF", "machine_power_off_or_wise_offline_without_order", False
|
||||
|
||||
if not tel_fresh and order_valid_loaded:
|
||||
return "ON", "BLINK", "order_loaded_but_no_telemetry_or_wise_offline", True
|
||||
|
||||
if not tel_fresh and order_present and not order_coherent:
|
||||
return "BLINK", "BLINK", "odoo_incoherent_but_no_telemetry_or_wise_offline", True
|
||||
|
||||
comm_ok = bool_from_payload(telemetry.get("comm_ok", False))
|
||||
sample_valid = bool_from_payload(telemetry.get("sample_valid", False))
|
||||
|
||||
if not comm_ok or not sample_valid:
|
||||
return "OFF", "BLINK", "telemetry_invalid", True
|
||||
|
||||
machine_running = bool_from_payload(telemetry.get("machine_running", False))
|
||||
last_cycle_age_s = telemetry.get("last_cycle_age_s")
|
||||
|
||||
machine_fault = force_machine_fault
|
||||
|
||||
if machine_running and isinstance(last_cycle_age_s, (int, float)) and last_cycle_age_s > 20:
|
||||
machine_fault = True
|
||||
|
||||
odoo_incoherent = False
|
||||
|
||||
if machine_running and not order_valid_loaded:
|
||||
odoo_incoherent = True
|
||||
|
||||
if order_present and not order_coherent:
|
||||
odoo_incoherent = True
|
||||
|
||||
if machine_fault and odoo_incoherent:
|
||||
return "BLINK", "BLINK", "machine_fault_and_odoo_incoherent", True
|
||||
|
||||
if machine_fault and order_valid_loaded:
|
||||
return "ON", "BLINK", "machine_fault_with_valid_order", True
|
||||
|
||||
if odoo_incoherent and machine_running:
|
||||
return "BLINK", "OFF", "machine_running_without_valid_odoo_order", True
|
||||
|
||||
if odoo_incoherent and not machine_running:
|
||||
return "BLINK", "ON", "odoo_incoherent_machine_stopped", True
|
||||
|
||||
if order_valid_loaded and machine_running:
|
||||
return "ON", "OFF", "valid_order_and_machine_running", True
|
||||
|
||||
if order_valid_loaded and not machine_running:
|
||||
return "ON", "ON", "valid_order_and_machine_stopped", True
|
||||
|
||||
return "OFF", "ON", "no_valid_order_machine_stopped", True
|
||||
|
||||
|
||||
def main():
|
||||
env = read_env()
|
||||
nodes = load_nodes()
|
||||
|
||||
machine_filter_raw = os.getenv("BEACON_MACHINE_FILTER", "").strip()
|
||||
if machine_filter_raw:
|
||||
allowed = {x.strip() for x in machine_filter_raw.split(",") if x.strip()}
|
||||
nodes = {mid: node for mid, node in nodes.items() if mid in allowed}
|
||||
|
||||
if not nodes:
|
||||
raise RuntimeError(
|
||||
f"BEACON_MACHINE_FILTER={machine_filter_raw} no coincide con ningún nodo"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Filtro de máquinas activo: {', '.join(nodes.keys())}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
mqtt_host = (
|
||||
os.getenv("MQTT_HOST_OVERRIDE")
|
||||
or env.get("MQTT_HOST")
|
||||
or "127.0.0.1"
|
||||
)
|
||||
mqtt_port = int(
|
||||
os.getenv("MQTT_PORT_OVERRIDE")
|
||||
or env.get("MQTT_PORT")
|
||||
or "1883"
|
||||
)
|
||||
|
||||
mqtt_user = (
|
||||
os.getenv("MQTT_USER_OVERRIDE")
|
||||
or os.getenv("MQTT_USERNAME_OVERRIDE")
|
||||
or env.get("MQTT_USER")
|
||||
or env.get("MQTT_USERNAME")
|
||||
or env.get("MQTT_USER_REVPI")
|
||||
)
|
||||
|
||||
mqtt_password = (
|
||||
os.getenv("MQTT_PASSWORD_OVERRIDE")
|
||||
or os.getenv("MQTT_PASS_OVERRIDE")
|
||||
or env.get("MQTT_PASSWORD")
|
||||
or env.get("MQTT_PASS")
|
||||
or env.get("MQTT_PASSWORD_REVPI")
|
||||
)
|
||||
|
||||
tenant = env.get("TENANT", "ucepsa")
|
||||
site = env.get("SITE", "ucepsa_onpremise")
|
||||
vertical = env.get("VERTICAL", "edge_oee")
|
||||
|
||||
telemetry_topic = f"vertical/{tenant}/{site}/{vertical}/+/telemetry"
|
||||
order_topic = f"vertical/{tenant}/{site}/{vertical}/order_state/+"
|
||||
|
||||
latest_telemetry = {}
|
||||
latest_order = {}
|
||||
last_logged_order = {}
|
||||
|
||||
outputs = WiseOutputs(nodes)
|
||||
|
||||
def on_connect(client, userdata, flags, rc, properties=None):
|
||||
print(f"MQTT conectado rc={rc}", flush=True)
|
||||
|
||||
if rc == 0:
|
||||
client.subscribe(telemetry_topic)
|
||||
client.subscribe(order_topic)
|
||||
print(f"SUB {telemetry_topic}", flush=True)
|
||||
print(f"SUB {order_topic}", flush=True)
|
||||
|
||||
def on_disconnect(client, userdata, rc, properties=None):
|
||||
print(f"MQTT desconectado rc={rc}", flush=True)
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode("utf-8"))
|
||||
payload["_rx_ts"] = now_ts()
|
||||
|
||||
topic = msg.topic
|
||||
|
||||
if "/order_state/" in topic:
|
||||
machine_id = payload.get("machine_id") or topic.rsplit("/", 1)[-1]
|
||||
latest_order[machine_id] = payload
|
||||
|
||||
diagnostics = payload.get("diagnostics") or {}
|
||||
|
||||
order_signature = (
|
||||
bool(payload.get("order_present", False)),
|
||||
bool(payload.get("order_coherent", False)),
|
||||
payload.get("odoo_state"),
|
||||
payload.get("workorder_id"),
|
||||
payload.get("production_order"),
|
||||
payload.get("product"),
|
||||
diagnostics.get("waiting_count"),
|
||||
diagnostics.get("ready_count"),
|
||||
diagnostics.get("progress_count"),
|
||||
)
|
||||
|
||||
if last_logged_order.get(machine_id) != order_signature:
|
||||
last_logged_order[machine_id] = order_signature
|
||||
print(
|
||||
f"ORDER_CHANGE {machine_id} "
|
||||
f"present={payload.get('order_present')} "
|
||||
f"coherent={payload.get('order_coherent')} "
|
||||
f"state={payload.get('odoo_state')} "
|
||||
f"mo={payload.get('production_order')} "
|
||||
f"product={payload.get('product')} "
|
||||
f"diag={diagnostics}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
elif topic.endswith("/telemetry"):
|
||||
machine_id = payload.get("machine_id")
|
||||
if machine_id:
|
||||
latest_telemetry[machine_id] = payload
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR procesando MQTT {msg.topic}: {e}", flush=True)
|
||||
|
||||
client = mqtt.Client(client_id="mesavault_beacon_controller_power_aware")
|
||||
|
||||
if mqtt_user:
|
||||
client.username_pw_set(mqtt_user, mqtt_password)
|
||||
print(f"MQTT auth user={mqtt_user}", flush=True)
|
||||
else:
|
||||
print("WARN: MQTT sin usuario", flush=True)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_disconnect = on_disconnect
|
||||
client.on_message = on_message
|
||||
|
||||
client.connect(mqtt_host, mqtt_port, 60)
|
||||
client.loop_start()
|
||||
|
||||
print("Controlador de balizas power-aware arrancado", flush=True)
|
||||
print(f"Máquinas: {', '.join(nodes.keys())}", flush=True)
|
||||
print("Ctrl+C para parar", flush=True)
|
||||
|
||||
last_reason = {}
|
||||
|
||||
try:
|
||||
while True:
|
||||
blink_on = int(time.time() / BLINK_PERIOD_S) % 2 == 0
|
||||
|
||||
for machine_id in nodes:
|
||||
telemetry = latest_telemetry.get(machine_id)
|
||||
order = latest_order.get(machine_id)
|
||||
|
||||
blue_mode, red_mode, reason, should_write = decide_modes(
|
||||
machine_id,
|
||||
telemetry,
|
||||
order,
|
||||
)
|
||||
|
||||
if last_reason.get(machine_id) != reason:
|
||||
last_reason[machine_id] = reason
|
||||
print(
|
||||
f"STATE {machine_id} "
|
||||
f"blue={blue_mode} red={red_mode} "
|
||||
f"write={should_write} reason={reason}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not should_write:
|
||||
continue
|
||||
|
||||
blue_on = mode_to_bool(blue_mode, blink_on)
|
||||
red_on = mode_to_bool(red_mode, blink_on)
|
||||
|
||||
if outputs.in_offline_backoff(machine_id):
|
||||
continue
|
||||
|
||||
try:
|
||||
outputs.set_beacon(
|
||||
machine_id,
|
||||
blue_on=blue_on,
|
||||
red_on=red_on,
|
||||
)
|
||||
except Exception as e:
|
||||
outputs.close_client(machine_id)
|
||||
outputs.mark_offline(machine_id)
|
||||
outputs.log_offline_error(machine_id, str(e))
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Parando controlador de balizas", flush=True)
|
||||
|
||||
finally:
|
||||
outputs.safe_connected_only()
|
||||
outputs.close_all()
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
print("Controlador parado", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
UNIT = 1
|
||||
|
||||
DO0_BLUE_COIL = 16
|
||||
DO1_RED_COIL = 17
|
||||
|
||||
WISE_NODES_FILE = Path("wise_nodes.json")
|
||||
|
||||
|
||||
def load_nodes():
|
||||
data = json.loads(WISE_NODES_FILE.read_text())
|
||||
nodes = data.get("nodes", [])
|
||||
|
||||
result = {}
|
||||
for node in nodes:
|
||||
machine_id = node.get("machine_id")
|
||||
host = node.get("wise_ip") or node.get("wise_host") or node.get("host") or node.get("ip")
|
||||
|
||||
if machine_id and host:
|
||||
result[machine_id] = {
|
||||
"machine_id": machine_id,
|
||||
"source_node": node.get("source_node", ""),
|
||||
"host": host,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def write_coil_checked(client, address, value):
|
||||
rr = client.write_coil(address=address, value=value, device_id=UNIT)
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Error escribiendo coil {address}: {rr}")
|
||||
|
||||
|
||||
def read_outputs(client):
|
||||
rr = client.read_coils(address=DO0_BLUE_COIL, count=2, device_id=UNIT)
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Error leyendo coils DO: {rr}")
|
||||
|
||||
do0 = bool(rr.bits[0])
|
||||
do1 = bool(rr.bits[1])
|
||||
|
||||
blue_on = do0
|
||||
red_on = not do1
|
||||
|
||||
return do0, do1, blue_on, red_on
|
||||
|
||||
|
||||
def set_beacon(client, blue_on, red_on):
|
||||
# DO0 gobierna azul directamente.
|
||||
write_coil_checked(client, DO0_BLUE_COIL, bool(blue_on))
|
||||
|
||||
# DO1 gobierna roja invertida por contacto NC.
|
||||
# red_on=True -> DO1 False
|
||||
# red_on=False -> DO1 True
|
||||
write_coil_checked(client, DO1_RED_COIL, not bool(red_on))
|
||||
|
||||
|
||||
def print_state(client, label):
|
||||
do0, do1, blue_on, red_on = read_outputs(client)
|
||||
print(
|
||||
f"{label}: "
|
||||
f"DO0_BLUE={do0} DO1_RED={do1} "
|
||||
f"=> blue_on={blue_on} red_on={red_on}"
|
||||
)
|
||||
|
||||
|
||||
def apply_mode(client, mode, seconds):
|
||||
blink_period = 1.0
|
||||
end = time.time() + seconds
|
||||
|
||||
if mode == "safe":
|
||||
set_beacon(client, blue_on=False, red_on=True)
|
||||
print_state(client, "safe")
|
||||
time.sleep(seconds)
|
||||
|
||||
elif mode == "ok_running":
|
||||
set_beacon(client, blue_on=True, red_on=False)
|
||||
print_state(client, "ok_running")
|
||||
time.sleep(seconds)
|
||||
|
||||
elif mode == "ok_stopped":
|
||||
set_beacon(client, blue_on=True, red_on=True)
|
||||
print_state(client, "ok_stopped")
|
||||
time.sleep(seconds)
|
||||
|
||||
elif mode == "odoo_bad_running":
|
||||
print("odoo_bad_running: azul intermitente, rojo apagado")
|
||||
while time.time() < end:
|
||||
set_beacon(client, blue_on=True, red_on=False)
|
||||
time.sleep(blink_period)
|
||||
set_beacon(client, blue_on=False, red_on=False)
|
||||
time.sleep(blink_period)
|
||||
|
||||
elif mode == "machine_fault":
|
||||
print("machine_fault: azul fijo, rojo intermitente")
|
||||
while time.time() < end:
|
||||
set_beacon(client, blue_on=True, red_on=True)
|
||||
time.sleep(blink_period)
|
||||
set_beacon(client, blue_on=True, red_on=False)
|
||||
time.sleep(blink_period)
|
||||
|
||||
elif mode == "odoo_bad_and_fault":
|
||||
print("odoo_bad_and_fault: azul intermitente, rojo intermitente")
|
||||
while time.time() < end:
|
||||
set_beacon(client, blue_on=True, red_on=True)
|
||||
time.sleep(blink_period)
|
||||
set_beacon(client, blue_on=False, red_on=False)
|
||||
time.sleep(blink_period)
|
||||
|
||||
elif mode == "system_fault":
|
||||
print("system_fault: azul apagado, rojo intermitente")
|
||||
while time.time() < end:
|
||||
set_beacon(client, blue_on=False, red_on=True)
|
||||
time.sleep(blink_period)
|
||||
set_beacon(client, blue_on=False, red_on=False)
|
||||
time.sleep(blink_period)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Modo no reconocido: {mode}")
|
||||
|
||||
|
||||
def test_machine(machine_id, host, mode, seconds):
|
||||
print(f"Probando {machine_id} en {host} modo={mode} durante {seconds}s")
|
||||
|
||||
client = ModbusTcpClient(host, port=502, timeout=3)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError(f"No conecta con {machine_id} {host}:502")
|
||||
|
||||
try:
|
||||
apply_mode(client, mode, seconds)
|
||||
print_state(client, "estado final antes de safe")
|
||||
|
||||
# Dejamos siempre estado seguro al terminar la prueba manual.
|
||||
set_beacon(client, blue_on=False, red_on=True)
|
||||
print_state(client, "safe final")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Uso:")
|
||||
print(" python beacon_manual_test.py CORT-00 safe 5")
|
||||
print(" python beacon_manual_test.py CORT-00 ok_running 5")
|
||||
print(" python beacon_manual_test.py CORT-00 ok_stopped 5")
|
||||
print(" python beacon_manual_test.py CORT-00 odoo_bad_running 10")
|
||||
print(" python beacon_manual_test.py CORT-00 machine_fault 10")
|
||||
print(" python beacon_manual_test.py CORT-00 odoo_bad_and_fault 10")
|
||||
print(" python beacon_manual_test.py CORT-00 system_fault 10")
|
||||
print(" python beacon_manual_test.py all safe 5")
|
||||
raise SystemExit(1)
|
||||
|
||||
target = sys.argv[1]
|
||||
mode = sys.argv[2]
|
||||
seconds = float(sys.argv[3]) if len(sys.argv) >= 4 else 5.0
|
||||
|
||||
nodes = load_nodes()
|
||||
|
||||
if target == "all":
|
||||
for machine_id, node in nodes.items():
|
||||
test_machine(machine_id, node["host"], mode, seconds)
|
||||
return
|
||||
|
||||
if target not in nodes:
|
||||
raise SystemExit(f"No encuentro {target} en wise_nodes.json")
|
||||
|
||||
node = nodes[target]
|
||||
test_machine(target, node["host"], mode, seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "--- HOSTNAME ---"
|
||||
hostname
|
||||
|
||||
echo "--- OS ---"
|
||||
cat /etc/os-release || true
|
||||
|
||||
echo "--- KERNEL ---"
|
||||
uname -a
|
||||
|
||||
echo "--- IP ---"
|
||||
ip -br a
|
||||
|
||||
echo "--- ROUTES ---"
|
||||
ip route
|
||||
|
||||
echo "--- SERIAL DEVICES ---"
|
||||
ls -l /dev/tty* | head -n 80
|
||||
|
||||
echo "--- DMESG TTY ---"
|
||||
dmesg | grep -i tty || true
|
||||
|
||||
echo "--- USB ---"
|
||||
lsusb || true
|
||||
@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
|
||||
def read_env(path=".env"):
|
||||
env = {}
|
||||
p = Path(path)
|
||||
|
||||
for raw in p.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip().strip('"').strip("'")
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def load_machine_ids():
|
||||
data = json.loads(Path("wise_nodes.json").read_text())
|
||||
return [
|
||||
n["machine_id"]
|
||||
for n in data.get("nodes", [])
|
||||
if n.get("enabled", True)
|
||||
]
|
||||
|
||||
|
||||
env = read_env()
|
||||
|
||||
mqtt_host = env.get("MQTT_HOST", "127.0.0.1")
|
||||
mqtt_port = int(env.get("MQTT_PORT", "1883"))
|
||||
mqtt_user = env.get("MQTT_USERNAME") or env.get("MQTT_USER")
|
||||
mqtt_password = env.get("MQTT_PASSWORD")
|
||||
|
||||
tenant = env.get("TENANT", "ucepsa")
|
||||
site = env.get("SITE", "ucepsa_onpremise")
|
||||
vertical = env.get("VERTICAL", "edge_oee")
|
||||
|
||||
client = mqtt.Client(client_id="clear_order_state_retained")
|
||||
|
||||
if mqtt_user:
|
||||
client.username_pw_set(mqtt_user, mqtt_password)
|
||||
|
||||
client.connect(mqtt_host, mqtt_port, 60)
|
||||
client.loop_start()
|
||||
|
||||
for machine_id in load_machine_ids():
|
||||
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
|
||||
|
||||
# Payload vacío + retain=True borra el mensaje retenido del broker.
|
||||
info = client.publish(topic, payload=b"", qos=0, retain=True)
|
||||
info.wait_for_publish(timeout=5)
|
||||
|
||||
print(f"Retained eliminado: {topic}")
|
||||
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
|
||||
print("OK: estados simulados de Odoo eliminados")
|
||||
@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import struct
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
WISE_IP = "17.126.1.201"
|
||||
UNIT = 1
|
||||
|
||||
def f32_abcd(hi, lo):
|
||||
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
def read_holding(client, start, count, label):
|
||||
rr = client.read_holding_registers(address=start, count=count, device_id=UNIT)
|
||||
if rr.isError():
|
||||
print(f"{label}: ERROR {rr}")
|
||||
return None
|
||||
print(f"{label}: {rr.registers}")
|
||||
return rr.registers
|
||||
|
||||
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
|
||||
|
||||
if not client.connect():
|
||||
raise SystemExit(f"No conecta con WISE {WISE_IP}:502")
|
||||
|
||||
try:
|
||||
print("CORT-01 / wise-cort-01 /", WISE_IP)
|
||||
|
||||
data = read_holding(client, 1000, 20, "DATA 1000..1019")
|
||||
bit_err = read_holding(client, 1100, 20, "BIT ERR 1100..1119")
|
||||
word_err = read_holding(client, 1200, 20, "WORD ERR 1200..1219")
|
||||
|
||||
if data:
|
||||
print("\nValores float interpretados:")
|
||||
labels = [
|
||||
("voltage_l1_v", 0),
|
||||
("current_l1_a", 2),
|
||||
("active_power_w", 4),
|
||||
("apparent_power_va", 6),
|
||||
("reactive_power_var", 8),
|
||||
("power_factor", 10),
|
||||
("frequency_hz", 12),
|
||||
("import_kwh", 14),
|
||||
("export_kwh", 16),
|
||||
("total_active_energy_kwh", 18),
|
||||
]
|
||||
|
||||
for name, offset in labels:
|
||||
print(f"{name:28s}: {f32_abcd(data[offset], data[offset+1]):.6f}")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
import revpimodio2
|
||||
|
||||
|
||||
RUNNING = True
|
||||
|
||||
|
||||
def handle_signal(signum, frame):
|
||||
global RUNNING
|
||||
RUNNING = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
|
||||
def main():
|
||||
rpi = revpimodio2.RevPiModIO(autorefresh=True)
|
||||
|
||||
auto_io = getattr(rpi.io, "I_1")
|
||||
cycle_io = getattr(rpi.io, "I_2")
|
||||
|
||||
cycle_pulse_count = 0
|
||||
previous_cycle = bool(cycle_io.value)
|
||||
last_edge_ts = 0.0
|
||||
last_cycle_ts = None
|
||||
|
||||
debounce_s = 0.05
|
||||
pulse_window_s = 60.0
|
||||
recent_pulses = deque()
|
||||
|
||||
print("[START] DI probe")
|
||||
print("I_1 = machine_auto_signal")
|
||||
print("I_2 = cycle_pulse")
|
||||
print("Pulsa CTRL+C para salir")
|
||||
|
||||
try:
|
||||
last_print = 0.0
|
||||
|
||||
while RUNNING:
|
||||
now = time.monotonic()
|
||||
|
||||
machine_auto_signal = bool(auto_io.value)
|
||||
current_cycle = bool(cycle_io.value)
|
||||
|
||||
# Flanco ascendente en I_2
|
||||
if current_cycle and not previous_cycle:
|
||||
if now - last_edge_ts >= debounce_s:
|
||||
cycle_pulse_count += 1
|
||||
last_edge_ts = now
|
||||
last_cycle_ts = now
|
||||
recent_pulses.append(now)
|
||||
|
||||
previous_cycle = current_cycle
|
||||
|
||||
# Limpiar pulsos fuera de la ventana
|
||||
while recent_pulses and now - recent_pulses[0] > pulse_window_s:
|
||||
recent_pulses.popleft()
|
||||
|
||||
cycle_rate_ppm = len(recent_pulses) * (60.0 / pulse_window_s)
|
||||
|
||||
if last_cycle_ts is None:
|
||||
last_cycle_age_s = None
|
||||
else:
|
||||
last_cycle_age_s = round(now - last_cycle_ts, 1)
|
||||
|
||||
# Para la demo, machine_running se asocia al interruptor I_1.
|
||||
# Más adelante podemos hacerlo depender también de pulsos recientes.
|
||||
machine_running = machine_auto_signal
|
||||
|
||||
if now - last_print >= 0.5:
|
||||
last_print = now
|
||||
print(
|
||||
"auto={auto} running={running} cycle_input={cycle} "
|
||||
"pulse_count={count} cycle_rate_ppm={rate:.1f} last_cycle_age_s={age}".format(
|
||||
auto=int(machine_auto_signal),
|
||||
running=int(machine_running),
|
||||
cycle=int(current_cycle),
|
||||
count=cycle_pulse_count,
|
||||
rate=cycle_rate_ppm,
|
||||
age=last_cycle_age_s,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
time.sleep(0.02)
|
||||
|
||||
finally:
|
||||
rpi.exit()
|
||||
print("[STOP] DI probe")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pymodbus.client import ModbusSerialClient
|
||||
|
||||
|
||||
def decode_float_abcd(registers):
|
||||
if len(registers) != 2:
|
||||
raise ValueError(f"Expected 2 registers, got {len(registers)}")
|
||||
|
||||
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def read_float(client, address, device_id):
|
||||
rr = client.read_input_registers(
|
||||
address=address,
|
||||
count=2,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Modbus error at address {address}: {rr}")
|
||||
|
||||
return decode_float_abcd(rr.registers)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", default="/dev/ttyRS485")
|
||||
parser.add_argument("--slave", type=int, default=1)
|
||||
parser.add_argument("--baud", type=int, default=9600)
|
||||
parser.add_argument("--parity", default="N")
|
||||
parser.add_argument("--stopbits", type=int, default=1)
|
||||
parser.add_argument("--bytesize", type=int, default=8)
|
||||
parser.add_argument("--timeout", type=float, default=1.5)
|
||||
args = parser.parse_args()
|
||||
|
||||
client = ModbusSerialClient(
|
||||
port=args.port,
|
||||
baudrate=args.baud,
|
||||
parity=args.parity,
|
||||
stopbits=args.stopbits,
|
||||
bytesize=args.bytesize,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
if not client.connect():
|
||||
print("[ERROR] Cannot open Modbus serial port", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
voltage_v = read_float(client, 0, args.slave)
|
||||
current_a = read_float(client, 6, args.slave)
|
||||
active_power_w = read_float(client, 12, args.slave)
|
||||
frequency_hz = read_float(client, 70, args.slave)
|
||||
import_kwh = read_float(client, 72, args.slave)
|
||||
export_kwh = read_float(client, 74, args.slave)
|
||||
|
||||
payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "revpi_sdm120m",
|
||||
"meter_model": "Eastron SDM120-M",
|
||||
"meter_id": f"modbus_{args.slave}",
|
||||
"port": args.port,
|
||||
"slave": args.slave,
|
||||
"baud": args.baud,
|
||||
"voltage_l1_v": round(voltage_v, 3),
|
||||
"current_l1_a": round(current_a, 3),
|
||||
"power_total_kw": round(active_power_w / 1000.0, 6),
|
||||
"frequency_hz": round(frequency_hz, 3),
|
||||
"import_kwh": round(import_kwh, 3),
|
||||
"export_kwh": round(export_kwh, 3),
|
||||
"comm_ok": True,
|
||||
}
|
||||
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
except Exception as exc:
|
||||
error_payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "revpi_sdm120m",
|
||||
"meter_model": "Eastron SDM120-M",
|
||||
"meter_id": f"modbus_{args.slave}",
|
||||
"port": args.port,
|
||||
"slave": args.slave,
|
||||
"baud": args.baud,
|
||||
"comm_ok": False,
|
||||
"error_text": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
print(json.dumps(error_payload, indent=2, ensure_ascii=False))
|
||||
return 1
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,205 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from pymodbus.client import ModbusSerialClient
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = BASE_DIR / ".env"
|
||||
START_TIME = time.monotonic()
|
||||
|
||||
|
||||
def load_env_file(path: Path):
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def getenv(name, default=None, required=False):
|
||||
value = os.getenv(name, default)
|
||||
if required and not value:
|
||||
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
|
||||
def decode_float_abcd(registers):
|
||||
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def read_float(client, address, device_id):
|
||||
rr = client.read_input_registers(
|
||||
address=address,
|
||||
count=2,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Modbus error at address {address}: {rr}")
|
||||
|
||||
return decode_float_abcd(rr.registers)
|
||||
|
||||
|
||||
def read_sdm120m():
|
||||
port = getenv("MODBUS_PORT", "/dev/ttyRS485")
|
||||
slave = int(getenv("MODBUS_SLAVE", "1"))
|
||||
baud = int(getenv("MODBUS_BAUD", "9600"))
|
||||
parity = getenv("MODBUS_PARITY", "N")
|
||||
stopbits = int(getenv("MODBUS_STOPBITS", "1"))
|
||||
bytesize = int(getenv("MODBUS_BYTESIZE", "8"))
|
||||
timeout = float(getenv("MODBUS_TIMEOUT", "1.5"))
|
||||
|
||||
client = ModbusSerialClient(
|
||||
port=port,
|
||||
baudrate=baud,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
bytesize=bytesize,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError(f"Cannot open Modbus serial port {port}")
|
||||
|
||||
try:
|
||||
voltage_v = read_float(client, 0, slave)
|
||||
current_a = read_float(client, 6, slave)
|
||||
active_power_w = read_float(client, 12, slave)
|
||||
frequency_hz = read_float(client, 70, slave)
|
||||
import_kwh = read_float(client, 72, slave)
|
||||
export_kwh = read_float(client, 74, slave)
|
||||
|
||||
return {
|
||||
"meter_model": "Eastron SDM120-M",
|
||||
"meter_id": f"modbus_{slave}",
|
||||
"modbus_port": port,
|
||||
"modbus_slave": slave,
|
||||
"modbus_baud": baud,
|
||||
|
||||
"voltage_l1_v": round(voltage_v, 3),
|
||||
"current_l1_a": round(current_a, 3),
|
||||
"power_total_kw": round(active_power_w / 1000.0, 6),
|
||||
"frequency_hz": round(frequency_hz, 3),
|
||||
"import_kwh": round(import_kwh, 3),
|
||||
"export_kwh": round(export_kwh, 3),
|
||||
|
||||
"comm_ok": True,
|
||||
"sample_valid": True,
|
||||
"error_text": None,
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def build_payload():
|
||||
tenant = getenv("TENANT", "ucepsa")
|
||||
site = getenv("SITE", "demo_edge_oee")
|
||||
vertical = getenv("VERTICAL", "edge_oee")
|
||||
asset = getenv("ASSET", "revpi_oee_node_01")
|
||||
|
||||
base = {
|
||||
"asset": asset,
|
||||
"tenant": tenant,
|
||||
"site": site,
|
||||
"vertical": vertical,
|
||||
"node_type": "revpi_sdm120m_oee_gateway",
|
||||
"fw": "0.1.0",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"uptime_s": round(time.monotonic() - START_TIME, 1),
|
||||
|
||||
"machine_id": None,
|
||||
"machine_running": None,
|
||||
"cycle_pulse_count": None,
|
||||
"cycle_rate_hz": None,
|
||||
"digital_inputs": {},
|
||||
}
|
||||
|
||||
try:
|
||||
data = read_sdm120m()
|
||||
base.update(data)
|
||||
except Exception as exc:
|
||||
base.update({
|
||||
"comm_ok": False,
|
||||
"sample_valid": False,
|
||||
"meter_model": "Eastron SDM120-M",
|
||||
"meter_id": f"modbus_{getenv('MODBUS_SLAVE', '1')}",
|
||||
"voltage_l1_v": None,
|
||||
"current_l1_a": None,
|
||||
"power_total_kw": None,
|
||||
"frequency_hz": None,
|
||||
"import_kwh": None,
|
||||
"export_kwh": None,
|
||||
"error_text": f"{type(exc).__name__}: {exc}",
|
||||
})
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def publish_once(payload):
|
||||
mqtt_host = getenv("MQTT_HOST", required=True)
|
||||
mqtt_port = int(getenv("MQTT_PORT", "1883"))
|
||||
mqtt_user = getenv("MQTT_USER", required=True)
|
||||
mqtt_password = getenv("MQTT_PASSWORD", required=True)
|
||||
mqtt_client_id = getenv("MQTT_CLIENT_ID", required=True)
|
||||
topic_telemetry = getenv("MQTT_TOPIC_TELEMETRY", required=True)
|
||||
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
|
||||
|
||||
client = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=mqtt_client_id,
|
||||
clean_session=True,
|
||||
)
|
||||
|
||||
client.username_pw_set(mqtt_user, mqtt_password)
|
||||
client.will_set(topic_status, payload="offline", qos=0, retain=True)
|
||||
|
||||
client.connect(mqtt_host, mqtt_port, keepalive=30)
|
||||
client.loop_start()
|
||||
|
||||
client.publish(topic_status, payload="online", qos=0, retain=True)
|
||||
result = client.publish(
|
||||
topic_telemetry,
|
||||
payload=json.dumps(payload, ensure_ascii=False),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
result.wait_for_publish(timeout=5)
|
||||
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
|
||||
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
raise RuntimeError(f"MQTT publish failed rc={result.rc}")
|
||||
|
||||
|
||||
def main():
|
||||
load_env_file(ENV_FILE)
|
||||
|
||||
payload = build_payload()
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
publish_once(payload)
|
||||
|
||||
print("[OK] MQTT telemetry published")
|
||||
return 0 if payload.get("comm_ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,419 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import revpimodio2
|
||||
from pymodbus.client import ModbusSerialClient
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = BASE_DIR / ".env"
|
||||
START_TIME = time.monotonic()
|
||||
RUNNING = True
|
||||
|
||||
|
||||
def handle_signal(signum, frame):
|
||||
global RUNNING
|
||||
RUNNING = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
|
||||
def utc_now_iso():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def load_env_file(path: Path):
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def getenv(name, default=None, required=False):
|
||||
value = os.getenv(name, default)
|
||||
if required and not value:
|
||||
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr, flush=True)
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
|
||||
def decode_float_abcd(registers):
|
||||
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def read_float(client, address, device_id):
|
||||
rr = client.read_input_registers(
|
||||
address=address,
|
||||
count=2,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Modbus error at address {address}: {rr}")
|
||||
|
||||
return decode_float_abcd(rr.registers)
|
||||
|
||||
|
||||
def read_sdm120m():
|
||||
port = getenv("MODBUS_PORT", "/dev/ttyRS485")
|
||||
slave = int(getenv("MODBUS_SLAVE", "1"))
|
||||
baud = int(getenv("MODBUS_BAUD", "9600"))
|
||||
parity = getenv("MODBUS_PARITY", "N")
|
||||
stopbits = int(getenv("MODBUS_STOPBITS", "1"))
|
||||
bytesize = int(getenv("MODBUS_BYTESIZE", "8"))
|
||||
timeout = float(getenv("MODBUS_TIMEOUT", "1.5"))
|
||||
|
||||
client = ModbusSerialClient(
|
||||
port=port,
|
||||
baudrate=baud,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
bytesize=bytesize,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError(f"Cannot open Modbus serial port {port}")
|
||||
|
||||
try:
|
||||
voltage_v = read_float(client, 0, slave)
|
||||
current_a = read_float(client, 6, slave)
|
||||
active_power_w = read_float(client, 12, slave)
|
||||
frequency_hz = read_float(client, 70, slave)
|
||||
import_kwh = read_float(client, 72, slave)
|
||||
export_kwh = read_float(client, 74, slave)
|
||||
|
||||
return {
|
||||
"meter_model": "Eastron SDM120-M",
|
||||
"meter_id": f"modbus_{slave}",
|
||||
"modbus_port": port,
|
||||
"modbus_slave": slave,
|
||||
"modbus_baud": baud,
|
||||
|
||||
"voltage_l1_v": round(voltage_v, 3),
|
||||
"current_l1_a": round(current_a, 3),
|
||||
"power_total_kw": round(active_power_w / 1000.0, 6),
|
||||
"frequency_hz": round(frequency_hz, 3),
|
||||
"import_kwh": round(import_kwh, 3),
|
||||
"export_kwh": round(export_kwh, 3),
|
||||
|
||||
"comm_ok": True,
|
||||
"sample_valid": True,
|
||||
"error_text": None,
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
class DigitalInputTracker:
|
||||
def __init__(self):
|
||||
self.auto_input_name = getenv("DI_AUTO_INPUT", "I_1")
|
||||
self.cycle_input_name = getenv("DI_CYCLE_INPUT", "I_2")
|
||||
self.debounce_s = float(getenv("DI_DEBOUNCE_MS", "50")) / 1000.0
|
||||
self.window_s = float(getenv("CYCLE_RATE_WINDOW_SECONDS", "60"))
|
||||
|
||||
self.rpi = revpimodio2.RevPiModIO(autorefresh=True)
|
||||
|
||||
self.auto_io = getattr(self.rpi.io, self.auto_input_name)
|
||||
self.cycle_io = getattr(self.rpi.io, self.cycle_input_name)
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
# Estado estable de I_1 con antirrebote.
|
||||
self.stable_auto = bool(self.auto_io.value)
|
||||
self.raw_auto = self.stable_auto
|
||||
self.raw_auto_since = now
|
||||
|
||||
# Pulsos de ciclo I_2.
|
||||
self.cycle_pulse_count = 0
|
||||
self.previous_cycle = bool(self.cycle_io.value)
|
||||
self.last_edge_ts = 0.0
|
||||
self.last_cycle_ts = None
|
||||
self.recent_pulses = deque()
|
||||
|
||||
self.pending_events = []
|
||||
|
||||
def update(self):
|
||||
now = time.monotonic()
|
||||
|
||||
# --- I_1: estado automático con antirrebote y evento inmediato ---
|
||||
raw_auto = bool(self.auto_io.value)
|
||||
|
||||
if raw_auto != self.raw_auto:
|
||||
self.raw_auto = raw_auto
|
||||
self.raw_auto_since = now
|
||||
|
||||
elif raw_auto != self.stable_auto and (now - self.raw_auto_since) >= self.debounce_s:
|
||||
previous = self.stable_auto
|
||||
self.stable_auto = raw_auto
|
||||
|
||||
event_name = "machine_started" if self.stable_auto else "machine_stopped"
|
||||
|
||||
self.pending_events.append({
|
||||
"event_type": "machine_state_changed",
|
||||
"event_name": event_name,
|
||||
"previous_auto_signal": previous,
|
||||
"new_auto_signal": self.stable_auto,
|
||||
"machine_running": self.stable_auto,
|
||||
"reason_pending": not self.stable_auto,
|
||||
"event_monotonic_ts": now,
|
||||
})
|
||||
|
||||
# --- I_2: flanco ascendente de ciclo con antirrebote ---
|
||||
current_cycle = bool(self.cycle_io.value)
|
||||
|
||||
if current_cycle and not self.previous_cycle:
|
||||
if now - self.last_edge_ts >= self.debounce_s:
|
||||
self.cycle_pulse_count += 1
|
||||
self.last_edge_ts = now
|
||||
self.last_cycle_ts = now
|
||||
self.recent_pulses.append(now)
|
||||
|
||||
self.previous_cycle = current_cycle
|
||||
|
||||
while self.recent_pulses and now - self.recent_pulses[0] > self.window_s:
|
||||
self.recent_pulses.popleft()
|
||||
|
||||
def snapshot(self):
|
||||
now = time.monotonic()
|
||||
|
||||
while self.recent_pulses and now - self.recent_pulses[0] > self.window_s:
|
||||
self.recent_pulses.popleft()
|
||||
|
||||
cycle_rate_ppm = len(self.recent_pulses) * (60.0 / self.window_s)
|
||||
|
||||
if self.last_cycle_ts is None:
|
||||
last_cycle_age_s = None
|
||||
else:
|
||||
last_cycle_age_s = round(now - self.last_cycle_ts, 1)
|
||||
|
||||
current_cycle = bool(self.cycle_io.value)
|
||||
|
||||
return {
|
||||
"io_ok": True,
|
||||
"machine_auto_signal": self.stable_auto,
|
||||
"machine_running": self.stable_auto,
|
||||
"cycle_input_state": current_cycle,
|
||||
"cycle_pulse_count": self.cycle_pulse_count,
|
||||
"cycle_rate_ppm": round(cycle_rate_ppm, 2),
|
||||
"last_cycle_age_s": last_cycle_age_s,
|
||||
"digital_inputs": {
|
||||
self.auto_input_name: self.stable_auto,
|
||||
self.cycle_input_name: current_cycle,
|
||||
},
|
||||
}
|
||||
|
||||
def pop_events(self):
|
||||
events = list(self.pending_events)
|
||||
self.pending_events.clear()
|
||||
return events
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.rpi.exit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def base_payload():
|
||||
return {
|
||||
"asset": getenv("ASSET", "revpi_oee_node_01"),
|
||||
"tenant": getenv("TENANT", "ucepsa"),
|
||||
"site": getenv("SITE", "demo_edge_oee"),
|
||||
"vertical": getenv("VERTICAL", "edge_oee"),
|
||||
"node_type": getenv("NODE_TYPE", "revpi_sdm120m_oee_gateway"),
|
||||
"fw": getenv("FW", "0.1.0"),
|
||||
"source": "revpi_edge_oee_node",
|
||||
"ts": utc_now_iso(),
|
||||
"uptime_s": round(time.monotonic() - START_TIME, 1),
|
||||
"machine_id": getenv("MACHINE_ID", "CORT-01"),
|
||||
"order_id": None,
|
||||
}
|
||||
|
||||
|
||||
def build_telemetry_payload(di_tracker):
|
||||
payload = base_payload()
|
||||
payload.update(di_tracker.snapshot())
|
||||
|
||||
try:
|
||||
payload.update(read_sdm120m())
|
||||
|
||||
except Exception as exc:
|
||||
payload.update({
|
||||
"comm_ok": False,
|
||||
"sample_valid": False,
|
||||
"meter_model": "Eastron SDM120-M",
|
||||
"meter_id": f"modbus_{getenv('MODBUS_SLAVE', '1')}",
|
||||
"voltage_l1_v": None,
|
||||
"current_l1_a": None,
|
||||
"power_total_kw": None,
|
||||
"frequency_hz": None,
|
||||
"import_kwh": None,
|
||||
"export_kwh": None,
|
||||
"error_text": f"{type(exc).__name__}: {exc}",
|
||||
})
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_event_payload(raw_event, di_tracker):
|
||||
payload = base_payload()
|
||||
payload.update(di_tracker.snapshot())
|
||||
|
||||
payload.update({
|
||||
"event_type": raw_event["event_type"],
|
||||
"event_name": raw_event["event_name"],
|
||||
"previous_auto_signal": raw_event["previous_auto_signal"],
|
||||
"new_auto_signal": raw_event["new_auto_signal"],
|
||||
"machine_running": raw_event["machine_running"],
|
||||
"reason_pending": raw_event["reason_pending"],
|
||||
"event_ts": utc_now_iso(),
|
||||
"event_source": "digital_input",
|
||||
"event_input": getenv("DI_AUTO_INPUT", "I_1"),
|
||||
})
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def create_mqtt_client():
|
||||
mqtt_host = getenv("MQTT_HOST", required=True)
|
||||
mqtt_port = int(getenv("MQTT_PORT", "1883"))
|
||||
mqtt_user = getenv("MQTT_USER", required=True)
|
||||
mqtt_password = getenv("MQTT_PASSWORD", required=True)
|
||||
mqtt_client_id = getenv("MQTT_CLIENT_ID", required=True)
|
||||
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
|
||||
|
||||
client = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=mqtt_client_id,
|
||||
clean_session=True,
|
||||
)
|
||||
|
||||
client.username_pw_set(mqtt_user, mqtt_password)
|
||||
client.will_set(topic_status, payload="offline", qos=0, retain=True)
|
||||
|
||||
client.connect(mqtt_host, mqtt_port, keepalive=30)
|
||||
client.loop_start()
|
||||
|
||||
client.publish(topic_status, payload="online", qos=0, retain=True)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def publish_json(client, topic, payload, retain=False):
|
||||
result = client.publish(
|
||||
topic,
|
||||
payload=json.dumps(payload, ensure_ascii=False),
|
||||
qos=0,
|
||||
retain=retain,
|
||||
)
|
||||
result.wait_for_publish(timeout=5)
|
||||
|
||||
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
raise RuntimeError(f"MQTT publish failed rc={result.rc}")
|
||||
|
||||
|
||||
def publish_loop():
|
||||
topic_telemetry = getenv("MQTT_TOPIC_TELEMETRY", required=True)
|
||||
topic_event = getenv("MQTT_TOPIC_EVENT", required=True)
|
||||
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
|
||||
interval_s = float(getenv("PUBLISH_INTERVAL_SECONDS", "10"))
|
||||
|
||||
di_tracker = DigitalInputTracker()
|
||||
client = create_mqtt_client()
|
||||
|
||||
print("[START] RevPi Edge-OEE MQTT loop", flush=True)
|
||||
print(f"[CONFIG] topic_telemetry={topic_telemetry}", flush=True)
|
||||
print(f"[CONFIG] topic_event={topic_event}", flush=True)
|
||||
print(f"[CONFIG] interval_s={interval_s}", flush=True)
|
||||
print(f"[CONFIG] auto_input={di_tracker.auto_input_name}", flush=True)
|
||||
print(f"[CONFIG] cycle_input={di_tracker.cycle_input_name}", flush=True)
|
||||
|
||||
next_publish = time.monotonic()
|
||||
|
||||
try:
|
||||
while RUNNING:
|
||||
di_tracker.update()
|
||||
|
||||
# Eventos inmediatos por cambio de I_1.
|
||||
for raw_event in di_tracker.pop_events():
|
||||
event_payload = build_event_payload(raw_event, di_tracker)
|
||||
publish_json(client, topic_event, event_payload, retain=False)
|
||||
|
||||
print(
|
||||
"[EVENT] "
|
||||
f"{event_payload.get('event_name')} "
|
||||
f"previous={event_payload.get('previous_auto_signal')} "
|
||||
f"new={event_payload.get('new_auto_signal')} "
|
||||
f"pulses={event_payload.get('cycle_pulse_count')} "
|
||||
f"rate_ppm={event_payload.get('cycle_rate_ppm')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Telemetría periódica.
|
||||
now = time.monotonic()
|
||||
|
||||
if now >= next_publish:
|
||||
telemetry_payload = build_telemetry_payload(di_tracker)
|
||||
publish_json(client, topic_telemetry, telemetry_payload, retain=False)
|
||||
|
||||
print(
|
||||
"[OK] telemetry "
|
||||
f"auto={telemetry_payload.get('machine_auto_signal')} "
|
||||
f"running={telemetry_payload.get('machine_running')} "
|
||||
f"pulses={telemetry_payload.get('cycle_pulse_count')} "
|
||||
f"rate_ppm={telemetry_payload.get('cycle_rate_ppm')} "
|
||||
f"voltage={telemetry_payload.get('voltage_l1_v')} "
|
||||
f"power_kw={telemetry_payload.get('power_total_kw')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
next_publish = now + interval_s
|
||||
|
||||
time.sleep(0.02)
|
||||
|
||||
finally:
|
||||
try:
|
||||
client.publish(topic_status, payload="offline", qos=0, retain=True)
|
||||
time.sleep(0.2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
di_tracker.close()
|
||||
print("[STOP] RevPi Edge-OEE MQTT loop", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
load_env_file(ENV_FILE)
|
||||
publish_loop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
WISE_IP = "10.43.54.249"
|
||||
UNIT = 1
|
||||
|
||||
|
||||
def u32_lo_hi(lo, hi):
|
||||
return (lo & 0xFFFF) + ((hi & 0xFFFF) << 16)
|
||||
|
||||
|
||||
client = ModbusTcpClient(WISE_IP, port=502, timeout=2)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError("No conecta con WISE")
|
||||
|
||||
last_di1_counter = None
|
||||
|
||||
try:
|
||||
print("Leyendo contador DI1 por Modbus/TCP. Pulsa DI1 y observa cambios.")
|
||||
while True:
|
||||
rr = client.read_holding_registers(
|
||||
address=0,
|
||||
count=4,
|
||||
device_id=UNIT,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
print("ERROR:", rr)
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
regs = rr.registers
|
||||
|
||||
di0_counter = u32_lo_hi(regs[0], regs[1])
|
||||
di1_counter = u32_lo_hi(regs[2], regs[3])
|
||||
|
||||
if di1_counter != last_di1_counter:
|
||||
print(
|
||||
f"DI0_counter={di0_counter} "
|
||||
f"DI1_counter={di1_counter} "
|
||||
f"raw={regs}"
|
||||
)
|
||||
last_di1_counter = di1_counter
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Parado")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
@ -1,212 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import csv
|
||||
import math
|
||||
import signal
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
WISE_IP = "17.126.1.200"
|
||||
UNIT = 1
|
||||
INTERVAL_S = 2.0
|
||||
|
||||
DATA_BASE = 1000
|
||||
DATA_COUNT = 20
|
||||
|
||||
WORD_ERR_BASE = 1200
|
||||
WORD_ERR_COUNT = 20
|
||||
|
||||
LOG_DIR = Path("logs")
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
csv_path = LOG_DIR / f"cort01_eastron_continuo_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
running = True
|
||||
|
||||
|
||||
def stop_handler(signum, frame):
|
||||
global running
|
||||
running = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, stop_handler)
|
||||
signal.signal(signal.SIGTERM, stop_handler)
|
||||
|
||||
|
||||
def f32_abcd(hi, lo):
|
||||
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def read_holding(client, addr, count):
|
||||
rr = client.read_holding_registers(address=addr, count=count, device_id=UNIT)
|
||||
if rr.isError():
|
||||
raise RuntimeError(rr)
|
||||
return rr.registers
|
||||
|
||||
|
||||
def valid_number(x):
|
||||
return isinstance(x, float) and math.isfinite(x)
|
||||
|
||||
|
||||
def open_client():
|
||||
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
|
||||
if not client.connect():
|
||||
raise RuntimeError(f"No conecta con WISE {WISE_IP}:502")
|
||||
return client
|
||||
|
||||
|
||||
fields = [
|
||||
"local_ts",
|
||||
"ok",
|
||||
"fail_reason",
|
||||
"voltage_l1_v",
|
||||
"current_l1_a",
|
||||
"active_power_w",
|
||||
"apparent_power_va",
|
||||
"reactive_power_var",
|
||||
"power_factor",
|
||||
"frequency_hz",
|
||||
"import_kwh",
|
||||
"export_kwh",
|
||||
"total_active_energy_kwh",
|
||||
"word_err_nonzero",
|
||||
"word_err_values",
|
||||
]
|
||||
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
client = None
|
||||
|
||||
print(f"Monitor continuo CORT-01 / wise-cort-01 / {WISE_IP}")
|
||||
print(f"CSV: {csv_path}")
|
||||
print("Ctrl+C para parar")
|
||||
print()
|
||||
|
||||
with csv_path.open("w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
|
||||
while running:
|
||||
ts = datetime.now().isoformat(timespec="seconds")
|
||||
row = {
|
||||
"local_ts": ts,
|
||||
"ok": False,
|
||||
"fail_reason": "",
|
||||
"voltage_l1_v": "",
|
||||
"current_l1_a": "",
|
||||
"active_power_w": "",
|
||||
"apparent_power_va": "",
|
||||
"reactive_power_var": "",
|
||||
"power_factor": "",
|
||||
"frequency_hz": "",
|
||||
"import_kwh": "",
|
||||
"export_kwh": "",
|
||||
"total_active_energy_kwh": "",
|
||||
"word_err_nonzero": "",
|
||||
"word_err_values": "",
|
||||
}
|
||||
|
||||
try:
|
||||
if client is None:
|
||||
client = open_client()
|
||||
|
||||
data = read_holding(client, DATA_BASE, DATA_COUNT)
|
||||
word_err = read_holding(client, WORD_ERR_BASE, WORD_ERR_COUNT)
|
||||
|
||||
voltage_l1_v = f32_abcd(data[0], data[1])
|
||||
current_l1_a = f32_abcd(data[2], data[3])
|
||||
active_power_w = f32_abcd(data[4], data[5])
|
||||
apparent_power_va = f32_abcd(data[6], data[7])
|
||||
reactive_power_var = f32_abcd(data[8], data[9])
|
||||
power_factor = f32_abcd(data[10], data[11])
|
||||
frequency_hz = f32_abcd(data[12], data[13])
|
||||
import_kwh = f32_abcd(data[14], data[15])
|
||||
export_kwh = f32_abcd(data[16], data[17])
|
||||
total_active_energy_kwh = f32_abcd(data[18], data[19])
|
||||
|
||||
non_zero_errs = [(WORD_ERR_BASE + i, v) for i, v in enumerate(word_err) if v != 0]
|
||||
|
||||
values_ok = (
|
||||
valid_number(voltage_l1_v)
|
||||
and valid_number(current_l1_a)
|
||||
and valid_number(active_power_w)
|
||||
and valid_number(frequency_hz)
|
||||
and 150 <= voltage_l1_v <= 260
|
||||
and 45 <= frequency_hz <= 55
|
||||
and current_l1_a >= 0
|
||||
and len(non_zero_errs) == 0
|
||||
)
|
||||
|
||||
row.update({
|
||||
"ok": values_ok,
|
||||
"voltage_l1_v": round(voltage_l1_v, 4),
|
||||
"current_l1_a": round(current_l1_a, 6),
|
||||
"active_power_w": round(active_power_w, 4),
|
||||
"apparent_power_va": round(apparent_power_va, 4),
|
||||
"reactive_power_var": round(reactive_power_var, 4),
|
||||
"power_factor": round(power_factor, 6),
|
||||
"frequency_hz": round(frequency_hz, 4),
|
||||
"import_kwh": round(import_kwh, 6),
|
||||
"export_kwh": round(export_kwh, 6),
|
||||
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
|
||||
"word_err_nonzero": len(non_zero_errs),
|
||||
"word_err_values": str(non_zero_errs),
|
||||
})
|
||||
|
||||
if values_ok:
|
||||
ok_count += 1
|
||||
status = "OK"
|
||||
else:
|
||||
fail_count += 1
|
||||
status = "BAD"
|
||||
row["fail_reason"] = "valores_fuera_rango_o_word_err"
|
||||
|
||||
print(
|
||||
f"{ts} {status} "
|
||||
f"OK={ok_count} FAIL={fail_count} "
|
||||
f"V={voltage_l1_v:7.2f} "
|
||||
f"I={current_l1_a:7.3f} "
|
||||
f"P={active_power_w:8.2f} "
|
||||
f"F={frequency_hz:6.2f} "
|
||||
f"kWh={import_kwh:.4f} "
|
||||
f"WORD_ERR={non_zero_errs}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
fail_count += 1
|
||||
row["ok"] = False
|
||||
row["fail_reason"] = str(e)
|
||||
|
||||
print(
|
||||
f"{ts} ERROR "
|
||||
f"OK={ok_count} FAIL={fail_count} "
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
if client is not None:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
client = None
|
||||
|
||||
writer.writerow(row)
|
||||
f.flush()
|
||||
time.sleep(INTERVAL_S)
|
||||
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
total = ok_count + fail_count
|
||||
print()
|
||||
print("Resumen")
|
||||
print("-------")
|
||||
print(f"OK={ok_count}")
|
||||
print(f"FAIL={fail_count}")
|
||||
if total:
|
||||
print(f"Disponibilidad lectura={ok_count / total * 100:.2f}%")
|
||||
print(f"CSV guardado en: {csv_path}")
|
||||
@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
NODES = [
|
||||
{"machine_id": "CORT-01", "source_node": "wise-cort-00", "ip": "10.43.54.249"},
|
||||
{"machine_id": "CORT-00", "source_node": "wise-cort-01", "ip": "10.43.54.251"},
|
||||
{"machine_id": "CORT-02", "source_node": "wise-cort-02", "ip": "10.43.54.55"},
|
||||
]
|
||||
|
||||
UNIT = 1
|
||||
|
||||
|
||||
def u32_lo_hi(lo, hi):
|
||||
return (lo & 0xFFFF) + ((hi & 0xFFFF) << 16)
|
||||
|
||||
|
||||
for node in NODES:
|
||||
print()
|
||||
print("==================================================")
|
||||
print(node["machine_id"], node["source_node"], node["ip"])
|
||||
print("==================================================")
|
||||
|
||||
client = ModbusTcpClient(node["ip"], port=502, timeout=3)
|
||||
|
||||
if not client.connect():
|
||||
print("ERROR: no conecta")
|
||||
continue
|
||||
|
||||
try:
|
||||
rr_di = client.read_discrete_inputs(address=0, count=2, device_id=UNIT)
|
||||
if rr_di.isError():
|
||||
print("ERROR DI:", rr_di)
|
||||
else:
|
||||
print("DI0 marcha:", bool(rr_di.bits[0]))
|
||||
print("DI1 estado:", bool(rr_di.bits[1]))
|
||||
|
||||
rr_cnt = client.read_holding_registers(address=0, count=4, device_id=UNIT)
|
||||
if rr_cnt.isError():
|
||||
print("ERROR counter:", rr_cnt)
|
||||
else:
|
||||
regs = rr_cnt.registers
|
||||
di0_counter = u32_lo_hi(regs[0], regs[1])
|
||||
di1_counter = u32_lo_hi(regs[2], regs[3])
|
||||
print("DI0 counter:", di0_counter)
|
||||
print("DI1 counter:", di1_counter)
|
||||
print("raw counter:", regs)
|
||||
|
||||
rr_energy = client.read_holding_registers(address=1000, count=20, device_id=UNIT)
|
||||
if rr_energy.isError():
|
||||
print("ERROR energy:", rr_energy)
|
||||
else:
|
||||
print("Energy registers OK, first values:", rr_energy.registers[:6])
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import struct
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
WISE_IP = "17.126.1.201"
|
||||
UNIT = 1
|
||||
|
||||
DATA_BASE = 1000 # WISE 4X 1001 en base cero
|
||||
DATA_COUNT = 20
|
||||
|
||||
ERROR_BASE = 1200 # WISE 4X 1201 en base cero, error codes COM1 expansion word
|
||||
ERROR_COUNT = 20
|
||||
|
||||
|
||||
def f32_abcd(hi, lo):
|
||||
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def read_holding(client, start, count):
|
||||
rr = client.read_holding_registers(address=start, count=count, device_id=UNIT)
|
||||
if rr.isError():
|
||||
raise RuntimeError(rr)
|
||||
return rr.registers
|
||||
|
||||
|
||||
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError(f"No conecta con WISE {WISE_IP}:502")
|
||||
|
||||
try:
|
||||
print("==================================================")
|
||||
print("CORT-01 / wise-cort-01 /", WISE_IP)
|
||||
print("==================================================")
|
||||
|
||||
regs = read_holding(client, DATA_BASE, DATA_COUNT)
|
||||
|
||||
print("\nRegistros mapeados Eastron en WISE")
|
||||
print("----------------------------------")
|
||||
for i in range(0, len(regs), 2):
|
||||
addr1 = DATA_BASE + i
|
||||
addr2 = DATA_BASE + i + 1
|
||||
hi = regs[i]
|
||||
lo = regs[i + 1]
|
||||
val = f32_abcd(hi, lo)
|
||||
print(f"{addr1}/{addr2}: raw=({hi},{lo}) float={val:.6f}")
|
||||
|
||||
print("\nInterpretación según mapeo actual")
|
||||
print("---------------------------------")
|
||||
labels = [
|
||||
("voltage_l1_v", 0),
|
||||
("current_l1_a", 2),
|
||||
("active_power_w", 4),
|
||||
("apparent_power_va", 6),
|
||||
("reactive_power_var", 8),
|
||||
("power_factor", 10),
|
||||
("frequency_hz", 12),
|
||||
("import_kwh", 14),
|
||||
("export_kwh", 16),
|
||||
("total_active_energy_kwh", 18),
|
||||
]
|
||||
|
||||
for name, offset in labels:
|
||||
if offset + 1 < len(regs):
|
||||
print(f"{name:28s}: {f32_abcd(regs[offset], regs[offset + 1]):.6f}")
|
||||
|
||||
print("\nRegistros de error COM1 Expansion Word")
|
||||
print("--------------------------------------")
|
||||
try:
|
||||
errs = read_holding(client, ERROR_BASE, ERROR_COUNT)
|
||||
non_zero = False
|
||||
for i, value in enumerate(errs):
|
||||
if value != 0:
|
||||
non_zero = True
|
||||
print(f"ERROR_REG {ERROR_BASE + i}: {value}")
|
||||
|
||||
if not non_zero:
|
||||
print("Todos los registros de error están a 0")
|
||||
except Exception as e:
|
||||
print("No se pudieron leer registros de error:", e)
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import struct
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
WISE_IP = "17.126.1.202" # ajusta si cambia
|
||||
UNIT = 1
|
||||
|
||||
def f32(hi, lo):
|
||||
return struct.unpack(">f", hi.to_bytes(2, "big") + lo.to_bytes(2, "big"))[0]
|
||||
|
||||
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
|
||||
|
||||
if not client.connect():
|
||||
raise SystemExit("No conecta con WISE")
|
||||
|
||||
try:
|
||||
rr = client.read_holding_registers(address=1000, count=34, device_id=UNIT)
|
||||
if rr.isError():
|
||||
raise SystemExit(rr)
|
||||
|
||||
r = rr.registers
|
||||
|
||||
values = {
|
||||
"V_L1_N": f32(r[0], r[1]),
|
||||
"V_L2_N": f32(r[2], r[3]),
|
||||
"V_L3_N": f32(r[4], r[5]),
|
||||
|
||||
"I_L1": f32(r[6], r[7]),
|
||||
"I_L2": f32(r[8], r[9]),
|
||||
"I_L3": f32(r[10], r[11]),
|
||||
|
||||
"P_L1_W": f32(r[12], r[13]),
|
||||
"P_L2_W": f32(r[14], r[15]),
|
||||
"P_L3_W": f32(r[16], r[17]),
|
||||
|
||||
"P_TOTAL_W": f32(r[18], r[19]),
|
||||
"S_TOTAL_VA": f32(r[20], r[21]),
|
||||
"Q_TOTAL_VAR": f32(r[22], r[23]),
|
||||
"PF_TOTAL": f32(r[24], r[25]),
|
||||
|
||||
"FREQ_HZ": f32(r[26], r[27]),
|
||||
"IMPORT_KWH": f32(r[28], r[29]),
|
||||
"EXPORT_KWH": f32(r[30], r[31]),
|
||||
"TOTAL_ACTIVE_KWH": f32(r[32], r[33]),
|
||||
}
|
||||
|
||||
for k, v in values.items():
|
||||
print(f"{k:18s}: {v:.4f}")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import struct
|
||||
from pymodbus.client import ModbusSerialClient
|
||||
|
||||
|
||||
def regs_to_bytes(regs, mode):
|
||||
a = regs[0]
|
||||
b = regs[1]
|
||||
|
||||
ab = a.to_bytes(2, "big")
|
||||
cd = b.to_bytes(2, "big")
|
||||
|
||||
if mode == "ABCD":
|
||||
return ab + cd
|
||||
if mode == "CDAB":
|
||||
return cd + ab
|
||||
if mode == "BADC":
|
||||
return ab[::-1] + cd[::-1]
|
||||
if mode == "DCBA":
|
||||
return cd[::-1] + ab[::-1]
|
||||
|
||||
raise ValueError(mode)
|
||||
|
||||
|
||||
def decode_float_candidates(regs):
|
||||
out = {}
|
||||
for mode in ["ABCD", "CDAB", "BADC", "DCBA"]:
|
||||
try:
|
||||
raw = regs_to_bytes(regs, mode)
|
||||
out[mode] = struct.unpack(">f", raw)[0]
|
||||
except Exception as exc:
|
||||
out[mode] = f"ERR: {exc}"
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", default="/dev/ttyRS485")
|
||||
parser.add_argument("--slave", type=int, default=1)
|
||||
parser.add_argument("--baud", type=int, default=9600)
|
||||
parser.add_argument("--parity", default="N", choices=["N", "E", "O"])
|
||||
parser.add_argument("--stopbits", type=int, default=1)
|
||||
parser.add_argument("--bytesize", type=int, default=8)
|
||||
parser.add_argument("--address", type=int, default=0)
|
||||
parser.add_argument("--count", type=int, default=2)
|
||||
parser.add_argument("--timeout", type=float, default=1.5)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("--- MODBUS RTU PROBE ---")
|
||||
print(f"port={args.port}")
|
||||
print(f"slave={args.slave}")
|
||||
print(f"baud={args.baud}")
|
||||
print(f"parity={args.parity}")
|
||||
print(f"address={args.address}")
|
||||
print(f"count={args.count}")
|
||||
|
||||
client = ModbusSerialClient(
|
||||
port=args.port,
|
||||
baudrate=args.baud,
|
||||
parity=args.parity,
|
||||
stopbits=args.stopbits,
|
||||
bytesize=args.bytesize,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
if not client.connect():
|
||||
print("[ERROR] Cannot open serial port")
|
||||
return 2
|
||||
|
||||
try:
|
||||
rr = client.read_input_registers(
|
||||
address=args.address,
|
||||
count=args.count,
|
||||
device_id=args.slave,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
print(f"[ERROR] Modbus error: {rr}")
|
||||
return 3
|
||||
|
||||
regs = rr.registers
|
||||
print(f"registers={regs}")
|
||||
print("float_candidates:")
|
||||
for mode, value in decode_float_candidates(regs).items():
|
||||
print(f" {mode}: {value}")
|
||||
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[ERROR] No Modbus response: {type(exc).__name__}: {exc}")
|
||||
return 3
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
WISE_IP = "10.43.54.249"
|
||||
WISE_PORT = 502
|
||||
UNIT = 1
|
||||
|
||||
|
||||
def read_holding(client, start, count):
|
||||
rr = client.read_holding_registers(
|
||||
address=start,
|
||||
count=count,
|
||||
device_id=UNIT,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(rr)
|
||||
|
||||
return rr.registers
|
||||
|
||||
|
||||
def u32_hi_lo(hi, lo):
|
||||
return ((hi & 0xFFFF) << 16) | (lo & 0xFFFF)
|
||||
|
||||
|
||||
def u32_lo_hi(hi, lo):
|
||||
return ((lo & 0xFFFF) << 16) | (hi & 0xFFFF)
|
||||
|
||||
|
||||
def print_block(name, start, regs):
|
||||
print(f"\n{name} start={start}")
|
||||
print("16-bit:")
|
||||
for i, value in enumerate(regs):
|
||||
print(f"addr {start+i:4d}: {value}")
|
||||
|
||||
print("\n32-bit pairs:")
|
||||
for i in range(0, len(regs) - 1, 2):
|
||||
a = start + i
|
||||
hi = regs[i]
|
||||
lo = regs[i + 1]
|
||||
print(
|
||||
f"addr {a:4d}/{a+1:4d}: "
|
||||
f"hi_lo={u32_hi_lo(hi, lo)} "
|
||||
f"lo_hi={u32_lo_hi(hi, lo)}"
|
||||
)
|
||||
|
||||
|
||||
def snapshot(client):
|
||||
return {
|
||||
"base0": read_holding(client, 0, 12),
|
||||
"base1": read_holding(client, 1, 12),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError("No conecta con WISE")
|
||||
|
||||
try:
|
||||
print("Snapshot inicial")
|
||||
before = snapshot(client)
|
||||
|
||||
for name, regs in before.items():
|
||||
start = 0 if name == "base0" else 1
|
||||
print_block(name, start, regs)
|
||||
|
||||
input("\nPulsa DI1 exactamente 10 veces y después pulsa ENTER...")
|
||||
|
||||
print("\nSnapshot final")
|
||||
after = snapshot(client)
|
||||
|
||||
for name, regs in after.items():
|
||||
start = 0 if name == "base0" else 1
|
||||
print_block(name, start, regs)
|
||||
|
||||
print("\nDeltas 16-bit:")
|
||||
for name in before:
|
||||
start = 0 if name == "base0" else 1
|
||||
for i, (b, a) in enumerate(zip(before[name], after[name])):
|
||||
if a != b:
|
||||
delta = (a - b) & 0xFFFF
|
||||
print(f"{name} addr {start+i}: before={b} after={a} delta={delta}")
|
||||
|
||||
print("\nDeltas 32-bit:")
|
||||
for name in before:
|
||||
start = 0 if name == "base0" else 1
|
||||
bregs = before[name]
|
||||
aregs = after[name]
|
||||
|
||||
for i in range(0, len(bregs) - 1, 2):
|
||||
b_hi_lo = u32_hi_lo(bregs[i], bregs[i + 1])
|
||||
a_hi_lo = u32_hi_lo(aregs[i], aregs[i + 1])
|
||||
d_hi_lo = (a_hi_lo - b_hi_lo) & 0xFFFFFFFF
|
||||
|
||||
b_lo_hi = u32_lo_hi(bregs[i], bregs[i + 1])
|
||||
a_lo_hi = u32_lo_hi(aregs[i], aregs[i + 1])
|
||||
d_lo_hi = (a_lo_hi - b_lo_hi) & 0xFFFFFFFF
|
||||
|
||||
if d_hi_lo or d_lo_hi:
|
||||
print(
|
||||
f"{name} addr {start+i}/{start+i+1}: "
|
||||
f"delta_hi_lo={d_hi_lo} "
|
||||
f"delta_lo_hi={d_lo_hi}"
|
||||
)
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import struct
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
def call_with_unit(fn, unit, **kwargs):
|
||||
"""
|
||||
Compatible con varias versiones de pymodbus:
|
||||
- pymodbus 3.x usa device_id
|
||||
- versiones anteriores pueden usar slave o unit
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for key in ("device_id", "slave", "unit"):
|
||||
try:
|
||||
return fn(**kwargs, **{key: unit})
|
||||
except TypeError as e:
|
||||
last_error = e
|
||||
|
||||
raise last_error
|
||||
|
||||
|
||||
def f32_abcd(reg_hi, reg_lo):
|
||||
raw = reg_hi.to_bytes(2, "big") + reg_lo.to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def print_holding(client, unit, start, count):
|
||||
print(f"\n=== HOLDING REGISTERS start={start}, count={count}, unit={unit} ===")
|
||||
|
||||
try:
|
||||
rr = call_with_unit(
|
||||
client.read_holding_registers,
|
||||
unit,
|
||||
address=start,
|
||||
count=count
|
||||
)
|
||||
except Exception as e:
|
||||
print("EXCEPTION:", repr(e))
|
||||
return
|
||||
|
||||
if rr.isError():
|
||||
print("MODBUS ERROR:", rr)
|
||||
return
|
||||
|
||||
regs = rr.registers
|
||||
|
||||
for i, value in enumerate(regs):
|
||||
print(f"{start + i}: {value}")
|
||||
|
||||
print("\nFloat ABCD por parejas:")
|
||||
for i in range(0, len(regs) - 1, 2):
|
||||
try:
|
||||
value = f32_abcd(regs[i], regs[i + 1])
|
||||
print(f"{start + i}/{start + i + 1}: {value:.6f}")
|
||||
except Exception as e:
|
||||
print(f"{start + i}/{start + i + 1}: ERROR {e}")
|
||||
|
||||
|
||||
def print_bits(client, unit, kind, start, count):
|
||||
print(f"\n=== {kind.upper()} start={start}, count={count}, unit={unit} ===")
|
||||
|
||||
fn = client.read_discrete_inputs if kind == "discrete" else client.read_coils
|
||||
|
||||
try:
|
||||
rr = call_with_unit(
|
||||
fn,
|
||||
unit,
|
||||
address=start,
|
||||
count=count
|
||||
)
|
||||
except Exception as e:
|
||||
print("EXCEPTION:", repr(e))
|
||||
return
|
||||
|
||||
if rr.isError():
|
||||
print("MODBUS ERROR:", rr)
|
||||
return
|
||||
|
||||
bits = rr.bits[:count]
|
||||
|
||||
for i, value in enumerate(bits):
|
||||
print(f"{start + i}: {int(value)}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", required=True)
|
||||
parser.add_argument("--port", type=int, default=502)
|
||||
parser.add_argument("--unit", type=int, default=1)
|
||||
args = parser.parse_args()
|
||||
|
||||
client = ModbusTcpClient(args.host, port=args.port, timeout=3)
|
||||
|
||||
if not client.connect():
|
||||
print(f"ERROR: no conecta con {args.host}:{args.port}")
|
||||
return
|
||||
|
||||
print(f"OK: conectado a {args.host}:{args.port}")
|
||||
|
||||
try:
|
||||
# El WISE muestra Mapping Address(4X) 1001.
|
||||
# Probamos base cero y base uno.
|
||||
print_holding(client, args.unit, 1000, 20)
|
||||
print_holding(client, args.unit, 1001, 20)
|
||||
|
||||
# Intentos típicos para DI por Modbus/TCP.
|
||||
# Luego afinamos según Bit Status del WISE.
|
||||
print_bits(client, args.unit, "discrete", 0, 16)
|
||||
print_bits(client, args.unit, "discrete", 1, 16)
|
||||
print_bits(client, args.unit, "coils", 0, 16)
|
||||
print_bits(client, args.unit, "coils", 1, 16)
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
|
||||
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
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return env
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def build_payload(machine_id, mode):
|
||||
base = {
|
||||
"machine_id": machine_id,
|
||||
"ts": utc_now(),
|
||||
"source": "odoo_simulator",
|
||||
}
|
||||
|
||||
if mode == "loaded":
|
||||
base.update({
|
||||
"order_present": True,
|
||||
"order_coherent": True,
|
||||
"odoo_state": "ready",
|
||||
"workorder_id": 1000,
|
||||
"production_order": f"SIM-{machine_id}-OK",
|
||||
"product": "Orden simulada correcta",
|
||||
})
|
||||
|
||||
elif mode == "no_order":
|
||||
base.update({
|
||||
"order_present": False,
|
||||
"order_coherent": False,
|
||||
"odoo_state": "none",
|
||||
"workorder_id": None,
|
||||
"production_order": None,
|
||||
"product": None,
|
||||
})
|
||||
|
||||
elif mode == "bad":
|
||||
base.update({
|
||||
"order_present": True,
|
||||
"order_coherent": False,
|
||||
"odoo_state": "wrong_machine",
|
||||
"workorder_id": 9999,
|
||||
"production_order": f"SIM-{machine_id}-BAD",
|
||||
"product": "Orden simulada incoherente",
|
||||
"reason": "Orden asignada a otra máquina o estado no válido",
|
||||
})
|
||||
|
||||
elif mode == "fault":
|
||||
base.update({
|
||||
"order_present": True,
|
||||
"order_coherent": True,
|
||||
"odoo_state": "ready",
|
||||
"workorder_id": 1001,
|
||||
"production_order": f"SIM-{machine_id}-FAULT",
|
||||
"product": "Orden simulada con fallo máquina",
|
||||
"force_machine_fault": True,
|
||||
})
|
||||
|
||||
else:
|
||||
raise SystemExit(f"Modo no reconocido: {mode}")
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("Uso:")
|
||||
print(" python publish_order_state.py CORT-00 loaded")
|
||||
print(" python publish_order_state.py CORT-00 no_order")
|
||||
print(" python publish_order_state.py CORT-00 bad")
|
||||
print(" python publish_order_state.py CORT-00 fault")
|
||||
raise SystemExit(1)
|
||||
|
||||
machine_id = sys.argv[1]
|
||||
mode = sys.argv[2]
|
||||
|
||||
env = read_env()
|
||||
|
||||
mqtt_host = env.get("MQTT_HOST", "127.0.0.1")
|
||||
mqtt_port = int(env.get("MQTT_PORT", "1883"))
|
||||
mqtt_user = env.get("MQTT_USERNAME") or env.get("MQTT_USER")
|
||||
mqtt_password = env.get("MQTT_PASSWORD")
|
||||
|
||||
tenant = env.get("TENANT", "ucepsa")
|
||||
site = env.get("SITE", "ucepsa_onpremise")
|
||||
vertical = env.get("VERTICAL", "edge_oee")
|
||||
|
||||
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
|
||||
payload = build_payload(machine_id, mode)
|
||||
|
||||
client = mqtt.Client(client_id=f"order_state_sim_{machine_id}")
|
||||
if mqtt_user:
|
||||
client.username_pw_set(mqtt_user, mqtt_password)
|
||||
|
||||
client.connect(mqtt_host, mqtt_port, 60)
|
||||
client.loop_start()
|
||||
|
||||
info = client.publish(topic, json.dumps(payload, separators=(",", ":")), qos=0, retain=True)
|
||||
info.wait_for_publish(timeout=5)
|
||||
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
|
||||
print(f"Publicado en {topic}")
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import time
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
def read_block(client, kind, start, count, unit):
|
||||
fn = client.read_holding_registers if kind == "holding" else client.read_input_registers
|
||||
|
||||
try:
|
||||
rr = fn(address=start, count=count, device_id=unit)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if rr.isError():
|
||||
return None
|
||||
|
||||
return rr.registers
|
||||
|
||||
|
||||
def snapshot(client, unit):
|
||||
ranges = [
|
||||
("holding", 0, 200),
|
||||
("holding", 1000, 100),
|
||||
("holding", 2000, 200),
|
||||
("holding", 3000, 200),
|
||||
("holding", 4000, 200),
|
||||
("input", 0, 200),
|
||||
("input", 1000, 100),
|
||||
("input", 2000, 200),
|
||||
("input", 3000, 200),
|
||||
("input", 4000, 200),
|
||||
]
|
||||
|
||||
data = {}
|
||||
|
||||
for kind, start, count in ranges:
|
||||
regs = read_block(client, kind, start, count, unit)
|
||||
if regs is None:
|
||||
continue
|
||||
|
||||
for i, value in enumerate(regs):
|
||||
data[(kind, start + i)] = value
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def u16_delta(before, after):
|
||||
return (after - before) & 0xFFFF
|
||||
|
||||
|
||||
def u32(hi, lo):
|
||||
return ((hi & 0xFFFF) << 16) | (lo & 0xFFFF)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", required=True)
|
||||
parser.add_argument("--unit", type=int, default=1)
|
||||
parser.add_argument("--expected", type=int, default=10)
|
||||
args = parser.parse_args()
|
||||
|
||||
client = ModbusTcpClient(args.host, port=502, timeout=2)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError("No conecta con WISE")
|
||||
|
||||
try:
|
||||
print("Tomando snapshot inicial...")
|
||||
before = snapshot(client, args.unit)
|
||||
|
||||
print(f"\nAhora pulsa DI1 exactamente {args.expected} veces.")
|
||||
input("Cuando termines, pulsa ENTER aquí...")
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
print("Tomando snapshot final...")
|
||||
after = snapshot(client, args.unit)
|
||||
|
||||
print("\nCambios 16-bit detectados:")
|
||||
changes = []
|
||||
|
||||
for key, before_value in before.items():
|
||||
if key not in after:
|
||||
continue
|
||||
|
||||
after_value = after[key]
|
||||
if after_value != before_value:
|
||||
delta = u16_delta(before_value, after_value)
|
||||
closeness = abs(delta - args.expected)
|
||||
changes.append((closeness, key, before_value, after_value, delta))
|
||||
|
||||
for closeness, key, b, a, delta in sorted(changes, key=lambda x: x[0])[:40]:
|
||||
kind, addr = key
|
||||
print(f"{kind:7s} addr={addr:5d} before={b:6d} after={a:6d} delta={delta}")
|
||||
|
||||
print("\nCambios 32-bit por parejas:")
|
||||
pair_changes = []
|
||||
|
||||
keys = sorted(before.keys())
|
||||
|
||||
for kind, addr in keys:
|
||||
key_hi = (kind, addr)
|
||||
key_lo = (kind, addr + 1)
|
||||
|
||||
if key_lo not in before or key_hi not in after or key_lo not in after:
|
||||
continue
|
||||
|
||||
before_u32 = u32(before[key_hi], before[key_lo])
|
||||
after_u32 = u32(after[key_hi], after[key_lo])
|
||||
|
||||
if before_u32 != after_u32:
|
||||
delta = (after_u32 - before_u32) & 0xFFFFFFFF
|
||||
closeness = abs(delta - args.expected)
|
||||
pair_changes.append((closeness, kind, addr, before_u32, after_u32, delta))
|
||||
|
||||
for closeness, kind, addr, b, a, delta in sorted(pair_changes, key=lambda x: x[0])[:40]:
|
||||
print(f"{kind:7s} addr={addr:5d}/{addr+1:5d} before={b} after={a} delta={delta}")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
|
||||
if ENV_PATH.exists():
|
||||
for raw in ENV_PATH.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
host = os.environ.get("MQTT_HOST", "17.126.1.70")
|
||||
port = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
|
||||
deadline = time.time() + 120
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=5):
|
||||
print(f"[OK] MQTT reachable {host}:{port}")
|
||||
sys.exit(0)
|
||||
except OSError as exc:
|
||||
print(f"[WAIT] MQTT {host}:{port} not reachable yet: {exc}")
|
||||
time.sleep(5)
|
||||
|
||||
print(f"[ERROR] MQTT {host}:{port} not reachable after timeout")
|
||||
sys.exit(1)
|
||||
@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
WISE_IP = "10.43.54.249"
|
||||
WISE_PORT = 502
|
||||
WISE_UNIT = 1
|
||||
|
||||
POLL_INTERVAL_S = 0.02
|
||||
DEBOUNCE_S = 0.02
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def read_di(client):
|
||||
rr = client.read_discrete_inputs(
|
||||
address=0,
|
||||
count=2,
|
||||
device_id=WISE_UNIT,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Error leyendo DI: {rr}")
|
||||
|
||||
di0 = bool(rr.bits[0])
|
||||
di1 = bool(rr.bits[1])
|
||||
|
||||
return di0, di1
|
||||
|
||||
|
||||
def main():
|
||||
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=1)
|
||||
|
||||
if not client.connect():
|
||||
raise RuntimeError(f"No conecta con WISE {WISE_IP}:{WISE_PORT}")
|
||||
|
||||
print(f"Conectado a WISE {WISE_IP}:{WISE_PORT}")
|
||||
print("DI0 = marcha / automático")
|
||||
print("DI1 = pulso sellador")
|
||||
print("Prueba: empieza con DI1 abierto, pulsa y suelta claramente.\n")
|
||||
|
||||
last_di0 = None
|
||||
last_di1 = None
|
||||
last_rising_ts = 0.0
|
||||
count = 0
|
||||
|
||||
loops = 0
|
||||
stats_start = time.perf_counter()
|
||||
max_loop_s = 0.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
loop_start = time.perf_counter()
|
||||
now = loop_start
|
||||
|
||||
di0, di1 = read_di(client)
|
||||
|
||||
if last_di0 is None:
|
||||
last_di0 = di0
|
||||
last_di1 = di1
|
||||
print(f"{utc_now()} INIT DI0={int(di0)} DI1={int(di1)}")
|
||||
|
||||
if di0 != last_di0:
|
||||
print(f"{utc_now()} CHANGE DI0 {int(last_di0)} -> {int(di0)}")
|
||||
last_di0 = di0
|
||||
|
||||
if di1 != last_di1:
|
||||
print(f"{utc_now()} CHANGE DI1 {int(last_di1)} -> {int(di1)}")
|
||||
|
||||
if di1 and not last_di1:
|
||||
if (now - last_rising_ts) >= DEBOUNCE_S:
|
||||
count += 1
|
||||
last_rising_ts = now
|
||||
print(f"{utc_now()} RISING DI1 count={count}")
|
||||
|
||||
last_di1 = di1
|
||||
|
||||
loops += 1
|
||||
loop_s = time.perf_counter() - loop_start
|
||||
max_loop_s = max(max_loop_s, loop_s)
|
||||
|
||||
if time.perf_counter() - stats_start >= 5.0:
|
||||
elapsed = time.perf_counter() - stats_start
|
||||
hz = loops / elapsed
|
||||
print(f"{utc_now()} STATS poll_hz={hz:.1f} max_loop_ms={max_loop_s * 1000:.1f} count={count}")
|
||||
loops = 0
|
||||
stats_start = time.perf_counter()
|
||||
max_loop_s = 0.0
|
||||
|
||||
sleep_s = max(0.0, POLL_INTERVAL_S - loop_s)
|
||||
time.sleep(sleep_s)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nParado. Conteo final DI1={count}")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,37 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"enabled": true,
|
||||
"machine_id": "CORT-01",
|
||||
"source_node": "wise-cort-01",
|
||||
"wise_ip": "17.126.1.201",
|
||||
"wise_port": 502,
|
||||
"unit": 1,
|
||||
"holding_base": 1000,
|
||||
"holding_count": 34,
|
||||
"session_env": "WISE_SESSION_ID_CORT_01"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"machine_id": "CORT-00",
|
||||
"source_node": "wise-cort-00",
|
||||
"wise_ip": "17.126.1.200",
|
||||
"wise_port": 502,
|
||||
"unit": 1,
|
||||
"holding_base": 1000,
|
||||
"holding_count": 34,
|
||||
"session_env": "WISE_SESSION_ID_CORT_00"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"machine_id": "CORT-02",
|
||||
"source_node": "wise-cort-02",
|
||||
"wise_ip": "17.126.1.202",
|
||||
"wise_port": 502,
|
||||
"unit": 1,
|
||||
"holding_base": 1000,
|
||||
"holding_count": 34,
|
||||
"session_env": "WISE_SESSION_ID_CORT_02"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,222 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import struct
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
WISE_IP = "10.43.54.249"
|
||||
WISE_PORT = 502
|
||||
WISE_UNIT = 1
|
||||
|
||||
HOLDING_BASE = 1000
|
||||
DI_BASE = 0
|
||||
|
||||
POLL_INTERVAL_S = 0.1
|
||||
PUBLISH_INTERVAL_S = 2.0
|
||||
DEBOUNCE_S = 0.05
|
||||
RATE_WINDOW_S = 60.0
|
||||
|
||||
TENANT = "ucepsa"
|
||||
SITE = "demo_edge_oee"
|
||||
VERTICAL = "edge_oee"
|
||||
ASSET = "revpi_oee_node_01"
|
||||
MACHINE_ID = "CORT-01"
|
||||
SOURCE_NODE = "wise-banco-01"
|
||||
NODE_TYPE = "revpi_wise_oee_gateway"
|
||||
FW = "0.1.0"
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def f32_abcd(reg_hi, reg_lo):
|
||||
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def read_holdings(client):
|
||||
rr = client.read_holding_registers(
|
||||
address=HOLDING_BASE,
|
||||
count=20,
|
||||
device_id=WISE_UNIT,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Error leyendo holding registers: {rr}")
|
||||
|
||||
return rr.registers
|
||||
|
||||
|
||||
def read_discrete_inputs(client):
|
||||
rr = client.read_discrete_inputs(
|
||||
address=DI_BASE,
|
||||
count=2,
|
||||
device_id=WISE_UNIT,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Error leyendo discrete inputs: {rr}")
|
||||
|
||||
di0 = bool(rr.bits[0])
|
||||
di1 = bool(rr.bits[1])
|
||||
|
||||
return di0, di1
|
||||
|
||||
|
||||
def decode_energy(regs):
|
||||
voltage_l1_v = f32_abcd(regs[0], regs[1])
|
||||
current_l1_a = f32_abcd(regs[2], regs[3])
|
||||
active_power_w = f32_abcd(regs[4], regs[5])
|
||||
apparent_power_va = f32_abcd(regs[6], regs[7])
|
||||
reactive_power_var = f32_abcd(regs[8], regs[9])
|
||||
power_factor = f32_abcd(regs[10], regs[11])
|
||||
frequency_hz = f32_abcd(regs[12], regs[13])
|
||||
import_kwh = f32_abcd(regs[14], regs[15])
|
||||
export_kwh = f32_abcd(regs[16], regs[17])
|
||||
total_active_energy_kwh = f32_abcd(regs[18], regs[19])
|
||||
|
||||
return {
|
||||
"voltage_l1_v": round(voltage_l1_v, 3),
|
||||
"current_l1_a": round(current_l1_a, 6),
|
||||
"active_power_w": round(active_power_w, 3),
|
||||
"power_total_kw": round(active_power_w / 1000.0, 6),
|
||||
"apparent_power_va": round(apparent_power_va, 3),
|
||||
"reactive_power_var": round(reactive_power_var, 3),
|
||||
"power_factor": round(power_factor, 6),
|
||||
"frequency_hz": round(frequency_hz, 3),
|
||||
"import_kwh": round(import_kwh, 6),
|
||||
"export_kwh": round(export_kwh, 6),
|
||||
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
|
||||
|
||||
cycle_pulses = deque()
|
||||
cycle_pulse_count = 0
|
||||
|
||||
last_cycle_state = None
|
||||
last_auto_signal = None
|
||||
last_pulse_ts = 0.0
|
||||
last_publish_ts = 0.0
|
||||
|
||||
print(f"Conectando con WISE {WISE_IP}:{WISE_PORT} unit={WISE_UNIT}")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
try:
|
||||
if not client.connected:
|
||||
if not client.connect():
|
||||
raise RuntimeError("No conecta con WISE")
|
||||
|
||||
auto_signal, cycle_state = read_discrete_inputs(client)
|
||||
|
||||
if last_cycle_state is None:
|
||||
last_cycle_state = cycle_state
|
||||
|
||||
rising_edge = cycle_state and not last_cycle_state
|
||||
|
||||
if rising_edge and (now - last_pulse_ts) >= DEBOUNCE_S:
|
||||
cycle_pulse_count += 1
|
||||
cycle_pulses.append(now)
|
||||
last_pulse_ts = now
|
||||
|
||||
last_cycle_state = cycle_state
|
||||
|
||||
while cycle_pulses and (now - cycle_pulses[0]) > RATE_WINDOW_S:
|
||||
cycle_pulses.popleft()
|
||||
|
||||
cycle_rate_ppm = len(cycle_pulses) * 60.0 / RATE_WINDOW_S
|
||||
last_cycle_age_s = None if not cycle_pulses else round(now - cycle_pulses[-1], 3)
|
||||
|
||||
if last_auto_signal is not None and auto_signal != last_auto_signal:
|
||||
event_name = "machine_started" if auto_signal else "machine_stopped"
|
||||
|
||||
event = {
|
||||
"ts": utc_now(),
|
||||
"tenant": TENANT,
|
||||
"site": SITE,
|
||||
"vertical": VERTICAL,
|
||||
"asset": ASSET,
|
||||
"machine_id": MACHINE_ID,
|
||||
"source_node": SOURCE_NODE,
|
||||
"event_type": "machine_state_changed",
|
||||
"event_name": event_name,
|
||||
"previous_auto_signal": last_auto_signal,
|
||||
"new_auto_signal": auto_signal,
|
||||
"machine_running": auto_signal,
|
||||
"reason_pending": not auto_signal,
|
||||
}
|
||||
|
||||
print("EVENT", json.dumps(event, ensure_ascii=False))
|
||||
|
||||
last_auto_signal = auto_signal
|
||||
|
||||
if (now - last_publish_ts) >= PUBLISH_INTERVAL_S:
|
||||
regs = read_holdings(client)
|
||||
energy = decode_energy(regs)
|
||||
|
||||
payload = {
|
||||
"ts": utc_now(),
|
||||
"asset": ASSET,
|
||||
"tenant": TENANT,
|
||||
"site": SITE,
|
||||
"vertical": VERTICAL,
|
||||
"node_type": NODE_TYPE,
|
||||
"fw": FW,
|
||||
"machine_id": MACHINE_ID,
|
||||
"source_node": SOURCE_NODE,
|
||||
"wise_host": WISE_IP,
|
||||
"machine_auto_signal": auto_signal,
|
||||
"machine_running": auto_signal,
|
||||
"cycle_input_state": cycle_state,
|
||||
"cycle_pulse_count": cycle_pulse_count,
|
||||
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
|
||||
"last_cycle_age_s": last_cycle_age_s,
|
||||
"digital_inputs": {
|
||||
"DI0": auto_signal,
|
||||
"DI1": cycle_state,
|
||||
},
|
||||
**energy,
|
||||
"comm_ok": True,
|
||||
"sample_valid": True,
|
||||
}
|
||||
|
||||
print("TELEMETRY", json.dumps(payload, ensure_ascii=False))
|
||||
last_publish_ts = now
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Parado por usuario")
|
||||
break
|
||||
|
||||
except Exception as exc:
|
||||
print("ERROR", json.dumps({
|
||||
"ts": utc_now(),
|
||||
"asset": ASSET,
|
||||
"machine_id": MACHINE_ID,
|
||||
"source_node": SOURCE_NODE,
|
||||
"wise_host": WISE_IP,
|
||||
"comm_ok": False,
|
||||
"sample_valid": False,
|
||||
"error": str(exc),
|
||||
}, ensure_ascii=False))
|
||||
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import urllib.request
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
WISE_IP = "10.43.54.249"
|
||||
WISE_PORT = 502
|
||||
WISE_UNIT = 1
|
||||
|
||||
WISE_DI_URL = f"http://{WISE_IP}/di_value/slot_0"
|
||||
WISE_SESSION_ID = os.getenv("WISE_SESSION_ID", "").strip()
|
||||
|
||||
HOLDING_BASE = 1000
|
||||
|
||||
DI_POLL_INTERVAL_S = 0.2
|
||||
ENERGY_INTERVAL_S = 2.0
|
||||
PRINT_INTERVAL_S = 2.0
|
||||
RATE_WINDOW_S = 60.0
|
||||
|
||||
TENANT = "ucepsa"
|
||||
SITE = "demo_edge_oee"
|
||||
VERTICAL = "edge_oee"
|
||||
ASSET = "revpi_oee_node_01"
|
||||
MACHINE_ID = "CORT-01"
|
||||
SOURCE_NODE = "wise-banco-01"
|
||||
NODE_TYPE = "revpi_wise_oee_gateway"
|
||||
FW = "0.2.0"
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def f32_abcd(reg_hi, reg_lo):
|
||||
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def read_wise_di_api():
|
||||
from urllib.error import HTTPError
|
||||
|
||||
if not WISE_SESSION_ID:
|
||||
raise RuntimeError(
|
||||
"Falta WISE_SESSION_ID. Ejecuta primero: export WISE_SESSION_ID"
|
||||
)
|
||||
|
||||
req = urllib.request.Request(
|
||||
WISE_DI_URL,
|
||||
headers={
|
||||
"Accept": "text/plain, */*; q=0.01",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": f"http://{WISE_IP}/config/index.html",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Cookie": f"adamsessionid={WISE_SESSION_ID}",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
text = response.read().decode("utf-8", errors="replace")
|
||||
except HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(
|
||||
f"WISE HTTP error {e.code}: {e.reason}. Body={body[:300]!r}"
|
||||
)
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
if isinstance(data, dict) and data.get("Err"):
|
||||
raise RuntimeError(
|
||||
f"WISE API error Err={data.get('Err')} Msg={data.get('Msg')!r}"
|
||||
)
|
||||
|
||||
channels = {int(item["Ch"]): item for item in data.get("DIVal", [])}
|
||||
|
||||
ch0 = channels.get(0, {})
|
||||
ch1 = channels.get(1, {})
|
||||
|
||||
return {
|
||||
"di0_stat": bool(ch0.get("Stat", 0)),
|
||||
"di1_stat": bool(ch1.get("Stat", 0)),
|
||||
"di1_counter_raw": int(ch1.get("CtFq", 0)),
|
||||
"di1_counting": bool(ch1.get("Cnting", 0)),
|
||||
"di1_mode": int(ch1.get("Md", -1)),
|
||||
"raw": data,
|
||||
}
|
||||
|
||||
|
||||
def read_holdings(client):
|
||||
if not client.connected:
|
||||
if not client.connect():
|
||||
raise RuntimeError("No conecta con WISE por Modbus/TCP")
|
||||
|
||||
rr = client.read_holding_registers(
|
||||
address=HOLDING_BASE,
|
||||
count=20,
|
||||
device_id=WISE_UNIT,
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Error leyendo holding registers: {rr}")
|
||||
|
||||
return rr.registers
|
||||
|
||||
|
||||
def decode_energy(regs):
|
||||
active_power_w = f32_abcd(regs[4], regs[5])
|
||||
|
||||
return {
|
||||
"voltage_l1_v": round(f32_abcd(regs[0], regs[1]), 3),
|
||||
"current_l1_a": round(f32_abcd(regs[2], regs[3]), 6),
|
||||
"active_power_w": round(active_power_w, 3),
|
||||
"power_total_kw": round(active_power_w / 1000.0, 6),
|
||||
"apparent_power_va": round(f32_abcd(regs[6], regs[7]), 3),
|
||||
"reactive_power_var": round(f32_abcd(regs[8], regs[9]), 3),
|
||||
"power_factor": round(f32_abcd(regs[10], regs[11]), 6),
|
||||
"frequency_hz": round(f32_abcd(regs[12], regs[13]), 3),
|
||||
"import_kwh": round(f32_abcd(regs[14], regs[15]), 6),
|
||||
"export_kwh": round(f32_abcd(regs[16], regs[17]), 6),
|
||||
"total_active_energy_kwh": round(f32_abcd(regs[18], regs[19]), 6),
|
||||
}
|
||||
|
||||
|
||||
def counter_delta(previous, current):
|
||||
if previous is None:
|
||||
return 0, False
|
||||
|
||||
if current >= previous:
|
||||
return current - previous, False
|
||||
|
||||
# Reset manual, reinicio del WISE o pérdida del contador.
|
||||
return 0, True
|
||||
|
||||
|
||||
def main():
|
||||
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
|
||||
|
||||
previous_wise_counter = None
|
||||
cycle_pulse_count = 0
|
||||
cycle_events = deque()
|
||||
|
||||
last_auto_signal = None
|
||||
last_energy = {}
|
||||
last_energy_ts = 0.0
|
||||
last_print_ts = 0.0
|
||||
last_delta = 0
|
||||
counter_reset_detected = False
|
||||
|
||||
print(f"Leyendo WISE {WISE_IP}")
|
||||
print(f"DI API: {WISE_DI_URL}")
|
||||
print(f"Modbus/TCP: {WISE_IP}:{WISE_PORT}, holding base {HOLDING_BASE}")
|
||||
print("DI0 = marcha / automático")
|
||||
print("DI1 = contador de ciclos\n")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
try:
|
||||
di = read_wise_di_api()
|
||||
|
||||
auto_signal = di["di0_stat"]
|
||||
cycle_input_state = di["di1_stat"]
|
||||
wise_counter = di["di1_counter_raw"]
|
||||
counter_valid = di["di1_mode"] == 1 and di["di1_counting"]
|
||||
|
||||
delta, reset = counter_delta(previous_wise_counter, wise_counter)
|
||||
counter_reset_detected = reset
|
||||
|
||||
if delta > 0:
|
||||
cycle_pulse_count += delta
|
||||
|
||||
for _ in range(min(delta, 1000)):
|
||||
cycle_events.append(now)
|
||||
|
||||
print(
|
||||
"CYCLES",
|
||||
json.dumps(
|
||||
{
|
||||
"ts": utc_now(),
|
||||
"delta": delta,
|
||||
"wise_counter": wise_counter,
|
||||
"cycle_pulse_count": cycle_pulse_count,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
previous_wise_counter = wise_counter
|
||||
last_delta = delta
|
||||
|
||||
while cycle_events and (now - cycle_events[0]) > RATE_WINDOW_S:
|
||||
cycle_events.popleft()
|
||||
|
||||
cycle_rate_ppm = len(cycle_events) * 60.0 / RATE_WINDOW_S
|
||||
last_cycle_age_s = None if not cycle_events else round(now - cycle_events[-1], 3)
|
||||
|
||||
if last_auto_signal is not None and auto_signal != last_auto_signal:
|
||||
event_name = "machine_started" if auto_signal else "machine_stopped"
|
||||
|
||||
print(
|
||||
"EVENT",
|
||||
json.dumps(
|
||||
{
|
||||
"ts": utc_now(),
|
||||
"tenant": TENANT,
|
||||
"site": SITE,
|
||||
"vertical": VERTICAL,
|
||||
"asset": ASSET,
|
||||
"machine_id": MACHINE_ID,
|
||||
"source_node": SOURCE_NODE,
|
||||
"event_type": "machine_state_changed",
|
||||
"event_name": event_name,
|
||||
"previous_auto_signal": last_auto_signal,
|
||||
"new_auto_signal": auto_signal,
|
||||
"machine_running": auto_signal,
|
||||
"reason_pending": not auto_signal,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
last_auto_signal = auto_signal
|
||||
|
||||
if now - last_energy_ts >= ENERGY_INTERVAL_S:
|
||||
regs = read_holdings(client)
|
||||
last_energy = decode_energy(regs)
|
||||
last_energy_ts = now
|
||||
|
||||
if now - last_print_ts >= PRINT_INTERVAL_S:
|
||||
payload = {
|
||||
"ts": utc_now(),
|
||||
"asset": ASSET,
|
||||
"tenant": TENANT,
|
||||
"site": SITE,
|
||||
"vertical": VERTICAL,
|
||||
"node_type": NODE_TYPE,
|
||||
"fw": FW,
|
||||
"machine_id": MACHINE_ID,
|
||||
"source_node": SOURCE_NODE,
|
||||
"wise_host": WISE_IP,
|
||||
"machine_auto_signal": auto_signal,
|
||||
"machine_running": auto_signal,
|
||||
"cycle_input_state": cycle_input_state,
|
||||
"wise_cycle_counter_raw": wise_counter,
|
||||
"cycle_delta_last": last_delta,
|
||||
"cycle_pulse_count": cycle_pulse_count,
|
||||
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
|
||||
"last_cycle_age_s": last_cycle_age_s,
|
||||
"counter_valid": counter_valid,
|
||||
"counter_reset_detected": counter_reset_detected,
|
||||
"digital_inputs": {
|
||||
"DI0": auto_signal,
|
||||
"DI1": cycle_input_state,
|
||||
},
|
||||
**last_energy,
|
||||
"comm_ok": True,
|
||||
"sample_valid": True,
|
||||
}
|
||||
|
||||
print("TELEMETRY", json.dumps(payload, ensure_ascii=False))
|
||||
last_print_ts = now
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nParado por usuario")
|
||||
break
|
||||
|
||||
except Exception as exc:
|
||||
print(
|
||||
"ERROR",
|
||||
json.dumps(
|
||||
{
|
||||
"ts": utc_now(),
|
||||
"asset": ASSET,
|
||||
"machine_id": MACHINE_ID,
|
||||
"source_node": SOURCE_NODE,
|
||||
"wise_host": WISE_IP,
|
||||
"comm_ok": False,
|
||||
"sample_valid": False,
|
||||
"error": str(exc),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
time.sleep(DI_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,491 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import time
|
||||
import urllib.request
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
NODES_PATH = BASE_DIR / "wise_nodes.json"
|
||||
|
||||
|
||||
def load_env(path: Path):
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def env(name, default=None, required=False, cast=str):
|
||||
value = os.environ.get(name, default)
|
||||
|
||||
if required and (value is None or str(value).strip() == ""):
|
||||
raise RuntimeError(f"Falta variable obligatoria: {name}")
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return cast(value)
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def dumps(data):
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def f32_abcd(reg_hi, reg_lo):
|
||||
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
|
||||
return struct.unpack(">f", raw)[0]
|
||||
|
||||
|
||||
def counter_delta(previous, current):
|
||||
if previous is None:
|
||||
return 0, False
|
||||
if current >= previous:
|
||||
return current - previous, False
|
||||
return 0, True
|
||||
|
||||
|
||||
def load_nodes():
|
||||
data = json.loads(NODES_PATH.read_text())
|
||||
nodes = [n for n in data.get("nodes", []) if n.get("enabled", True)]
|
||||
|
||||
if not nodes:
|
||||
raise RuntimeError("No hay nodos WISE habilitados en wise_nodes.json")
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def make_mqtt_client():
|
||||
host = env("MQTT_HOST", required=True)
|
||||
port = env("MQTT_PORT", 1883, cast=int)
|
||||
user = env("MQTT_USER", "")
|
||||
password = env("MQTT_PASSWORD", "")
|
||||
client_id = env("MQTT_CLIENT_ID", required=True)
|
||||
|
||||
try:
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
|
||||
except AttributeError:
|
||||
client = mqtt.Client(client_id=client_id)
|
||||
|
||||
if user:
|
||||
client.username_pw_set(user, password)
|
||||
|
||||
client.connect(host, port, keepalive=60)
|
||||
client.loop_start()
|
||||
return client
|
||||
|
||||
|
||||
def publish(client, topic, payload):
|
||||
client.publish(topic, dumps(payload), qos=0, retain=False)
|
||||
|
||||
|
||||
def read_wise_di_api(node):
|
||||
wise_ip = node["wise_ip"]
|
||||
session_env = node["session_env"]
|
||||
session_id = os.environ.get(session_env, "").strip()
|
||||
|
||||
if not session_id:
|
||||
raise RuntimeError(f"Falta {session_env}. Exporta la cookie adamsessionid del WISE.")
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"http://{wise_ip}/di_value/slot_0",
|
||||
headers={
|
||||
"Accept": "text/plain, */*; q=0.01",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": f"http://{wise_ip}/config/index.html",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Cookie": f"adamsessionid={session_id}",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
text = response.read().decode("utf-8", errors="replace")
|
||||
except HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"WISE HTTP {e.code}: {e.reason}. Body={body[:200]!r}")
|
||||
except URLError as e:
|
||||
raise RuntimeError(f"WISE HTTP unreachable: {e}")
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
if isinstance(data, dict) and data.get("Err"):
|
||||
raise RuntimeError(f"WISE API Err={data.get('Err')} Msg={data.get('Msg')!r}")
|
||||
|
||||
channels = {int(item["Ch"]): item for item in data.get("DIVal", [])}
|
||||
|
||||
ch0 = channels.get(0, {})
|
||||
ch1 = channels.get(1, {})
|
||||
|
||||
return {
|
||||
"di0_stat": bool(ch0.get("Stat", 0)),
|
||||
"di1_stat": bool(ch1.get("Stat", 0)),
|
||||
"di1_counter_raw": int(ch1.get("CtFq", 0)),
|
||||
"di1_counting": bool(ch1.get("Cnting", 0)),
|
||||
"di1_mode": int(ch1.get("Md", -1)),
|
||||
}
|
||||
|
||||
|
||||
|
||||
def u32_lo_hi(lo, hi):
|
||||
return (int(lo) & 0xFFFF) + ((int(hi) & 0xFFFF) << 16)
|
||||
|
||||
|
||||
def read_wise_inputs_modbus(state, node):
|
||||
client = ensure_modbus_client(state, node)
|
||||
unit = int(node.get("unit", 1))
|
||||
|
||||
rr_di = client.read_discrete_inputs(
|
||||
address=0,
|
||||
count=2,
|
||||
device_id=unit,
|
||||
)
|
||||
|
||||
if rr_di.isError():
|
||||
raise RuntimeError(f"Error leyendo DI status: {rr_di}")
|
||||
|
||||
rr_counter = client.read_holding_registers(
|
||||
address=0,
|
||||
count=4,
|
||||
device_id=unit,
|
||||
)
|
||||
|
||||
if rr_counter.isError():
|
||||
raise RuntimeError(f"Error leyendo contadores DI: {rr_counter}")
|
||||
|
||||
regs = rr_counter.registers
|
||||
|
||||
di0_counter = u32_lo_hi(regs[0], regs[1])
|
||||
di1_counter = u32_lo_hi(regs[2], regs[3])
|
||||
|
||||
return {
|
||||
"di0_stat": bool(rr_di.bits[0]),
|
||||
"di1_stat": bool(rr_di.bits[1]),
|
||||
"di0_counter_raw": di0_counter,
|
||||
"di1_counter_raw": di1_counter,
|
||||
"di1_counting": True,
|
||||
"di1_mode": 1,
|
||||
}
|
||||
|
||||
def ensure_modbus_client(state, node):
|
||||
client = state.get("modbus_client")
|
||||
|
||||
if client is None:
|
||||
client = ModbusTcpClient(
|
||||
node["wise_ip"],
|
||||
port=int(node.get("wise_port", 502)),
|
||||
timeout=3,
|
||||
)
|
||||
state["modbus_client"] = client
|
||||
|
||||
if not client.connected:
|
||||
if not client.connect():
|
||||
raise RuntimeError("No conecta con WISE por Modbus/TCP")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def read_holdings(state, node):
|
||||
client = ensure_modbus_client(state, node)
|
||||
|
||||
rr = client.read_holding_registers(
|
||||
address=int(node.get("holding_base", 1000)),
|
||||
count=int(node.get("holding_count", 20)),
|
||||
device_id=int(node.get("unit", 1)),
|
||||
)
|
||||
|
||||
if rr.isError():
|
||||
raise RuntimeError(f"Error leyendo holding registers: {rr}")
|
||||
|
||||
return rr.registers
|
||||
|
||||
|
||||
def decode_energy(regs):
|
||||
if len(regs) < 34:
|
||||
raise ValueError(f"Se esperaban 34 registros SDM630, recibidos {len(regs)}")
|
||||
|
||||
def v(offset):
|
||||
return f32_abcd(regs[offset], regs[offset + 1])
|
||||
|
||||
voltage_l1_v = v(0)
|
||||
voltage_l2_v = v(2)
|
||||
voltage_l3_v = v(4)
|
||||
|
||||
current_l1_a = v(6)
|
||||
current_l2_a = v(8)
|
||||
current_l3_a = v(10)
|
||||
|
||||
active_power_l1_w = v(12)
|
||||
active_power_l2_w = v(14)
|
||||
active_power_l3_w = v(16)
|
||||
|
||||
active_power_w = v(18)
|
||||
apparent_power_va = v(20)
|
||||
reactive_power_var = v(22)
|
||||
power_factor = v(24)
|
||||
|
||||
frequency_hz = v(26)
|
||||
import_kwh = v(28)
|
||||
export_kwh = v(30)
|
||||
total_active_energy_kwh = v(32)
|
||||
|
||||
return {
|
||||
"energy_meter_model": "eastron_sdm630",
|
||||
"energy_map": "sdm630_3phase_v1",
|
||||
|
||||
"voltage_l1_v": round(voltage_l1_v, 3),
|
||||
"voltage_l2_v": round(voltage_l2_v, 3),
|
||||
"voltage_l3_v": round(voltage_l3_v, 3),
|
||||
|
||||
"current_l1_a": round(current_l1_a, 6),
|
||||
"current_l2_a": round(current_l2_a, 6),
|
||||
"current_l3_a": round(current_l3_a, 6),
|
||||
|
||||
"active_power_l1_w": round(active_power_l1_w, 3),
|
||||
"active_power_l2_w": round(active_power_l2_w, 3),
|
||||
"active_power_l3_w": round(active_power_l3_w, 3),
|
||||
|
||||
"active_power_w": round(active_power_w, 3),
|
||||
"power_total_kw": round(active_power_w / 1000.0, 6),
|
||||
|
||||
"apparent_power_va": round(apparent_power_va, 3),
|
||||
"reactive_power_var": round(reactive_power_var, 3),
|
||||
"power_factor": round(power_factor, 6),
|
||||
|
||||
"frequency_hz": round(frequency_hz, 3),
|
||||
"import_kwh": round(import_kwh, 6),
|
||||
"export_kwh": round(export_kwh, 6),
|
||||
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
|
||||
}
|
||||
|
||||
|
||||
def base_payload(node):
|
||||
return {
|
||||
"tenant": env("TENANT", "ucepsa"),
|
||||
"site": env("SITE", "ucepsa_test"),
|
||||
"vertical": env("VERTICAL", "edge_oee"),
|
||||
"asset": env("ASSET", "revpi_oee_node_01"),
|
||||
"machine_id": node["machine_id"],
|
||||
"source_node": node["source_node"],
|
||||
"wise_host": node["wise_ip"],
|
||||
"node_type": "revpi_wise_oee_gateway",
|
||||
"fw": "0.3.0",
|
||||
}
|
||||
|
||||
|
||||
def init_state():
|
||||
return {
|
||||
"previous_counter": None,
|
||||
"cycle_pulse_count": 0,
|
||||
"cycle_events": deque(),
|
||||
"last_auto_signal": None,
|
||||
"last_energy": {},
|
||||
"last_energy_ts": 0.0,
|
||||
"last_publish_ts": 0.0,
|
||||
"last_delta": 0,
|
||||
"counter_reset_detected": False,
|
||||
"modbus_client": None,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
load_env(ENV_PATH)
|
||||
|
||||
nodes = load_nodes()
|
||||
states = {node["source_node"]: init_state() for node in nodes}
|
||||
|
||||
telemetry_topic = env("MQTT_TOPIC_TELEMETRY", required=True)
|
||||
status_topic = env("MQTT_TOPIC_STATUS", required=True)
|
||||
event_topic = env("MQTT_TOPIC_EVENT", required=True)
|
||||
|
||||
publish_interval_s = env("PUBLISH_INTERVAL_SECONDS", 10, cast=float)
|
||||
di_poll_interval_s = env("WISE_DI_POLL_INTERVAL_SECONDS", 0.5, cast=float)
|
||||
energy_interval_s = env("WISE_ENERGY_INTERVAL_SECONDS", 2.0, cast=float)
|
||||
rate_window_s = env("CYCLE_RATE_WINDOW_SECONDS", 60, cast=float)
|
||||
|
||||
mqtt_client = make_mqtt_client()
|
||||
|
||||
stop = {"value": False}
|
||||
|
||||
def on_stop(signum, frame):
|
||||
stop["value"] = True
|
||||
|
||||
signal.signal(signal.SIGINT, on_stop)
|
||||
signal.signal(signal.SIGTERM, on_stop)
|
||||
|
||||
print(f"Nodos WISE habilitados: {len(nodes)}")
|
||||
for node in nodes:
|
||||
print(f"- {node['machine_id']} {node['source_node']} {node['wise_ip']}")
|
||||
|
||||
publish(mqtt_client, status_topic, {
|
||||
"ts": utc_now(),
|
||||
"status": "started",
|
||||
"asset": env("ASSET", "revpi_oee_node_01"),
|
||||
"enabled_nodes": [
|
||||
{
|
||||
"machine_id": n["machine_id"],
|
||||
"source_node": n["source_node"],
|
||||
"wise_ip": n["wise_ip"],
|
||||
}
|
||||
for n in nodes
|
||||
],
|
||||
})
|
||||
|
||||
while not stop["value"]:
|
||||
loop_start = time.time()
|
||||
|
||||
for node in nodes:
|
||||
state = states[node["source_node"]]
|
||||
now = time.time()
|
||||
|
||||
try:
|
||||
di = read_wise_inputs_modbus(state, node)
|
||||
|
||||
auto_signal = di["di0_stat"]
|
||||
cycle_input_state = di["di1_stat"]
|
||||
wise_counter = di["di1_counter_raw"]
|
||||
counter_valid = di["di1_mode"] == 1 and di["di1_counting"]
|
||||
|
||||
delta, reset = counter_delta(state["previous_counter"], wise_counter)
|
||||
state["counter_reset_detected"] = reset
|
||||
|
||||
if delta > 0:
|
||||
state["cycle_pulse_count"] += delta
|
||||
|
||||
for _ in range(min(delta, 1000)):
|
||||
state["cycle_events"].append(now)
|
||||
|
||||
event = {
|
||||
"ts": utc_now(),
|
||||
**base_payload(node),
|
||||
"event_type": "cycle_count_changed",
|
||||
"delta": delta,
|
||||
"wise_cycle_counter_raw": wise_counter,
|
||||
"cycle_pulse_count": state["cycle_pulse_count"],
|
||||
}
|
||||
publish(mqtt_client, event_topic, event)
|
||||
print("CYCLES", dumps(event))
|
||||
|
||||
state["previous_counter"] = wise_counter
|
||||
state["last_delta"] = delta
|
||||
|
||||
while state["cycle_events"] and (now - state["cycle_events"][0]) > rate_window_s:
|
||||
state["cycle_events"].popleft()
|
||||
|
||||
cycle_rate_ppm = len(state["cycle_events"]) * 60.0 / rate_window_s
|
||||
last_cycle_age_s = None
|
||||
if state["cycle_events"]:
|
||||
last_cycle_age_s = round(now - state["cycle_events"][-1], 3)
|
||||
|
||||
if state["last_auto_signal"] is not None and auto_signal != state["last_auto_signal"]:
|
||||
event_name = "machine_started" if auto_signal else "machine_stopped"
|
||||
event = {
|
||||
"ts": utc_now(),
|
||||
**base_payload(node),
|
||||
"event_type": "machine_state_changed",
|
||||
"event_name": event_name,
|
||||
"previous_auto_signal": state["last_auto_signal"],
|
||||
"new_auto_signal": auto_signal,
|
||||
"machine_running": auto_signal,
|
||||
"reason_pending": not auto_signal,
|
||||
}
|
||||
publish(mqtt_client, event_topic, event)
|
||||
print("EVENT", dumps(event))
|
||||
|
||||
state["last_auto_signal"] = auto_signal
|
||||
|
||||
if now - state["last_energy_ts"] >= energy_interval_s:
|
||||
regs = read_holdings(state, node)
|
||||
state["last_energy"] = decode_energy(regs)
|
||||
state["last_energy_ts"] = now
|
||||
|
||||
if now - state["last_publish_ts"] >= publish_interval_s:
|
||||
payload = {
|
||||
"ts": utc_now(),
|
||||
**base_payload(node),
|
||||
"machine_auto_signal": auto_signal,
|
||||
"machine_running": auto_signal,
|
||||
"cycle_input_state": cycle_input_state,
|
||||
"wise_cycle_counter_raw": wise_counter,
|
||||
"cycle_delta_last": state["last_delta"],
|
||||
"cycle_pulse_count": state["cycle_pulse_count"],
|
||||
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
|
||||
"last_cycle_age_s": last_cycle_age_s,
|
||||
"counter_valid": counter_valid,
|
||||
"counter_reset_detected": state["counter_reset_detected"],
|
||||
"digital_inputs": {
|
||||
"DI0": auto_signal,
|
||||
"DI1": cycle_input_state,
|
||||
},
|
||||
**state["last_energy"],
|
||||
"comm_ok": True,
|
||||
"sample_valid": True,
|
||||
}
|
||||
|
||||
publish(mqtt_client, telemetry_topic, payload)
|
||||
print("TELEMETRY", dumps(payload))
|
||||
state["last_publish_ts"] = now
|
||||
|
||||
except Exception as exc:
|
||||
error_payload = {
|
||||
"ts": utc_now(),
|
||||
**base_payload(node),
|
||||
"comm_ok": False,
|
||||
"sample_valid": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
publish(mqtt_client, status_topic, error_payload)
|
||||
print("ERROR", dumps(error_payload))
|
||||
|
||||
client = state.get("modbus_client")
|
||||
if client is not None:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
state["modbus_client"] = None
|
||||
|
||||
elapsed = time.time() - loop_start
|
||||
time.sleep(max(0.05, di_poll_interval_s - elapsed))
|
||||
|
||||
publish(mqtt_client, status_topic, {
|
||||
"ts": utc_now(),
|
||||
"status": "stopped",
|
||||
"asset": env("ASSET", "revpi_oee_node_01"),
|
||||
})
|
||||
|
||||
for state in states.values():
|
||||
client = state.get("modbus_client")
|
||||
if client is not None:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mqtt_client.loop_stop()
|
||||
mqtt_client.disconnect()
|
||||
print("Parado correctamente")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,2 +0,0 @@
|
||||
[sudo] password for pi:
|
||||
^C^C^C^C^Csudo: a password is required
|
||||
@ -1,2 +0,0 @@
|
||||
[sudo] password for pi:
|
||||
^C^C^C^C^C^Csudo: a password is required
|
||||
@ -1,14 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@ -1,540 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
|
||||
|
||||
RUNNING = True
|
||||
|
||||
|
||||
def stop_handler(signum, frame):
|
||||
global RUNNING
|
||||
RUNNING = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, stop_handler)
|
||||
signal.signal(signal.SIGINT, stop_handler)
|
||||
|
||||
|
||||
def getenv(name, default=None, required=False):
|
||||
value = os.getenv(name, default)
|
||||
if required and not value:
|
||||
print(f"[ERROR] Missing required env var: {name}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
|
||||
MQTT_HOST = getenv("MQTT_HOST", required=True)
|
||||
MQTT_PORT = int(getenv("MQTT_PORT", "1883"))
|
||||
MQTT_TOPIC = getenv("MQTT_TOPIC", required=True)
|
||||
MQTT_USER = getenv("MQTT_USER")
|
||||
MQTT_PASSWORD = getenv("MQTT_PASSWORD")
|
||||
|
||||
PGHOST = getenv("PGHOST", required=True)
|
||||
PGPORT = int(getenv("PGPORT", "5432"))
|
||||
PGDATABASE = getenv("PGDATABASE", required=True)
|
||||
PGUSER = getenv("PGUSER", required=True)
|
||||
PGPASSWORD = getenv("PGPASSWORD", required=True)
|
||||
|
||||
DEFAULT_TENANT = getenv("DEFAULT_TENANT", "ucepsa")
|
||||
DEFAULT_SITE = getenv("DEFAULT_SITE", "demo_edge_oee")
|
||||
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
|
||||
|
||||
|
||||
def num(payload, key):
|
||||
value = payload.get(key)
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def bool_or_false(payload, key):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "ok")
|
||||
return bool(value)
|
||||
|
||||
|
||||
def parse_ts(payload):
|
||||
raw = payload.get("ts")
|
||||
if not raw:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def connect_pg():
|
||||
return psycopg2.connect(
|
||||
host=PGHOST,
|
||||
port=PGPORT,
|
||||
dbname=PGDATABASE,
|
||||
user=PGUSER,
|
||||
password=PGPASSWORD,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
PG_CONN = connect_pg()
|
||||
PG_CONN.autocommit = True
|
||||
|
||||
|
||||
def ensure_pg():
|
||||
global PG_CONN
|
||||
try:
|
||||
with PG_CONN.cursor() as cur:
|
||||
cur.execute("SELECT 1;")
|
||||
except Exception:
|
||||
try:
|
||||
PG_CONN.close()
|
||||
except Exception:
|
||||
pass
|
||||
PG_CONN = connect_pg()
|
||||
PG_CONN.autocommit = True
|
||||
|
||||
|
||||
def insert_payload(topic, payload):
|
||||
ensure_pg()
|
||||
|
||||
tenant = payload.get("tenant") or DEFAULT_TENANT
|
||||
site = payload.get("site") or DEFAULT_SITE
|
||||
asset = payload.get("asset") or DEFAULT_ASSET
|
||||
|
||||
machine_id = payload.get("machine_id") or None
|
||||
order_id = payload.get("order_id") or None
|
||||
|
||||
comm_ok = bool_or_false(payload, "comm_ok")
|
||||
error_text = payload.get("error_text")
|
||||
|
||||
sql = """
|
||||
INSERT INTO mv_hot.edge_oee_eastron_readings (
|
||||
ts,
|
||||
tenant,
|
||||
site,
|
||||
asset,
|
||||
source,
|
||||
meter_model,
|
||||
meter_id,
|
||||
machine_id,
|
||||
order_id,
|
||||
voltage_l1_v,
|
||||
voltage_l2_v,
|
||||
voltage_l3_v,
|
||||
current_l1_a,
|
||||
current_l2_a,
|
||||
current_l3_a,
|
||||
power_l1_kw,
|
||||
power_l2_kw,
|
||||
power_l3_kw,
|
||||
power_total_kw,
|
||||
frequency_hz,
|
||||
import_kwh,
|
||||
export_kwh,
|
||||
comm_ok,
|
||||
error_text,
|
||||
payload_json
|
||||
)
|
||||
VALUES (
|
||||
%(ts)s,
|
||||
%(tenant)s,
|
||||
%(site)s,
|
||||
%(asset)s,
|
||||
%(source)s,
|
||||
%(meter_model)s,
|
||||
%(meter_id)s,
|
||||
%(machine_id)s,
|
||||
%(order_id)s,
|
||||
%(voltage_l1_v)s,
|
||||
%(voltage_l2_v)s,
|
||||
%(voltage_l3_v)s,
|
||||
%(current_l1_a)s,
|
||||
%(current_l2_a)s,
|
||||
%(current_l3_a)s,
|
||||
%(power_l1_kw)s,
|
||||
%(power_l2_kw)s,
|
||||
%(power_l3_kw)s,
|
||||
%(power_total_kw)s,
|
||||
%(frequency_hz)s,
|
||||
%(import_kwh)s,
|
||||
%(export_kwh)s,
|
||||
%(comm_ok)s,
|
||||
%(error_text)s,
|
||||
%(payload_json)s
|
||||
);
|
||||
"""
|
||||
|
||||
power_total_kw = num(payload, "power_total_kw")
|
||||
|
||||
params = {
|
||||
"ts": parse_ts(payload),
|
||||
"tenant": tenant,
|
||||
"site": site,
|
||||
"asset": asset,
|
||||
"source": payload.get("source") or "mqtt_edge_oee_sink",
|
||||
"meter_model": payload.get("meter_model"),
|
||||
"meter_id": payload.get("meter_id"),
|
||||
"machine_id": machine_id,
|
||||
"order_id": order_id,
|
||||
|
||||
"voltage_l1_v": num(payload, "voltage_l1_v"),
|
||||
"voltage_l2_v": num(payload, "voltage_l2_v"),
|
||||
"voltage_l3_v": num(payload, "voltage_l3_v"),
|
||||
|
||||
"current_l1_a": num(payload, "current_l1_a"),
|
||||
"current_l2_a": num(payload, "current_l2_a"),
|
||||
"current_l3_a": num(payload, "current_l3_a"),
|
||||
|
||||
"power_l1_kw": num(payload, "power_l1_kw") if payload.get("power_l1_kw") is not None else power_total_kw,
|
||||
"power_l2_kw": num(payload, "power_l2_kw"),
|
||||
"power_l3_kw": num(payload, "power_l3_kw"),
|
||||
"power_total_kw": power_total_kw,
|
||||
|
||||
"frequency_hz": num(payload, "frequency_hz"),
|
||||
"import_kwh": num(payload, "import_kwh"),
|
||||
"export_kwh": num(payload, "export_kwh"),
|
||||
|
||||
"comm_ok": comm_ok,
|
||||
"error_text": error_text,
|
||||
"payload_json": Json({
|
||||
**payload,
|
||||
"_mqtt_topic": topic,
|
||||
"_ingested_by": "edge_oee_mqtt_pg_sink",
|
||||
}),
|
||||
}
|
||||
|
||||
with PG_CONN.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
|
||||
def insert_event(topic, payload):
|
||||
ensure_pg()
|
||||
|
||||
tenant = payload.get("tenant") or DEFAULT_TENANT
|
||||
site = payload.get("site") or DEFAULT_SITE
|
||||
vertical = payload.get("vertical") or "edge_oee"
|
||||
asset = payload.get("asset") or DEFAULT_ASSET
|
||||
|
||||
raw_ts = payload.get("event_ts") or payload.get("ts")
|
||||
|
||||
try:
|
||||
event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")) if raw_ts else datetime.now(timezone.utc)
|
||||
except Exception:
|
||||
event_ts = datetime.now(timezone.utc)
|
||||
|
||||
def to_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "ok")
|
||||
return bool(value)
|
||||
|
||||
def to_int(value):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def to_float(value):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
sql = """
|
||||
INSERT INTO mv_hot.edge_oee_node_events (
|
||||
event_ts,
|
||||
topic,
|
||||
tenant,
|
||||
site,
|
||||
vertical,
|
||||
asset,
|
||||
machine_id,
|
||||
order_id,
|
||||
event_type,
|
||||
event_name,
|
||||
previous_auto_signal,
|
||||
new_auto_signal,
|
||||
machine_running,
|
||||
reason_pending,
|
||||
cycle_pulse_count,
|
||||
cycle_rate_ppm,
|
||||
last_cycle_age_s,
|
||||
payload_json
|
||||
)
|
||||
VALUES (
|
||||
%(event_ts)s,
|
||||
%(topic)s,
|
||||
%(tenant)s,
|
||||
%(site)s,
|
||||
%(vertical)s,
|
||||
%(asset)s,
|
||||
%(machine_id)s,
|
||||
%(order_id)s,
|
||||
%(event_type)s,
|
||||
%(event_name)s,
|
||||
%(previous_auto_signal)s,
|
||||
%(new_auto_signal)s,
|
||||
%(machine_running)s,
|
||||
%(reason_pending)s,
|
||||
%(cycle_pulse_count)s,
|
||||
%(cycle_rate_ppm)s,
|
||||
%(last_cycle_age_s)s,
|
||||
%(payload_json)s
|
||||
);
|
||||
"""
|
||||
|
||||
params = {
|
||||
"event_ts": event_ts,
|
||||
"topic": topic,
|
||||
"tenant": tenant,
|
||||
"site": site,
|
||||
"vertical": vertical,
|
||||
"asset": asset,
|
||||
"machine_id": payload.get("machine_id") or None,
|
||||
"order_id": payload.get("order_id") or None,
|
||||
"event_type": payload.get("event_type") or "unknown",
|
||||
"event_name": payload.get("event_name") or "unknown",
|
||||
"previous_auto_signal": to_bool(payload.get("previous_auto_signal")),
|
||||
"new_auto_signal": to_bool(payload.get("new_auto_signal")),
|
||||
"machine_running": to_bool(payload.get("machine_running")),
|
||||
"reason_pending": to_bool(payload.get("reason_pending")),
|
||||
"cycle_pulse_count": to_int(payload.get("cycle_pulse_count")),
|
||||
"cycle_rate_ppm": to_float(payload.get("cycle_rate_ppm")),
|
||||
"last_cycle_age_s": to_float(payload.get("last_cycle_age_s")),
|
||||
"payload_json": Json({
|
||||
**payload,
|
||||
"_mqtt_topic": topic,
|
||||
"_ingested_by": "edge_oee_mqtt_pg_sink",
|
||||
}),
|
||||
}
|
||||
|
||||
with PG_CONN.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
|
||||
def process_machine_stop_event(topic, payload):
|
||||
if payload.get("event_type") != "machine_state_changed":
|
||||
return
|
||||
|
||||
event_name = payload.get("event_name")
|
||||
if event_name not in ("machine_stopped", "machine_started"):
|
||||
return
|
||||
|
||||
ensure_pg()
|
||||
|
||||
tenant = payload.get("tenant") or DEFAULT_TENANT
|
||||
site = payload.get("site") or DEFAULT_SITE
|
||||
vertical = payload.get("vertical") or "edge_oee"
|
||||
asset = payload.get("asset") or DEFAULT_ASSET
|
||||
machine_id = payload.get("machine_id") or None
|
||||
order_id = payload.get("order_id") or None
|
||||
|
||||
raw_ts = payload.get("event_ts") or payload.get("ts")
|
||||
try:
|
||||
event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")) if raw_ts else datetime.now(timezone.utc)
|
||||
except Exception:
|
||||
event_ts = datetime.now(timezone.utc)
|
||||
|
||||
if event_name == "machine_stopped":
|
||||
sql = """
|
||||
INSERT INTO mv_hot.edge_oee_machine_stops (
|
||||
tenant,
|
||||
site,
|
||||
vertical,
|
||||
asset,
|
||||
machine_id,
|
||||
order_id,
|
||||
started_at,
|
||||
status,
|
||||
classification_status,
|
||||
start_event_name,
|
||||
start_payload_json
|
||||
)
|
||||
SELECT
|
||||
%(tenant)s,
|
||||
%(site)s,
|
||||
%(vertical)s,
|
||||
%(asset)s,
|
||||
%(machine_id)s,
|
||||
%(order_id)s,
|
||||
%(started_at)s,
|
||||
'OPEN',
|
||||
'PENDING',
|
||||
%(event_name)s,
|
||||
%(payload_json)s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM mv_hot.edge_oee_machine_stops
|
||||
WHERE asset = %(asset)s
|
||||
AND COALESCE(machine_id, '') = COALESCE(%(machine_id)s, '')
|
||||
AND status = 'OPEN'
|
||||
);
|
||||
"""
|
||||
|
||||
params = {
|
||||
"tenant": tenant,
|
||||
"site": site,
|
||||
"vertical": vertical,
|
||||
"asset": asset,
|
||||
"machine_id": machine_id,
|
||||
"order_id": order_id,
|
||||
"started_at": event_ts,
|
||||
"event_name": event_name,
|
||||
"payload_json": Json({
|
||||
**payload,
|
||||
"_mqtt_topic": topic,
|
||||
"_stop_logic": "open_stop",
|
||||
}),
|
||||
}
|
||||
|
||||
with PG_CONN.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
|
||||
return
|
||||
|
||||
if event_name == "machine_started":
|
||||
sql = """
|
||||
WITH open_stop AS (
|
||||
SELECT id, started_at, value_hour_eur
|
||||
FROM mv_hot.edge_oee_machine_stops
|
||||
WHERE asset = %(asset)s
|
||||
AND COALESCE(machine_id, '') = COALESCE(%(machine_id)s, '')
|
||||
AND status = 'OPEN'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE mv_hot.edge_oee_machine_stops s
|
||||
SET
|
||||
ended_at = %(ended_at)s,
|
||||
status = 'CLOSED',
|
||||
duration_s = EXTRACT(EPOCH FROM (%(ended_at)s - s.started_at))::numeric(14,2),
|
||||
cost_eur = ROUND(((EXTRACT(EPOCH FROM (%(ended_at)s - s.started_at)) / 3600.0) * s.value_hour_eur)::numeric, 2),
|
||||
end_event_name = %(event_name)s,
|
||||
end_payload_json = %(payload_json)s,
|
||||
updated_at = now()
|
||||
FROM open_stop
|
||||
WHERE s.id = open_stop.id;
|
||||
"""
|
||||
|
||||
params = {
|
||||
"asset": asset,
|
||||
"machine_id": machine_id,
|
||||
"ended_at": event_ts,
|
||||
"event_name": event_name,
|
||||
"payload_json": Json({
|
||||
**payload,
|
||||
"_mqtt_topic": topic,
|
||||
"_stop_logic": "close_stop",
|
||||
}),
|
||||
}
|
||||
|
||||
with PG_CONN.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
|
||||
return
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
print(f"[MQTT] connected reason_code={reason_code}")
|
||||
client.subscribe(MQTT_TOPIC)
|
||||
print(f"[MQTT] subscribed topic={MQTT_TOPIC}")
|
||||
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
body = msg.payload.decode("utf-8")
|
||||
payload = json.loads(body)
|
||||
|
||||
if msg.topic.endswith("/telemetry"):
|
||||
insert_payload(msg.topic, payload)
|
||||
|
||||
asset = payload.get("asset")
|
||||
voltage = payload.get("voltage_l1_v")
|
||||
power = payload.get("power_total_kw")
|
||||
comm_ok = payload.get("comm_ok")
|
||||
pulses = payload.get("cycle_pulse_count")
|
||||
rate = payload.get("cycle_rate_ppm")
|
||||
|
||||
print(
|
||||
f"[OK][telemetry] topic={msg.topic} asset={asset} "
|
||||
f"voltage={voltage} power_kw={power} pulses={pulses} rate_ppm={rate} comm_ok={comm_ok}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
elif msg.topic.endswith("/event"):
|
||||
insert_event(msg.topic, payload)
|
||||
process_machine_stop_event(msg.topic, payload)
|
||||
print(
|
||||
f"[OK][event] topic={msg.topic} "
|
||||
f"event_name={payload.get('event_name')} "
|
||||
f"asset={payload.get('asset')} "
|
||||
f"previous={payload.get('previous_auto_signal')} "
|
||||
f"new={payload.get('new_auto_signal')} "
|
||||
f"pulses={payload.get('cycle_pulse_count')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
else:
|
||||
print(f"[WARN] Ignored topic={msg.topic}", flush=True)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ERROR] processing topic={msg.topic}: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
||||
|
||||
def main():
|
||||
print("[START] edge_oee_mqtt_pg_sink")
|
||||
print(f"[CONFIG] MQTT {MQTT_HOST}:{MQTT_PORT} topic={MQTT_TOPIC}")
|
||||
print(f"[CONFIG] PostgreSQL {PGHOST}:{PGPORT}/{PGDATABASE} user={PGUSER}")
|
||||
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
||||
|
||||
if MQTT_USER:
|
||||
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
|
||||
print(f"[CONFIG] MQTT auth user={MQTT_USER}", flush=True)
|
||||
else:
|
||||
print("[WARN] MQTT auth disabled: MQTT_USER not set", flush=True)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
while RUNNING:
|
||||
signal.pause()
|
||||
except AttributeError:
|
||||
import time
|
||||
while RUNNING:
|
||||
time.sleep(1)
|
||||
finally:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
try:
|
||||
PG_CONN.close()
|
||||
except Exception:
|
||||
pass
|
||||
print("[STOP] edge_oee_mqtt_pg_sink")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,2 +0,0 @@
|
||||
paho-mqtt>=2,<3
|
||||
psycopg2-binary==2.9.9
|
||||
@ -1,14 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@ -1,452 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import xmlrpc.client
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
|
||||
def env(name, default=None, required=False):
|
||||
value = os.getenv(name, default)
|
||||
if required and not value:
|
||||
print(f"[ERROR] Missing required env var: {name}", file=sys.stderr, flush=True)
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
|
||||
ODOO_URL = env("ODOO_URL", required=True).rstrip("/")
|
||||
ODOO_DB = env("ODOO_DB", required=True)
|
||||
ODOO_USER = env("ODOO_USER", required=True)
|
||||
ODOO_PASSWORD = env("ODOO_PASSWORD", required=True)
|
||||
|
||||
ODOO_WORKCENTER_CODE = env("ODOO_WORKCENTER_CODE", "CORT-01")
|
||||
ODOO_INSTANCE = env("ODOO_INSTANCE", "odoo_demo")
|
||||
ODOO_SYNC_INTERVAL_SECONDS = int(env("ODOO_SYNC_INTERVAL_SECONDS", "30"))
|
||||
ODOO_WORKORDER_LIMIT = int(env("ODOO_WORKORDER_LIMIT", "50"))
|
||||
ODOO_WORKORDER_STATES = [
|
||||
s.strip()
|
||||
for s in env("ODOO_WORKORDER_STATES", "waiting,ready,progress,done").split(",")
|
||||
if s.strip()
|
||||
]
|
||||
|
||||
TENANT = env("TENANT", "ucepsa")
|
||||
SITE = env("SITE", "demo_edge_oee")
|
||||
MACHINE_ID = env("MACHINE_ID", "CORT-01")
|
||||
ASSET = env("ASSET", "revpi_oee_node_01")
|
||||
TARGET_CYCLE_RATE_PPM = float(env("TARGET_CYCLE_RATE_PPM", "10"))
|
||||
|
||||
PG_CONFIG = {
|
||||
"host": env("PGHOST", required=True),
|
||||
"port": int(env("PGPORT", "5432")),
|
||||
"dbname": env("PGDATABASE", required=True),
|
||||
"user": env("PGUSER", required=True),
|
||||
"password": env("PGPASSWORD", required=True),
|
||||
"connect_timeout": 5,
|
||||
}
|
||||
|
||||
|
||||
def parse_odoo_datetime(value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
# Odoo devuelve fechas en UTC como string naive: YYYY-MM-DD HH:MM:SS
|
||||
if isinstance(value, str):
|
||||
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def odoo_connect():
|
||||
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
|
||||
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
|
||||
|
||||
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {})
|
||||
if not uid:
|
||||
raise RuntimeError("Odoo authentication failed")
|
||||
|
||||
return uid, models
|
||||
|
||||
|
||||
def existing_fields(models, uid, model, wanted):
|
||||
fields = models.execute_kw(
|
||||
ODOO_DB,
|
||||
uid,
|
||||
ODOO_PASSWORD,
|
||||
model,
|
||||
"fields_get",
|
||||
[],
|
||||
{"attributes": ["string", "type"]},
|
||||
)
|
||||
return [f for f in wanted if f in fields]
|
||||
|
||||
|
||||
def search_read(models, uid, model, domain, wanted_fields, limit=20, order="id desc"):
|
||||
fields = existing_fields(models, uid, model, wanted_fields)
|
||||
|
||||
return models.execute_kw(
|
||||
ODOO_DB,
|
||||
uid,
|
||||
ODOO_PASSWORD,
|
||||
model,
|
||||
"search_read",
|
||||
[domain],
|
||||
{
|
||||
"fields": fields,
|
||||
"limit": limit,
|
||||
"order": order,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def read_records(models, uid, model, ids, wanted_fields):
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
fields = existing_fields(models, uid, model, wanted_fields)
|
||||
|
||||
return models.execute_kw(
|
||||
ODOO_DB,
|
||||
uid,
|
||||
ODOO_PASSWORD,
|
||||
model,
|
||||
"read",
|
||||
[ids],
|
||||
{"fields": fields},
|
||||
)
|
||||
|
||||
|
||||
def get_workcenter(models, uid):
|
||||
wc_fields = existing_fields(models, uid, "mrp.workcenter", ["id", "name", "code", "tag_ids"])
|
||||
|
||||
if "code" in wc_fields:
|
||||
domain = ["|", ("code", "=", ODOO_WORKCENTER_CODE), ("name", "ilike", ODOO_WORKCENTER_CODE)]
|
||||
else:
|
||||
domain = [("name", "ilike", ODOO_WORKCENTER_CODE)]
|
||||
|
||||
workcenters = search_read(
|
||||
models,
|
||||
uid,
|
||||
"mrp.workcenter",
|
||||
domain,
|
||||
["id", "name", "code", "tag_ids"],
|
||||
limit=10,
|
||||
order="id asc",
|
||||
)
|
||||
|
||||
if not workcenters:
|
||||
raise RuntimeError(f"No workcenter found for code/name {ODOO_WORKCENTER_CODE}")
|
||||
|
||||
# Preferencia exacta por code
|
||||
for wc in workcenters:
|
||||
if wc.get("code") == ODOO_WORKCENTER_CODE:
|
||||
return wc
|
||||
|
||||
return workcenters[0]
|
||||
|
||||
|
||||
def normalize_many2one(value):
|
||||
if isinstance(value, list) and len(value) >= 2:
|
||||
return value[0], value[1]
|
||||
return None, None
|
||||
|
||||
|
||||
def upsert_workcenter_map(conn, wc):
|
||||
wc_id = wc.get("id")
|
||||
wc_name = wc.get("name") or ODOO_WORKCENTER_CODE
|
||||
wc_code = wc.get("code") or ODOO_WORKCENTER_CODE
|
||||
|
||||
sql = """
|
||||
INSERT INTO mv_edge_oee_ucepsa_demo.odoo_workcenter_map (
|
||||
tenant,
|
||||
site,
|
||||
odoo_instance,
|
||||
odoo_workcenter_id,
|
||||
odoo_workcenter_code,
|
||||
odoo_workcenter_name,
|
||||
machine_id,
|
||||
asset,
|
||||
is_active
|
||||
)
|
||||
VALUES (
|
||||
%(tenant)s,
|
||||
%(site)s,
|
||||
%(odoo_instance)s,
|
||||
%(odoo_workcenter_id)s,
|
||||
%(odoo_workcenter_code)s,
|
||||
%(odoo_workcenter_name)s,
|
||||
%(machine_id)s,
|
||||
%(asset)s,
|
||||
true
|
||||
)
|
||||
ON CONFLICT (tenant, site, odoo_instance, odoo_workcenter_code)
|
||||
DO UPDATE SET
|
||||
odoo_workcenter_id = EXCLUDED.odoo_workcenter_id,
|
||||
odoo_workcenter_name = EXCLUDED.odoo_workcenter_name,
|
||||
machine_id = EXCLUDED.machine_id,
|
||||
asset = EXCLUDED.asset,
|
||||
is_active = true;
|
||||
"""
|
||||
|
||||
params = {
|
||||
"tenant": TENANT,
|
||||
"site": SITE,
|
||||
"odoo_instance": ODOO_INSTANCE,
|
||||
"odoo_workcenter_id": wc_id,
|
||||
"odoo_workcenter_code": wc_code,
|
||||
"odoo_workcenter_name": wc_name,
|
||||
"machine_id": MACHINE_ID,
|
||||
"asset": ASSET,
|
||||
}
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
|
||||
|
||||
def fetch_workorders(models, uid, wc):
|
||||
wc_id = wc["id"]
|
||||
|
||||
domain = [
|
||||
("workcenter_id", "=", wc_id),
|
||||
("state", "in", ODOO_WORKORDER_STATES),
|
||||
]
|
||||
|
||||
workorders = search_read(
|
||||
models,
|
||||
uid,
|
||||
"mrp.workorder",
|
||||
domain,
|
||||
[
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"workcenter_id",
|
||||
"production_id",
|
||||
"product_id",
|
||||
"date_start",
|
||||
"date_finished",
|
||||
"duration_expected",
|
||||
"duration",
|
||||
"qty_production",
|
||||
],
|
||||
limit=ODOO_WORKORDER_LIMIT,
|
||||
order="id desc",
|
||||
)
|
||||
|
||||
production_ids = []
|
||||
for wo in workorders:
|
||||
prod_id, _ = normalize_many2one(wo.get("production_id"))
|
||||
if prod_id:
|
||||
production_ids.append(prod_id)
|
||||
|
||||
productions = read_records(
|
||||
models,
|
||||
uid,
|
||||
"mrp.production",
|
||||
production_ids,
|
||||
[
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"product_id",
|
||||
"product_qty",
|
||||
"qty_producing",
|
||||
"date_start",
|
||||
"date_finished",
|
||||
"workorder_ids",
|
||||
],
|
||||
)
|
||||
|
||||
productions_by_id = {p["id"]: p for p in productions}
|
||||
|
||||
return workorders, productions_by_id
|
||||
|
||||
|
||||
def upsert_workorder(conn, wc, wo, productions_by_id):
|
||||
wo_id = wo.get("id")
|
||||
wo_name = wo.get("name")
|
||||
wo_state = wo.get("state")
|
||||
|
||||
wc_id, wc_name = normalize_many2one(wo.get("workcenter_id"))
|
||||
prod_id, prod_ref = normalize_many2one(wo.get("production_id"))
|
||||
product_id, product_name = normalize_many2one(wo.get("product_id"))
|
||||
|
||||
production = productions_by_id.get(prod_id, {})
|
||||
|
||||
prod_state = production.get("state")
|
||||
prod_product_id, prod_product_name = normalize_many2one(production.get("product_id"))
|
||||
|
||||
product = product_name or prod_product_name or "Producto sin nombre"
|
||||
order_ref = prod_ref or production.get("name") or f"WO-{wo_id}"
|
||||
|
||||
qty_planned = wo.get("qty_production")
|
||||
if qty_planned is None:
|
||||
qty_planned = production.get("product_qty")
|
||||
|
||||
qty_done = production.get("qty_producing")
|
||||
|
||||
real_start = parse_odoo_datetime(wo.get("date_start"))
|
||||
real_end = parse_odoo_datetime(wo.get("date_finished"))
|
||||
|
||||
if real_end is None and wo_state in ("done", "cancel"):
|
||||
real_end = parse_odoo_datetime(production.get("date_finished"))
|
||||
|
||||
|
||||
has_real_start = real_start is not None
|
||||
|
||||
# Si Odoo no tiene date_start, usamos now() solo en el primer insert.
|
||||
# En updates posteriores, si sigue sin date_start real, conservamos el odoo_start anterior.
|
||||
insert_start = real_start or datetime.now(timezone.utc)
|
||||
|
||||
wc_code = wc.get("code") or ODOO_WORKCENTER_CODE
|
||||
wc_display_name = wc.get("name") or wc_name or ODOO_WORKCENTER_CODE
|
||||
|
||||
sql = """
|
||||
INSERT INTO mv_edge_oee_ucepsa_demo.odoo_workorders_demo (
|
||||
tenant,
|
||||
site,
|
||||
odoo_instance,
|
||||
odoo_workorder_id,
|
||||
odoo_production_id,
|
||||
odoo_order_ref,
|
||||
odoo_state,
|
||||
odoo_workcenter_code,
|
||||
odoo_workcenter_name,
|
||||
machine_id,
|
||||
asset,
|
||||
product,
|
||||
qty_planned,
|
||||
qty_done,
|
||||
uom,
|
||||
planned_cycles,
|
||||
target_cycle_rate_ppm,
|
||||
odoo_start,
|
||||
odoo_end,
|
||||
notes,
|
||||
snapshot_at
|
||||
)
|
||||
VALUES (
|
||||
%(tenant)s,
|
||||
%(site)s,
|
||||
%(odoo_instance)s,
|
||||
%(odoo_workorder_id)s,
|
||||
%(odoo_production_id)s,
|
||||
%(odoo_order_ref)s,
|
||||
%(odoo_state)s,
|
||||
%(odoo_workcenter_code)s,
|
||||
%(odoo_workcenter_name)s,
|
||||
%(machine_id)s,
|
||||
%(asset)s,
|
||||
%(product)s,
|
||||
%(qty_planned)s,
|
||||
%(qty_done)s,
|
||||
%(uom)s,
|
||||
%(planned_cycles)s,
|
||||
%(target_cycle_rate_ppm)s,
|
||||
%(odoo_start)s,
|
||||
%(odoo_end)s,
|
||||
%(notes)s,
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (tenant, site, odoo_instance, odoo_workorder_id)
|
||||
WHERE odoo_workorder_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
odoo_production_id = EXCLUDED.odoo_production_id,
|
||||
odoo_order_ref = EXCLUDED.odoo_order_ref,
|
||||
odoo_state = EXCLUDED.odoo_state,
|
||||
odoo_workcenter_code = EXCLUDED.odoo_workcenter_code,
|
||||
odoo_workcenter_name = EXCLUDED.odoo_workcenter_name,
|
||||
machine_id = EXCLUDED.machine_id,
|
||||
asset = EXCLUDED.asset,
|
||||
product = EXCLUDED.product,
|
||||
qty_planned = EXCLUDED.qty_planned,
|
||||
qty_done = EXCLUDED.qty_done,
|
||||
uom = EXCLUDED.uom,
|
||||
planned_cycles = EXCLUDED.planned_cycles,
|
||||
target_cycle_rate_ppm = EXCLUDED.target_cycle_rate_ppm,
|
||||
odoo_start = CASE
|
||||
WHEN %(has_real_start)s THEN EXCLUDED.odoo_start
|
||||
ELSE mv_edge_oee_ucepsa_demo.odoo_workorders_demo.odoo_start
|
||||
END,
|
||||
odoo_end = EXCLUDED.odoo_end,
|
||||
notes = EXCLUDED.notes,
|
||||
snapshot_at = now();
|
||||
"""
|
||||
|
||||
params = {
|
||||
"tenant": TENANT,
|
||||
"site": SITE,
|
||||
"odoo_instance": ODOO_INSTANCE,
|
||||
"odoo_workorder_id": wo_id,
|
||||
"odoo_production_id": prod_id,
|
||||
"odoo_order_ref": order_ref,
|
||||
"odoo_state": wo_state,
|
||||
"odoo_workcenter_code": wc_code,
|
||||
"odoo_workcenter_name": wc_display_name,
|
||||
"machine_id": MACHINE_ID,
|
||||
"asset": ASSET,
|
||||
"product": product,
|
||||
"qty_planned": qty_planned,
|
||||
"qty_done": qty_done,
|
||||
"uom": "unidades",
|
||||
"planned_cycles": qty_planned,
|
||||
"target_cycle_rate_ppm": TARGET_CYCLE_RATE_PPM,
|
||||
"odoo_start": insert_start,
|
||||
"odoo_end": real_end,
|
||||
"notes": f"Sincronizado desde Odoo workorder {wo_id} / production {prod_id}. Production state: {prod_state}. Workorder name: {wo_name}.",
|
||||
"has_real_start": has_real_start,
|
||||
}
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
|
||||
|
||||
def sync_once():
|
||||
uid, models = odoo_connect()
|
||||
|
||||
wc = get_workcenter(models, uid)
|
||||
|
||||
with psycopg2.connect(**PG_CONFIG) as conn:
|
||||
conn.autocommit = False
|
||||
|
||||
upsert_workcenter_map(conn, wc)
|
||||
|
||||
workorders, productions_by_id = fetch_workorders(models, uid, wc)
|
||||
|
||||
for wo in workorders:
|
||||
upsert_workorder(conn, wc, wo, productions_by_id)
|
||||
|
||||
conn.commit()
|
||||
|
||||
print(
|
||||
f"[OK] sync complete workcenter={wc.get('name')} code={wc.get('code')} "
|
||||
f"workorders={len(workorders)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
print("[START] odoo-workorders-sync", flush=True)
|
||||
print(f"[CONFIG] ODOO_URL={ODOO_URL}", flush=True)
|
||||
print(f"[CONFIG] ODOO_DB={ODOO_DB}", flush=True)
|
||||
print(f"[CONFIG] ODOO_WORKCENTER_CODE={ODOO_WORKCENTER_CODE}", flush=True)
|
||||
print(f"[CONFIG] INTERVAL={ODOO_SYNC_INTERVAL_SECONDS}s", flush=True)
|
||||
|
||||
run_once = env("RUN_ONCE", "0") == "1"
|
||||
|
||||
while True:
|
||||
try:
|
||||
sync_once()
|
||||
except Exception as exc:
|
||||
print(f"[ERROR] {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
||||
|
||||
if run_once:
|
||||
break
|
||||
|
||||
time.sleep(ODOO_SYNC_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,181 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import xmlrpc.client
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
ENV_FILE = BASE_DIR / ".env.odoo-sync"
|
||||
|
||||
|
||||
def load_env(path):
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def env(name, default=None, required=False):
|
||||
value = os.getenv(name, default)
|
||||
if required and not value:
|
||||
print(f"[ERROR] Falta variable: {name}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
|
||||
def existing_fields(models, db, uid, password, model, wanted):
|
||||
fields = models.execute_kw(
|
||||
db,
|
||||
uid,
|
||||
password,
|
||||
model,
|
||||
"fields_get",
|
||||
[],
|
||||
{"attributes": ["string", "type"]},
|
||||
)
|
||||
return [f for f in wanted if f in fields]
|
||||
|
||||
|
||||
def search_read(models, db, uid, password, model, domain, wanted_fields, limit=20):
|
||||
fields = existing_fields(models, db, uid, password, model, wanted_fields)
|
||||
|
||||
return models.execute_kw(
|
||||
db,
|
||||
uid,
|
||||
password,
|
||||
model,
|
||||
"search_read",
|
||||
[domain],
|
||||
{
|
||||
"fields": fields,
|
||||
"limit": limit,
|
||||
"order": "id desc",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
load_env(ENV_FILE)
|
||||
|
||||
url = env("ODOO_URL", required=True).rstrip("/")
|
||||
db = env("ODOO_DB", required=True)
|
||||
user = env("ODOO_USER", required=True)
|
||||
password = env("ODOO_PASSWORD", required=True)
|
||||
workcenter_code = env("ODOO_WORKCENTER_CODE", "CORT-01")
|
||||
|
||||
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
|
||||
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
|
||||
|
||||
uid = common.authenticate(db, user, password, {})
|
||||
if not uid:
|
||||
print("[ERROR] Autenticación fallida")
|
||||
return 1
|
||||
|
||||
print(f"[OK] Autenticado en Odoo uid={uid}")
|
||||
print()
|
||||
|
||||
print("=== Centros de trabajo ===")
|
||||
workcenters = search_read(
|
||||
models,
|
||||
db,
|
||||
uid,
|
||||
password,
|
||||
"mrp.workcenter",
|
||||
[],
|
||||
["id", "name", "code", "tag_ids"],
|
||||
limit=50,
|
||||
)
|
||||
|
||||
for wc in workcenters:
|
||||
print(wc)
|
||||
|
||||
print()
|
||||
print(f"=== Centro de trabajo con código/nombre {workcenter_code} ===")
|
||||
|
||||
# Algunos Odoo tienen campo code, otros no. Probamos primero code si existe.
|
||||
wc_fields = existing_fields(models, db, uid, password, "mrp.workcenter", ["id", "name", "code"])
|
||||
if "code" in wc_fields:
|
||||
domain = ["|", ("code", "=", workcenter_code), ("name", "ilike", workcenter_code)]
|
||||
else:
|
||||
domain = [("name", "ilike", workcenter_code)]
|
||||
|
||||
target_wcs = search_read(
|
||||
models,
|
||||
db,
|
||||
uid,
|
||||
password,
|
||||
"mrp.workcenter",
|
||||
domain,
|
||||
["id", "name", "code"],
|
||||
limit=10,
|
||||
)
|
||||
|
||||
for wc in target_wcs:
|
||||
print(wc)
|
||||
|
||||
print()
|
||||
print("=== Órdenes de trabajo recientes ===")
|
||||
workorders = search_read(
|
||||
models,
|
||||
db,
|
||||
uid,
|
||||
password,
|
||||
"mrp.workorder",
|
||||
[],
|
||||
[
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"workcenter_id",
|
||||
"production_id",
|
||||
"product_id",
|
||||
"date_start",
|
||||
"date_finished",
|
||||
"duration_expected",
|
||||
"duration",
|
||||
"qty_production",
|
||||
],
|
||||
limit=20,
|
||||
)
|
||||
|
||||
for wo in workorders:
|
||||
print(wo)
|
||||
|
||||
print()
|
||||
print("=== Órdenes de fabricación recientes ===")
|
||||
productions = search_read(
|
||||
models,
|
||||
db,
|
||||
uid,
|
||||
password,
|
||||
"mrp.production",
|
||||
[],
|
||||
[
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"product_id",
|
||||
"product_qty",
|
||||
"qty_producing",
|
||||
"date_start",
|
||||
"date_finished",
|
||||
"workorder_ids",
|
||||
],
|
||||
limit=20,
|
||||
)
|
||||
|
||||
for mo in productions:
|
||||
print(mo)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@ -1,14 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@ -1,310 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values, Json
|
||||
|
||||
|
||||
def getenv(name: str, default: str | None = None, required: bool = False) -> str:
|
||||
value = os.getenv(name, default)
|
||||
if required and not value:
|
||||
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return value
|
||||
|
||||
|
||||
PGHOST = getenv("PGHOST", required=True)
|
||||
PGPORT = int(getenv("PGPORT", "5432"))
|
||||
PGDATABASE = getenv("PGDATABASE", required=True)
|
||||
PGUSER = getenv("PGUSER", required=True)
|
||||
PGPASSWORD = getenv("PGPASSWORD", required=True)
|
||||
|
||||
TENANT = getenv("TENANT", "ucepsa")
|
||||
SITE = getenv("SITE", "demo_edge_oee")
|
||||
TIMEZONE = getenv("TIMEZONE", "Europe/Madrid")
|
||||
ANCHOR_MODE = getenv("ANCHOR_MODE", "shift_today")
|
||||
SCENARIO = getenv("SCENARIO", "standard")
|
||||
|
||||
VALUE_HOUR = 36.00
|
||||
|
||||
|
||||
def local_base_time() -> datetime:
|
||||
tz = ZoneInfo(TIMEZONE)
|
||||
now = datetime.now(tz)
|
||||
|
||||
if ANCHOR_MODE == "rolling":
|
||||
return now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
|
||||
|
||||
return now.replace(hour=8, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def cost(duration_min: float, event_type: str) -> float:
|
||||
if event_type == "RUNNING":
|
||||
return 0.0
|
||||
return round((duration_min / 60.0) * VALUE_HOUR, 2)
|
||||
|
||||
|
||||
def minutes_between(start: datetime, end: datetime) -> float:
|
||||
return round((end - start).total_seconds() / 60.0, 2)
|
||||
|
||||
|
||||
def ts(base: datetime, minutes: float) -> datetime:
|
||||
return base + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def build_orders(base: datetime):
|
||||
return [
|
||||
(
|
||||
"OF00481",
|
||||
TENANT,
|
||||
SITE,
|
||||
"C0",
|
||||
"Bolsa 300x400",
|
||||
ts(base, 0),
|
||||
ts(base, 210),
|
||||
50000,
|
||||
48900,
|
||||
1100,
|
||||
"unidades",
|
||||
"Orden con arranque real retrasado 22 minutos.",
|
||||
),
|
||||
(
|
||||
"OF00482",
|
||||
TENANT,
|
||||
SITE,
|
||||
"C1",
|
||||
"Bolsa 250x350",
|
||||
ts(base, 15),
|
||||
ts(base, 250),
|
||||
42000,
|
||||
41600,
|
||||
400,
|
||||
"unidades",
|
||||
"Orden casi normal, usada como comparación.",
|
||||
),
|
||||
(
|
||||
"OF00493",
|
||||
TENANT,
|
||||
SITE,
|
||||
"C2",
|
||||
"Bolsa 300x400",
|
||||
ts(base, 60),
|
||||
ts(base, 320),
|
||||
52000,
|
||||
50500,
|
||||
1500,
|
||||
"unidades",
|
||||
"Orden asociada a caso ficticio de sellado débil.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def build_events(base: datetime):
|
||||
raw_events = [
|
||||
("C0", "OF00481", "PREPARATION", "Búsqueda de bobina", 0, 22, "Odoo inicia la orden, pero la primera marcha real llega 22 minutos después."),
|
||||
("C0", "OF00481", "RUNNING", "Producción", 22, 74, "Producción real."),
|
||||
("C0", "OF00481", "MICROSTOP", "Microparos acumulados", 74, 78, "Microparos invisibles para un registro manual."),
|
||||
("C0", "OF00481", "STOP", "Cambio bobina", 125, 138, "Paro por cambio de bobina."),
|
||||
("C1", "OF00482", "PREPARATION", "Preparación normal", 15, 18, "Preparación corta, caso de comparación."),
|
||||
("C1", "OF00482", "RUNNING", "Producción", 18, 160, "Producción estable."),
|
||||
("C1", None, "RUNNING_WITHOUT_ORDER", "Máquina en marcha sin orden activa", 270, 296, "Brecha de dato: producción no imputada correctamente a Odoo."),
|
||||
("C2", "OF00493", "PREPARATION", "Ajuste inicial de máquina", 60, 76, "Preparación superior a 15 minutos."),
|
||||
("C2", "OF00493", "RUNNING", "Producción", 76, 100, "Producción real."),
|
||||
("C2", "OF00493", "STOP", "Ajuste de sellado", 100, 112, "Paro vinculado a sellado."),
|
||||
("C2", "OF00493", "RUNNING", "Producción", 112, 145, "Producción real."),
|
||||
("C2", "OF00493", "MICROSTOP", "Microparos durante orden", 145, 148.5, "Microparos durante producto con reclamación."),
|
||||
("C2", "OF00493", "STOP", "Ajuste de sellado", 200, 212, "Segundo ajuste de sellado."),
|
||||
("C2", "OF00493", "STOP", "Ajuste de sellado", 244, 252, "Tercer ajuste de sellado."),
|
||||
]
|
||||
|
||||
events = []
|
||||
for machine_id, order_id, event_type, cause, start_min, end_min, notes in raw_events:
|
||||
start = ts(base, start_min)
|
||||
end = ts(base, end_min)
|
||||
duration = minutes_between(start, end)
|
||||
events.append(
|
||||
(
|
||||
TENANT,
|
||||
SITE,
|
||||
machine_id,
|
||||
order_id,
|
||||
event_type,
|
||||
cause,
|
||||
start,
|
||||
end,
|
||||
duration,
|
||||
cost(duration, event_type),
|
||||
"oee_demo_generator",
|
||||
notes,
|
||||
)
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def build_energy(base: datetime):
|
||||
return [
|
||||
(ts(base, 60), TENANT, SITE, "C2", "OF00493", 4.800, 102.100, "oee_demo_generator", Json({"note": "inicio orden sellado"})),
|
||||
(ts(base, 120), TENANT, SITE, "C2", "OF00493", 5.150, 106.900, "oee_demo_generator", Json({"note": "energía sobre media durante ajustes"})),
|
||||
(ts(base, 180), TENANT, SITE, "C2", "OF00493", 5.090, 112.050, "oee_demo_generator", Json({"note": "energía sobre media"})),
|
||||
(ts(base, 240), TENANT, SITE, "C2", "OF00493", 5.200, 117.250, "oee_demo_generator", Json({"note": "ajuste sellado"})),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
base = local_base_time()
|
||||
|
||||
machines = [
|
||||
("C0", TENANT, SITE, "Cortadora 0", "corte", "cortadora", 36.00, 34.90, 3.23, 0.80, 1.25, "Máquina de alta carga. Valor hora demo prudente."),
|
||||
("C1", TENANT, SITE, "Cortadora 1", "corte", "cortadora", 36.00, 34.90, 3.23, 0.80, 1.25, "Máquina de alta carga. Comparable con C0."),
|
||||
("C2", TENANT, SITE, "Cortadora 2", "corte", "cortadora", 36.00, 35.40, 2.80, 0.80, 1.25, "Máquina crítica por carga y problemas de sellado."),
|
||||
]
|
||||
|
||||
orders = build_orders(base)
|
||||
events = build_events(base)
|
||||
energy = build_energy(base)
|
||||
|
||||
losses = [
|
||||
("Ajuste de sellado", 18.00, 36.00, 648.00, "Revisar mordazas, teflón, temperatura y presión", "Medio", "Alta", "C2"),
|
||||
("Espera de material", 14.00, 36.00, 504.00, "Crear prealistamiento de bobina antes del turno", "Bajo", "Alta", "C0/C1/C2"),
|
||||
("Microparos acumulados", 11.50, 36.00, 414.00, "Revisar fotocélula, tensión de material y cuchilla", "Bajo", "Alta", "C0/C2"),
|
||||
("Cambio bobina/formato", 9.00, 36.00, 324.00, "Implantar checklist de cambio de bobina/formato", "Bajo", "Media", "C0/C1"),
|
||||
("Arranque lento de orden", 6.00, 36.00, 216.00, "Preparar primera orden y material antes del inicio del turno", "Bajo", "Media", "C0/C2"),
|
||||
("Paro sin causa", 5.00, 36.00, 180.00, "Mejorar registro de causas y disciplina de imputación", "Bajo", "Alta", "Todas"),
|
||||
]
|
||||
|
||||
data_quality = [
|
||||
("orders_started_no_run_10m", "Órdenes iniciadas sin marcha en 10 min", 6, None, "órdenes", "Warning", "Odoo registra inicio, pero la máquina no produce.", "El tiempo de preparación queda mezclado con tiempo productivo.", "Separar preparación, espera de material y marcha real.", 1),
|
||||
("machine_running_no_order", "Máquina en marcha sin orden activa", 3, None, "eventos", "Warning", "Hay producción física sin orden asignada.", "Producción no imputada correctamente a Odoo.", "Bloquear o alertar producción sin orden activa.", 2),
|
||||
("orders_closed_before_real_end", "Órdenes cerradas antes de fin real", 2, None, "órdenes", "Warning", "El cierre administrativo no coincide con la realidad física.", "Descuadre de tiempos, coste y trazabilidad.", "Comparar fin Odoo contra última marcha real.", 3),
|
||||
("stops_without_cause", "Paros sin causa", 5, None, "h/mes", "Warning", "Hay pérdidas sin clasificación útil.", "No se puede decidir ninguna acción concreta.", "Simplificar selección de causas en planta.", 4),
|
||||
("preparation_gt_15m", "Órdenes con preparación >15 min", 8, None, "órdenes", "Warning", "Arranques lentos repetidos.", "Pérdida de capacidad al inicio de orden o turno.", "Prealistamiento de bobinas, útiles y parámetros.", 5),
|
||||
("energy_above_avg", "Órdenes con energía superior a media", 5, None, "órdenes", "Info", "Consumo superior al patrón esperado.", "Puede indicar ajustes, fricción, vacío, temperatura o régimen anómalo.", "Cruzar energía con producto, máquina y causa de paro.", 6),
|
||||
]
|
||||
|
||||
quality_cases = [
|
||||
(
|
||||
"QC_SELLADO_OF00493",
|
||||
"OF00493",
|
||||
"C2",
|
||||
"Bolsa 300x400",
|
||||
"Sellado débil",
|
||||
4,
|
||||
32,
|
||||
23,
|
||||
2.80,
|
||||
6.00,
|
||||
"La reclamación no queda aislada: durante la orden aparecen ajustes de sellado, microparos, merma y energía superior a la media.",
|
||||
"Revisar mordazas, teflón, temperatura, presión y procedimiento de arranque. Comprobar si el patrón se repite en C2 o con Bolsa 300x400.",
|
||||
)
|
||||
]
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=PGHOST,
|
||||
port=PGPORT,
|
||||
dbname=PGDATABASE,
|
||||
user=PGUSER,
|
||||
password=PGPASSWORD,
|
||||
)
|
||||
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM mv_hot.edge_oee_energy_demo WHERE tenant = %s AND site = %s;", (TENANT, SITE))
|
||||
cur.execute("DELETE FROM mv_hot.edge_oee_quality_cases_demo WHERE machine_id IN ('C0','C1','C2');")
|
||||
cur.execute("DELETE FROM mv_hot.edge_oee_data_quality_demo;")
|
||||
cur.execute("DELETE FROM mv_hot.edge_oee_loss_summary_demo;")
|
||||
cur.execute("DELETE FROM mv_hot.edge_oee_events_demo WHERE tenant = %s AND site = %s;", (TENANT, SITE))
|
||||
cur.execute("DELETE FROM mv_hot.edge_oee_orders_demo WHERE tenant = %s AND site = %s;", (TENANT, SITE))
|
||||
cur.execute("DELETE FROM mv_hot.edge_oee_machines WHERE machine_id IN ('C0','C1','C2');")
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO mv_hot.edge_oee_machines
|
||||
(machine_id, tenant, site, name, area, machine_type, value_hour_eur,
|
||||
standard_kg_h, sale_price_eur_kg, transformation_cost_eur_kg,
|
||||
raw_material_cost_eur_kg, notes)
|
||||
VALUES %s
|
||||
""",
|
||||
machines,
|
||||
)
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO mv_hot.edge_oee_orders_demo
|
||||
(order_id, tenant, site, machine_id, product, odoo_start, odoo_end,
|
||||
qty_planned, qty_done, scrap_qty, uom, commercial_note)
|
||||
VALUES %s
|
||||
""",
|
||||
orders,
|
||||
)
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO mv_hot.edge_oee_events_demo
|
||||
(tenant, site, machine_id, order_id, event_type, cause, start_time,
|
||||
end_time, duration_min, cost_eur, source, notes)
|
||||
VALUES %s
|
||||
""",
|
||||
events,
|
||||
)
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO mv_hot.edge_oee_loss_summary_demo
|
||||
(cause, hours_month, value_hour_eur, monthly_cost_eur,
|
||||
recommended_action, effort, priority, machine_focus)
|
||||
VALUES %s
|
||||
""",
|
||||
losses,
|
||||
)
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO mv_hot.edge_oee_data_quality_demo
|
||||
(metric_id, metric, value_numeric, value_text, unit, status,
|
||||
problem, likely_impact, recommended_action, sort_order)
|
||||
VALUES %s
|
||||
""",
|
||||
data_quality,
|
||||
)
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO mv_hot.edge_oee_quality_cases_demo
|
||||
(case_id, order_id, machine_id, product, claim,
|
||||
sealing_adjustment_stops, sealing_adjustment_min,
|
||||
microstops_count, scrap_pct, energy_vs_avg_pct,
|
||||
interpretation, recommended_action)
|
||||
VALUES %s
|
||||
""",
|
||||
quality_cases,
|
||||
)
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO mv_hot.edge_oee_energy_demo
|
||||
(ts, tenant, site, machine_id, order_id, power_kw, kwh_total, source, payload_json)
|
||||
VALUES %s
|
||||
""",
|
||||
energy,
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
print("[OK] Edge-OEE demo regenerated")
|
||||
print(f"[OK] tenant={TENANT} site={SITE} scenario={SCENARIO}")
|
||||
print(f"[OK] anchor_mode={ANCHOR_MODE} base={base.isoformat()}")
|
||||
print(f"[OK] machines={len(machines)} orders={len(orders)} events={len(events)} energy_points={len(energy)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@ -1,14 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py .
|
||||
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:8080", "--workers", "2", "--threads", "4", "app:app"]
|
||||
@ -1,701 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from flask import Flask, Response, redirect, render_template_string, request, url_for
|
||||
|
||||
def getenv(name, default=None, required=False):
|
||||
value = os.getenv(name, default)
|
||||
if required and not value:
|
||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||
return value
|
||||
|
||||
|
||||
PG_CONFIG = {
|
||||
"host": getenv("PGHOST", required=True),
|
||||
"port": int(getenv("PGPORT", "5432")),
|
||||
"dbname": getenv("PGDATABASE", required=True),
|
||||
"user": getenv("PGUSER", required=True),
|
||||
"password": getenv("PGPASSWORD", required=True),
|
||||
"connect_timeout": 5,
|
||||
}
|
||||
|
||||
APP_TITLE = getenv("APP_TITLE", "MESAVAULT Edge-OEE - Consola de paros UCEPSA")
|
||||
|
||||
DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORT-01")
|
||||
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
|
||||
AUTO_REFRESH_SECONDS = int(getenv("AUTO_REFRESH_SECONDS", "5"))
|
||||
|
||||
ALLOWED_MACHINES = [
|
||||
item.strip()
|
||||
for item in getenv("ALLOWED_MACHINES", DEFAULT_MACHINE_ID).split(",")
|
||||
if item.strip()
|
||||
]
|
||||
|
||||
|
||||
def resolve_machine_id(raw_value):
|
||||
machine_id = (raw_value or DEFAULT_MACHINE_ID).strip()
|
||||
|
||||
if machine_id not in ALLOWED_MACHINES:
|
||||
return DEFAULT_MACHINE_ID
|
||||
|
||||
return machine_id
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def get_conn():
|
||||
return psycopg2.connect(**PG_CONFIG)
|
||||
|
||||
|
||||
def fetch_pending_stops(machine_id):
|
||||
sql = """
|
||||
SELECT
|
||||
id,
|
||||
tenant,
|
||||
site,
|
||||
vertical,
|
||||
asset,
|
||||
machine_id,
|
||||
order_id,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
classification_status,
|
||||
cause_code,
|
||||
cause_name,
|
||||
operator_note,
|
||||
duration_min,
|
||||
cost_eur
|
||||
FROM mv_hot.edge_oee_stops_recent
|
||||
WHERE classification_status = 'PENDING'
|
||||
AND machine_id = %(machine_id)s
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50;
|
||||
"""
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, {"machine_id": machine_id})
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_causes():
|
||||
sql = """
|
||||
SELECT
|
||||
cause_code,
|
||||
cause_name,
|
||||
description,
|
||||
is_planned
|
||||
FROM mv_hot.edge_oee_stop_cause_options
|
||||
ORDER BY sort_order, cause_name;
|
||||
"""
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def classify_stop(stop_id, machine_id, cause_code, operator_note):
|
||||
sql = """
|
||||
UPDATE mv_hot.edge_oee_machine_stops
|
||||
SET
|
||||
cause_code = %(cause_code)s,
|
||||
operator_note = %(operator_note)s,
|
||||
classification_status = 'CLASSIFIED',
|
||||
updated_at = now()
|
||||
WHERE id = %(stop_id)s
|
||||
AND machine_id = %(machine_id)s
|
||||
AND classification_status = 'PENDING';
|
||||
"""
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
sql,
|
||||
{
|
||||
"stop_id": stop_id,
|
||||
"machine_id": machine_id,
|
||||
"cause_code": cause_code,
|
||||
"operator_note": operator_note,
|
||||
},
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def fmt_dt(value):
|
||||
if not value:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value)
|
||||
|
||||
|
||||
def fmt_num(value, decimals=2):
|
||||
if value is None:
|
||||
return "0"
|
||||
try:
|
||||
return f"{float(value):.{decimals}f}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ app_title }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="manifest" href="{{ url_for('manifest', machine_id=selected_machine) }}">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="MESAVAULT {{ selected_machine }}">
|
||||
<link rel="icon" href="{{ url_for('pwa_icon') }}" type="image/svg+xml">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--panel: #111827;
|
||||
--panel2: #1f2937;
|
||||
--text: #e5e7eb;
|
||||
--muted: #94a3b8;
|
||||
--line: #334155;
|
||||
--blue: #2563eb;
|
||||
--blue2: #60a5fa;
|
||||
--green: #22c55e;
|
||||
--red: #ef4444;
|
||||
--orange: #f97316;
|
||||
--input: #020617;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 22px;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.machine-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 22px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.machine-tab {
|
||||
padding: 9px 13px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel2);
|
||||
color: #dbeafe;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--line);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.machine-tab.active {
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
border-color: var(--blue2);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1300px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--panel2);
|
||||
color: white;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.machine-main {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.machine-sub {
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.badge-open {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: #fecaca;
|
||||
border-color: rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #bbf7d0;
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: rgba(249, 115, 22, 0.14);
|
||||
color: #fed7aa;
|
||||
border-color: rgba(249, 115, 22, 0.45);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
min-width: 210px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
main {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{ app_title }}</h1>
|
||||
<div class="subtitle">
|
||||
Clasificación manual de paros detectados por Edge-OEE ·
|
||||
Máquina seleccionada: <strong>{{ selected_machine }}</strong> ·
|
||||
Última carga: {{ loaded_at }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="machine-tabs">
|
||||
{% for machine in allowed_machines %}
|
||||
<a
|
||||
class="machine-tab {% if machine == selected_machine %}active{% endif %}"
|
||||
href="{{ url_for('index', machine_id=machine) }}"
|
||||
>
|
||||
{{ machine }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card-title">Paros pendientes</div>
|
||||
<div class="card-value">{{ pending_count }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Máquina monitorizada</div>
|
||||
<div class="card-value">{{ selected_machine }}</div>
|
||||
<div class="card-subtitle">{{ default_asset }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
{% if stops %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Máquina</th>
|
||||
<th>Inicio</th>
|
||||
<th>Fin</th>
|
||||
<th>Estado</th>
|
||||
<th>Duración</th>
|
||||
<th>Coste</th>
|
||||
<th>Causa</th>
|
||||
<th>Nota</th>
|
||||
<th>Guardar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stop in stops %}
|
||||
<tr>
|
||||
<td>#{{ stop.id }}</td>
|
||||
|
||||
<td>
|
||||
<div class="machine-main">{{ stop.machine_id }}</div>
|
||||
<div class="machine-sub">{{ stop.asset }}</div>
|
||||
</td>
|
||||
|
||||
<td>{{ fmt_dt(stop.started_at) }}</td>
|
||||
|
||||
<td>
|
||||
{% if stop.ended_at %}
|
||||
{{ fmt_dt(stop.ended_at) }}
|
||||
{% else %}
|
||||
Abierto
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if stop.status == "OPEN" %}
|
||||
<span class="badge badge-open">OPEN</span>
|
||||
{% else %}
|
||||
<span class="badge badge-closed">{{ stop.status }}</span>
|
||||
{% endif %}
|
||||
<br>
|
||||
<span class="badge badge-pending">{{ stop.classification_status }}</span>
|
||||
</td>
|
||||
|
||||
<td>{{ fmt_num(stop.duration_min, 2) }} min</td>
|
||||
<td>{{ fmt_num(stop.cost_eur, 2) }} €</td>
|
||||
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('classify', stop_id=stop.id) }}">
|
||||
<input type="hidden" name="machine_id" value="{{ selected_machine }}">
|
||||
|
||||
<select name="cause_code" required>
|
||||
<option value="">Seleccionar causa...</option>
|
||||
{% for cause in causes %}
|
||||
<option value="{{ cause.cause_code }}">
|
||||
{{ cause.cause_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
name="operator_note"
|
||||
placeholder="Nota opcional"
|
||||
value="{{ stop.operator_note or '' }}"
|
||||
>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<button type="submit">Guardar</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
No hay paros pendientes para {{ selected_machine }}.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", function () {
|
||||
navigator.serviceWorker.register("/sw.js").catch(function (err) {
|
||||
console.log("Service worker no registrado:", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const refreshSeconds = Number("{{ auto_refresh_seconds }}");
|
||||
const refreshMs = Math.max(refreshSeconds, 2) * 1000;
|
||||
let formDirty = false;
|
||||
|
||||
document.addEventListener("input", function (event) {
|
||||
if (event.target.closest("form")) {
|
||||
formDirty = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", function (event) {
|
||||
if (event.target.closest("form")) {
|
||||
formDirty = true;
|
||||
}
|
||||
});
|
||||
|
||||
function userIsEditing() {
|
||||
const active = document.activeElement;
|
||||
if (!active) return false;
|
||||
|
||||
const tag = active.tagName;
|
||||
return tag === "INPUT" ||
|
||||
tag === "SELECT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
active.isContentEditable;
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
if (document.hidden) return;
|
||||
if (formDirty || userIsEditing()) return;
|
||||
|
||||
window.location.reload();
|
||||
}, refreshMs);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def index():
|
||||
selected_machine = resolve_machine_id(request.args.get("machine_id"))
|
||||
|
||||
stops = fetch_pending_stops(selected_machine)
|
||||
causes = fetch_causes()
|
||||
|
||||
return render_template_string(
|
||||
HTML,
|
||||
app_title=APP_TITLE,
|
||||
stops=stops,
|
||||
causes=causes,
|
||||
pending_count=len(stops),
|
||||
selected_machine=selected_machine,
|
||||
allowed_machines=ALLOWED_MACHINES,
|
||||
default_machine=selected_machine,
|
||||
default_asset=DEFAULT_ASSET,
|
||||
auto_refresh_seconds=AUTO_REFRESH_SECONDS,
|
||||
loaded_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
fmt_dt=fmt_dt,
|
||||
fmt_num=fmt_num,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/classify/<int:stop_id>", methods=["POST"])
|
||||
def classify(stop_id):
|
||||
selected_machine = resolve_machine_id(
|
||||
request.form.get("machine_id") or request.args.get("machine_id")
|
||||
)
|
||||
|
||||
cause_code = (request.form.get("cause_code") or "").strip()
|
||||
operator_note = (request.form.get("operator_note") or "").strip()
|
||||
|
||||
if cause_code:
|
||||
classify_stop(
|
||||
stop_id=stop_id,
|
||||
machine_id=selected_machine,
|
||||
cause_code=cause_code,
|
||||
operator_note=operator_note,
|
||||
)
|
||||
|
||||
return redirect(url_for("index", machine_id=selected_machine))
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1;")
|
||||
cur.fetchone()
|
||||
|
||||
return {"status": "ok"}, 200
|
||||
|
||||
except Exception as exc:
|
||||
return {"status": "error", "error": str(exc)}, 500
|
||||
|
||||
|
||||
@app.route("/manifest.webmanifest", methods=["GET"])
|
||||
def manifest():
|
||||
selected_machine = resolve_machine_id(request.args.get("machine_id"))
|
||||
|
||||
payload = {
|
||||
"name": f"MESAVAULT Edge-OEE {selected_machine}",
|
||||
"short_name": selected_machine,
|
||||
"description": "Consola de paros MESAVAULT Edge-OEE para UCEPSA",
|
||||
"start_url": url_for("index", machine_id=selected_machine),
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "landscape",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": url_for("pwa_icon"),
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
return Response(
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
mimetype="application/manifest+json",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/sw.js", methods=["GET"])
|
||||
def service_worker():
|
||||
js = """
|
||||
self.addEventListener("install", function (event) {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", function (event) {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
"""
|
||||
return Response(
|
||||
js,
|
||||
mimetype="application/javascript",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/pwa-icon.svg", methods=["GET"])
|
||||
def pwa_icon():
|
||||
svg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#0f172a"/>
|
||||
<path d="M96 328h320v48H96z" fill="#60a5fa"/>
|
||||
<path d="M136 136h240v128H136z" fill="#2563eb"/>
|
||||
<path d="M168 176h176v48H168z" fill="#dbeafe"/>
|
||||
<circle cx="160" cy="392" r="28" fill="#22c55e"/>
|
||||
<circle cx="352" cy="392" r="28" fill="#22c55e"/>
|
||||
<text x="256" y="304" text-anchor="middle" font-size="64" font-family="Arial" font-weight="700" fill="#ffffff">OEE</text>
|
||||
</svg>
|
||||
"""
|
||||
return Response(svg, mimetype="image/svg+xml")
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(getenv("APP_PORT", "8080"))
|
||||
app.run(host="0.0.0.0", port=port)
|
||||
@ -1,3 +0,0 @@
|
||||
Flask==3.0.3
|
||||
gunicorn==22.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
@ -1,8 +0,0 @@
|
||||
.env.local
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
@ -1,28 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
fonts-dejavu-core \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libharfbuzz-subset0 \
|
||||
libffi8 \
|
||||
libjpeg62-turbo \
|
||||
libopenjp2-7 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||
@ -1,634 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.requests import Request
|
||||
from weasyprint import HTML
|
||||
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
REPORT_TITLE = os.getenv("REPORT_TITLE", "MESAVAULT Edge-OEE UCEPSA")
|
||||
|
||||
if not DATABASE_URL:
|
||||
raise RuntimeError("DATABASE_URL environment variable is required")
|
||||
|
||||
|
||||
app = FastAPI(title="MESAVAULT Order Report API")
|
||||
templates = Jinja2Templates(directory="/app/app/templates")
|
||||
|
||||
|
||||
def safe_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")
|
||||
return cleaned or "order-report"
|
||||
|
||||
|
||||
def fmt_dt(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%d/%m/%Y %H:%M:%S")
|
||||
text = str(value)
|
||||
text = re.sub(r"\.\d+", "", text)
|
||||
text = text.replace("+02:00", "").replace("+01:00", "")
|
||||
return text
|
||||
|
||||
|
||||
def fmt_num(value: Any, decimals: int = 2) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
try:
|
||||
number = float(value)
|
||||
return f"{number:.{decimals}f}"
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
templates.env.filters["dt"] = fmt_dt
|
||||
templates.env.filters["num"] = fmt_num
|
||||
|
||||
|
||||
def fetch_one(query: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
|
||||
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def fetch_all(query: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
|
||||
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def build_report_context(order_ref: str) -> dict[str, Any]:
|
||||
report = fetch_one(
|
||||
"""
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
|
||||
WHERE odoo_order_ref = %s
|
||||
""",
|
||||
(order_ref,),
|
||||
)
|
||||
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail=f"Order not found: {order_ref}")
|
||||
|
||||
stops = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
stop_id,
|
||||
stop_started_at,
|
||||
stop_ended_at,
|
||||
attributed_duration_min,
|
||||
cause_code,
|
||||
operator_note,
|
||||
classification_status,
|
||||
attributed_cost_eur,
|
||||
stop_quality_status
|
||||
FROM mv_reports_ucepsa_demo.order_execution_stops_v1
|
||||
WHERE odoo_order_ref = %s
|
||||
ORDER BY attributed_started_at
|
||||
""",
|
||||
(order_ref,),
|
||||
)
|
||||
|
||||
timeline = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
event_ts,
|
||||
event_type,
|
||||
event_label,
|
||||
severity,
|
||||
cause_code,
|
||||
duration_min,
|
||||
cost_eur,
|
||||
description
|
||||
FROM mv_reports_ucepsa_demo.order_execution_timeline_v1
|
||||
WHERE odoo_order_ref = %s
|
||||
ORDER BY event_ts, event_type
|
||||
""",
|
||||
(order_ref,),
|
||||
)
|
||||
|
||||
margin = fetch_one(
|
||||
"""
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_demo.order_margin_report_v1
|
||||
WHERE odoo_order_ref = %s
|
||||
""",
|
||||
(order_ref,),
|
||||
)
|
||||
|
||||
raw_materials = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
raw_product_default_code,
|
||||
raw_product_name,
|
||||
planned_qty,
|
||||
consumed_qty,
|
||||
raw_uom,
|
||||
material_cost_eur_per_kg,
|
||||
material_cost_estimated_eur_rounded,
|
||||
material_cost_status
|
||||
FROM mv_reports_ucepsa_demo.order_raw_material_costs_v1
|
||||
WHERE odoo_order_ref = %s
|
||||
ORDER BY raw_product_default_code, raw_product_name
|
||||
""",
|
||||
(order_ref,),
|
||||
)
|
||||
|
||||
return {
|
||||
"title": REPORT_TITLE,
|
||||
"report": report,
|
||||
"stops": stops,
|
||||
"timeline": timeline,
|
||||
"margin": margin,
|
||||
"raw_materials": raw_materials,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/report")
|
||||
def api_report(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")):
|
||||
return jsonable_encoder(build_report_context(order_ref))
|
||||
|
||||
|
||||
@app.get("/report.html", response_class=HTMLResponse)
|
||||
def report_html(
|
||||
request: Request,
|
||||
order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025"),
|
||||
):
|
||||
context = build_report_context(order_ref)
|
||||
context["request"] = request
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"order_report.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/report.pdf")
|
||||
def report_pdf(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")):
|
||||
context = build_report_context(order_ref)
|
||||
|
||||
template = templates.env.get_template("order_report.html")
|
||||
html_content = template.render(**context)
|
||||
|
||||
pdf_bytes = HTML(
|
||||
string=html_content,
|
||||
base_url="/app/app",
|
||||
).write_pdf()
|
||||
|
||||
filename = f"mesavault-order-report-{safe_filename(order_ref)}.pdf"
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{filename}"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/cost-readiness")
|
||||
def api_cost_readiness(limit: int = Query(100, ge=1, le=500)):
|
||||
summary = fetch_one(
|
||||
"""
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
orders = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
odoo_order_ref,
|
||||
odoo_state,
|
||||
sale_order_ref,
|
||||
customer_name,
|
||||
product_default_code,
|
||||
product_name,
|
||||
machine_id,
|
||||
revenue_net_eur,
|
||||
raw_total_consumed_qty,
|
||||
raw_materials_summary,
|
||||
cost_readiness_score,
|
||||
cost_readiness_status,
|
||||
cost_readiness_description,
|
||||
technical_quality_level,
|
||||
final_report_status,
|
||||
margin_status
|
||||
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
|
||||
ORDER BY cost_readiness_score ASC, odoo_order_ref DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
raw_materials = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
raw_product_default_code,
|
||||
raw_product_name,
|
||||
raw_uom,
|
||||
affected_order_count,
|
||||
affected_orders,
|
||||
affected_sale_orders,
|
||||
affected_customers,
|
||||
total_consumed_qty,
|
||||
current_material_cost_eur_per_kg,
|
||||
raw_material_readiness_status,
|
||||
recommended_action
|
||||
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
|
||||
ORDER BY affected_order_count DESC, raw_product_default_code
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"summary": summary or {},
|
||||
"orders": orders,
|
||||
"raw_materials": raw_materials,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/cost-readiness.html", response_class=HTMLResponse)
|
||||
def cost_readiness_html(request: Request, limit: int = Query(100, ge=1, le=500)):
|
||||
summary = fetch_one(
|
||||
"""
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
orders = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
odoo_order_ref,
|
||||
odoo_state,
|
||||
sale_order_ref,
|
||||
customer_name,
|
||||
product_default_code,
|
||||
product_name,
|
||||
machine_id,
|
||||
revenue_net_eur,
|
||||
raw_total_consumed_qty,
|
||||
raw_materials_summary,
|
||||
cost_readiness_score,
|
||||
cost_readiness_status,
|
||||
cost_readiness_description,
|
||||
technical_quality_level,
|
||||
final_report_status,
|
||||
margin_status
|
||||
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
|
||||
ORDER BY cost_readiness_score ASC, odoo_order_ref DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
raw_materials = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
raw_product_default_code,
|
||||
raw_product_name,
|
||||
raw_uom,
|
||||
affected_order_count,
|
||||
affected_orders,
|
||||
affected_sale_orders,
|
||||
affected_customers,
|
||||
total_consumed_qty,
|
||||
current_material_cost_eur_per_kg,
|
||||
raw_material_readiness_status,
|
||||
recommended_action
|
||||
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
|
||||
ORDER BY affected_order_count DESC, raw_product_default_code
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"cost_readiness.html",
|
||||
{
|
||||
"request": request,
|
||||
"title": REPORT_TITLE,
|
||||
"summary": summary or {},
|
||||
"orders": orders,
|
||||
"raw_materials": raw_materials,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/orders")
|
||||
def api_orders(limit: int = Query(50, ge=1, le=200)):
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
odoo_order_ref,
|
||||
odoo_state,
|
||||
product,
|
||||
machine_id,
|
||||
start_ts,
|
||||
end_ts,
|
||||
window_min,
|
||||
real_cycle_delta,
|
||||
kwh_consumed,
|
||||
stop_count,
|
||||
stop_min,
|
||||
technical_quality_level,
|
||||
final_report_status
|
||||
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
|
||||
ORDER BY start_ts DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return jsonable_encoder({"orders": rows})
|
||||
|
||||
|
||||
@app.get("/orders.html", response_class=HTMLResponse)
|
||||
def orders_html(request: Request, limit: int = Query(50, ge=1, le=200)):
|
||||
orders = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
odoo_order_ref,
|
||||
odoo_state,
|
||||
product,
|
||||
machine_id,
|
||||
start_ts,
|
||||
end_ts,
|
||||
window_min,
|
||||
real_cycle_delta,
|
||||
kwh_consumed,
|
||||
stop_count,
|
||||
stop_min,
|
||||
technical_quality_level,
|
||||
final_report_status
|
||||
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
|
||||
ORDER BY start_ts DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"orders_index.html",
|
||||
{
|
||||
"request": request,
|
||||
"title": REPORT_TITLE,
|
||||
"orders": orders,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/data-quality-contract")
|
||||
def api_data_quality_contract(limit: int = Query(100, ge=1, le=500)):
|
||||
summary = fetch_one(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS total_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'CONTRATO_CUMPLIDO') AS contract_ok_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'VALIDO_CON_OBSERVACIONES') AS contract_warn_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_CRITICO') AS blocked_critical_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_ALTO') AS blocked_high_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_MEDIO') AS blocked_medium_orders,
|
||||
round(avg(contract_score), 2) AS avg_contract_score,
|
||||
sum(critical_failures) AS total_critical_failures,
|
||||
sum(high_failures) AS total_high_failures,
|
||||
sum(medium_failures) AS total_medium_failures,
|
||||
sum(total_issue_score) AS total_issue_score
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
orders = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
odoo_order_ref,
|
||||
odoo_state,
|
||||
sale_order_ref,
|
||||
customer_name,
|
||||
product_default_code,
|
||||
product_name,
|
||||
machine_id,
|
||||
contract_score,
|
||||
contract_status,
|
||||
failed_rules,
|
||||
warn_rules,
|
||||
critical_failures,
|
||||
high_failures,
|
||||
medium_failures,
|
||||
main_blocker_rule_code,
|
||||
main_blocker_rule_name,
|
||||
main_blocker_domain,
|
||||
main_blocker_severity,
|
||||
owner_area_to_act,
|
||||
main_blocker_finding,
|
||||
main_recommended_action,
|
||||
contract_summary
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
|
||||
ORDER BY contract_score ASC, total_issue_score DESC, odoo_order_ref DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
owners = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
owner_area,
|
||||
failed_checks,
|
||||
warn_checks,
|
||||
critical_failed_checks,
|
||||
high_failed_checks,
|
||||
affected_orders,
|
||||
total_issue_score,
|
||||
affected_rules,
|
||||
affected_order_refs
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_owner_summary_v1
|
||||
WHERE failed_checks > 0
|
||||
OR warn_checks > 0
|
||||
ORDER BY total_issue_score DESC NULLS LAST, owner_area
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
rules = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
rule_code,
|
||||
rule_name,
|
||||
domain,
|
||||
severity,
|
||||
owner_area,
|
||||
check_status,
|
||||
count(*) AS order_count,
|
||||
sum(issue_score) AS total_issue_score,
|
||||
string_agg(
|
||||
DISTINCT odoo_order_ref,
|
||||
', '
|
||||
ORDER BY odoo_order_ref
|
||||
) AS affected_orders
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_order_checks_v1
|
||||
WHERE check_status IN ('FAIL', 'WARN')
|
||||
GROUP BY
|
||||
rule_code,
|
||||
rule_name,
|
||||
domain,
|
||||
severity,
|
||||
owner_area,
|
||||
check_status
|
||||
ORDER BY
|
||||
total_issue_score DESC NULLS LAST,
|
||||
rule_code,
|
||||
check_status
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"summary": summary or {},
|
||||
"orders": orders,
|
||||
"owners": owners,
|
||||
"rules": rules,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/data-quality-contract.html", response_class=HTMLResponse)
|
||||
def data_quality_contract_html(request: Request, limit: int = Query(100, ge=1, le=500)):
|
||||
summary = fetch_one(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS total_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'CONTRATO_CUMPLIDO') AS contract_ok_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'VALIDO_CON_OBSERVACIONES') AS contract_warn_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_CRITICO') AS blocked_critical_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_ALTO') AS blocked_high_orders,
|
||||
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_MEDIO') AS blocked_medium_orders,
|
||||
round(avg(contract_score), 2) AS avg_contract_score,
|
||||
sum(critical_failures) AS total_critical_failures,
|
||||
sum(high_failures) AS total_high_failures,
|
||||
sum(medium_failures) AS total_medium_failures,
|
||||
sum(total_issue_score) AS total_issue_score
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
orders = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
odoo_order_ref,
|
||||
odoo_state,
|
||||
sale_order_ref,
|
||||
customer_name,
|
||||
product_default_code,
|
||||
product_name,
|
||||
machine_id,
|
||||
contract_score,
|
||||
contract_status,
|
||||
failed_rules,
|
||||
warn_rules,
|
||||
critical_failures,
|
||||
high_failures,
|
||||
medium_failures,
|
||||
main_blocker_rule_code,
|
||||
main_blocker_rule_name,
|
||||
main_blocker_domain,
|
||||
main_blocker_severity,
|
||||
owner_area_to_act,
|
||||
main_blocker_finding,
|
||||
main_recommended_action,
|
||||
contract_summary
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
|
||||
ORDER BY contract_score ASC, total_issue_score DESC, odoo_order_ref DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
owners = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
owner_area,
|
||||
failed_checks,
|
||||
warn_checks,
|
||||
critical_failed_checks,
|
||||
high_failed_checks,
|
||||
affected_orders,
|
||||
total_issue_score,
|
||||
affected_rules,
|
||||
affected_order_refs
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_owner_summary_v1
|
||||
WHERE failed_checks > 0
|
||||
OR warn_checks > 0
|
||||
ORDER BY total_issue_score DESC NULLS LAST, owner_area
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
rules = fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
rule_code,
|
||||
rule_name,
|
||||
domain,
|
||||
severity,
|
||||
owner_area,
|
||||
check_status,
|
||||
count(*) AS order_count,
|
||||
sum(issue_score) AS total_issue_score,
|
||||
string_agg(
|
||||
DISTINCT odoo_order_ref,
|
||||
', '
|
||||
ORDER BY odoo_order_ref
|
||||
) AS affected_orders
|
||||
FROM mv_reports_ucepsa_demo.data_quality_contract_order_checks_v1
|
||||
WHERE check_status IN ('FAIL', 'WARN')
|
||||
GROUP BY
|
||||
rule_code,
|
||||
rule_name,
|
||||
domain,
|
||||
severity,
|
||||
owner_area,
|
||||
check_status
|
||||
ORDER BY
|
||||
total_issue_score DESC NULLS LAST,
|
||||
rule_code,
|
||||
check_status
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"data_quality_contract.html",
|
||||
{
|
||||
"request": request,
|
||||
"title": REPORT_TITLE,
|
||||
"summary": summary or {},
|
||||
"orders": orders,
|
||||
"owners": owners,
|
||||
"rules": rules,
|
||||
},
|
||||
)
|
||||
|
||||
@ -1,253 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title }} - Cost Readiness</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 28px;
|
||||
color: #1f2933;
|
||||
background: #f7f9fb;
|
||||
font-size: 13px;
|
||||
}
|
||||
.page {
|
||||
background: white;
|
||||
padding: 28px;
|
||||
border-radius: 12px;
|
||||
max-width: 1600px;
|
||||
margin: auto;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 4px 0;
|
||||
color: #0f172a;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 26px;
|
||||
color: #0f172a;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.label {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.value {
|
||||
color: #0f172a;
|
||||
font-weight: bold;
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
th {
|
||||
background: #f1f5f9;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.ok {
|
||||
color: #166534;
|
||||
font-weight: bold;
|
||||
}
|
||||
.warn {
|
||||
color: #92400e;
|
||||
font-weight: bold;
|
||||
}
|
||||
.bad {
|
||||
color: #991b1b;
|
||||
font-weight: bold;
|
||||
}
|
||||
.small {
|
||||
font-size: 11px;
|
||||
}
|
||||
a {
|
||||
color: #0f766e;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<h1>Cost Readiness Cockpit</h1>
|
||||
<p class="muted">{{ title }}</p>
|
||||
<p>
|
||||
<a href="/data-quality-contract.html" target="_blank">Abrir Data Quality Contract</a>
|
||||
</p>
|
||||
<p>
|
||||
Esta pantalla muestra qué órdenes están preparadas para cálculo de margen y qué datos lo bloquean.
|
||||
No calcula margen real si falta coste de materia prima.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">Órdenes totales</div>
|
||||
<div class="value">{{ summary.total_orders or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Con ingreso</div>
|
||||
<div class="value">{{ summary.orders_with_revenue or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Sin ingreso</div>
|
||||
<div class="value">{{ summary.orders_without_revenue or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Sin coste material</div>
|
||||
<div class="value">{{ summary.orders_missing_raw_material_cost or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Listas para margen</div>
|
||||
<div class="value">{{ summary.orders_ready_for_margin or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Score medio</div>
|
||||
<div class="value">{{ summary.avg_cost_readiness_score|num(2) if summary.avg_cost_readiness_score is not none else "0.00" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>1. Estado por orden</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Orden</th>
|
||||
<th>Pedido venta</th>
|
||||
<th>Cliente</th>
|
||||
<th>Producto</th>
|
||||
<th>Máquina</th>
|
||||
<th>Ingreso neto</th>
|
||||
<th>Kg materia prima</th>
|
||||
<th>Score</th>
|
||||
<th>Estado</th>
|
||||
<th>Bloqueo / acción</th>
|
||||
<th>Informe</th>
|
||||
</tr>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.odoo_order_ref }}</td>
|
||||
<td>{{ order.sale_order_ref or "" }}</td>
|
||||
<td>{{ order.customer_name or "" }}</td>
|
||||
<td>
|
||||
<strong>{{ order.product_default_code or "" }}</strong><br>
|
||||
<span class="small">{{ order.product_name or "" }}</span>
|
||||
</td>
|
||||
<td>{{ order.machine_id }}</td>
|
||||
<td>{{ order.revenue_net_eur|num(2) if order.revenue_net_eur is not none else "" }} €</td>
|
||||
<td>{{ order.raw_total_consumed_qty|num(3) if order.raw_total_consumed_qty is not none else "" }}</td>
|
||||
<td>
|
||||
{% if order.cost_readiness_score >= 80 %}
|
||||
<span class="ok">{{ order.cost_readiness_score }}</span>
|
||||
{% elif order.cost_readiness_score >= 50 %}
|
||||
<span class="warn">{{ order.cost_readiness_score }}</span>
|
||||
{% else %}
|
||||
<span class="bad">{{ order.cost_readiness_score }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if order.cost_readiness_status == 'LISTO_PARA_MARGEN' %}
|
||||
<span class="ok">Listo para margen</span>
|
||||
{% elif order.cost_readiness_status == 'BLOQUEADO_FALTA_COSTE_MATERIA_PRIMA' %}
|
||||
<span class="warn">Falta coste materia prima</span>
|
||||
{% elif order.cost_readiness_status == 'BLOQUEADO_SIN_INGRESO' %}
|
||||
<span class="bad">Sin ingreso</span>
|
||||
{% elif order.cost_readiness_status == 'BLOQUEADO_DATOS_TECNICOS' %}
|
||||
<span class="bad">Datos técnicos débiles</span>
|
||||
{% else %}
|
||||
<span class="pill">{{ order.cost_readiness_status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small">{{ order.cost_readiness_description }}</td>
|
||||
<td>
|
||||
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
|
||||
|
|
||||
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="11">No hay órdenes disponibles.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h2>2. Materias primas pendientes de coste</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Materia prima</th>
|
||||
<th>UdM</th>
|
||||
<th>Órdenes afectadas</th>
|
||||
<th>Pedidos venta</th>
|
||||
<th>Clientes</th>
|
||||
<th>Cantidad consumida</th>
|
||||
<th>Coste €/kg actual</th>
|
||||
<th>Estado</th>
|
||||
<th>Acción recomendada</th>
|
||||
</tr>
|
||||
{% for raw in raw_materials %}
|
||||
<tr>
|
||||
<td>{{ raw.raw_product_default_code or "" }}</td>
|
||||
<td>{{ raw.raw_product_name or "" }}</td>
|
||||
<td>{{ raw.raw_uom or "" }}</td>
|
||||
<td>
|
||||
<strong>{{ raw.affected_order_count }}</strong><br>
|
||||
<span class="small">{{ raw.affected_orders }}</span>
|
||||
</td>
|
||||
<td class="small">{{ raw.affected_sale_orders }}</td>
|
||||
<td class="small">{{ raw.affected_customers }}</td>
|
||||
<td>{{ raw.total_consumed_qty|num(3) }}</td>
|
||||
<td>{{ raw.current_material_cost_eur_per_kg|num(4) if raw.current_material_cost_eur_per_kg is not none else "" }}</td>
|
||||
<td>
|
||||
{% if raw.raw_material_readiness_status == 'COSTE_DISPONIBLE' %}
|
||||
<span class="ok">Coste disponible</span>
|
||||
{% else %}
|
||||
<span class="warn">Pendiente coste</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ raw.recommended_action }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10">No hay materias primas detectadas.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,296 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title }} - Data Quality Contract</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 28px;
|
||||
color: #1f2933;
|
||||
background: #f7f9fb;
|
||||
font-size: 13px;
|
||||
}
|
||||
.page {
|
||||
background: white;
|
||||
padding: 28px;
|
||||
border-radius: 12px;
|
||||
max-width: 1700px;
|
||||
margin: auto;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 4px 0;
|
||||
color: #0f172a;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 28px;
|
||||
color: #0f172a;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.label {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.value {
|
||||
color: #0f172a;
|
||||
font-weight: bold;
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
th {
|
||||
background: #f1f5f9;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.ok {
|
||||
color: #166534;
|
||||
font-weight: bold;
|
||||
}
|
||||
.warn {
|
||||
color: #92400e;
|
||||
font-weight: bold;
|
||||
}
|
||||
.bad {
|
||||
color: #991b1b;
|
||||
font-weight: bold;
|
||||
}
|
||||
.small {
|
||||
font-size: 11px;
|
||||
}
|
||||
a {
|
||||
color: #0f766e;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<h1>Data Quality Contract Odoo–MESAVAULT</h1>
|
||||
<p class="muted">{{ title }}</p>
|
||||
<p>
|
||||
Esta pantalla resume si las órdenes cumplen el contrato mínimo de datos para informes técnicos,
|
||||
margen industrial, pérdidas y readiness económico.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="/orders.html" target="_blank">Órdenes</a>
|
||||
|
|
||||
<a href="/cost-readiness.html" target="_blank">Cost Readiness</a>
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">Órdenes</div>
|
||||
<div class="value">{{ summary.total_orders or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Contrato cumplido</div>
|
||||
<div class="value">{{ summary.contract_ok_orders or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Con observaciones</div>
|
||||
<div class="value">{{ summary.contract_warn_orders or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Bloqueo crítico</div>
|
||||
<div class="value">{{ summary.blocked_critical_orders or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Fallos críticos</div>
|
||||
<div class="value">{{ summary.total_critical_failures or 0 }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Score medio</div>
|
||||
<div class="value">{{ summary.avg_contract_score|num(2) if summary.avg_contract_score is not none else "0.00" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>1. Estado del contrato por orden</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Orden</th>
|
||||
<th>Pedido venta</th>
|
||||
<th>Cliente</th>
|
||||
<th>Producto</th>
|
||||
<th>Máquina</th>
|
||||
<th>Score</th>
|
||||
<th>Estado contrato</th>
|
||||
<th>Fallos</th>
|
||||
<th>Bloqueo principal</th>
|
||||
<th>Área responsable</th>
|
||||
<th>Acción recomendada</th>
|
||||
<th>Informe</th>
|
||||
</tr>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.odoo_order_ref }}</td>
|
||||
<td>{{ order.sale_order_ref or "" }}</td>
|
||||
<td>{{ order.customer_name or "" }}</td>
|
||||
<td>
|
||||
<strong>{{ order.product_default_code or "" }}</strong><br>
|
||||
<span class="small">{{ order.product_name or "" }}</span>
|
||||
</td>
|
||||
<td>{{ order.machine_id or "" }}</td>
|
||||
<td>
|
||||
{% if order.contract_score >= 80 %}
|
||||
<span class="ok">{{ order.contract_score }}</span>
|
||||
{% elif order.contract_score >= 50 %}
|
||||
<span class="warn">{{ order.contract_score }}</span>
|
||||
{% else %}
|
||||
<span class="bad">{{ order.contract_score }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if order.contract_status == 'CONTRATO_CUMPLIDO' %}
|
||||
<span class="ok">Contrato cumplido</span>
|
||||
{% elif order.contract_status == 'VALIDO_CON_OBSERVACIONES' %}
|
||||
<span class="warn">Válido con observaciones</span>
|
||||
{% elif order.contract_status == 'BLOQUEADO_CRITICO' %}
|
||||
<span class="bad">Bloqueado crítico</span>
|
||||
{% elif order.contract_status == 'BLOQUEADO_ALTO' %}
|
||||
<span class="bad">Bloqueado alto</span>
|
||||
{% elif order.contract_status == 'BLOQUEADO_MEDIO' %}
|
||||
<span class="warn">Bloqueado medio</span>
|
||||
{% else %}
|
||||
<span class="pill">{{ order.contract_status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
Críticos: {{ order.critical_failures }}<br>
|
||||
Altos: {{ order.high_failures }}<br>
|
||||
Medios: {{ order.medium_failures }}<br>
|
||||
Avisos: {{ order.warn_rules }}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ order.main_blocker_rule_code or "" }}</strong><br>
|
||||
<span class="small">{{ order.main_blocker_rule_name or "" }}</span><br>
|
||||
<span class="small">{{ order.main_blocker_finding or "" }}</span>
|
||||
</td>
|
||||
<td>{{ order.owner_area_to_act or "" }}</td>
|
||||
<td class="small">{{ order.main_recommended_action or "" }}</td>
|
||||
<td>
|
||||
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
|
||||
|
|
||||
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="12">No hay órdenes evaluadas.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h2>2. Resumen por área responsable</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Área</th>
|
||||
<th>Fallos</th>
|
||||
<th>Avisos</th>
|
||||
<th>Fallos críticos</th>
|
||||
<th>Fallos altos</th>
|
||||
<th>Órdenes afectadas</th>
|
||||
<th>Score incidencia</th>
|
||||
<th>Reglas afectadas</th>
|
||||
<th>Órdenes</th>
|
||||
</tr>
|
||||
{% for owner in owners %}
|
||||
<tr>
|
||||
<td>{{ owner.owner_area }}</td>
|
||||
<td>{{ owner.failed_checks }}</td>
|
||||
<td>{{ owner.warn_checks }}</td>
|
||||
<td>{{ owner.critical_failed_checks }}</td>
|
||||
<td>{{ owner.high_failed_checks }}</td>
|
||||
<td>{{ owner.affected_orders }}</td>
|
||||
<td>{{ owner.total_issue_score }}</td>
|
||||
<td>{{ owner.affected_rules }}</td>
|
||||
<td class="small">{{ owner.affected_order_refs }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9">Sin incidencias por área.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h2>3. Reglas con incidencias</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Regla</th>
|
||||
<th>Nombre</th>
|
||||
<th>Dominio</th>
|
||||
<th>Severidad</th>
|
||||
<th>Estado</th>
|
||||
<th>Área</th>
|
||||
<th>Órdenes afectadas</th>
|
||||
<th>Score total</th>
|
||||
<th>Órdenes</th>
|
||||
</tr>
|
||||
{% for rule in rules %}
|
||||
<tr>
|
||||
<td>{{ rule.rule_code }}</td>
|
||||
<td>{{ rule.rule_name }}</td>
|
||||
<td>{{ rule.domain }}</td>
|
||||
<td>{{ rule.severity }}</td>
|
||||
<td>
|
||||
{% if rule.check_status == 'FAIL' %}
|
||||
<span class="bad">FAIL</span>
|
||||
{% elif rule.check_status == 'WARN' %}
|
||||
<span class="warn">WARN</span>
|
||||
{% else %}
|
||||
{{ rule.check_status }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ rule.owner_area }}</td>
|
||||
<td>{{ rule.order_count }}</td>
|
||||
<td>{{ rule.total_issue_score }}</td>
|
||||
<td class="small">{{ rule.affected_orders }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9">Sin reglas con incidencias.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,375 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title }} - {{ report.odoo_order_ref }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 12mm;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
color: #1f2933;
|
||||
background: #ffffff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #0f172a;
|
||||
font-size: 24px;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
margin: 18px 0 8px 0;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.summary {
|
||||
padding: 10px 12px;
|
||||
background: #eef6f7;
|
||||
border-left: 4px solid #0f766e;
|
||||
margin: 12px 0 14px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: #ffffff;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 9px;
|
||||
color: #64748b;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12px;
|
||||
font-size: 10px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f1f5f9;
|
||||
text-align: left;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
tr {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.kv-table th:first-child,
|
||||
.kv-table td:first-child {
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
.stop-table th:nth-child(1),
|
||||
.stop-table td:nth-child(1) {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
.stop-table th:nth-child(2),
|
||||
.stop-table td:nth-child(2),
|
||||
.stop-table th:nth-child(3),
|
||||
.stop-table td:nth-child(3) {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stop-table th:nth-child(4),
|
||||
.stop-table td:nth-child(4) {
|
||||
width: 9%;
|
||||
}
|
||||
|
||||
.stop-table th:nth-child(5),
|
||||
.stop-table td:nth-child(5) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.timeline-table th:nth-child(1),
|
||||
.timeline-table td:nth-child(1) {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-table th:nth-child(2),
|
||||
.timeline-table td:nth-child(2) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.timeline-table th:nth-child(3),
|
||||
.timeline-table td:nth-child(3) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.timeline-table th:nth-child(4),
|
||||
.timeline-table td:nth-child(4) {
|
||||
width: 7%;
|
||||
}
|
||||
|
||||
.timeline-table th:nth-child(5),
|
||||
.timeline-table td:nth-child(5) {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.timeline-table th:nth-child(6),
|
||||
.timeline-table td:nth-child(6),
|
||||
.timeline-table th:nth-child(7),
|
||||
.timeline-table td:nth-child(7) {
|
||||
width: 7%;
|
||||
}
|
||||
|
||||
.timeline-table th:nth-child(8),
|
||||
.timeline-table td:nth-child(8) {
|
||||
width: 27%;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.section-break {
|
||||
break-before: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<h1>Informe de ejecución de orden</h1>
|
||||
<p class="muted">{{ title }}</p>
|
||||
|
||||
<div class="summary">
|
||||
{{ report.executive_summary }}
|
||||
</div>
|
||||
|
||||
<h2>1. Identificación</h2>
|
||||
<table class="kv-table">
|
||||
<tr><th>Campo</th><th>Valor</th></tr>
|
||||
<tr><td>Orden</td><td>{{ report.odoo_order_ref }}</td></tr>
|
||||
<tr><td>Workorder Odoo</td><td>{{ report.odoo_workorder_id }}</td></tr>
|
||||
<tr><td>Production Odoo</td><td>{{ report.odoo_production_id }}</td></tr>
|
||||
<tr><td>Estado Odoo</td><td>{{ report.odoo_state }}</td></tr>
|
||||
<tr><td>Producto</td><td>{{ report.product }}</td></tr>
|
||||
<tr><td>Máquina</td><td>{{ report.machine_id }}</td></tr>
|
||||
<tr><td>Centro de trabajo</td><td>{{ report.odoo_workcenter_name }}</td></tr>
|
||||
<tr><td>Inicio</td><td>{{ report.start_ts|dt }}</td></tr>
|
||||
<tr><td>Fin</td><td>{{ report.end_ts|dt }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>2. Indicadores principales</h2>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Duración total</div><div class="value">{{ report.window_min|num(2) }} min</div></div>
|
||||
<div class="card"><div class="label">Tiempo marcha</div><div class="value">{{ report.run_min|num(2) }} min</div></div>
|
||||
<div class="card"><div class="label">Tiempo parado</div><div class="value">{{ report.stop_min|num(2) }} min</div></div>
|
||||
<div class="card"><div class="label">Disponibilidad</div><div class="value">{{ report.availability_pct|num(2) }} %</div></div>
|
||||
<div class="card"><div class="label">Ciclos reales</div><div class="value">{{ report.real_cycle_delta }}</div></div>
|
||||
<div class="card"><div class="label">Ciclos/min marcha</div><div class="value">{{ report.cycles_per_run_min|num(2) }}</div></div>
|
||||
<div class="card"><div class="label">Energía</div><div class="value">{{ report.kwh_consumed|num(3) }} kWh</div></div>
|
||||
<div class="card"><div class="label">Coste paros</div><div class="value">{{ report.stop_cost_eur|num(2) }} €</div></div>
|
||||
</div>
|
||||
|
||||
<h2>3. Energía y ciclos</h2>
|
||||
<table class="kv-table">
|
||||
<tr><th>Métrica</th><th>Valor</th></tr>
|
||||
<tr><td>Muestras energía</td><td>{{ report.energy_samples }}</td></tr>
|
||||
<tr><td>Primera muestra</td><td>{{ report.energy_first_ts|dt }}</td></tr>
|
||||
<tr><td>Última muestra</td><td>{{ report.energy_last_ts|dt }}</td></tr>
|
||||
<tr><td>kWh inicial</td><td>{{ report.start_import_kwh|num(3) }}</td></tr>
|
||||
<tr><td>kWh final</td><td>{{ report.end_import_kwh|num(3) }}</td></tr>
|
||||
<tr><td>Consumo</td><td>{{ report.kwh_consumed|num(3) }} kWh</td></tr>
|
||||
<tr><td>Potencia media</td><td>{{ report.avg_kw|num(3) }} kW</td></tr>
|
||||
<tr><td>Potencia máxima</td><td>{{ report.max_kw|num(3) }} kW</td></tr>
|
||||
<tr><td>kWh/ciclo</td><td>{{ report.kwh_per_cycle|num(6) }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>4. Paros</h2>
|
||||
<p><strong>Resumen por causa:</strong> {{ report.stop_causes_summary }}</p>
|
||||
|
||||
<table class="stop-table">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Inicio</th>
|
||||
<th>Fin</th>
|
||||
<th>Duración min</th>
|
||||
<th>Causa</th>
|
||||
<th>Nota</th>
|
||||
<th>Coste €</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
{% for stop in stops %}
|
||||
<tr>
|
||||
<td>{{ stop.stop_id }}</td>
|
||||
<td>{{ stop.stop_started_at|dt }}</td>
|
||||
<td>{{ stop.stop_ended_at|dt }}</td>
|
||||
<td>{{ stop.attributed_duration_min|num(2) }}</td>
|
||||
<td>{{ stop.cause_code }}</td>
|
||||
<td>{{ stop.operator_note or "" }}</td>
|
||||
<td>{{ stop.attributed_cost_eur|num(2) }}</td>
|
||||
<td>{{ stop.stop_quality_status }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">Sin paros imputados a la orden.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h2>5. Calidad del dato</h2>
|
||||
<table class="kv-table">
|
||||
<tr><th>Métrica</th><th>Valor</th></tr>
|
||||
<tr><td>Estado informe</td><td>{{ report.final_report_status }}</td></tr>
|
||||
<tr><td>Score calidad técnica</td><td>{{ report.technical_quality_score }}</td></tr>
|
||||
<tr><td>Nivel calidad técnica</td><td>{{ report.technical_quality_level }}</td></tr>
|
||||
<tr><td>Cobertura telemetría</td><td>{{ report.telemetry_sample_coverage_pct|num(2) }} %</td></tr>
|
||||
<tr><td>Gaps > 30 s</td><td>{{ report.telemetry_gap_count_30s }}</td></tr>
|
||||
<tr><td>Máximo gap</td><td>{{ report.max_sample_gap_s|num(2) }} s</td></tr>
|
||||
<tr><td>Muestras no OK</td><td>{{ report.non_ok_samples }}</td></tr>
|
||||
<tr><td>Conversión producción</td><td>{{ report.production_conversion_status }}</td></tr>
|
||||
<tr><td>Acción recomendada</td><td>{{ report.recommended_action }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>6. Margin Intelligence</h2>
|
||||
|
||||
{% if margin %}
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Ingreso neto</div><div class="value">{{ margin.revenue_net_eur|num(2) }} €</div></div>
|
||||
<div class="card"><div class="label">Cliente</div><div class="value" style="font-size: 11px;">{{ margin.customer_name or "Sin cliente" }}</div></div>
|
||||
<div class="card"><div class="label">Pedido venta</div><div class="value">{{ margin.sale_order_ref or "Sin pedido" }}</div></div>
|
||||
<div class="card"><div class="label">Coste paros</div><div class="value">{{ margin.stop_cost_eur|num(2) }} €</div></div>
|
||||
<div class="card"><div class="label">Coste material</div><div class="value">{{ margin.material_cost_estimated_eur|num(2) if margin.material_cost_estimated_eur is not none else "Pendiente" }}</div></div>
|
||||
<div class="card"><div class="label">Coste conocido</div><div class="value">{{ margin.known_or_estimated_cost_eur|num(2) }} €</div></div>
|
||||
<div class="card"><div class="label">Margen estimado</div><div class="value">{{ margin.estimated_margin_eur|num(2) if margin.estimated_margin_eur is not none else "No calculable" }}</div></div>
|
||||
<div class="card"><div class="label">Estado margen</div><div class="value" style="font-size: 10px;">{% if margin.margin_status == 'PARTIAL_MARGIN_MISSING_RAW_MATERIAL_COST' %}Pendiente coste de materia prima{% elif margin.margin_status == 'ESTIMATED_MARGIN_AVAILABLE' %}Margen estimado disponible{% elif margin.margin_status == 'NO_MARGIN_WITHOUT_REVENUE' %}Sin ingreso de venta vinculado{% else %}{{ margin.margin_status }}{% endif %}</div></div>
|
||||
</div>
|
||||
|
||||
<table class="kv-table">
|
||||
<tr><th>Métrica</th><th>Valor</th></tr>
|
||||
<tr><td>Ingreso neto venta</td><td>{{ margin.revenue_net_eur|num(2) if margin.revenue_net_eur is not none else "" }} €</td></tr>
|
||||
<tr><td>Ingreso total venta</td><td>{{ margin.revenue_total_eur|num(2) if margin.revenue_total_eur is not none else "" }} €</td></tr>
|
||||
<tr><td>Pedido venta</td><td>{{ margin.sale_order_ref or "" }}</td></tr>
|
||||
<tr><td>Cliente</td><td>{{ margin.customer_name or "" }}</td></tr>
|
||||
<tr><td>Materia prima consumida</td><td>{{ margin.raw_materials_summary or "Sin materia prima detectada" }}</td></tr>
|
||||
<tr><td>Coste materia prima</td><td>{{ margin.material_cost_estimated_eur|num(2) if margin.material_cost_estimated_eur is not none else "Pendiente de coste" }}</td></tr>
|
||||
<tr><td>Estado coste materia prima</td><td>{% if margin.material_cost_source_status == 'MISSING_RAW_MATERIAL_COST_PARAMETER' %}Pendiente coste de materia prima{% elif margin.material_cost_source_status == 'FROM_RAW_MATERIAL_COSTS' %}Calculado desde coste de materia prima{% elif margin.material_cost_source_status == 'FROM_ODOO_RAW_MATERIAL_VALUE' %}Calculado desde valoración Odoo{% else %}{{ margin.material_cost_source_status }}{% endif %}</td></tr>
|
||||
<tr><td>Coste paros</td><td>{{ margin.stop_cost_eur|num(2) }} €</td></tr>
|
||||
<tr><td>Coste conocido o estimado</td><td>{{ margin.known_or_estimated_cost_eur|num(2) }} €</td></tr>
|
||||
<tr><td>Margen parcial tras costes conocidos</td><td>{{ margin.partial_margin_after_known_costs_eur|num(2) if margin.partial_margin_after_known_costs_eur is not none else "" }} €</td></tr>
|
||||
<tr><td>Margen estimado</td><td>{{ margin.estimated_margin_eur|num(2) if margin.estimated_margin_eur is not none else "No calculable todavía" }}</td></tr>
|
||||
<tr><td>% margen estimado</td><td>{{ margin.estimated_margin_pct|num(2) if margin.estimated_margin_pct is not none else "No calculable todavía" }}</td></tr>
|
||||
<tr><td>Estado económico</td><td>{% if margin.margin_status == 'PARTIAL_MARGIN_MISSING_RAW_MATERIAL_COST' %}Pendiente coste de materia prima{% elif margin.margin_status == 'ESTIMATED_MARGIN_AVAILABLE' %}Margen estimado disponible{% elif margin.margin_status == 'NO_MARGIN_WITHOUT_REVENUE' %}Sin ingreso de venta vinculado{% else %}{{ margin.margin_status }}{% endif %}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>7. Materias primas</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Materia prima</th>
|
||||
<th>Cantidad prevista</th>
|
||||
<th>Cantidad consumida</th>
|
||||
<th>UdM</th>
|
||||
<th>Coste €/kg</th>
|
||||
<th>Coste estimado</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
{% for raw in raw_materials %}
|
||||
<tr>
|
||||
<td>{{ raw.raw_product_default_code or "" }}</td>
|
||||
<td>{{ raw.raw_product_name or "" }}</td>
|
||||
<td>{{ raw.planned_qty|num(3) if raw.planned_qty is not none else "" }}</td>
|
||||
<td>{{ raw.consumed_qty|num(3) if raw.consumed_qty is not none else "" }}</td>
|
||||
<td>{{ raw.raw_uom or "" }}</td>
|
||||
<td>{{ raw.material_cost_eur_per_kg|num(4) if raw.material_cost_eur_per_kg is not none else "" }}</td>
|
||||
<td>{{ raw.material_cost_estimated_eur_rounded|num(2) if raw.material_cost_estimated_eur_rounded is not none else "" }}</td>
|
||||
<td>{% if raw.material_cost_status == 'MISSING_RAW_MATERIAL_COST_PARAMETER' %}Pendiente coste materia prima{% elif raw.material_cost_status == 'FROM_RAW_MATERIAL_COST_PARAMETER' %}Coste manual validado{% elif raw.material_cost_status == 'FROM_ODOO_STOCK_MOVE_VALUE' %}Coste desde Odoo{% else %}{{ raw.material_cost_status }}{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">Sin materias primas detectadas para la orden.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No hay datos comerciales o económicos vinculados a esta orden.</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>8. Timeline</h2>
|
||||
<table class="timeline-table">
|
||||
<tr>
|
||||
<th>Hora</th>
|
||||
<th>Tipo</th>
|
||||
<th>Evento</th>
|
||||
<th>Nivel</th>
|
||||
<th>Causa</th>
|
||||
<th>Duración</th>
|
||||
<th>Coste</th>
|
||||
<th>Descripción</th>
|
||||
</tr>
|
||||
{% for event in timeline %}
|
||||
<tr>
|
||||
<td>{{ event.event_ts|dt }}</td>
|
||||
<td>{{ event.event_type }}</td>
|
||||
<td>{{ event.event_label }}</td>
|
||||
<td>{{ event.severity }}</td>
|
||||
<td>{{ event.cause_code or "" }}</td>
|
||||
<td>{{ event.duration_min|num(2) if event.duration_min is not none else "" }}</td>
|
||||
<td>{{ event.cost_eur|num(2) if event.cost_eur is not none else "" }}</td>
|
||||
<td>{{ event.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<p class="muted small">
|
||||
Informe generado desde MESAVAULT Edge-OEE usando vistas SQL de PostgreSQL.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,123 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title }} - Informes de orden</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 28px;
|
||||
color: #1f2933;
|
||||
background: #f7f9fb;
|
||||
font-size: 13px;
|
||||
}
|
||||
.page {
|
||||
background: white;
|
||||
padding: 28px;
|
||||
border-radius: 12px;
|
||||
max-width: 1500px;
|
||||
margin: auto;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: #0f172a;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 18px;
|
||||
}
|
||||
th {
|
||||
background: #f1f5f9;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
vertical-align: top;
|
||||
}
|
||||
a {
|
||||
color: #0f766e;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.status {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ok { color: #166534; }
|
||||
.warn { color: #92400e; }
|
||||
.bad { color: #991b1b; }
|
||||
.small { font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Informes de ejecución de orden</h1>
|
||||
<p class="muted">{{ title }}</p>
|
||||
<p>
|
||||
<a href="/data-quality-contract.html" target="_blank">Abrir Data Quality Contract</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="/cost-readiness.html" target="_blank">Abrir Cost Readiness Cockpit</a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Orden</th>
|
||||
<th>Estado</th>
|
||||
<th>Producto</th>
|
||||
<th>Máquina</th>
|
||||
<th>Inicio</th>
|
||||
<th>Fin</th>
|
||||
<th>Duración min</th>
|
||||
<th>Ciclos</th>
|
||||
<th>kWh</th>
|
||||
<th>Paros</th>
|
||||
<th>Min paro</th>
|
||||
<th>Calidad</th>
|
||||
<th>Estado informe</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.odoo_order_ref }}</td>
|
||||
<td>{{ order.odoo_state }}</td>
|
||||
<td>{{ order.product }}</td>
|
||||
<td>{{ order.machine_id }}</td>
|
||||
<td>{{ order.start_ts|dt }}</td>
|
||||
<td>{{ order.end_ts|dt }}</td>
|
||||
<td>{{ order.window_min|num(2) }}</td>
|
||||
<td>{{ order.real_cycle_delta }}</td>
|
||||
<td>{{ order.kwh_consumed|num(3) }}</td>
|
||||
<td>{{ order.stop_count }}</td>
|
||||
<td>{{ order.stop_min|num(2) }}</td>
|
||||
<td class="status {% if order.technical_quality_level == 'FIABLE' %}ok{% elif order.technical_quality_level == 'NO_DEFENDIBLE' %}bad{% else %}warn{% endif %}">
|
||||
{{ order.technical_quality_level }}
|
||||
</td>
|
||||
<td class="small">{{ order.final_report_status }}</td>
|
||||
<td>
|
||||
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
|
||||
|
|
||||
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="14">No hay órdenes disponibles.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,6 +0,0 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
psycopg[binary]==3.2.3
|
||||
jinja2==3.1.5
|
||||
python-dotenv==1.0.1
|
||||
weasyprint==63.1
|
||||
@ -1,43 +0,0 @@
|
||||
-- =========================================================
|
||||
-- MESAVAULT PostgreSQL governance baseline
|
||||
-- Fase 1: creación de schemas sin mover tablas existentes
|
||||
-- =========================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS mv_core;
|
||||
CREATE SCHEMA IF NOT EXISTS mv_raw;
|
||||
CREATE SCHEMA IF NOT EXISTS mv_observability;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS mv_edge_oee_ucepsa_demo;
|
||||
CREATE SCHEMA IF NOT EXISTS mv_reports_ucepsa_demo;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS mv_hydro_lab;
|
||||
CREATE SCHEMA IF NOT EXISTS mv_weather_lab;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS mv_sandbox;
|
||||
|
||||
COMMENT ON SCHEMA mv_core IS
|
||||
'Catálogos comunes MESAVAULT: tenants, sites, assets, verticales, máquinas y metadatos compartidos.';
|
||||
|
||||
COMMENT ON SCHEMA mv_raw IS
|
||||
'Capa raw: datos crudos de entrada, mensajes MQTT, eventos sin normalizar y dead letters. No debe ser consultada directamente por dashboards cliente.';
|
||||
|
||||
COMMENT ON SCHEMA mv_observability IS
|
||||
'Observabilidad interna: salud de servicios, ingesta, latencias, errores y checks.';
|
||||
|
||||
COMMENT ON SCHEMA mv_edge_oee_ucepsa_demo IS
|
||||
'Capa normalizada de la demo Edge-OEE UCEPSA. Inicialmente contiene vistas puente hacia mv_hot.';
|
||||
|
||||
COMMENT ON SCHEMA mv_reports_ucepsa_demo IS
|
||||
'Capa de reporting para Grafana UCEPSA demo. Grafana debe consultar preferentemente este schema.';
|
||||
|
||||
COMMENT ON SCHEMA mv_hydro_lab IS
|
||||
'Capa normalizada de laboratorio Hydro / Aquaponics.';
|
||||
|
||||
COMMENT ON SCHEMA mv_weather_lab IS
|
||||
'Capa meteorológica de laboratorio.';
|
||||
|
||||
COMMENT ON SCHEMA mv_sandbox IS
|
||||
'Zona temporal de pruebas. No usar para dashboards cliente ni datos productivos.';
|
||||
|
||||
-- Endurecimiento básico del schema public
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
@ -1,383 +0,0 @@
|
||||
CREATE SCHEMA IF NOT EXISTS mv_hot;
|
||||
|
||||
DROP VIEW IF EXISTS mv_hot.edge_oee_quality_case_demo CASCADE;
|
||||
DROP VIEW IF EXISTS mv_hot.edge_oee_actions_demo CASCADE;
|
||||
DROP VIEW IF EXISTS mv_hot.edge_oee_data_quality_dashboard_demo CASCADE;
|
||||
DROP VIEW IF EXISTS mv_hot.edge_oee_order_timeline_demo CASCADE;
|
||||
DROP VIEW IF EXISTS mv_hot.edge_oee_pareto_demo CASCADE;
|
||||
DROP VIEW IF EXISTS mv_hot.edge_oee_kpi_cards_demo CASCADE;
|
||||
DROP VIEW IF EXISTS mv_hot.edge_oee_executive_summary_demo CASCADE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_machines (
|
||||
machine_id TEXT PRIMARY KEY,
|
||||
tenant TEXT NOT NULL DEFAULT 'ucepsa',
|
||||
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
|
||||
name TEXT NOT NULL,
|
||||
area TEXT NOT NULL DEFAULT 'corte',
|
||||
machine_type TEXT NOT NULL DEFAULT 'cortadora',
|
||||
value_hour_eur NUMERIC(10,2) NOT NULL,
|
||||
standard_kg_h NUMERIC(10,2),
|
||||
sale_price_eur_kg NUMERIC(10,2),
|
||||
transformation_cost_eur_kg NUMERIC(10,2),
|
||||
raw_material_cost_eur_kg NUMERIC(10,2),
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_orders_demo (
|
||||
order_id TEXT PRIMARY KEY,
|
||||
tenant TEXT NOT NULL DEFAULT 'ucepsa',
|
||||
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
|
||||
machine_id TEXT NOT NULL REFERENCES mv_hot.edge_oee_machines(machine_id),
|
||||
product TEXT NOT NULL,
|
||||
odoo_start TIMESTAMPTZ NOT NULL,
|
||||
odoo_end TIMESTAMPTZ NOT NULL,
|
||||
qty_planned NUMERIC(14,2),
|
||||
qty_done NUMERIC(14,2),
|
||||
scrap_qty NUMERIC(14,2),
|
||||
uom TEXT DEFAULT 'unidades',
|
||||
commercial_note TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_events_demo (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
tenant TEXT NOT NULL DEFAULT 'ucepsa',
|
||||
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
|
||||
machine_id TEXT NOT NULL REFERENCES mv_hot.edge_oee_machines(machine_id),
|
||||
order_id TEXT NULL REFERENCES mv_hot.edge_oee_orders_demo(order_id),
|
||||
event_type TEXT NOT NULL,
|
||||
cause TEXT NOT NULL,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
duration_min NUMERIC(10,2) NOT NULL,
|
||||
cost_eur NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'mesavault_demo',
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_loss_summary_demo (
|
||||
cause TEXT PRIMARY KEY,
|
||||
hours_month NUMERIC(10,2) NOT NULL,
|
||||
value_hour_eur NUMERIC(10,2) NOT NULL,
|
||||
monthly_cost_eur NUMERIC(10,2) NOT NULL,
|
||||
recommended_action TEXT NOT NULL,
|
||||
effort TEXT,
|
||||
priority TEXT,
|
||||
machine_focus TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_data_quality_demo (
|
||||
metric_id TEXT PRIMARY KEY,
|
||||
metric TEXT NOT NULL,
|
||||
value_numeric NUMERIC(10,2),
|
||||
value_text TEXT,
|
||||
unit TEXT,
|
||||
status TEXT NOT NULL,
|
||||
problem TEXT,
|
||||
likely_impact TEXT,
|
||||
recommended_action TEXT,
|
||||
sort_order INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_quality_cases_demo (
|
||||
case_id TEXT PRIMARY KEY,
|
||||
order_id TEXT REFERENCES mv_hot.edge_oee_orders_demo(order_id),
|
||||
machine_id TEXT REFERENCES mv_hot.edge_oee_machines(machine_id),
|
||||
product TEXT,
|
||||
claim TEXT,
|
||||
sealing_adjustment_stops INT,
|
||||
sealing_adjustment_min NUMERIC(10,2),
|
||||
microstops_count INT,
|
||||
scrap_pct NUMERIC(10,2),
|
||||
energy_vs_avg_pct NUMERIC(10,2),
|
||||
interpretation TEXT,
|
||||
recommended_action TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_energy_demo (
|
||||
ts TIMESTAMPTZ NOT NULL,
|
||||
tenant TEXT NOT NULL DEFAULT 'ucepsa',
|
||||
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
|
||||
machine_id TEXT NOT NULL REFERENCES mv_hot.edge_oee_machines(machine_id),
|
||||
order_id TEXT NULL REFERENCES mv_hot.edge_oee_orders_demo(order_id),
|
||||
power_kw NUMERIC(10,3),
|
||||
kwh_total NUMERIC(14,3),
|
||||
source TEXT DEFAULT 'demo',
|
||||
payload_json JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
TRUNCATE TABLE
|
||||
mv_hot.edge_oee_energy_demo,
|
||||
mv_hot.edge_oee_quality_cases_demo,
|
||||
mv_hot.edge_oee_data_quality_demo,
|
||||
mv_hot.edge_oee_loss_summary_demo,
|
||||
mv_hot.edge_oee_events_demo,
|
||||
mv_hot.edge_oee_orders_demo,
|
||||
mv_hot.edge_oee_machines
|
||||
RESTART IDENTITY CASCADE;
|
||||
|
||||
INSERT INTO mv_hot.edge_oee_machines
|
||||
(machine_id, name, value_hour_eur, standard_kg_h, sale_price_eur_kg, transformation_cost_eur_kg, raw_material_cost_eur_kg, notes)
|
||||
VALUES
|
||||
('C0', 'Cortadora 0', 36.00, 34.90, 3.23, 0.80, 1.25, 'Máquina de alta carga. Valor hora demo prudente.'),
|
||||
('C1', 'Cortadora 1', 36.00, 34.90, 3.23, 0.80, 1.25, 'Máquina de alta carga. Comparable con C0.'),
|
||||
('C2', 'Cortadora 2', 36.00, 35.40, 2.80, 0.80, 1.25, 'Máquina crítica por carga y problemas de sellado.');
|
||||
|
||||
WITH d AS (
|
||||
SELECT current_date::timestamp AS base
|
||||
)
|
||||
INSERT INTO mv_hot.edge_oee_orders_demo
|
||||
(order_id, machine_id, product, odoo_start, odoo_end, qty_planned, qty_done, scrap_qty, uom, commercial_note)
|
||||
SELECT 'OF00481', 'C0', 'Bolsa 300x400', base + interval '08 hours', base + interval '11 hours 30 minutes', 50000, 48900, 1100, 'unidades', 'Orden con arranque real retrasado 22 minutos.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'OF00482', 'C1', 'Bolsa 250x350', base + interval '08 hours 15 minutes', base + interval '12 hours 10 minutes', 42000, 41600, 400, 'unidades', 'Orden casi normal, usada como comparación.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'OF00493', 'C2', 'Bolsa 300x400', base + interval '09 hours', base + interval '13 hours 20 minutes', 52000, 50500, 1500, 'unidades', 'Orden asociada a caso ficticio de sellado débil.' FROM d;
|
||||
|
||||
WITH d AS (
|
||||
SELECT current_date::timestamp AS base
|
||||
)
|
||||
INSERT INTO mv_hot.edge_oee_events_demo
|
||||
(machine_id, order_id, event_type, cause, start_time, end_time, duration_min, cost_eur, notes)
|
||||
SELECT 'C0','OF00481','PREPARATION','Búsqueda de bobina',base + interval '08 hours',base + interval '08 hours 22 minutes',22,13.20,'Odoo inicia a las 08:00, pero la primera marcha real llega a las 08:22.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C0','OF00481','RUNNING','Producción',base + interval '08 hours 22 minutes',base + interval '09 hours 14 minutes',52,0,'Producción real.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C0','OF00481','MICROSTOP','Microparos acumulados',base + interval '09 hours 14 minutes',base + interval '09 hours 18 minutes',4,2.40,'Microparos invisibles para un registro manual.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C0','OF00481','STOP','Cambio bobina',base + interval '10 hours 05 minutes',base + interval '10 hours 18 minutes',13,7.80,'Paro operativo por cambio de bobina.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C1','OF00482','PREPARATION','Preparación normal',base + interval '08 hours 15 minutes',base + interval '08 hours 18 minutes',3,1.80,'Preparación corta, caso de comparación.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C1','OF00482','RUNNING','Producción',base + interval '08 hours 18 minutes',base + interval '10 hours 40 minutes',142,0,'Producción estable.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C1',NULL,'RUNNING_WITHOUT_ORDER','Máquina en marcha sin orden activa',base + interval '12 hours 30 minutes',base + interval '12 hours 56 minutes',26,15.60,'Brecha de dato: producción no imputada a Odoo.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C2','OF00493','PREPARATION','Ajuste inicial de máquina',base + interval '09 hours',base + interval '09 hours 16 minutes',16,9.60,'Preparación superior a 15 minutos.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C2','OF00493','RUNNING','Producción',base + interval '09 hours 16 minutes',base + interval '09 hours 40 minutes',24,0,'Producción real.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C2','OF00493','STOP','Ajuste de sellado',base + interval '09 hours 40 minutes',base + interval '09 hours 52 minutes',12,7.20,'Paro vinculado a sellado.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C2','OF00493','RUNNING','Producción',base + interval '09 hours 52 minutes',base + interval '10 hours 25 minutes',33,0,'Producción real.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C2','OF00493','MICROSTOP','Microparos durante orden',base + interval '10 hours 25 minutes',base + interval '10 hours 28 minutes 30 seconds',3.5,2.10,'Microparos durante producto con reclamación.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C2','OF00493','STOP','Ajuste de sellado',base + interval '11 hours 20 minutes',base + interval '11 hours 32 minutes',12,7.20,'Segundo ajuste de sellado.' FROM d
|
||||
UNION ALL
|
||||
SELECT 'C2','OF00493','STOP','Ajuste de sellado',base + interval '12 hours 04 minutes',base + interval '12 hours 12 minutes',8,4.80,'Tercer ajuste de sellado.' FROM d;
|
||||
|
||||
INSERT INTO mv_hot.edge_oee_loss_summary_demo
|
||||
(cause, hours_month, value_hour_eur, monthly_cost_eur, recommended_action, effort, priority, machine_focus)
|
||||
VALUES
|
||||
('Ajuste de sellado', 18.00, 36.00, 648.00, 'Revisar mordazas, teflón, temperatura y presión', 'Medio', 'Alta', 'C2'),
|
||||
('Espera de material', 14.00, 36.00, 504.00, 'Crear prealistamiento de bobina antes del turno', 'Bajo', 'Alta', 'C0/C1/C2'),
|
||||
('Microparos acumulados', 11.50, 36.00, 414.00, 'Revisar fotocélula, tensión de material y cuchilla', 'Bajo', 'Alta', 'C0/C2'),
|
||||
('Cambio bobina/formato', 9.00, 36.00, 324.00, 'Implantar checklist de cambio de bobina/formato', 'Bajo', 'Media', 'C0/C1'),
|
||||
('Arranque lento de orden', 6.00, 36.00, 216.00, 'Preparar primera orden y material antes del inicio del turno', 'Bajo', 'Media', 'C0/C2'),
|
||||
('Paro sin causa', 5.00, 36.00, 180.00, 'Mejorar registro de causas y disciplina de imputación', 'Bajo', 'Alta', 'Todas');
|
||||
|
||||
INSERT INTO mv_hot.edge_oee_data_quality_demo
|
||||
(metric_id, metric, value_numeric, value_text, unit, status, problem, likely_impact, recommended_action, sort_order)
|
||||
VALUES
|
||||
('orders_started_no_run_10m', 'Órdenes iniciadas sin marcha en 10 min', 6, NULL, 'órdenes', 'Warning', 'Odoo registra inicio, pero la máquina no produce.', 'El tiempo de preparación queda mezclado con tiempo productivo.', 'Separar preparación, espera de material y marcha real.', 1),
|
||||
('machine_running_no_order', 'Máquina en marcha sin orden activa', 3, NULL, 'eventos', 'Warning', 'Hay producción física sin orden asignada.', 'Producción no imputada correctamente a Odoo.', 'Bloquear o alertar producción sin orden activa.', 2),
|
||||
('orders_closed_before_real_end', 'Órdenes cerradas antes de fin real', 2, NULL, 'órdenes', 'Warning', 'El cierre administrativo no coincide con la realidad física.', 'Descuadre de tiempos, coste y trazabilidad.', 'Comparar fin Odoo contra última marcha real.', 3),
|
||||
('stops_without_cause', 'Paros sin causa', 5, NULL, 'h/mes', 'Warning', 'Hay pérdidas sin clasificación útil.', 'No se puede decidir ninguna acción concreta.', 'Simplificar selección de causas en planta.', 4),
|
||||
('preparation_gt_15m', 'Órdenes con preparación >15 min', 8, NULL, 'órdenes', 'Warning', 'Arranques lentos repetidos.', 'Pérdida de capacidad al inicio de orden o turno.', 'Prealistamiento de bobinas, útiles y parámetros.', 5),
|
||||
('energy_above_avg', 'Órdenes con energía superior a media', 5, NULL, 'órdenes', 'Info', 'Consumo superior al patrón esperado.', 'Puede indicar ajustes, fricción, vacío, temperatura o régimen anómalo.', 'Cruzar energía con producto, máquina y causa de paro.', 6);
|
||||
|
||||
INSERT INTO mv_hot.edge_oee_quality_cases_demo
|
||||
(case_id, order_id, machine_id, product, claim, sealing_adjustment_stops, sealing_adjustment_min, microstops_count, scrap_pct, energy_vs_avg_pct, interpretation, recommended_action)
|
||||
VALUES
|
||||
(
|
||||
'QC_SELLADO_OF00493',
|
||||
'OF00493',
|
||||
'C2',
|
||||
'Bolsa 300x400',
|
||||
'Sellado débil',
|
||||
4,
|
||||
32,
|
||||
23,
|
||||
2.80,
|
||||
6.00,
|
||||
'La reclamación no queda aislada: durante la orden aparecen ajustes de sellado, microparos, merma y energía superior a la media.',
|
||||
'Revisar mordazas, teflón, temperatura, presión y procedimiento de arranque. Comprobar si el patrón se repite en C2 o con Bolsa 300x400.'
|
||||
);
|
||||
|
||||
WITH d AS (
|
||||
SELECT current_date::timestamp AS base
|
||||
)
|
||||
INSERT INTO mv_hot.edge_oee_energy_demo
|
||||
(ts, machine_id, order_id, power_kw, kwh_total, source, payload_json)
|
||||
SELECT base + interval '09 hours', 'C2', 'OF00493', 4.800, 102.100, 'demo', '{"note":"inicio orden sellado"}'::jsonb FROM d
|
||||
UNION ALL
|
||||
SELECT base + interval '10 hours', 'C2', 'OF00493', 5.150, 106.900, 'demo', '{"note":"energía sobre media durante ajustes"}'::jsonb FROM d
|
||||
UNION ALL
|
||||
SELECT base + interval '11 hours', 'C2', 'OF00493', 5.090, 112.050, 'demo', '{"note":"energía sobre media"}'::jsonb FROM d
|
||||
UNION ALL
|
||||
SELECT base + interval '12 hours', 'C2', 'OF00493', 5.200, 117.250, 'demo', '{"note":"ajuste sellado"}'::jsonb FROM d;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_hot.edge_oee_executive_summary_demo AS
|
||||
WITH losses AS (
|
||||
SELECT
|
||||
SUM(hours_month) AS classified_loss_hours,
|
||||
SUM(monthly_cost_eur) AS estimated_loss_cost_eur
|
||||
FROM mv_hot.edge_oee_loss_summary_demo
|
||||
),
|
||||
principal AS (
|
||||
SELECT cause
|
||||
FROM mv_hot.edge_oee_loss_summary_demo
|
||||
ORDER BY monthly_cost_eur DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
now() AS evaluated_at,
|
||||
816.0::numeric(10,1) AS hours_analyzed,
|
||||
losses.classified_loss_hours,
|
||||
losses.estimated_loss_cost_eur,
|
||||
principal.cause AS main_loss_cause,
|
||||
'Cortadora 2'::text AS machine_with_more_losses,
|
||||
5.0::numeric(10,1) AS unclassified_stop_hours,
|
||||
7::int AS orders_with_odoo_machine_discrepancy
|
||||
FROM losses, principal;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_hot.edge_oee_kpi_cards_demo AS
|
||||
SELECT 1 AS sort_order, 'Horas analizadas' AS kpi, hours_analyzed AS value, 'h' AS unit, 'OK' AS status
|
||||
FROM mv_hot.edge_oee_executive_summary_demo
|
||||
UNION ALL
|
||||
SELECT 2, 'Pérdidas clasificadas', classified_loss_hours, 'h/mes', 'Warning'
|
||||
FROM mv_hot.edge_oee_executive_summary_demo
|
||||
UNION ALL
|
||||
SELECT 3, 'Coste estimado de pérdidas', estimated_loss_cost_eur, '€/mes', 'Warning'
|
||||
FROM mv_hot.edge_oee_executive_summary_demo
|
||||
UNION ALL
|
||||
SELECT 4, 'Paros sin causa', unclassified_stop_hours, 'h/mes', 'Warning'
|
||||
FROM mv_hot.edge_oee_executive_summary_demo
|
||||
UNION ALL
|
||||
SELECT 5, 'Órdenes con discrepancia Odoo/máquina', orders_with_odoo_machine_discrepancy, 'órdenes', 'Warning'
|
||||
FROM mv_hot.edge_oee_executive_summary_demo;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_hot.edge_oee_pareto_demo AS
|
||||
SELECT
|
||||
RANK() OVER (ORDER BY monthly_cost_eur DESC) AS ranking,
|
||||
cause,
|
||||
hours_month,
|
||||
value_hour_eur,
|
||||
monthly_cost_eur,
|
||||
recommended_action,
|
||||
effort,
|
||||
priority,
|
||||
machine_focus
|
||||
FROM mv_hot.edge_oee_loss_summary_demo
|
||||
ORDER BY monthly_cost_eur DESC;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_hot.edge_oee_order_timeline_demo AS
|
||||
SELECT
|
||||
o.odoo_start AS time,
|
||||
o.odoo_start + interval '1 minute' AS time_end,
|
||||
o.order_id,
|
||||
o.machine_id,
|
||||
m.name AS machine_name,
|
||||
o.product,
|
||||
'ODOO' AS source,
|
||||
'ODOO_START' AS state,
|
||||
'Odoo inicia la orden' AS detail,
|
||||
0::numeric AS duration_min,
|
||||
0::numeric AS cost_eur,
|
||||
1 AS sort_order
|
||||
FROM mv_hot.edge_oee_orders_demo o
|
||||
JOIN mv_hot.edge_oee_machines m ON m.machine_id = o.machine_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
e.start_time AS time,
|
||||
e.end_time AS time_end,
|
||||
e.order_id,
|
||||
e.machine_id,
|
||||
m.name AS machine_name,
|
||||
COALESCE(o.product, 'Sin orden activa') AS product,
|
||||
'MESAVAULT' AS source,
|
||||
e.event_type AS state,
|
||||
e.cause AS detail,
|
||||
e.duration_min,
|
||||
e.cost_eur,
|
||||
2 AS sort_order
|
||||
FROM mv_hot.edge_oee_events_demo e
|
||||
JOIN mv_hot.edge_oee_machines m ON m.machine_id = e.machine_id
|
||||
LEFT JOIN mv_hot.edge_oee_orders_demo o ON o.order_id = e.order_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
o.odoo_end AS time,
|
||||
o.odoo_end + interval '1 minute' AS time_end,
|
||||
o.order_id,
|
||||
o.machine_id,
|
||||
m.name AS machine_name,
|
||||
o.product,
|
||||
'ODOO' AS source,
|
||||
'ODOO_END' AS state,
|
||||
'Odoo finaliza la orden' AS detail,
|
||||
0::numeric AS duration_min,
|
||||
0::numeric AS cost_eur,
|
||||
3 AS sort_order
|
||||
FROM mv_hot.edge_oee_orders_demo o
|
||||
JOIN mv_hot.edge_oee_machines m ON m.machine_id = o.machine_id;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_hot.edge_oee_data_quality_dashboard_demo AS
|
||||
SELECT
|
||||
sort_order,
|
||||
metric AS "Indicador",
|
||||
COALESCE(value_numeric::text, value_text) AS "Valor",
|
||||
unit AS "Unidad",
|
||||
status AS "Estado",
|
||||
problem AS "Qué está mal",
|
||||
likely_impact AS "Impacto probable",
|
||||
recommended_action AS "Qué revisar / acción"
|
||||
FROM mv_hot.edge_oee_data_quality_demo
|
||||
ORDER BY sort_order;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_hot.edge_oee_actions_demo AS
|
||||
SELECT
|
||||
cause AS "Causa",
|
||||
monthly_cost_eur AS "Impacto estimado €/mes",
|
||||
effort AS "Esfuerzo",
|
||||
priority AS "Prioridad",
|
||||
recommended_action AS "Acción recomendada",
|
||||
CASE
|
||||
WHEN cause = 'Ajuste de sellado' THEN 4000::numeric
|
||||
ELSE NULL::numeric
|
||||
END AS "Inversión ejemplo €",
|
||||
CASE
|
||||
WHEN cause = 'Ajuste de sellado' THEN ROUND(4000::numeric / NULLIF(monthly_cost_eur, 0), 1)
|
||||
ELSE NULL::numeric
|
||||
END AS "Payback ejemplo meses"
|
||||
FROM mv_hot.edge_oee_loss_summary_demo
|
||||
ORDER BY monthly_cost_eur DESC;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_hot.edge_oee_quality_case_demo AS
|
||||
SELECT
|
||||
qc.case_id,
|
||||
qc.order_id,
|
||||
m.name AS machine_name,
|
||||
qc.product,
|
||||
qc.claim,
|
||||
qc.sealing_adjustment_stops,
|
||||
qc.sealing_adjustment_min,
|
||||
qc.microstops_count,
|
||||
qc.scrap_pct,
|
||||
qc.energy_vs_avg_pct,
|
||||
qc.interpretation,
|
||||
qc.recommended_action
|
||||
FROM mv_hot.edge_oee_quality_cases_demo qc
|
||||
JOIN mv_hot.edge_oee_machines m ON m.machine_id = qc.machine_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_edge_oee_events_demo_time
|
||||
ON mv_hot.edge_oee_events_demo(start_time DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_edge_oee_events_demo_machine_time
|
||||
ON mv_hot.edge_oee_events_demo(machine_id, start_time DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_edge_oee_energy_demo_time
|
||||
ON mv_hot.edge_oee_energy_demo(ts DESC);
|
||||
@ -1,94 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS mv_core.tenants (
|
||||
tenant TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
tenant_type TEXT NOT NULL DEFAULT 'demo',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_core.sites (
|
||||
tenant TEXT NOT NULL REFERENCES mv_core.tenants(tenant),
|
||||
site TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
site_type TEXT NOT NULL DEFAULT 'demo',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant, site)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_core.verticals (
|
||||
vertical TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_core.assets (
|
||||
tenant TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
vertical TEXT NOT NULL REFERENCES mv_core.verticals(vertical),
|
||||
asset TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
asset_type TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant, site, vertical, asset),
|
||||
FOREIGN KEY (tenant, site) REFERENCES mv_core.sites(tenant, site)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_core.machines (
|
||||
tenant TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
area TEXT,
|
||||
machine_type TEXT,
|
||||
asset TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant, site, machine_id),
|
||||
FOREIGN KEY (tenant, site) REFERENCES mv_core.sites(tenant, site)
|
||||
);
|
||||
|
||||
INSERT INTO mv_core.tenants (tenant, display_name, tenant_type)
|
||||
VALUES
|
||||
('ucepsa', 'UCEPSA', 'demo'),
|
||||
('mesavault_lab', 'MESAVAULT Lab', 'lab')
|
||||
ON CONFLICT (tenant) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
tenant_type = EXCLUDED.tenant_type;
|
||||
|
||||
INSERT INTO mv_core.sites (tenant, site, display_name, site_type)
|
||||
VALUES
|
||||
('ucepsa', 'demo_edge_oee', 'Demo Edge-OEE UCEPSA', 'demo')
|
||||
ON CONFLICT (tenant, site) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
site_type = EXCLUDED.site_type;
|
||||
|
||||
INSERT INTO mv_core.verticals (vertical, display_name, description)
|
||||
VALUES
|
||||
('edge_oee', 'Edge-OEE', 'Captura de estado de máquina, ciclos, energía, paros y causas.'),
|
||||
('aquaponics', 'Hydro / Aquaponics Climate', 'Vertical de hidroponía / acuaponía y clima.')
|
||||
ON CONFLICT (vertical) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
description = EXCLUDED.description;
|
||||
|
||||
INSERT INTO mv_core.assets (
|
||||
tenant, site, vertical, asset, display_name, asset_type
|
||||
)
|
||||
VALUES
|
||||
('ucepsa', 'demo_edge_oee', 'edge_oee', 'revpi_oee_node_01', 'RevPi Edge-OEE Node 01', 'revpi_gateway')
|
||||
ON CONFLICT (tenant, site, vertical, asset) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
asset_type = EXCLUDED.asset_type;
|
||||
|
||||
INSERT INTO mv_core.machines (
|
||||
tenant, site, machine_id, display_name, area, machine_type, asset
|
||||
)
|
||||
VALUES
|
||||
('ucepsa', 'demo_edge_oee', 'C_DEMO', 'Cortadora demo', 'corte', 'cortadora', 'revpi_oee_node_01')
|
||||
ON CONFLICT (tenant, site, machine_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
area = EXCLUDED.area,
|
||||
machine_type = EXCLUDED.machine_type,
|
||||
asset = EXCLUDED.asset;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user