diff --git a/ucepsa/edge-oee-demo/sql/070_loss_intelligence_v0.sql b/ucepsa/edge-oee-demo/sql/070_loss_intelligence_v0.sql new file mode 100644 index 0000000..e762515 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/070_loss_intelligence_v0.sql @@ -0,0 +1,285 @@ +DROP VIEW IF EXISTS mv_reports_ucepsa_demo.loss_priority_board; +DROP VIEW IF EXISTS mv_reports_ucepsa_demo.loss_events_current; + +CREATE VIEW mv_reports_ucepsa_demo.loss_events_current AS +WITH open_stop_losses AS ( + SELECT + now()::timestamptz AS detected_at, + s.machine_id::text AS machine_id, + COALESCE(s.asset, '')::text AS asset, + COALESCE(s.order_id, '')::text AS order_ref, + 'OPEN_STOP'::text AS loss_type, + CASE + WHEN COALESCE(s.current_duration_min, 0) >= 15 THEN 'ACTION_REQUIRED' + WHEN COALESCE(s.current_duration_min, 0) >= 5 THEN 'ATTENTION' + ELSE 'INFO' + END AS severity, + CASE + WHEN COALESCE(s.current_duration_min, 0) >= 15 THEN 80 + WHEN COALESCE(s.current_duration_min, 0) >= 5 THEN 55 + ELSE 30 + END::int AS severity_score, + COALESCE(s.current_duration_min, 0)::numeric AS duration_min, + COALESCE(s.current_cost_eur, 0)::numeric AS cost_eur, + COALESCE(s.status, 'OPEN')::text AS status, + 'Paro abierto pendiente de cierre o clasificación.'::text AS description, + 'Comprobar causa en máquina y clasificar el paro desde la consola.'::text AS recommended_action, + jsonb_build_object( + 'stop_id', s.id, + 'classification_status', s.classification_status, + 'cause_name', s.cause_name, + 'started_at', s.started_at, + 'source', 'mv_reports_ucepsa_demo.open_stops' + ) AS evidence_json + FROM mv_reports_ucepsa_demo.open_stops s +), + +unclassified_closed_stop_losses AS ( + SELECT + now()::timestamptz AS detected_at, + s.machine_id::text AS machine_id, + COALESCE(s.asset, '')::text AS asset, + COALESCE(s.order_id, '')::text AS order_ref, + 'UNCLASSIFIED_STOP'::text AS loss_type, + CASE + WHEN COALESCE(s.duration_min, 0) >= 15 THEN 'ACTION_REQUIRED' + ELSE 'ATTENTION' + END AS severity, + CASE + WHEN COALESCE(s.duration_min, 0) >= 15 THEN 75 + ELSE 55 + END::int AS severity_score, + COALESCE(s.duration_min, 0)::numeric AS duration_min, + COALESCE(s.cost_eur, 0)::numeric AS cost_eur, + COALESCE(s.status, 'CLOSED')::text AS status, + 'Paro cerrado pendiente de clasificación.'::text AS description, + 'Asignar causa para evitar pérdida sin explicación.'::text AS recommended_action, + jsonb_build_object( + 'stop_id', s.id, + 'classification_status', s.classification_status, + 'cause_name', s.cause_name, + 'started_at', s.started_at, + 'ended_at', s.ended_at, + 'source', 'mv_reports_ucepsa_demo.stops_recent' + ) AS evidence_json + FROM mv_reports_ucepsa_demo.stops_recent s + WHERE s.status = 'CLOSED' + AND s.classification_status = 'PENDING' +), + +odoo_machine_losses AS ( + SELECT + now()::timestamptz AS detected_at, + o.machine_id::text AS machine_id, + COALESCE(o.asset, '')::text AS asset, + COALESCE(o.odoo_order_ref, '')::text AS order_ref, + o.status_check::text AS loss_type, + CASE + WHEN o.status_check IN ('ORDEN_EN_CURSO_MAQUINA_PARADA', 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA') THEN 'ACTION_REQUIRED' + WHEN o.status_check = 'SIN_TELEMETRIA' THEN 'ATTENTION' + ELSE 'INFO' + END AS severity, + CASE + WHEN o.status_check = 'ORDEN_EN_CURSO_MAQUINA_PARADA' THEN 85 + WHEN o.status_check = 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA' THEN 75 + WHEN o.status_check = 'SIN_TELEMETRIA' THEN 60 + ELSE 20 + END::int AS severity_score, + COALESCE(o.stop_min, 0)::numeric AS duration_min, + COALESCE(o.stop_cost_eur, 0)::numeric AS cost_eur, + o.status_check::text AS status, + CASE + WHEN o.status_check = 'ORDEN_EN_CURSO_MAQUINA_PARADA' + THEN 'Odoo indica orden en curso, pero la máquina está parada.' + WHEN o.status_check = 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA' + THEN 'La máquina está en marcha, pero la orden no aparece iniciada en Odoo.' + WHEN o.status_check = 'SIN_TELEMETRIA' + THEN 'No hay telemetría reciente para contrastar Odoo contra máquina.' + ELSE 'Sin discrepancia relevante.' + END AS description, + CASE + WHEN o.status_check = 'ORDEN_EN_CURSO_MAQUINA_PARADA' + THEN 'Revisar paro real, operario, causa pendiente y estado de máquina.' + WHEN o.status_check = 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA' + THEN 'Revisar disciplina de inicio de orden en Odoo o desfase de sincronización.' + WHEN o.status_check = 'SIN_TELEMETRIA' + THEN 'Revisar RevPi, MQTT, VPN/red, contador y sink.' + ELSE 'Sin acción.' + END AS recommended_action, + jsonb_build_object( + 'odoo_order_ref', o.odoo_order_ref, + 'odoo_state', o.odoo_state, + 'machine_running', o.machine_running, + 'telemetry_age_s', o.telemetry_age_s, + 'real_cycles', o.real_cycles, + 'planned_cycles', o.planned_cycles, + 'cycle_completion_pct', o.cycle_completion_pct, + 'source', 'mv_reports_ucepsa_demo.odoo_vs_machine_demo' + ) AS evidence_json + FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo o + WHERE o.status_check IS DISTINCT FROM 'OK' +), + +telemetry_losses AS ( + SELECT + now()::timestamptz AS detected_at, + l.machine_id::text AS machine_id, + COALESCE(l.asset, '')::text AS asset, + COALESCE(l.order_id, '')::text AS order_ref, + 'STALE_OR_BAD_TELEMETRY'::text AS loss_type, + CASE + WHEN COALESCE(l.comm_ok, false) = false THEN 'ACTION_REQUIRED' + WHEN COALESCE(l.age_s, 999999) > 120 THEN 'ACTION_REQUIRED' + WHEN COALESCE(l.age_s, 999999) > 30 THEN 'ATTENTION' + ELSE 'INFO' + END AS severity, + CASE + WHEN COALESCE(l.comm_ok, false) = false THEN 80 + WHEN COALESCE(l.age_s, 999999) > 120 THEN 75 + WHEN COALESCE(l.age_s, 999999) > 30 THEN 45 + ELSE 10 + END::int AS severity_score, + 0::numeric AS duration_min, + 0::numeric AS cost_eur, + CASE + WHEN COALESCE(l.comm_ok, false) = false THEN 'BAD_COMMUNICATION' + WHEN COALESCE(l.age_s, 999999) > 120 THEN 'STALE_TELEMETRY' + WHEN COALESCE(l.age_s, 999999) > 30 THEN 'DELAYED_TELEMETRY' + ELSE 'OK' + END::text AS status, + CASE + WHEN COALESCE(l.comm_ok, false) = false THEN 'La RevPi publica, pero la lectura Modbus no es válida.' + WHEN COALESCE(l.age_s, 999999) > 120 THEN 'No hay telemetría reciente de máquina.' + WHEN COALESCE(l.age_s, 999999) > 30 THEN 'La telemetría llega con retraso.' + ELSE 'Telemetría correcta.' + END AS description, + CASE + WHEN COALESCE(l.comm_ok, false) = false THEN 'Revisar RS-485, SDM120-M, alimentación del contador y logs de la RevPi.' + WHEN COALESCE(l.age_s, 999999) > 120 THEN 'Revisar RevPi, red/VPN, Mosquitto y sink MQTT.' + WHEN COALESCE(l.age_s, 999999) > 30 THEN 'Revisar estabilidad de red y frecuencia de publicación.' + ELSE 'Sin acción.' + END AS recommended_action, + jsonb_build_object( + 'age_s', l.age_s, + 'comm_ok', l.comm_ok, + 'error_text', l.error_text, + 'ingest_ts', l.ingest_ts, + 'source', 'mv_reports_ucepsa_demo.latest_machine_state_fast' + ) AS evidence_json + FROM mv_reports_ucepsa_demo.latest_machine_state_fast l + WHERE COALESCE(l.comm_ok, false) = false + OR COALESCE(l.age_s, 999999) > 30 +), + +low_cycle_rate_losses AS ( + SELECT + now()::timestamptz AS detected_at, + l.machine_id::text AS machine_id, + COALESCE(l.asset, '')::text AS asset, + COALESCE(o.odoo_order_ref, '')::text AS order_ref, + 'LOW_CYCLE_RATE'::text AS loss_type, + CASE + WHEN COALESCE(l.cycle_rate_ppm, 0) <= 0 THEN 'ATTENTION' + WHEN COALESCE(l.cycle_rate_ppm, 0) < 7 THEN 'ATTENTION' + ELSE 'INFO' + END AS severity, + CASE + WHEN COALESCE(l.cycle_rate_ppm, 0) <= 0 THEN 50 + WHEN COALESCE(l.cycle_rate_ppm, 0) < 7 THEN 40 + ELSE 10 + END::int AS severity_score, + 0::numeric AS duration_min, + 0::numeric AS cost_eur, + 'LOW_RATE'::text AS status, + 'La máquina aparece en marcha, pero la velocidad de ciclos/golpes es baja respecto al objetivo inicial.'::text AS description, + 'Revisar golpes reales, sensor I_2, velocidad programada, material, ajuste de máquina y criterio de bolsas por golpe.'::text AS recommended_action, + jsonb_build_object( + 'cycle_rate_ppm', l.cycle_rate_ppm, + 'cycle_pulse_count', l.cycle_pulse_count, + 'machine_running', l.machine_running, + 'target_cycle_rate_ppm_assumed', 10, + 'odoo_order_ref', o.odoo_order_ref, + 'source', 'mv_reports_ucepsa_demo.latest_machine_state_fast' + ) AS evidence_json + FROM mv_reports_ucepsa_demo.latest_machine_state_fast l + LEFT JOIN mv_reports_ucepsa_demo.odoo_current_order_demo o + ON o.machine_id = l.machine_id + WHERE COALESCE(l.age_s, 999999) <= 120 + AND COALESCE(l.machine_running, false) = true + AND COALESCE(l.cycle_rate_ppm, 0) < 7 +) + +SELECT * +FROM open_stop_losses + +UNION ALL + +SELECT * +FROM unclassified_closed_stop_losses + +UNION ALL + +SELECT * +FROM odoo_machine_losses + +UNION ALL + +SELECT * +FROM telemetry_losses + +UNION ALL + +SELECT * +FROM low_cycle_rate_losses; + +CREATE VIEW mv_reports_ucepsa_demo.loss_priority_board AS +SELECT + row_number() OVER ( + ORDER BY + ( + severity_score + + LEAST((COALESCE(cost_eur, 0) * 10), 50)::int + + LEAST((COALESCE(duration_min, 0) * 2), 40)::int + ) DESC, + detected_at DESC + ) AS priority_rank, + ( + severity_score + + LEAST((COALESCE(cost_eur, 0) * 10), 50)::int + + LEAST((COALESCE(duration_min, 0) * 2), 40)::int + ) AS priority_score, + CASE + WHEN ( + severity_score + + LEAST((COALESCE(cost_eur, 0) * 10), 50)::int + + LEAST((COALESCE(duration_min, 0) * 2), 40)::int + ) >= 90 THEN 'CRÍTICA' + WHEN ( + severity_score + + LEAST((COALESCE(cost_eur, 0) * 10), 50)::int + + LEAST((COALESCE(duration_min, 0) * 2), 40)::int + ) >= 60 THEN 'ALTA' + WHEN ( + severity_score + + LEAST((COALESCE(cost_eur, 0) * 10), 50)::int + + LEAST((COALESCE(duration_min, 0) * 2), 40)::int + ) >= 35 THEN 'MEDIA' + ELSE 'BAJA' + END AS priority_level, + detected_at, + machine_id, + asset, + order_ref, + loss_type, + severity, + duration_min, + cost_eur, + status, + description, + recommended_action, + evidence_json +FROM mv_reports_ucepsa_demo.loss_events_current +ORDER BY priority_score DESC, detected_at DESC; + +GRANT SELECT ON mv_reports_ucepsa_demo.loss_events_current TO grafana_ucepsa_ro; +GRANT SELECT ON mv_reports_ucepsa_demo.loss_priority_board TO grafana_ucepsa_ro;