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;