diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd672cd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/ucepsa/edge-oee-demo/README.md b/ucepsa/edge-oee-demo/README.md new file mode 100644 index 0000000..87c1520 --- /dev/null +++ b/ucepsa/edge-oee-demo/README.md @@ -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 diff --git a/ucepsa/edge-oee-demo/deploy/platform40-reference/docker-compose.platform40.yml b/ucepsa/edge-oee-demo/deploy/platform40-reference/docker-compose.platform40.yml new file mode 100644 index 0000000..966d279 --- /dev/null +++ b/ucepsa/edge-oee-demo/deploy/platform40-reference/docker-compose.platform40.yml @@ -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} diff --git a/ucepsa/edge-oee-demo/docs/README.platform40-original.md b/ucepsa/edge-oee-demo/docs/README.platform40-original.md new file mode 100644 index 0000000..149a3b3 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/README.platform40-original.md @@ -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. diff --git a/ucepsa/edge-oee-demo/docs/postgres_inventory_before_reorg.txt b/ucepsa/edge-oee-demo/docs/postgres_inventory_before_reorg.txt new file mode 100644 index 0000000..ebe3303 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/postgres_inventory_before_reorg.txt @@ -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) + diff --git a/ucepsa/edge-oee-demo/env-examples/.env.odoo-sync.example b/ucepsa/edge-oee-demo/env-examples/.env.odoo-sync.example new file mode 100644 index 0000000..6693d3e --- /dev/null +++ b/ucepsa/edge-oee-demo/env-examples/.env.odoo-sync.example @@ -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 diff --git a/ucepsa/edge-oee-demo/env-examples/.env.operator.example b/ucepsa/edge-oee-demo/env-examples/.env.operator.example new file mode 100644 index 0000000..b067631 --- /dev/null +++ b/ucepsa/edge-oee-demo/env-examples/.env.operator.example @@ -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 diff --git a/ucepsa/edge-oee-demo/env-examples/.env.sink.example b/ucepsa/edge-oee-demo/env-examples/.env.sink.example new file mode 100644 index 0000000..7622533 --- /dev/null +++ b/ucepsa/edge-oee-demo/env-examples/.env.sink.example @@ -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 diff --git a/ucepsa/edge-oee-demo/ops/prepare_demo.sh b/ucepsa/edge-oee-demo/ops/prepare_demo.sh new file mode 100755 index 0000000..0421269 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/prepare_demo.sh @@ -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" diff --git a/ucepsa/edge-oee-demo/ops/run_oee_demo_generator.sh b/ucepsa/edge-oee-demo/ops/run_oee_demo_generator.sh new file mode 100755 index 0000000..15237d6 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/run_oee_demo_generator.sh @@ -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 diff --git a/ucepsa/edge-oee-demo/ops/validate_edge_oee_demo.sh b/ucepsa/edge-oee-demo/ops/validate_edge_oee_demo.sh new file mode 100755 index 0000000..62b98b0 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_edge_oee_demo.sh @@ -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 diff --git a/ucepsa/edge-oee-demo/ops/validate_edge_oee_demo.sql b/ucepsa/edge-oee-demo/ops/validate_edge_oee_demo.sql new file mode 100644 index 0000000..0cbf07b --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_edge_oee_demo.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/Dockerfile b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/Dockerfile new file mode 100644 index 0000000..5521310 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/Dockerfile @@ -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"] diff --git a/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py new file mode 100644 index 0000000..1854408 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py @@ -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()) diff --git a/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py.bak.2026-05-08_09-18-47 b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py.bak.2026-05-08_09-18-47 new file mode 100644 index 0000000..8c826a9 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py.bak.2026-05-08_09-18-47 @@ -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()) diff --git a/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py.bak.2026-05-08_09-32-17 b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py.bak.2026-05-08_09-32-17 new file mode 100644 index 0000000..d885a09 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/main.py.bak.2026-05-08_09-32-17 @@ -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()) diff --git a/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/requirements.txt b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/requirements.txt new file mode 100644 index 0000000..615e5ab --- /dev/null +++ b/ucepsa/edge-oee-demo/services/edge-oee-mqtt-pg-sink/requirements.txt @@ -0,0 +1,2 @@ +paho-mqtt>=2,<3 +psycopg2-binary==2.9.9 diff --git a/ucepsa/edge-oee-demo/services/odoo-workorders-sync/Dockerfile b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/Dockerfile new file mode 100644 index 0000000..5521310 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/Dockerfile @@ -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"] diff --git a/ucepsa/edge-oee-demo/services/odoo-workorders-sync/main.py b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/main.py new file mode 100644 index 0000000..d7a0466 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/main.py @@ -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() diff --git a/ucepsa/edge-oee-demo/services/odoo-workorders-sync/probe_odoo.py b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/probe_odoo.py new file mode 100755 index 0000000..f240415 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/probe_odoo.py @@ -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()) diff --git a/ucepsa/edge-oee-demo/services/odoo-workorders-sync/requirements.txt b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/requirements.txt new file mode 100644 index 0000000..58ab769 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/odoo-workorders-sync/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary==2.9.9 diff --git a/ucepsa/edge-oee-demo/services/oee-demo-generator/Dockerfile b/ucepsa/edge-oee-demo/services/oee-demo-generator/Dockerfile new file mode 100644 index 0000000..5521310 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/oee-demo-generator/Dockerfile @@ -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"] diff --git a/ucepsa/edge-oee-demo/services/oee-demo-generator/main.py b/ucepsa/edge-oee-demo/services/oee-demo-generator/main.py new file mode 100644 index 0000000..adde463 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/oee-demo-generator/main.py @@ -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() diff --git a/ucepsa/edge-oee-demo/services/oee-demo-generator/requirements.txt b/ucepsa/edge-oee-demo/services/oee-demo-generator/requirements.txt new file mode 100644 index 0000000..58ab769 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/oee-demo-generator/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary==2.9.9 diff --git a/ucepsa/edge-oee-demo/services/operator-stop-console/Dockerfile b/ucepsa/edge-oee-demo/services/operator-stop-console/Dockerfile new file mode 100644 index 0000000..e8c65e9 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/operator-stop-console/Dockerfile @@ -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"] diff --git a/ucepsa/edge-oee-demo/services/operator-stop-console/app.py b/ucepsa/edge-oee-demo/services/operator-stop-console/app.py new file mode 100644 index 0000000..bd4173e --- /dev/null +++ b/ucepsa/edge-oee-demo/services/operator-stop-console/app.py @@ -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 = """ + + + + + {{ app_title }} + + + + + + +
+

