feat(ucepsa): add Edge-OEE local stack sources

This commit is contained in:
Victor Fraile 2026-05-26 16:29:34 +00:00
parent ec0a6d09c2
commit 29b0e0456b
45 changed files with 5909 additions and 0 deletions

48
.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,51 @@
# UCEPSA — MESAVAULT Edge-OEE
Stack Edge-OEE local para UCEPSA.
## Objetivo
Capturar señales reales de máquina, registrar paros, calcular duración/coste y cruzar la realidad física con órdenes de trabajo de Odoo UCEPSA.
## Máquina inicial
- Máquina: CORTADORA 01
- machine_id: CORT-01
- asset: revpi_oee_node_01
- Odoo Work Center code: CORT-01
## Componentes
- Mosquitto local
- PostgreSQL MESAVAULT local
- Grafana
- Consola de paros
- MQTT sink
- Odoo workorders sync
## Entorno UCEPSA
- VM MESAVAULT: mv-edge-oee
- IP: 17.126.1.54
- Odoo UCEPSA: 17.126.1.61
- DB Odoo test: ucepsa_test_jp
## Estructura
- `services/`: servicios Python/Docker de MESAVAULT.
- `sql/`: migraciones, vistas y funciones PostgreSQL.
- `grafana/`: dashboards exportados.
- `deploy/platform40-reference/`: configuración de referencia del laboratorio platform-40.
- `deploy/ucepsa-local/`: configuración objetivo para la VM local UCEPSA.
- `env-examples/`: plantillas de variables sin secretos.
- `docs/`: documentación técnica y runbooks.
## No versionar
- `.env` reales
- contraseñas
- backups
- dumps SQL
- filestore
- datos PostgreSQL
- certificados
- claves privadas

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
#!/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"

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd /srv/verticals/edge-oee-ucepsa-demo
docker-compose run --rm oee-demo-generator

View File

@ -0,0 +1,5 @@
#!/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

View File

@ -0,0 +1,42 @@
\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;

View File

@ -0,0 +1,14 @@
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"]

View File

@ -0,0 +1,531 @@
#!/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)
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)
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())

View File

@ -0,0 +1,278 @@
#!/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)
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 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)
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")
print(f"[OK] topic={msg.topic} asset={asset} voltage={voltage} power_kw={power} comm_ok={comm_ok}")
except Exception as exc:
print(f"[ERROR] processing topic={msg.topic}: {type(exc).__name__}: {exc}", file=sys.stderr)
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)
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())

View File

@ -0,0 +1,410 @@
#!/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)
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 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)
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)
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())

View File

@ -0,0 +1,2 @@
paho-mqtt>=2,<3
psycopg2-binary==2.9.9

View File

@ -0,0 +1,14 @@
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"]

View File

@ -0,0 +1,452 @@
#!/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()

View File

@ -0,0 +1,181 @@
#!/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())

View File

@ -0,0 +1 @@
psycopg2-binary==2.9.9

View File

@ -0,0 +1,14 @@
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"]

View File

@ -0,0 +1,310 @@
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()

View File

@ -0,0 +1 @@
psycopg2-binary==2.9.9

View File

@ -0,0 +1,14 @@
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"]

View File

@ -0,0 +1,430 @@
import os
from datetime import datetime
import psycopg2
import psycopg2.extras
from flask import Flask, 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")
DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORTADORA 01")
app = Flask(__name__)
def get_conn():
return psycopg2.connect(**PG_CONFIG)
def fetch_pending_stops():
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'
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)
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, 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 classification_status = 'PENDING';
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
sql,
{
"stop_id": stop_id,
"cause_code": cause_code,
"operator_note": operator_note,
},
)
PAGE = """
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ app_title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="15">
<style>
:root {
--bg: #0f172a;
--card: #111827;
--card2: #1f2937;
--text: #e5e7eb;
--muted: #9ca3af;
--line: #374151;
--accent: #f97316;
--danger: #ef4444;
--ok: #22c55e;
--button: #2563eb;
--button-hover: #1d4ed8;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 24px;
border-bottom: 1px solid var(--line);
background: #020617;
}
h1 {
margin: 0;
font-size: 24px;
}
.subtitle {
color: var(--muted);
margin-top: 6px;
font-size: 14px;
}
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.summary-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px 18px;
min-width: 180px;
}
.summary-card .label {
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}
.summary-card .value {
font-size: 26px;
font-weight: bold;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
text-align: left;
font-size: 14px;
}
th {
background: var(--card2);
color: #f9fafb;
font-weight: bold;
}
tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
}
.badge-open {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.badge-closed {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
border: 1px solid rgba(34, 197, 94, 0.4);
}
.badge-pending {
background: rgba(249, 115, 22, 0.15);
color: #fdba74;
border: 1px solid rgba(249, 115, 22, 0.4);
}
select, input[type="text"] {
width: 100%;
box-sizing: border-box;
background: #020617;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px;
font-size: 14px;
}
button {
background: var(--button);
color: white;
border: none;
border-radius: 6px;
padding: 10px 14px;
cursor: pointer;
font-weight: bold;
width: 100%;
}
button:hover {
background: var(--button-hover);
}
.empty {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 32px;
text-align: center;
color: var(--muted);
}
.small {
font-size: 12px;
color: var(--muted);
}
.nowrap {
white-space: nowrap;
}
</style>
</head>
<body>
<header>
<h1>{{ app_title }}</h1>
<div class="subtitle">
Clasificación manual de paros detectados automáticamente por Edge-OEE.
Última carga: {{ now }}
</div>
</header>
<main>
<div class="summary">
<div class="summary-card">
<div class="label">Paros pendientes</div>
<div class="value">{{ stops|length }}</div>
</div>
<div class="summary-card">
<div class="label">Máquina demo</div>
<div class="value">{{DEFAULT_MACHINE_ID}}</div>
</div>
</div>
{% if stops|length == 0 %}
<div class="empty">
No hay paros pendientes de clasificar.
</div>
{% else %}
<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>
<form method="post" action="{{ url_for('classify') }}">
<input type="hidden" name="stop_id" value="{{ stop.id }}">
<td class="nowrap">#{{ stop.id }}</td>
<td>
<strong>{{ stop.machine_id or "-" }}</strong>
<div class="small">{{ stop.asset }}</div>
</td>
<td class="nowrap">
{{ stop.started_at }}
</td>
<td class="nowrap">
{{ stop.ended_at or "Abierto" }}
</td>
<td>
{% if stop.status == 'OPEN' %}
<span class="badge badge-open">OPEN</span>
{% else %}
<span class="badge badge-closed">CLOSED</span>
{% endif %}
<br>
<span class="badge badge-pending">{{ stop.classification_status }}</span>
</td>
<td class="nowrap">
{{ "%.2f"|format(stop.duration_min or 0) }} min
</td>
<td class="nowrap">
{{ "%.2f"|format(stop.cost_eur or 0) }}
</td>
<td>
<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">
</td>
<td>
<button type="submit">Guardar</button>
</td>
</form>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</main>
</body>
</html>
"""
@app.route("/", methods=["GET"])
def index():
stops = fetch_pending_stops()
causes = fetch_causes()
return render_template_string(
PAGE,
app_title=APP_TITLE,
stops=stops,
causes=causes,
now=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
default_machine=DEFAULT_MACHINE_ID,
)
@app.route("/classify", methods=["POST"])
def classify():
stop_id = request.form.get("stop_id")
cause_code = request.form.get("cause_code")
operator_note = request.form.get("operator_note", "").strip() or None
if stop_id and cause_code:
classify_stop(stop_id, cause_code, operator_note)
return redirect(url_for("index"))
@app.route("/health", methods=["GET"])
def health():
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1;")
return {"status": "ok"}, 200
except Exception as exc:
return {"status": "error", "error": str(exc)}, 500

View File

@ -0,0 +1,3 @@
Flask==3.0.3
gunicorn==22.0.0
psycopg2-binary==2.9.9

View File

@ -0,0 +1,43 @@
-- =========================================================
-- 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;

View File

@ -0,0 +1,383 @@
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);

View File

@ -0,0 +1,94 @@
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;

View File