{{ app_title }}

+
+ Clasificación manual de paros detectados automáticamente por Edge-OEE. + Última carga: {{ now }} +
+
+ +
+
+
+
Paros pendientes
+
{{ stops|length }}
+
+
+
Máquina demo
+
{{DEFAULT_MACHINE_ID}}
+
+
+ + {% if stops|length == 0 %} +
+ No hay paros pendientes de clasificar. +
+ {% else %} + + + + + + + + + + + + + + + + + {% for stop in stops %} + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
IDMáquinaInicioFinEstadoDuraciónCosteCausaNotaGuardar
#{{ stop.id }} + {{ stop.machine_id or "-" }} +
{{ stop.asset }}
+
+ {{ stop.started_at }} + + {{ stop.ended_at or "Abierto" }} + + {% if stop.status == 'OPEN' %} + OPEN + {% else %} + CLOSED + {% endif %} +
+ {{ stop.classification_status }} +
+ {{ "%.2f"|format(stop.duration_min or 0) }} min + + {{ "%.2f"|format(stop.cost_eur or 0) }} € + + + + + + +
+ {% endif %} +
+ + +""" + + +@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 diff --git a/ucepsa/edge-oee-demo/services/operator-stop-console/requirements.txt b/ucepsa/edge-oee-demo/services/operator-stop-console/requirements.txt new file mode 100644 index 0000000..1536589 --- /dev/null +++ b/ucepsa/edge-oee-demo/services/operator-stop-console/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +gunicorn==22.0.0 +psycopg2-binary==2.9.9 diff --git a/ucepsa/edge-oee-demo/sql/000_database_governance.sql b/ucepsa/edge-oee-demo/sql/000_database_governance.sql new file mode 100644 index 0000000..98b879c --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/000_database_governance.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/001_edge_oee_demo_init.sql b/ucepsa/edge-oee-demo/sql/001_edge_oee_demo_init.sql new file mode 100644 index 0000000..1c7fca9 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/001_edge_oee_demo_init.sql @@ -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); diff --git a/ucepsa/edge-oee-demo/sql/001_mv_core_catalog.sql b/ucepsa/edge-oee-demo/sql/001_mv_core_catalog.sql new file mode 100644 index 0000000..5d91068 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/001_mv_core_catalog.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/002_eastron_real_energy.sql b/ucepsa/edge-oee-demo/sql/002_eastron_real_energy.sql new file mode 100644 index 0000000..c8f9893 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/002_eastron_real_energy.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/003_edge_oee_node_events.sql b/ucepsa/edge-oee-demo/sql/003_edge_oee_node_events.sql new file mode 100644 index 0000000..d9b69ad --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/003_edge_oee_node_events.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/004_edge_oee_stop_model.sql b/ucepsa/edge-oee-demo/sql/004_edge_oee_stop_model.sql new file mode 100644 index 0000000..a70bc1d --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/004_edge_oee_stop_model.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/005_edge_oee_oee_dashboard.sql b/ucepsa/edge-oee-demo/sql/005_edge_oee_oee_dashboard.sql new file mode 100644 index 0000000..0c8bd54 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/005_edge_oee_oee_dashboard.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/006_edge_oee_fix_freshness_ingest_ts.sql b/ucepsa/edge-oee-demo/sql/006_edge_oee_fix_freshness_ingest_ts.sql new file mode 100644 index 0000000..ae4a6e2 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/006_edge_oee_fix_freshness_ingest_ts.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/007_fix_negative_stop_durations.sql b/ucepsa/edge-oee-demo/sql/007_fix_negative_stop_durations.sql new file mode 100644 index 0000000..9df506e --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/007_fix_negative_stop_durations.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/008_fix_open_stop_effective_started_at.sql b/ucepsa/edge-oee-demo/sql/008_fix_open_stop_effective_started_at.sql new file mode 100644 index 0000000..6141259 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/008_fix_open_stop_effective_started_at.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/010_edge_oee_ucepsa_bridge_views.sql b/ucepsa/edge-oee-demo/sql/010_edge_oee_ucepsa_bridge_views.sql new file mode 100644 index 0000000..b040b74 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/010_edge_oee_ucepsa_bridge_views.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/020_reports_ucepsa_demo_views.sql b/ucepsa/edge-oee-demo/sql/020_reports_ucepsa_demo_views.sql new file mode 100644 index 0000000..9013450 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/020_reports_ucepsa_demo_views.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/030_grants_reports_ucepsa_demo.sql b/ucepsa/edge-oee-demo/sql/030_grants_reports_ucepsa_demo.sql new file mode 100644 index 0000000..92fb125 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/030_grants_reports_ucepsa_demo.sql @@ -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. diff --git a/ucepsa/edge-oee-demo/sql/040_rename_c_demo_to_cortadora_01.sql b/ucepsa/edge-oee-demo/sql/040_rename_c_demo_to_cortadora_01.sql new file mode 100644 index 0000000..b60a170 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/040_rename_c_demo_to_cortadora_01.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/050_reports_oee_functions.sql b/ucepsa/edge-oee-demo/sql/050_reports_oee_functions.sql new file mode 100644 index 0000000..54600d7 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/050_reports_oee_functions.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/060_odoo_shadow_layer_demo.sql b/ucepsa/edge-oee-demo/sql/060_odoo_shadow_layer_demo.sql new file mode 100644 index 0000000..5d0bd17 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/060_odoo_shadow_layer_demo.sql @@ -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; + + diff --git a/ucepsa/edge-oee-demo/sql/061_fix_odoo_vs_machine_semantics.sql b/ucepsa/edge-oee-demo/sql/061_fix_odoo_vs_machine_semantics.sql new file mode 100644 index 0000000..7f0b42c --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/061_fix_odoo_vs_machine_semantics.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/062_odoo_workorders_reality_history.sql b/ucepsa/edge-oee-demo/sql/062_odoo_workorders_reality_history.sql new file mode 100644 index 0000000..413babb --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/062_odoo_workorders_reality_history.sql @@ -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;