@ -0,0 +1,140 @@
CREATE SCHEMA IF NOT EXISTS mv_hot;
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_eastron_readings (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant TEXT NOT NULL DEFAULT 'ucepsa',
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
asset TEXT NOT NULL DEFAULT 'revpi_eastron_demo',
source TEXT NOT NULL DEFAULT 'eastron_reader',
meter_model TEXT,
meter_id TEXT,
machine_id TEXT NULL,
order_id TEXT NULL,
voltage_l1_v NUMERIC(10,3),
voltage_l2_v NUMERIC(10,3),
voltage_l3_v NUMERIC(10,3),
current_l1_a NUMERIC(10,3),
current_l2_a NUMERIC(10,3),
current_l3_a NUMERIC(10,3),
power_l1_kw NUMERIC(10,3),
power_l2_kw NUMERIC(10,3),
power_l3_kw NUMERIC(10,3),
power_total_kw NUMERIC(10,3),
frequency_hz NUMERIC(10,3),
import_kwh NUMERIC(14,3),
export_kwh NUMERIC(14,3),
comm_ok BOOLEAN NOT NULL DEFAULT true,
error_text TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
ingest_ts TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_edge_oee_eastron_readings_ts
ON mv_hot.edge_oee_eastron_readings(ts DESC);
CREATE INDEX IF NOT EXISTS idx_edge_oee_eastron_readings_asset_ts
ON mv_hot.edge_oee_eastron_readings(asset, ts DESC);
CREATE OR REPLACE VIEW mv_hot.edge_oee_eastron_latest AS
SELECT DISTINCT ON (asset)
*
FROM mv_hot.edge_oee_eastron_readings
ORDER BY asset, ts DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_eastron_dashboard AS
SELECT
ts,
EXTRACT(EPOCH FROM (now() - ts))::int AS age_s,
tenant,
site,
asset,
meter_model,
meter_id,
machine_id,
voltage_l1_v,
voltage_l2_v,
voltage_l3_v,
current_l1_a,
current_l2_a,
current_l3_a,
power_total_kw,
frequency_hz,
import_kwh,
export_kwh,
comm_ok,
error_text,
CASE
WHEN comm_ok = false THEN 2
WHEN EXTRACT(EPOCH FROM (now() - ts)) > 180 THEN 2
WHEN EXTRACT(EPOCH FROM (now() - ts)) > 60 THEN 1
ELSE 0
END AS comm_risk_level,
CASE
WHEN comm_ok = false THEN 'Sin comunicación Modbus'
WHEN EXTRACT(EPOCH FROM (now() - ts)) > 180 THEN 'Lectura antigua'
WHEN EXTRACT(EPOCH FROM (now() - ts)) > 60 THEN 'Lectura no reciente'
ELSE 'Comunicación correcta'
END AS comm_status
FROM mv_hot.edge_oee_eastron_latest;
CREATE OR REPLACE VIEW mv_hot.edge_oee_eastron_diagnosis_latest AS
SELECT
1 AS sort_order,
'Comunicación' AS group_name,
'Edad último dato' AS metric,
age_s::numeric AS value,
's' AS unit,
CASE
WHEN comm_risk_level = 0 THEN 'OK'
WHEN comm_risk_level = 1 THEN 'Warning'
ELSE 'Peligro'
END AS status,
CASE
WHEN comm_risk_level = 0 THEN 'Lectura reciente desde Eastron'
ELSE 'La lectura no es reciente o hay fallo de comunicación'
END AS problem,
CASE
WHEN comm_risk_level = 0 THEN 'La captura industrial está viva'
ELSE 'El dashboard puede estar mostrando un valor antiguo'
END AS likely_impact,
CASE
WHEN comm_risk_level = 0 THEN 'Sin acción'
ELSE 'Revisar RevPi, RS-485, alimentación del Eastron, parámetros Modbus y logs del lector'
END AS recommended_action
FROM mv_hot.edge_oee_eastron_dashboard
UNION ALL
SELECT
2,
'Energía',
'Potencia total',
power_total_kw,
'kW',
CASE
WHEN power_total_kw IS NULL THEN 'Warning'
ELSE 'OK'
END,
CASE
WHEN power_total_kw IS NULL THEN 'No hay potencia total disponible'
ELSE 'Potencia total leída correctamente'
END,
CASE
WHEN power_total_kw IS NULL THEN 'No se puede validar carga eléctrica'
ELSE 'Permite demostrar captura energética industrial'
END,
CASE
WHEN power_total_kw IS NULL THEN 'Revisar mapa de registros Modbus'
ELSE 'Sin acción'
END
FROM mv_hot.edge_oee_eastron_dashboard;

View File

@ -0,0 +1,91 @@
CREATE SCHEMA IF NOT EXISTS mv_hot;
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_node_events (
id BIGSERIAL PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_ts TIMESTAMPTZ NOT NULL,
topic TEXT NOT NULL,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
vertical TEXT NOT NULL,
asset TEXT NOT NULL,
machine_id TEXT,
order_id TEXT,
event_type TEXT NOT NULL,
event_name TEXT NOT NULL,
previous_auto_signal BOOLEAN,
new_auto_signal BOOLEAN,
machine_running BOOLEAN,
reason_pending BOOLEAN,
cycle_pulse_count BIGINT,
cycle_rate_ppm NUMERIC(10,2),
last_cycle_age_s NUMERIC(10,2),
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_edge_oee_node_events_received_at
ON mv_hot.edge_oee_node_events(received_at DESC);
CREATE INDEX IF NOT EXISTS idx_edge_oee_node_events_event_ts
ON mv_hot.edge_oee_node_events(event_ts DESC);
CREATE INDEX IF NOT EXISTS idx_edge_oee_node_events_asset_ts
ON mv_hot.edge_oee_node_events(asset, event_ts DESC);
CREATE INDEX IF NOT EXISTS idx_edge_oee_node_events_machine_ts
ON mv_hot.edge_oee_node_events(machine_id, event_ts DESC);
CREATE OR REPLACE VIEW mv_hot.edge_oee_node_events_recent AS
SELECT
received_at,
EXTRACT(EPOCH FROM (now() - received_at))::int AS age_s,
event_ts,
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,
topic,
payload_json
FROM mv_hot.edge_oee_node_events
ORDER BY event_ts DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_machine_state_latest AS
SELECT DISTINCT ON (asset)
received_at,
EXTRACT(EPOCH FROM (now() - received_at))::int AS age_s,
event_ts,
tenant,
site,
vertical,
asset,
machine_id,
event_name,
previous_auto_signal,
new_auto_signal,
machine_running,
reason_pending,
cycle_pulse_count,
cycle_rate_ppm,
last_cycle_age_s
FROM mv_hot.edge_oee_node_events
WHERE event_type = 'machine_state_changed'
ORDER BY asset, event_ts DESC;

View File

@ -0,0 +1,156 @@
CREATE SCHEMA IF NOT EXISTS mv_hot;
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_stop_causes (
cause_code TEXT PRIMARY KEY,
cause_name TEXT NOT NULL,
description TEXT,
is_planned BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 100
);
INSERT INTO mv_hot.edge_oee_stop_causes
(cause_code, cause_name, description, is_planned, sort_order)
VALUES
('cambio_bobina', 'Cambio de bobina', 'Paro por cambio de bobina o material.', false, 10),
('fallo_empalme', 'Fallo en empalme', 'Paro por fallo o ajuste en empalme de material.', false, 20),
('ajuste_sellado', 'Ajuste de sellado', 'Paro por ajuste de mordazas, temperatura, presión o teflón.', false, 30),
('falta_material', 'Falta de material', 'Paro por ausencia de bobina, material no preparado o espera logística.', false, 40),
('limpieza_fotocelula', 'Limpieza fotocélula', 'Paro por limpieza o ajuste de sensor/fotocélula.', false, 50),
('cambio_formato', 'Cambio de formato', 'Paro por cambio de medidas, producto o configuración.', false, 60),
('averia_mecanica', 'Avería mecánica', 'Paro por fallo mecánico de la máquina.', false, 70),
('espera_instrucciones', 'Espera de instrucciones', 'Paro por falta de definición, autorización o instrucción.', false, 80),
('pausa_cafe', 'Pausa café', 'Pausa del operario o descanso.', true, 90),
('otro', 'Otro', 'Causa no clasificada en el catálogo principal.', false, 999)
ON CONFLICT (cause_code) DO UPDATE SET
cause_name = EXCLUDED.cause_name,
description = EXCLUDED.description,
is_planned = EXCLUDED.is_planned,
sort_order = EXCLUDED.sort_order;
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_machine_stops (
id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
vertical TEXT NOT NULL,
asset TEXT NOT NULL,
machine_id TEXT,
order_id TEXT,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'OPEN',
classification_status TEXT NOT NULL DEFAULT 'PENDING',
cause_code TEXT REFERENCES mv_hot.edge_oee_stop_causes(cause_code),
operator_note TEXT,
value_hour_eur NUMERIC(10,2) NOT NULL DEFAULT 36.00,
duration_s NUMERIC(14,2),
cost_eur NUMERIC(14,2),
start_event_name TEXT,
end_event_name TEXT,
start_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
end_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_edge_oee_machine_stops_asset_status
ON mv_hot.edge_oee_machine_stops(asset, status);
CREATE INDEX IF NOT EXISTS idx_edge_oee_machine_stops_started_at
ON mv_hot.edge_oee_machine_stops(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_edge_oee_machine_stops_machine_started
ON mv_hot.edge_oee_machine_stops(machine_id, started_at DESC);
CREATE OR REPLACE VIEW mv_hot.edge_oee_open_stops AS
SELECT
s.id,
s.tenant,
s.site,
s.vertical,
s.asset,
s.machine_id,
s.order_id,
s.started_at,
EXTRACT(EPOCH FROM (now() - s.started_at))::numeric(14,2) AS current_duration_s,
ROUND((EXTRACT(EPOCH FROM (now() - s.started_at)) / 60.0)::numeric, 2) AS current_duration_min,
ROUND(((EXTRACT(EPOCH FROM (now() - s.started_at)) / 3600.0) * s.value_hour_eur)::numeric, 2) AS current_cost_eur,
s.status,
s.classification_status,
s.cause_code,
c.cause_name,
s.operator_note,
s.value_hour_eur
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
WHERE s.status = 'OPEN'
ORDER BY s.started_at DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_stops_recent AS
SELECT
s.id,
s.tenant,
s.site,
s.vertical,
s.asset,
s.machine_id,
s.order_id,
s.started_at,
s.ended_at,
COALESCE(
s.duration_s,
EXTRACT(EPOCH FROM (now() - s.started_at))::numeric(14,2)
) AS duration_s,
ROUND((
COALESCE(s.duration_s, EXTRACT(EPOCH FROM (now() - s.started_at))::numeric) / 60.0
)::numeric, 2) AS duration_min,
COALESCE(
s.cost_eur,
ROUND(((EXTRACT(EPOCH FROM (now() - s.started_at)) / 3600.0) * s.value_hour_eur)::numeric, 2)
) AS cost_eur,
s.status,
s.classification_status,
s.cause_code,
c.cause_name,
c.is_planned,
s.operator_note,
s.value_hour_eur,
s.start_event_name,
s.end_event_name,
s.created_at,
s.updated_at
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
ORDER BY s.started_at DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_stop_summary_demo AS
SELECT
COALESCE(c.cause_name, 'Pendiente de clasificar') AS cause_name,
COALESCE(s.cause_code, 'pending') AS cause_code,
COUNT(*) AS stop_count,
ROUND(SUM(COALESCE(s.duration_s, EXTRACT(EPOCH FROM (now() - s.started_at))::numeric)) / 60.0, 2) AS total_min,
ROUND(SUM(COALESCE(s.cost_eur, ((EXTRACT(EPOCH FROM (now() - s.started_at)) / 3600.0) * s.value_hour_eur)))::numeric, 2) AS total_cost_eur
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
GROUP BY COALESCE(c.cause_name, 'Pendiente de clasificar'), COALESCE(s.cause_code, 'pending')
ORDER BY total_cost_eur DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_stop_cause_options AS
SELECT
cause_code,
cause_name,
description,
is_planned,
sort_order
FROM mv_hot.edge_oee_stop_causes
WHERE is_active = true
ORDER BY sort_order, cause_name;

View File

@ -0,0 +1,102 @@
CREATE SCHEMA IF NOT EXISTS mv_hot;
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_machine_targets (
machine_id TEXT PRIMARY KEY,
machine_name TEXT NOT NULL,
asset TEXT NOT NULL,
target_cycle_rate_ppm NUMERIC(10,2) NOT NULL DEFAULT 10.00,
value_hour_eur NUMERIC(10,2) NOT NULL DEFAULT 36.00,
quality_pct_default NUMERIC(10,2) NOT NULL DEFAULT 100.00,
notes TEXT
);
INSERT INTO mv_hot.edge_oee_machine_targets (
machine_id,
machine_name,
asset,
target_cycle_rate_ppm,
value_hour_eur,
quality_pct_default,
notes
)
VALUES (
'C_DEMO',
'Cortadora demo',
'revpi_oee_node_01',
10.00,
36.00,
100.00,
'Demo Edge-OEE con I_1 como marcha/paro, I_2 como pulso de ciclo y SDM120-M como energía real.'
)
ON CONFLICT (machine_id) DO UPDATE SET
machine_name = EXCLUDED.machine_name,
asset = EXCLUDED.asset,
target_cycle_rate_ppm = EXCLUDED.target_cycle_rate_ppm,
value_hour_eur = EXCLUDED.value_hour_eur,
quality_pct_default = EXCLUDED.quality_pct_default,
notes = EXCLUDED.notes;
CREATE OR REPLACE VIEW mv_hot.edge_oee_live_telemetry AS
SELECT
r.ts,
EXTRACT(EPOCH FROM (now() - r.ts))::int AS age_s,
r.tenant,
r.site,
r.asset,
COALESCE(r.machine_id, r.payload_json ->> 'machine_id') AS machine_id,
COALESCE(r.order_id, r.payload_json ->> 'order_id') AS order_id,
r.voltage_l1_v,
r.current_l1_a,
r.power_total_kw,
r.frequency_hz,
r.import_kwh,
r.export_kwh,
r.comm_ok,
r.error_text,
CASE
WHEN r.payload_json ? 'machine_auto_signal'
THEN (r.payload_json ->> 'machine_auto_signal')::boolean
ELSE NULL
END AS machine_auto_signal,
CASE
WHEN r.payload_json ? 'machine_running'
THEN (r.payload_json ->> 'machine_running')::boolean
ELSE NULL
END AS machine_running,
CASE
WHEN r.payload_json ? 'cycle_pulse_count'
THEN (r.payload_json ->> 'cycle_pulse_count')::bigint
ELSE NULL
END AS cycle_pulse_count,
CASE
WHEN r.payload_json ? 'cycle_rate_ppm'
THEN (r.payload_json ->> 'cycle_rate_ppm')::numeric
ELSE NULL
END AS cycle_rate_ppm,
CASE
WHEN r.payload_json ? 'last_cycle_age_s'
AND r.payload_json ->> 'last_cycle_age_s' IS NOT NULL
AND r.payload_json ->> 'last_cycle_age_s' <> 'null'
THEN (r.payload_json ->> 'last_cycle_age_s')::numeric
ELSE NULL
END AS last_cycle_age_s,
r.payload_json -> 'digital_inputs' AS digital_inputs,
r.payload_json
FROM mv_hot.edge_oee_eastron_readings r
WHERE r.asset = 'revpi_oee_node_01'
ORDER BY r.ts DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_latest_machine_snapshot AS
SELECT DISTINCT ON (asset)
*
FROM mv_hot.edge_oee_live_telemetry
ORDER BY asset, ts DESC;

View File

@ -0,0 +1,175 @@
DROP VIEW IF EXISTS mv_hot.edge_oee_eastron_diagnosis_latest;
DROP VIEW IF EXISTS mv_hot.edge_oee_eastron_dashboard;
DROP VIEW IF EXISTS mv_hot.edge_oee_eastron_latest;
DROP VIEW IF EXISTS mv_hot.edge_oee_latest_machine_snapshot;
DROP VIEW IF EXISTS mv_hot.edge_oee_live_telemetry;
CREATE OR REPLACE VIEW mv_hot.edge_oee_eastron_latest AS
SELECT DISTINCT ON (asset)
*
FROM mv_hot.edge_oee_eastron_readings
ORDER BY asset, ingest_ts DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_eastron_dashboard AS
SELECT
ts,
ingest_ts,
GREATEST(EXTRACT(EPOCH FROM (now() - ingest_ts))::int, 0) AS age_s,
tenant,
site,
asset,
meter_model,
meter_id,
machine_id,
voltage_l1_v,
voltage_l2_v,
voltage_l3_v,
current_l1_a,
current_l2_a,
current_l3_a,
power_total_kw,
frequency_hz,
import_kwh,
export_kwh,
comm_ok,
error_text,
CASE
WHEN comm_ok = false THEN 2
WHEN GREATEST(EXTRACT(EPOCH FROM (now() - ingest_ts))::int, 0) > 180 THEN 2
WHEN GREATEST(EXTRACT(EPOCH FROM (now() - ingest_ts))::int, 0) > 60 THEN 1
ELSE 0
END AS comm_risk_level,
CASE
WHEN comm_ok = false THEN 'Sin comunicación Modbus'
WHEN GREATEST(EXTRACT(EPOCH FROM (now() - ingest_ts))::int, 0) > 180 THEN 'Lectura antigua'
WHEN GREATEST(EXTRACT(EPOCH FROM (now() - ingest_ts))::int, 0) > 60 THEN 'Lectura no reciente'
ELSE 'Comunicación correcta'
END AS comm_status
FROM mv_hot.edge_oee_eastron_latest;
CREATE OR REPLACE VIEW mv_hot.edge_oee_eastron_diagnosis_latest AS
SELECT
1 AS sort_order,
'Comunicación' AS group_name,
'Edad último dato' AS metric,
age_s::numeric AS value,
's' AS unit,
CASE
WHEN comm_risk_level = 0 THEN 'OK'
WHEN comm_risk_level = 1 THEN 'Warning'
ELSE 'Peligro'
END AS status,
CASE
WHEN comm_risk_level = 0 THEN 'Lectura reciente recibida por PostgreSQL'
ELSE 'La lectura no es reciente o hay fallo de comunicación'
END AS problem,
CASE
WHEN comm_risk_level = 0 THEN 'La captura industrial está viva'
ELSE 'El dashboard puede estar mostrando un valor antiguo'
END AS likely_impact,
CASE
WHEN comm_risk_level = 0 THEN 'Sin acción'
ELSE 'Revisar RevPi, MQTT, sink, RS-485, SDM120-M y logs'
END AS recommended_action
FROM mv_hot.edge_oee_eastron_dashboard
UNION ALL
SELECT
2,
'Energía',
'Potencia total',
power_total_kw,
'kW',
CASE
WHEN power_total_kw IS NULL THEN 'Warning'
ELSE 'OK'
END,
CASE
WHEN power_total_kw IS NULL THEN 'No hay potencia total disponible'
ELSE 'Potencia total leída correctamente'
END,
CASE
WHEN power_total_kw IS NULL THEN 'No se puede validar carga eléctrica'
ELSE 'Permite demostrar captura energética industrial'
END,
CASE
WHEN power_total_kw IS NULL THEN 'Revisar mapa de registros Modbus'
ELSE 'Sin acción'
END
FROM mv_hot.edge_oee_eastron_dashboard;
CREATE OR REPLACE VIEW mv_hot.edge_oee_live_telemetry AS
SELECT
r.ts,
r.ingest_ts,
GREATEST(EXTRACT(EPOCH FROM (now() - r.ingest_ts))::int, 0) AS age_s,
r.tenant,
r.site,
r.asset,
COALESCE(r.machine_id, r.payload_json ->> 'machine_id') AS machine_id,
COALESCE(r.order_id, r.payload_json ->> 'order_id') AS order_id,
r.voltage_l1_v,
r.current_l1_a,
r.power_total_kw,
r.frequency_hz,
r.import_kwh,
r.export_kwh,
r.comm_ok,
r.error_text,
CASE
WHEN r.payload_json ? 'machine_auto_signal'
THEN (r.payload_json ->> 'machine_auto_signal')::boolean
ELSE NULL
END AS machine_auto_signal,
CASE
WHEN r.payload_json ? 'machine_running'
THEN (r.payload_json ->> 'machine_running')::boolean
ELSE NULL
END AS machine_running,
CASE
WHEN r.payload_json ? 'cycle_pulse_count'
THEN (r.payload_json ->> 'cycle_pulse_count')::bigint
ELSE NULL
END AS cycle_pulse_count,
CASE
WHEN r.payload_json ? 'cycle_rate_ppm'
THEN (r.payload_json ->> 'cycle_rate_ppm')::numeric
ELSE NULL
END AS cycle_rate_ppm,
CASE
WHEN r.payload_json ? 'last_cycle_age_s'
AND r.payload_json ->> 'last_cycle_age_s' IS NOT NULL
AND r.payload_json ->> 'last_cycle_age_s' <> 'null'
THEN (r.payload_json ->> 'last_cycle_age_s')::numeric
ELSE NULL
END AS last_cycle_age_s,
r.payload_json -> 'digital_inputs' AS digital_inputs,
r.payload_json
FROM mv_hot.edge_oee_eastron_readings r
WHERE r.asset = 'revpi_oee_node_01'
ORDER BY r.ingest_ts DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_latest_machine_snapshot AS
SELECT DISTINCT ON (asset)
*
FROM mv_hot.edge_oee_live_telemetry
ORDER BY asset, ingest_ts DESC;
GRANT SELECT ON
mv_hot.edge_oee_eastron_latest,
mv_hot.edge_oee_eastron_dashboard,
mv_hot.edge_oee_eastron_diagnosis_latest,
mv_hot.edge_oee_live_telemetry,
mv_hot.edge_oee_latest_machine_snapshot
TO grafana_ucepsa_ro;

View File

@ -0,0 +1,156 @@
CREATE OR REPLACE VIEW mv_hot.edge_oee_open_stops AS
SELECT
s.id,
s.tenant,
s.site,
s.vertical,
s.asset,
s.machine_id,
s.order_id,
s.started_at,
GREATEST(
EXTRACT(EPOCH FROM (now() - s.started_at))::numeric,
0::numeric
)::numeric(14,2) AS current_duration_s,
ROUND((
GREATEST(
EXTRACT(EPOCH FROM (now() - s.started_at))::numeric,
0::numeric
) / 60.0
)::numeric, 2) AS current_duration_min,
ROUND((
(
GREATEST(
EXTRACT(EPOCH FROM (now() - s.started_at))::numeric,
0::numeric
) / 3600.0
) * s.value_hour_eur
)::numeric, 2) AS current_cost_eur,
s.status,
s.classification_status,
s.cause_code,
c.cause_name,
s.operator_note,
s.value_hour_eur
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
WHERE s.status = 'OPEN'
ORDER BY s.started_at DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_stops_recent AS
WITH base AS (
SELECT
s.*,
c.cause_name,
c.is_planned,
CASE
WHEN s.ended_at IS NOT NULL THEN
GREATEST(
EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::numeric,
0::numeric
)
ELSE
GREATEST(
EXTRACT(EPOCH FROM (now() - s.started_at))::numeric,
0::numeric
)
END AS safe_duration_s
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
)
SELECT
id,
tenant,
site,
vertical,
asset,
machine_id,
order_id,
started_at,
ended_at,
safe_duration_s::numeric(14,2) AS duration_s,
ROUND((safe_duration_s / 60.0)::numeric, 2) AS duration_min,
ROUND(((safe_duration_s / 3600.0) * value_hour_eur)::numeric, 2) AS cost_eur,
status,
classification_status,
cause_code,
cause_name,
is_planned,
operator_note,
value_hour_eur,
start_event_name,
end_event_name,
created_at,
updated_at
FROM base
ORDER BY started_at DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_stop_summary_demo AS
WITH base AS (
SELECT
s.*,
c.cause_name,
CASE
WHEN s.ended_at IS NOT NULL THEN
GREATEST(
EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::numeric,
0::numeric
)
ELSE
GREATEST(
EXTRACT(EPOCH FROM (now() - s.started_at))::numeric,
0::numeric
)
END AS safe_duration_s
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
)
SELECT
COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name,
COALESCE(cause_code, 'pending') AS cause_code,
COUNT(*) AS stop_count,
ROUND(SUM(safe_duration_s) / 60.0, 2) AS total_min,
ROUND(SUM((safe_duration_s / 3600.0) * value_hour_eur)::numeric, 2) AS total_cost_eur
FROM base
GROUP BY
COALESCE(cause_name, 'Pendiente de clasificar'),
COALESCE(cause_code, 'pending')
ORDER BY total_cost_eur DESC;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.open_stops AS
SELECT *
FROM mv_hot.edge_oee_open_stops
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.stops_recent AS
SELECT *
FROM mv_hot.edge_oee_stops_recent
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.stop_summary AS
SELECT *
FROM mv_hot.edge_oee_stop_summary_demo;
GRANT SELECT ON
mv_hot.edge_oee_open_stops,
mv_hot.edge_oee_stops_recent,
mv_hot.edge_oee_stop_summary_demo
TO grafana_ucepsa_ro, edge_oee_operator_writer, edge_oee_mqtt_sink_writer;
GRANT SELECT ON
mv_reports_ucepsa_demo.open_stops,
mv_reports_ucepsa_demo.stops_recent,
mv_reports_ucepsa_demo.stop_summary
TO grafana_ucepsa_ro;

View File

@ -0,0 +1,185 @@
CREATE OR REPLACE VIEW mv_hot.edge_oee_open_stops AS
WITH src AS (
SELECT
s.*,
CASE
WHEN s.started_at > s.created_at + interval '5 seconds'
THEN s.created_at
ELSE s.started_at
END AS effective_started_at
FROM mv_hot.edge_oee_machine_stops s
)
SELECT
s.id,
s.tenant,
s.site,
s.vertical,
s.asset,
s.machine_id,
s.order_id,
s.started_at,
GREATEST(
EXTRACT(EPOCH FROM (now() - s.effective_started_at))::numeric,
0::numeric
)::numeric(14,2) AS current_duration_s,
ROUND((
GREATEST(
EXTRACT(EPOCH FROM (now() - s.effective_started_at))::numeric,
0::numeric
) / 60.0
)::numeric, 2) AS current_duration_min,
ROUND((
(
GREATEST(
EXTRACT(EPOCH FROM (now() - s.effective_started_at))::numeric,
0::numeric
) / 3600.0
) * s.value_hour_eur
)::numeric, 2) AS current_cost_eur,
s.status,
s.classification_status,
s.cause_code,
c.cause_name,
s.operator_note,
s.value_hour_eur
FROM src s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
WHERE s.status = 'OPEN'
ORDER BY s.started_at DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_stops_recent AS
WITH src AS (
SELECT
s.*,
c.cause_name,
c.is_planned,
CASE
WHEN s.started_at > s.created_at + interval '5 seconds'
THEN s.created_at
ELSE s.started_at
END AS effective_started_at
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
),
base AS (
SELECT
src.*,
CASE
WHEN src.duration_s IS NOT NULL THEN
GREATEST(src.duration_s, 0::numeric)
WHEN src.ended_at IS NOT NULL THEN
GREATEST(EXTRACT(EPOCH FROM (src.ended_at - src.effective_started_at))::numeric, 0::numeric)
ELSE
GREATEST(EXTRACT(EPOCH FROM (now() - src.effective_started_at))::numeric, 0::numeric)
END AS safe_duration_s
FROM src
)
SELECT
id,
tenant,
site,
vertical,
asset,
machine_id,
order_id,
started_at,
ended_at,
safe_duration_s::numeric(14,2) AS duration_s,
ROUND((safe_duration_s / 60.0)::numeric, 2) AS duration_min,
COALESCE(
cost_eur,
ROUND(((safe_duration_s / 3600.0) * value_hour_eur)::numeric, 2)
)::numeric AS cost_eur,
status,
classification_status,
cause_code,
cause_name,
is_planned,
operator_note,
value_hour_eur,
start_event_name,
end_event_name,
created_at,
updated_at
FROM base
ORDER BY started_at DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_stop_summary_demo AS
WITH src AS (
SELECT
s.*,
c.cause_name,
CASE
WHEN s.started_at > s.created_at + interval '5 seconds'
THEN s.created_at
ELSE s.started_at
END AS effective_started_at
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code
),
base AS (
SELECT
src.*,
CASE
WHEN src.duration_s IS NOT NULL THEN
GREATEST(src.duration_s, 0::numeric)
WHEN src.ended_at IS NOT NULL THEN
GREATEST(EXTRACT(EPOCH FROM (src.ended_at - src.effective_started_at))::numeric, 0::numeric)
ELSE
GREATEST(EXTRACT(EPOCH FROM (now() - src.effective_started_at))::numeric, 0::numeric)
END AS safe_duration_s
FROM src
)
SELECT
COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name,
COALESCE(cause_code, 'pending') AS cause_code,
COUNT(*) AS stop_count,
ROUND(SUM(safe_duration_s) / 60.0, 2) AS total_min,
ROUND(SUM(
COALESCE(
cost_eur,
((safe_duration_s / 3600.0) * value_hour_eur)
)
)::numeric, 2) AS total_cost_eur
FROM base
GROUP BY
COALESCE(cause_name, 'Pendiente de clasificar'),
COALESCE(cause_code, 'pending')
ORDER BY total_cost_eur DESC;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.open_stops AS
SELECT *
FROM mv_hot.edge_oee_open_stops
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.stops_recent AS
SELECT *
FROM mv_hot.edge_oee_stops_recent
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.stop_summary AS
SELECT *
FROM mv_hot.edge_oee_stop_summary_demo;
GRANT SELECT ON
mv_hot.edge_oee_open_stops,
mv_hot.edge_oee_stops_recent,
mv_hot.edge_oee_stop_summary_demo
TO grafana_ucepsa_ro, edge_oee_operator_writer, edge_oee_mqtt_sink_writer;
GRANT SELECT ON
mv_reports_ucepsa_demo.open_stops,
mv_reports_ucepsa_demo.stops_recent,
mv_reports_ucepsa_demo.stop_summary
TO grafana_ucepsa_ro;

View File

@ -0,0 +1,68 @@
-- =========================================================
-- Vistas puente Edge-OEE UCEPSA demo
-- No mueven datos. Solo exponen mv_hot.edge_oee_* bajo schema ordenado.
-- =========================================================
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.eastron_readings AS
SELECT *
FROM mv_hot.edge_oee_eastron_readings;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.eastron_latest AS
SELECT *
FROM mv_hot.edge_oee_eastron_latest;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.eastron_dashboard AS
SELECT *
FROM mv_hot.edge_oee_eastron_dashboard;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.eastron_diagnosis_latest AS
SELECT *
FROM mv_hot.edge_oee_eastron_diagnosis_latest;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.live_telemetry AS
SELECT *
FROM mv_hot.edge_oee_live_telemetry;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.latest_machine_snapshot AS
SELECT *
FROM mv_hot.edge_oee_latest_machine_snapshot;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.node_events AS
SELECT *
FROM mv_hot.edge_oee_node_events;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.node_events_recent AS
SELECT *
FROM mv_hot.edge_oee_node_events_recent;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.machine_state_latest AS
SELECT *
FROM mv_hot.edge_oee_machine_state_latest;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.machine_stops AS
SELECT *
FROM mv_hot.edge_oee_machine_stops;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.open_stops AS
SELECT *
FROM mv_hot.edge_oee_open_stops;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.stops_recent AS
SELECT *
FROM mv_hot.edge_oee_stops_recent;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.stop_causes AS
SELECT *
FROM mv_hot.edge_oee_stop_causes;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.stop_cause_options AS
SELECT *
FROM mv_hot.edge_oee_stop_cause_options;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.stop_summary AS
SELECT *
FROM mv_hot.edge_oee_stop_summary_demo;
CREATE OR REPLACE VIEW mv_edge_oee_ucepsa_demo.machine_targets AS
SELECT *
FROM mv_hot.edge_oee_machine_targets;

View File

@ -0,0 +1,97 @@
-- =========================================================
-- Reporting views para Grafana UCEPSA demo
-- Grafana debe consultar preferentemente este schema.
-- =========================================================
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.energy_latest AS
SELECT
ts,
ingest_ts,
age_s,
tenant,
site,
asset,
meter_model,
meter_id,
machine_id,
voltage_l1_v,
current_l1_a,
power_total_kw,
frequency_hz,
import_kwh,
export_kwh,
comm_ok,
error_text,
comm_risk_level,
comm_status
FROM mv_hot.edge_oee_eastron_dashboard
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.energy_diagnosis AS
SELECT *
FROM mv_hot.edge_oee_eastron_diagnosis_latest;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.live_telemetry AS
SELECT *
FROM mv_hot.edge_oee_live_telemetry
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.latest_machine_snapshot AS
SELECT *
FROM mv_hot.edge_oee_latest_machine_snapshot
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.node_events_recent AS
SELECT *
FROM mv_hot.edge_oee_node_events_recent
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.machine_state_latest AS
SELECT *
FROM mv_hot.edge_oee_machine_state_latest
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.open_stops AS
SELECT *
FROM mv_hot.edge_oee_open_stops
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.stops_recent AS
SELECT *
FROM mv_hot.edge_oee_stops_recent
WHERE asset = 'revpi_oee_node_01';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.stop_summary AS
SELECT *
FROM mv_hot.edge_oee_stop_summary_demo;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.machine_targets AS
SELECT *
FROM mv_hot.edge_oee_machine_targets
WHERE machine_id = 'C_DEMO';
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.energy_power_timeseries AS
SELECT
ingest_ts AS time,
ROUND((power_total_kw * 1000)::numeric, 2) AS power_w
FROM mv_hot.edge_oee_eastron_readings
WHERE asset = 'revpi_oee_node_01'
ORDER BY ingest_ts;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.energy_import_timeseries AS
SELECT
ingest_ts AS time,
ROUND((import_kwh * 1000)::numeric, 2) AS import_wh
FROM mv_hot.edge_oee_eastron_readings
WHERE asset = 'revpi_oee_node_01'
ORDER BY ingest_ts;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.cycle_rate_timeseries AS
SELECT
ts AS time,
cycle_rate_ppm,
10.0::numeric AS target_cycle_rate_ppm
FROM mv_hot.edge_oee_live_telemetry
WHERE asset = 'revpi_oee_node_01'
AND cycle_rate_ppm IS NOT NULL
ORDER BY ts;

View File

@ -0,0 +1,9 @@
GRANT USAGE ON SCHEMA mv_reports_ucepsa_demo TO grafana_ucepsa_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA mv_reports_ucepsa_demo TO grafana_ucepsa_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA mv_reports_ucepsa_demo
GRANT SELECT ON TABLES TO grafana_ucepsa_ro;
-- De momento mantenemos permisos existentes sobre mv_hot para no romper paneles antiguos.
-- En una fase posterior se podrán retirar cuando todos los dashboards consulten mv_reports_ucepsa_demo.

View File

@ -0,0 +1,183 @@
BEGIN;
-- =========================================================
-- 1. Catálogo core MESAVAULT
-- =========================================================
INSERT INTO mv_core.machines (
tenant,
site,
machine_id,
display_name,
area,
machine_type,
asset
)
VALUES (
'ucepsa',
'demo_edge_oee',
'CORT-01',
'Cortadora 01',
'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;
DELETE FROM mv_core.machines
WHERE tenant = 'ucepsa'
AND site = 'demo_edge_oee'
AND machine_id = 'C_DEMO';
-- =========================================================
-- 2. Target de máquina OEE
-- =========================================================
INSERT INTO mv_hot.edge_oee_machine_targets (
machine_id,
machine_name,
asset,
target_cycle_rate_ppm,
value_hour_eur,
quality_pct_default,
notes
)
VALUES (
'CORT-01',
'Cortadora 01',
'revpi_oee_node_01',
10.00,
36.00,
100.00,
'Cortadora 01 vinculada al centro de trabajo Odoo CORT-01 y al asset físico revpi_oee_node_01.'
)
ON CONFLICT (machine_id) DO UPDATE SET
machine_name = EXCLUDED.machine_name,
asset = EXCLUDED.asset,
target_cycle_rate_ppm = EXCLUDED.target_cycle_rate_ppm,
value_hour_eur = EXCLUDED.value_hour_eur,
quality_pct_default = EXCLUDED.quality_pct_default,
notes = EXCLUDED.notes;
DELETE FROM mv_hot.edge_oee_machine_targets
WHERE machine_id = 'C_DEMO';
-- =========================================================
-- 3. Actualizar histórico normalizado
-- =========================================================
UPDATE mv_hot.edge_oee_machine_stops
SET machine_id = 'CORT-01'
WHERE machine_id = 'C_DEMO';
UPDATE mv_hot.edge_oee_node_events
SET machine_id = 'CORT-01'
WHERE machine_id = 'C_DEMO';
UPDATE mv_hot.edge_oee_eastron_readings
SET machine_id = 'CORT-01'
WHERE machine_id = 'C_DEMO';
-- =========================================================
-- 4. Actualizar machine_id dentro de payload_json
-- =========================================================
UPDATE mv_hot.edge_oee_machine_stops
SET
start_payload_json = CASE
WHEN start_payload_json ->> 'machine_id' = 'C_DEMO'
THEN jsonb_set(start_payload_json, '{machine_id}', to_jsonb('CORT-01'::text), true)
ELSE start_payload_json
END,
end_payload_json = CASE
WHEN end_payload_json ->> 'machine_id' = 'C_DEMO'
THEN jsonb_set(end_payload_json, '{machine_id}', to_jsonb('CORT-01'::text), true)
ELSE end_payload_json
END
WHERE start_payload_json ->> 'machine_id' = 'C_DEMO'
OR end_payload_json ->> 'machine_id' = 'C_DEMO';
UPDATE mv_hot.edge_oee_node_events
SET payload_json = jsonb_set(payload_json, '{machine_id}', to_jsonb('CORT-01'::text), true)
WHERE payload_json ->> 'machine_id' = 'C_DEMO';
UPDATE mv_hot.edge_oee_eastron_readings
SET payload_json = jsonb_set(payload_json, '{machine_id}', to_jsonb('CORT-01'::text), true)
WHERE payload_json ->> 'machine_id' = 'C_DEMO';
-- =========================================================
-- 5. Mapeo Odoo ↔ MESAVAULT
-- =========================================================
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_demo.odoo_workcenter_map (
id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
odoo_instance TEXT NOT NULL,
odoo_workcenter_id INTEGER,
odoo_workcenter_code TEXT NOT NULL,
odoo_workcenter_name TEXT NOT NULL,
machine_id TEXT NOT NULL,
asset TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant, site, odoo_instance, odoo_workcenter_code),
UNIQUE (tenant, site, machine_id)
);
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
)
VALUES (
'ucepsa',
'demo_edge_oee',
'odoo_demo',
NULL,
'CORT-01',
'Cortadora 01',
'CORT-01',
'revpi_oee_node_01'
)
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;
-- =========================================================
-- 6. Actualizar vistas de reporting que filtraban por C_DEMO
-- =========================================================
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.machine_targets AS
SELECT *
FROM mv_hot.edge_oee_machine_targets
WHERE machine_id = 'CORT-01';
GRANT SELECT ON
mv_edge_oee_ucepsa_demo.odoo_workcenter_map,
mv_reports_ucepsa_demo.machine_targets
TO grafana_ucepsa_ro;
COMMIT;

View File

@ -0,0 +1,272 @@
CREATE OR REPLACE FUNCTION mv_reports_ucepsa_demo.oee_kpis(
p_from timestamptz,
p_to timestamptz,
p_machine_id text DEFAULT 'CORT-01',
p_asset text DEFAULT 'revpi_oee_node_01'
)
RETURNS TABLE (
oee_pct numeric,
availability_pct numeric,
performance_pct numeric,
quality_pct numeric
)
LANGUAGE sql
STABLE
AS $$
WITH dashboard_bounds AS (
SELECT
p_from AS dashboard_from,
LEAST(p_to, now()) AS dashboard_to
),
telemetry_span AS (
SELECT
MIN(ingest_ts) AS first_ts,
MAX(ingest_ts) AS last_ts
FROM mv_hot.edge_oee_live_telemetry
WHERE asset = p_asset
AND ingest_ts >= (SELECT dashboard_from FROM dashboard_bounds)
AND ingest_ts <= (SELECT dashboard_to FROM dashboard_bounds)
),
bounds AS (
SELECT
CASE
WHEN first_ts IS NULL THEN dashboard_from
ELSE GREATEST(dashboard_from, first_ts)
END AS t_from,
CASE
WHEN last_ts IS NULL THEN dashboard_to
ELSE LEAST(dashboard_to, last_ts)
END AS t_to
FROM dashboard_bounds
CROSS JOIN telemetry_span
),
target AS (
SELECT
COALESCE((
SELECT mt.target_cycle_rate_ppm
FROM mv_hot.edge_oee_machine_targets mt
WHERE mt.machine_id = p_machine_id
LIMIT 1
), 10.00)::numeric AS target_cycle_rate_ppm,
COALESCE((
SELECT mt.quality_pct_default
FROM mv_hot.edge_oee_machine_targets mt
WHERE mt.machine_id = p_machine_id
LIMIT 1
), 100.00)::numeric AS quality_pct_default
),
planned AS (
SELECT
GREATEST(EXTRACT(EPOCH FROM (t_to - t_from))::numeric, 0) AS planned_s
FROM bounds
),
stops AS (
SELECT
COALESCE(
SUM(
GREATEST(
EXTRACT(EPOCH FROM (
LEAST(COALESCE(s.ended_at, b.t_to), b.t_to)
-
GREATEST(
CASE
WHEN s.started_at > s.created_at + interval '5 seconds'
THEN s.created_at
ELSE s.started_at
END,
b.t_from
)
))::numeric,
0
)
) FILTER (
WHERE s.started_at < b.t_to
AND COALESCE(s.ended_at, now()) > b.t_from
),
0
)::numeric AS stop_s
FROM mv_hot.edge_oee_machine_stops s
CROSS JOIN bounds b
WHERE s.machine_id = p_machine_id
),
cycles AS (
SELECT
COALESCE(
GREATEST(
MAX(cycle_pulse_count) - MIN(cycle_pulse_count),
0
),
0
)::numeric AS actual_cycles
FROM mv_hot.edge_oee_live_telemetry l
CROSS JOIN bounds b
WHERE l.asset = p_asset
AND l.ingest_ts >= b.t_from
AND l.ingest_ts <= b.t_to
AND l.cycle_pulse_count IS NOT NULL
),
calc AS (
SELECT
p.planned_s,
st.stop_s,
GREATEST(p.planned_s - st.stop_s, 0)::numeric AS running_s,
c.actual_cycles,
t.target_cycle_rate_ppm,
t.quality_pct_default,
CASE
WHEN p.planned_s > 0
THEN ROUND(((GREATEST(p.planned_s - st.stop_s, 0) / p.planned_s) * 100)::numeric, 2)
ELSE 0
END AS availability_pct,
CASE
WHEN GREATEST(p.planned_s - st.stop_s, 0) > 0
AND t.target_cycle_rate_ppm > 0
THEN ROUND(
LEAST(
100::numeric,
(
c.actual_cycles
/
NULLIF(
t.target_cycle_rate_ppm * (GREATEST(p.planned_s - st.stop_s, 0) / 60.0),
0
)
) * 100
)::numeric,
2
)
ELSE 0
END AS performance_pct,
t.quality_pct_default AS quality_pct
FROM planned p
CROSS JOIN stops st
CROSS JOIN cycles c
CROSS JOIN target t
)
SELECT
ROUND(((calc.availability_pct * calc.performance_pct * calc.quality_pct) / 10000.0)::numeric, 2) AS oee_pct,
calc.availability_pct,
calc.performance_pct,
calc.quality_pct
FROM calc;
$$;
CREATE OR REPLACE FUNCTION mv_reports_ucepsa_demo.oee_operational_data(
p_from timestamptz,
p_to timestamptz,
p_machine_id text DEFAULT 'CORT-01',
p_asset text DEFAULT 'revpi_oee_node_01'
)
RETURNS TABLE (
running_min numeric,
stop_min numeric,
actual_cycles numeric
)
LANGUAGE sql
STABLE
AS $$
WITH dashboard_bounds AS (
SELECT
p_from AS dashboard_from,
LEAST(p_to, now()) AS dashboard_to
),
telemetry_span AS (
SELECT
MIN(ingest_ts) AS first_ts,
MAX(ingest_ts) AS last_ts
FROM mv_hot.edge_oee_live_telemetry
WHERE asset = p_asset
AND ingest_ts >= (SELECT dashboard_from FROM dashboard_bounds)
AND ingest_ts <= (SELECT dashboard_to FROM dashboard_bounds)
),
bounds AS (
SELECT
CASE
WHEN first_ts IS NULL THEN dashboard_from
ELSE GREATEST(dashboard_from, first_ts)
END AS t_from,
CASE
WHEN last_ts IS NULL THEN dashboard_to
ELSE LEAST(dashboard_to, last_ts)
END AS t_to
FROM dashboard_bounds
CROSS JOIN telemetry_span
),
planned AS (
SELECT
GREATEST(EXTRACT(EPOCH FROM (t_to - t_from))::numeric, 0) AS planned_s
FROM bounds
),
stops AS (
SELECT
COALESCE(
SUM(
GREATEST(
EXTRACT(EPOCH FROM (
LEAST(COALESCE(s.ended_at, b.t_to), b.t_to)
-
GREATEST(
CASE
WHEN s.started_at > s.created_at + interval '5 seconds'
THEN s.created_at
ELSE s.started_at
END,
b.t_from
)
))::numeric,
0
)
) FILTER (
WHERE s.started_at < b.t_to
AND COALESCE(s.ended_at, now()) > b.t_from
),
0
)::numeric AS stop_s
FROM mv_hot.edge_oee_machine_stops s
CROSS JOIN bounds b
WHERE s.machine_id = p_machine_id
),
cycles AS (
SELECT
COALESCE(
GREATEST(
MAX(cycle_pulse_count) - MIN(cycle_pulse_count),
0
),
0
)::numeric AS actual_cycles
FROM mv_hot.edge_oee_live_telemetry l
CROSS JOIN bounds b
WHERE l.asset = p_asset
AND l.ingest_ts >= b.t_from
AND l.ingest_ts <= b.t_to
AND l.cycle_pulse_count IS NOT NULL
),
calc AS (
SELECT
p.planned_s,
st.stop_s,
GREATEST(p.planned_s - st.stop_s, 0)::numeric AS running_s,
c.actual_cycles
FROM planned p
CROSS JOIN stops st
CROSS JOIN cycles c
)
SELECT
ROUND((running_s / 60.0)::numeric, 2) AS running_min,
ROUND((stop_s / 60.0)::numeric, 2) AS stop_min,
actual_cycles
FROM calc;
$$;
GRANT EXECUTE ON FUNCTION mv_reports_ucepsa_demo.oee_kpis(timestamptz, timestamptz, text, text)
TO grafana_ucepsa_ro;
GRANT EXECUTE ON FUNCTION mv_reports_ucepsa_demo.oee_operational_data(timestamptz, timestamptz, text, text)
TO grafana_ucepsa_ro;

View File

@ -0,0 +1,265 @@
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_demo.odoo_workorders_demo (
id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL DEFAULT 'ucepsa',
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
odoo_instance TEXT NOT NULL DEFAULT 'odoo_demo',
odoo_workorder_id INTEGER,
odoo_production_id INTEGER,
odoo_order_ref TEXT NOT NULL,
odoo_state TEXT NOT NULL,
odoo_workcenter_code TEXT NOT NULL,
odoo_workcenter_name TEXT NOT NULL,
machine_id TEXT NOT NULL,
asset TEXT NOT NULL,
product TEXT NOT NULL,
qty_planned NUMERIC(14,2),
qty_done NUMERIC(14,2),
uom TEXT,
planned_cycles NUMERIC(14,2),
target_cycle_rate_ppm NUMERIC(10,2),
odoo_start TIMESTAMPTZ NOT NULL,
odoo_end TIMESTAMPTZ,
notes TEXT,
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_odoo_workorders_demo_machine_time
ON mv_edge_oee_ucepsa_demo.odoo_workorders_demo(machine_id, odoo_start DESC);
CREATE INDEX IF NOT EXISTS idx_odoo_workorders_demo_state
ON mv_edge_oee_ucepsa_demo.odoo_workorders_demo(odoo_state);
DELETE FROM mv_edge_oee_ucepsa_demo.odoo_workorders_demo
WHERE tenant = 'ucepsa'
AND site = 'demo_edge_oee'
AND odoo_instance = 'odoo_demo';
INSERT INTO mv_edge_oee_ucepsa_demo.odoo_workorders_demo (
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
)
VALUES
(
'OF-DEMO-001',
'in_progress',
'CORT-01',
'Cortadora 01',
'CORT-01',
'revpi_oee_node_01',
'Bolsa 300x400',
50000,
0,
'unidades',
500,
10,
now() - interval '20 minutes',
now() + interval '40 minutes',
'Orden demo activa para cruce Odoo vs máquina.'
),
(
'OF-DEMO-002',
'planned',
'CORT-01',
'Cortadora 01',
'CORT-01',
'revpi_oee_node_01',
'Bolsa 250x350',
42000,
0,
'unidades',
420,
10,
now() + interval '60 minutes',
now() + interval '120 minutes',
'Orden demo planificada.'
),
(
'OF-DEMO-000',
'done',
'CORT-01',
'Cortadora 01',
'CORT-01',
'revpi_oee_node_01',
'Bolsa 300x400',
52000,
51000,
'unidades',
520,
10,
now() - interval '3 hours',
now() - interval '2 hours',
'Orden demo ya cerrada.'
);
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_current_order_demo AS
SELECT
o.id,
o.tenant,
o.site,
o.odoo_instance,
o.odoo_order_ref,
o.odoo_state,
o.odoo_workcenter_code,
o.odoo_workcenter_name,
o.machine_id,
o.asset,
o.product,
o.qty_planned,
o.qty_done,
o.uom,
o.planned_cycles,
o.target_cycle_rate_ppm,
o.odoo_start,
o.odoo_end,
o.notes,
o.snapshot_at
FROM mv_edge_oee_ucepsa_demo.odoo_workorders_demo o
WHERE o.machine_id = 'CORT-01'
AND o.odoo_state IN ('in_progress', 'progress', 'started')
ORDER BY o.odoo_start DESC
LIMIT 1;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_vs_machine_demo AS
WITH current_order AS (
SELECT *
FROM mv_reports_ucepsa_demo.odoo_current_order_demo
),
machine AS (
SELECT *
FROM mv_reports_ucepsa_demo.latest_machine_state_fast
),
first_real_activity AS (
SELECT
MIN(l.ingest_ts) AS first_machine_activity
FROM mv_reports_ucepsa_demo.live_telemetry l
CROSS JOIN current_order o
WHERE l.asset = o.asset
AND l.ingest_ts >= o.odoo_start
AND (
l.machine_running = true
OR COALESCE(l.cycle_pulse_count, 0) > 0
)
),
stops_in_order AS (
SELECT
COUNT(*) AS stop_count,
ROUND(COALESCE(SUM(s.duration_min), 0)::numeric, 2) AS stop_min,
ROUND(COALESCE(SUM(s.cost_eur), 0)::numeric, 2) AS stop_cost_eur
FROM mv_reports_ucepsa_demo.stops_recent s
CROSS JOIN current_order o
WHERE s.machine_id = o.machine_id
AND s.started_at >= o.odoo_start
AND s.started_at <= COALESCE(o.odoo_end, now())
),
cycles_in_order AS (
SELECT
COALESCE(
GREATEST(MAX(l.cycle_pulse_count) - MIN(l.cycle_pulse_count), 0),
0
)::numeric AS real_cycles
FROM mv_reports_ucepsa_demo.live_telemetry l
CROSS JOIN current_order o
WHERE l.asset = o.asset
AND l.ingest_ts >= o.odoo_start
AND l.ingest_ts <= COALESCE(o.odoo_end, now())
AND l.cycle_pulse_count IS NOT NULL
)
SELECT
o.odoo_order_ref,
o.odoo_state,
o.product,
o.machine_id,
o.asset,
o.odoo_start,
o.odoo_end,
m.machine_running,
m.age_s AS telemetry_age_s,
m.cycle_pulse_count,
m.cycle_rate_ppm,
f.first_machine_activity,
CASE
WHEN f.first_machine_activity IS NULL THEN NULL
ELSE ROUND((EXTRACT(EPOCH FROM (f.first_machine_activity - o.odoo_start)) / 60.0)::numeric, 2)
END AS start_delay_min,
s.stop_count,
s.stop_min,
s.stop_cost_eur,
c.real_cycles,
o.planned_cycles,
CASE
WHEN o.planned_cycles > 0
THEN ROUND(((c.real_cycles / o.planned_cycles) * 100)::numeric, 2)
ELSE NULL
END AS cycle_completion_pct,
CASE
WHEN m.age_s > 30 THEN 'SIN_TELEMETRIA'
WHEN o.odoo_state IN ('in_progress', 'progress', 'started') AND m.machine_running = false THEN 'ORDEN_ACTIVA_MAQUINA_PARADA'
WHEN o.odoo_state IN ('in_progress', 'progress', 'started') AND f.first_machine_activity IS NULL THEN 'ORDEN_ACTIVA_SIN_ACTIVIDAD_REAL'
WHEN f.first_machine_activity IS NOT NULL
AND EXTRACT(EPOCH FROM (f.first_machine_activity - o.odoo_start)) > 600 THEN 'ARRANQUE_REAL_TARDIO'
ELSE 'OK'
END AS status_check
FROM current_order o
LEFT JOIN machine m ON m.asset = o.asset
LEFT JOIN first_real_activity f ON true
LEFT JOIN stops_in_order s ON true
LEFT JOIN cycles_in_order c ON true;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_discrepancies_demo AS
SELECT
now() AS checked_at,
odoo_order_ref,
product,
machine_id,
status_check,
CASE
WHEN status_check = 'SIN_TELEMETRIA' THEN 'No hay telemetría reciente de la RevPi.'
WHEN status_check = 'ORDEN_ACTIVA_MAQUINA_PARADA' THEN 'Odoo tiene una orden activa, pero la máquina aparece parada.'
WHEN status_check = 'ORDEN_ACTIVA_SIN_ACTIVIDAD_REAL' THEN 'Odoo tiene una orden activa, pero no se detecta actividad real de máquina.'
WHEN status_check = 'ARRANQUE_REAL_TARDIO' THEN 'La primera actividad real de máquina llega más de 10 minutos después del inicio Odoo.'
ELSE 'Sin discrepancia relevante.'
END AS descripcion,
start_delay_min,
stop_count,
stop_min,
stop_cost_eur,
cycle_completion_pct
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo;
GRANT SELECT ON
mv_edge_oee_ucepsa_demo.odoo_workorders_demo,
mv_reports_ucepsa_demo.odoo_current_order_demo,
mv_reports_ucepsa_demo.odoo_vs_machine_demo,
mv_reports_ucepsa_demo.odoo_discrepancies_demo
TO grafana_ucepsa_ro;

View File

@ -0,0 +1,228 @@
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_discrepancies_demo;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_vs_machine_demo;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_current_order_demo;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_current_order_demo AS
SELECT
o.id,
o.tenant,
o.site,
o.odoo_instance,
o.odoo_workorder_id,
o.odoo_production_id,
o.odoo_order_ref,
o.odoo_state,
o.odoo_workcenter_code,
o.odoo_workcenter_name,
o.machine_id,
o.asset,
o.product,
o.qty_planned,
o.qty_done,
o.uom,
o.planned_cycles,
o.target_cycle_rate_ppm,
CASE
WHEN o.odoo_state IN ('progress', 'in_progress', 'started')
THEN o.odoo_start
ELSE NULL::timestamptz
END AS odoo_start,
o.odoo_start AS odoo_reference_start,
CASE
WHEN o.odoo_state IN ('progress', 'in_progress', 'started')
THEN true
ELSE false
END AS odoo_is_started,
o.odoo_end,
o.notes,
o.snapshot_at
FROM mv_edge_oee_ucepsa_demo.odoo_workorders_demo o
WHERE o.machine_id = 'CORT-01'
AND o.odoo_instance = 'odoo_demo'
AND o.odoo_workorder_id IS NOT NULL
AND o.odoo_state IN ('progress', 'in_progress', 'started', 'ready', 'waiting')
ORDER BY
CASE
WHEN o.odoo_state IN ('progress', 'in_progress', 'started') THEN 1
WHEN o.odoo_state = 'ready' THEN 2
WHEN o.odoo_state = 'waiting' THEN 3
ELSE 9
END,
o.odoo_workorder_id DESC NULLS LAST
LIMIT 1;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_vs_machine_demo AS
WITH current_order AS (
SELECT *
FROM mv_reports_ucepsa_demo.odoo_current_order_demo
),
machine AS (
SELECT *
FROM mv_reports_ucepsa_demo.latest_machine_state_fast
),
first_real_activity AS (
SELECT
MIN(l.ingest_ts) AS first_machine_activity
FROM current_order o
LEFT JOIN mv_reports_ucepsa_demo.live_telemetry l
ON o.odoo_is_started = true
AND l.asset = o.asset
AND l.ingest_ts >= o.odoo_start
AND l.ingest_ts <= COALESCE(o.odoo_end, now())
AND (
l.machine_running = true
OR COALESCE(l.cycle_pulse_count, 0) > 0
)
),
stops_in_order AS (
SELECT
COUNT(s.id) AS stop_count,
ROUND(COALESCE(SUM(s.duration_min), 0)::numeric, 2) AS stop_min,
ROUND(COALESCE(SUM(s.cost_eur), 0)::numeric, 2) AS stop_cost_eur
FROM current_order o
LEFT JOIN mv_reports_ucepsa_demo.stops_recent s
ON o.odoo_is_started = true
AND s.machine_id = o.machine_id
AND s.started_at >= o.odoo_start
AND s.started_at <= COALESCE(o.odoo_end, now())
),
cycles_in_order AS (
SELECT
COALESCE(
GREATEST(MAX(l.cycle_pulse_count) - MIN(l.cycle_pulse_count), 0),
0
)::numeric AS real_cycles
FROM current_order o
LEFT JOIN mv_reports_ucepsa_demo.live_telemetry l
ON o.odoo_is_started = true
AND l.asset = o.asset
AND l.ingest_ts >= o.odoo_start
AND l.ingest_ts <= COALESCE(o.odoo_end, now())
AND l.cycle_pulse_count IS NOT NULL
)
SELECT
o.odoo_workorder_id,
o.odoo_production_id,
o.odoo_order_ref,
o.odoo_state,
o.odoo_is_started,
o.product,
o.machine_id,
o.asset,
o.odoo_start,
o.odoo_reference_start,
o.odoo_end,
m.machine_running,
m.age_s AS telemetry_age_s,
m.cycle_pulse_count,
m.cycle_rate_ppm,
f.first_machine_activity,
CASE
WHEN o.odoo_is_started = false THEN NULL::numeric
WHEN f.first_machine_activity IS NULL THEN NULL::numeric
ELSE ROUND((EXTRACT(EPOCH FROM (f.first_machine_activity - o.odoo_start)) / 60.0)::numeric, 2)
END AS start_delay_min,
s.stop_count,
s.stop_min,
s.stop_cost_eur,
CASE
WHEN o.odoo_is_started = true THEN c.real_cycles
ELSE 0::numeric
END AS real_cycles,
o.planned_cycles,
CASE
WHEN o.odoo_is_started = true
AND o.planned_cycles > 0
THEN ROUND(((c.real_cycles / o.planned_cycles) * 100)::numeric, 2)
WHEN o.odoo_is_started = false
THEN 0::numeric
ELSE NULL::numeric
END AS cycle_completion_pct,
CASE
WHEN m.age_s > 30 THEN 'SIN_TELEMETRIA'
WHEN o.odoo_is_started = false
AND m.machine_running = true
THEN 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA'
WHEN o.odoo_is_started = false
AND COALESCE(m.machine_running, false) = false
THEN 'ORDEN_PREPARADA_MAQUINA_PARADA'
WHEN o.odoo_is_started = true
AND f.first_machine_activity IS NULL
THEN 'ORDEN_ACTIVA_SIN_ACTIVIDAD_REAL'
WHEN o.odoo_is_started = true
AND m.machine_running = false
THEN 'ORDEN_ACTIVA_MAQUINA_PARADA'
WHEN o.odoo_is_started = true
AND f.first_machine_activity IS NOT NULL
AND EXTRACT(EPOCH FROM (f.first_machine_activity - o.odoo_start)) > 600
THEN 'ARRANQUE_REAL_TARDIO'
ELSE 'OK'
END AS status_check
FROM current_order o
LEFT JOIN machine m ON m.asset = o.asset
LEFT JOIN first_real_activity f ON true
LEFT JOIN stops_in_order s ON true
LEFT JOIN cycles_in_order c ON true;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_discrepancies_demo AS
SELECT
now() AS checked_at,
odoo_order_ref,
product,
machine_id,
odoo_state,
odoo_is_started,
status_check,
CASE
WHEN status_check = 'SIN_TELEMETRIA'
THEN 'No hay telemetría reciente de la RevPi.'
WHEN status_check = 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA'
THEN 'La máquina está en marcha, pero la orden de Odoo está preparada o en espera, no iniciada.'
WHEN status_check = 'ORDEN_PREPARADA_MAQUINA_PARADA'
THEN 'La orden está preparada/en espera y la máquina está parada. No hay desviación productiva activa.'
WHEN status_check = 'ORDEN_ACTIVA_SIN_ACTIVIDAD_REAL'
THEN 'Odoo tiene una orden activa, pero no se detecta actividad real de máquina.'
WHEN status_check = 'ORDEN_ACTIVA_MAQUINA_PARADA'
THEN 'Odoo tiene una orden activa, pero la máquina aparece parada.'
WHEN status_check = 'ARRANQUE_REAL_TARDIO'
THEN 'La primera actividad real de máquina llega más de 10 minutos después del inicio Odoo.'
ELSE 'Sin discrepancia relevante.'
END AS descripcion,
start_delay_min,
stop_count,
stop_min,
stop_cost_eur,
cycle_completion_pct
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo;
GRANT SELECT ON
mv_reports_ucepsa_demo.odoo_current_order_demo,
mv_reports_ucepsa_demo.odoo_vs_machine_demo,
mv_reports_ucepsa_demo.odoo_discrepancies_demo
TO grafana_ucepsa_ro, edge_oee_odoo_sync_writer;

View File

@ -0,0 +1,201 @@
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_workorders_reality_history AS
WITH orders AS (
SELECT
o.*,
CASE
WHEN o.odoo_state IN ('progress', 'in_progress', 'started', 'done', 'to_close')
AND o.odoo_start IS NOT NULL
THEN true
ELSE false
END AS odoo_is_started_calc,
COALESCE(o.odoo_end, now()) AS analysis_end
FROM mv_edge_oee_ucepsa_demo.odoo_workorders_demo o
WHERE o.machine_id = 'CORT-01'
AND o.odoo_instance = 'odoo_demo'
AND o.odoo_workorder_id IS NOT NULL
),
first_activity AS (
SELECT
o.id AS order_row_id,
MIN(l.ingest_ts) AS first_machine_activity
FROM orders o
LEFT JOIN mv_reports_ucepsa_demo.live_telemetry l
ON o.odoo_is_started_calc = true
AND l.asset = o.asset
AND l.ingest_ts >= o.odoo_start
AND l.ingest_ts <= o.analysis_end
AND (
l.machine_running = true
OR COALESCE(l.cycle_pulse_count, 0) > 0
)
GROUP BY o.id
),
cycle_calc AS (
SELECT
o.id AS order_row_id,
COALESCE(
GREATEST(
MAX(l.cycle_pulse_count) - MIN(l.cycle_pulse_count),
0
),
0
)::numeric AS real_cycles
FROM orders o
LEFT JOIN mv_reports_ucepsa_demo.live_telemetry l
ON o.odoo_is_started_calc = true
AND l.asset = o.asset
AND l.ingest_ts >= o.odoo_start
AND l.ingest_ts <= o.analysis_end
AND l.cycle_pulse_count IS NOT NULL
GROUP BY o.id
),
stop_calc AS (
SELECT
o.id AS order_row_id,
COUNT(s.stop_id) AS stop_count,
ROUND(COALESCE(SUM(s.overlap_s), 0)::numeric / 60.0, 2) AS stop_min,
ROUND(COALESCE(SUM((s.overlap_s / 3600.0) * s.value_hour_eur), 0)::numeric, 2) AS stop_cost_eur
FROM orders o
LEFT JOIN LATERAL (
SELECT
ms.id AS stop_id,
ms.value_hour_eur,
GREATEST(
EXTRACT(EPOCH FROM (
LEAST(COALESCE(ms.ended_at, o.analysis_end), o.analysis_end)
-
GREATEST(
CASE
WHEN ms.started_at > ms.created_at + interval '5 seconds'
THEN ms.created_at
ELSE ms.started_at
END,
o.odoo_start
)
))::numeric,
0::numeric
) AS overlap_s
FROM mv_hot.edge_oee_machine_stops ms
WHERE o.odoo_is_started_calc = true
AND ms.machine_id = o.machine_id
AND (
CASE
WHEN ms.started_at > ms.created_at + interval '5 seconds'
THEN ms.created_at
ELSE ms.started_at
END
) < o.analysis_end
AND COALESCE(ms.ended_at, now()) > o.odoo_start
) s ON true
GROUP BY o.id
)
SELECT
o.id,
o.tenant,
o.site,
o.odoo_instance,
o.odoo_workorder_id,
o.odoo_production_id,
o.odoo_order_ref,
o.odoo_state,
o.odoo_workcenter_code,
o.odoo_workcenter_name,
o.machine_id,
o.asset,
o.product,
o.qty_planned,
o.qty_done,
o.uom,
o.planned_cycles,
o.target_cycle_rate_ppm,
o.odoo_start,
o.odoo_end,
o.snapshot_at,
o.odoo_is_started_calc AS odoo_is_started,
fa.first_machine_activity,
CASE
WHEN o.odoo_is_started_calc = false THEN NULL::numeric
WHEN fa.first_machine_activity IS NULL THEN NULL::numeric
ELSE ROUND((EXTRACT(EPOCH FROM (fa.first_machine_activity - o.odoo_start)) / 60.0)::numeric, 2)
END AS start_delay_min,
COALESCE(sc.stop_count, 0) AS stop_count,
COALESCE(sc.stop_min, 0) AS stop_min,
COALESCE(sc.stop_cost_eur, 0) AS stop_cost_eur,
CASE
WHEN o.odoo_is_started_calc = true THEN COALESCE(cc.real_cycles, 0)
ELSE 0::numeric
END AS real_cycles,
CASE
WHEN o.odoo_is_started_calc = true
AND o.planned_cycles > 0
THEN ROUND(((COALESCE(cc.real_cycles, 0) / o.planned_cycles) * 100)::numeric, 2)
WHEN o.odoo_is_started_calc = false
THEN 0::numeric
ELSE NULL::numeric
END AS cycle_completion_pct,
CASE
WHEN o.odoo_state IN ('ready', 'waiting') THEN 'ORDEN_NO_INICIADA'
WHEN o.odoo_is_started_calc = true
AND fa.first_machine_activity IS NULL THEN 'ORDEN_SIN_ACTIVIDAD_REAL'
WHEN o.odoo_is_started_calc = true
AND COALESCE(sc.stop_count, 0) > 0 THEN 'OK_CON_PAROS'
WHEN o.odoo_is_started_calc = true THEN 'OK'
ELSE 'SIN_EVALUAR'
END AS status_check,
CASE
WHEN o.odoo_state IN ('ready', 'waiting')
THEN 'Orden preparada o en espera. Todavía no debe imputar ciclos, paros ni retrasos.'
WHEN o.odoo_is_started_calc = true
AND fa.first_machine_activity IS NULL
THEN 'La orden está iniciada/cerrada en Odoo, pero no se detectó actividad real de máquina en su ventana.'
WHEN o.odoo_is_started_calc = true
AND COALESCE(sc.stop_count, 0) > 0
THEN 'La orden tuvo actividad real y paros imputados durante su ventana.'
WHEN o.odoo_is_started_calc = true
THEN 'La orden tuvo actividad real sin paros imputados.'
ELSE 'Orden sin evaluación suficiente.'
END AS descripcion
FROM orders o
LEFT JOIN first_activity fa ON fa.order_row_id = o.id
LEFT JOIN cycle_calc cc ON cc.order_row_id = o.id
LEFT JOIN stop_calc sc ON sc.order_row_id = o.id
ORDER BY
COALESCE(o.odoo_end, o.odoo_start, o.snapshot_at) DESC,
o.odoo_workorder_id DESC;
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.odoo_last_finished_order_demo AS
SELECT *
FROM mv_reports_ucepsa_demo.odoo_workorders_reality_history
WHERE odoo_state IN ('done', 'to_close')
ORDER BY COALESCE(odoo_end, snapshot_at) DESC
LIMIT 1;
GRANT SELECT ON
mv_reports_ucepsa_demo.odoo_workorders_reality_history,
mv_reports_ucepsa_demo.odoo_last_finished_order_demo
TO grafana_ucepsa_ro, edge_oee_odoo_sync_writer;