From e5cb98e349849d90079fc911f987030bfbe653d1 Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Mon, 20 Jul 2026 11:37:22 +0200 Subject: [PATCH] feat(ucepsa): add Shop Floor context health shadow --- ...shopfloor_context_health_queries_v0.3.5.md | 76 + .../101_shopfloor_context_health_v0.3.5.md | 211 +++ ...validate_shopfloor_context_health_v035.sql | 222 +++ ...8_ucepsa_shopfloor_context_health_v035.sql | 1287 +++++++++++++++++ 4 files changed, 1796 insertions(+) create mode 100644 ucepsa/edge-oee-demo/docs/grafana/101_shopfloor_context_health_queries_v0.3.5.md create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/101_shopfloor_context_health_v0.3.5.md create mode 100644 ucepsa/edge-oee-demo/ops/validate_shopfloor_context_health_v035.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/108_ucepsa_shopfloor_context_health_v035.sql diff --git a/ucepsa/edge-oee-demo/docs/grafana/101_shopfloor_context_health_queries_v0.3.5.md b/ucepsa/edge-oee-demo/docs/grafana/101_shopfloor_context_health_queries_v0.3.5.md new file mode 100644 index 0000000..b1f697d --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/grafana/101_shopfloor_context_health_queries_v0.3.5.md @@ -0,0 +1,76 @@ +# Consultas Grafana — Shop Floor Context Health v0.3.5 + +## Estado por máquina + +```sql +SELECT + machine_id AS "Máquina", + live_context_status AS "Estado", + live_severity AS "Severidad", + published_production_order AS "Orden publicada", + odoo_workorder_id AS "Workorder sesión", + COALESCE( + operator_name, + operator_candidate_name + ) AS "Operario", + sync_health_status AS "Sync", + recommended_action AS "Acción" +FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1 +ORDER BY machine_id; +``` + +## Incidencias + +```sql +SELECT + observed_at AS "Hora", + severity AS "Severidad", + machine_id AS "Máquina", + incident_code AS "Código", + production_order AS "Orden", + odoo_workorder_id AS "Workorder", + operator_name AS "Operario", + detail AS "Situación", + recommended_action AS "Acción" +FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incidents_v1 +ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'WARNING' THEN 2 + ELSE 3 + END, + machine_id; +``` + +## Sesiones abiertas + +```sql +SELECT + machine_id AS "Máquina", + production_order AS "Orden", + odoo_workorder_id AS "Workorder", + operator_name AS "Operario", + session_started_at AS "Inicio", + session_age_min AS "Minutos abierta", + machine_running AS "Máquina en marcha", + open_stop_duration_min AS "Paro abierto min", + watch_status AS "Estado", + watch_severity AS "Severidad" +FROM + mv_reports_ucepsa_prod + .li_shopfloor_open_session_watch_v1 +ORDER BY machine_id; +``` + +## Resumen + +```sql +SELECT * +FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_health_summary_v1; +``` diff --git a/ucepsa/edge-oee-demo/docs/runbooks/101_shopfloor_context_health_v0.3.5.md b/ucepsa/edge-oee-demo/docs/runbooks/101_shopfloor_context_health_v0.3.5.md new file mode 100644 index 0000000..dc6df5b --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/101_shopfloor_context_health_v0.3.5.md @@ -0,0 +1,211 @@ +# UCEPSA — Shop Floor Context Health v0.3.5 + +## Objetivo + +Supervisar en vivo cinco capas: + +```text +máquina física +orden Odoo publicada +sesión Odoo Shop Floor +sincronizador +calidad temporal +``` + +Esta versión no cambia la atribución de paros v0.3.4, no modifica Odoo y no +alimenta el Ledger oficial. + +## Nuevos objetos + +### Política + +```text +mv_loss_intelligence.odoo_shopfloor_health_policies +``` + +Umbrales iniciales: + +```text +intervalo esperado 15 s +DELAYED a partir de 45 s +STALE a partir de 120 s +sesión OPEN obsoleta 12 h +tolerancia fecha futura 5 min +``` + +### Vistas + +```text +li_shopfloor_sync_health_v1 +li_shopfloor_session_data_quality_v1 +li_context_identity_registry_v1 +li_shopfloor_machine_live_context_v1 +li_shopfloor_open_session_watch_v1 +li_shopfloor_context_incidents_v1 +li_shopfloor_context_health_summary_v1 +``` + +## Estados vivos principales + +### Producción coherente + +```text +máquina en marcha ++ orden publicada ++ una sesión OPEN del mismo workorder +→ HEALTHY_OPEN_SESSION +``` + +### Parada dentro de una orden + +```text +máquina parada ++ orden publicada ++ sesión OPEN +→ TEMPORARY_STOP_INSIDE_SESSION +``` + +La parada física no cierra la sesión. + +### Producción sin Shop Floor + +```text +máquina en marcha ++ sin orden ++ sin sesión +→ RUNNING_WITHOUT_SHOPFLOOR_SESSION +``` + +Acción: confirmar sistema anterior, prueba, material residual o inicio omitido. + +### Orden sin sesión + +```text +orden publicada ++ ninguna sesión OPEN +→ RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION + u ORDER_WITHOUT_SHOPFLOOR_SESSION +``` + +### Conflicto + +```text +más de una sesión OPEN +o workorder publicado distinto del workorder de la sesión +→ CRITICAL +``` + +## Identidades con namespace + +Nunca se cruza un identificador numérico desnudo. + +Ejemplos: + +```text +ODOO_SHOPFLOOR:WORKORDER:340 +PRODUCTION_CONTEXT:LEGACY_ERP:1 +``` + +## Incidencias + +La vista de incidencias expone: + +```text +severidad +código +máquina +context_key +orden +workorder +operario +detalle +acción recomendada +``` + +No crea tickets ni escribe en Odoo. + +## Despliegue + +No es necesario detener servicios. + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/108_ucepsa_shopfloor_context_health_v035.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$FILE" +``` + +## Validación + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_health_v035.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$VALIDATION" +``` + +Valores estructurales obligatorios: + +```text +live_machine_rows = 3 +distinct_machine_rows = 3 +pending_has_time_count = 0 +open_missing_start_count = 0 +open_has_end_count = 0 +closed_missing_start_count = 0 +closed_missing_end_count = 0 +invalid_interval_count = 0 +context_key_collision_count = 0 +temporary_stop_wrong_severity = 0 +healthy_session_wrong_severity = 0 +idle_wrong_severity = 0 +official_live_rows = 0 +official_incident_rows = 0 +official_operator_queue_rows = 0 +``` + +`multiple_open_session_rows` debe ser cero en operación normal. Si no lo es, la +vista lo elevará como incidencia crítica. + +## Lectura esperada + +Mientras el workorder 340 siga abierto: + +```text +CORT-02 +context_key = ODOO_SHOPFLOOR:WORKORDER:340 +operario = Esteban +estado = HEALTHY_OPEN_SESSION +``` + +Durante una parada física: + +```text +estado = TEMPORARY_STOP_INSIDE_SESSION +severidad = INFO +``` + +CORT-00 solo aparecerá como advertencia cuando realmente esté en marcha sin +orden ni sesión. + +## Grafana + +La vista recomendada para una tabla operativa es: + +```text +li_shopfloor_machine_live_context_v1 +``` + +Para una lista de excepciones: + +```text +li_shopfloor_context_incidents_v1 +``` + +Para un indicador único: + +```text +li_shopfloor_context_health_summary_v1 +``` diff --git a/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_health_v035.sql b/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_health_v035.sql new file mode 100644 index 0000000..c66b82d --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_health_v035.sql @@ -0,0 +1,222 @@ +\pset pager off + +\echo '=== 1. Objetos v0.3.5 ===' + +SELECT + to_regclass( + 'mv_loss_intelligence.odoo_shopfloor_health_policies' + ) AS policy_table, + to_regclass( + 'mv_reports_ucepsa_prod.li_shopfloor_sync_health_v1' + ) AS sync_health_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shopfloor_session_data_quality_v1' + ) AS session_dq_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_identity_registry_v1' + ) AS identity_registry_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shopfloor_machine_live_context_v1' + ) AS live_context_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shopfloor_open_session_watch_v1' + ) AS open_watch_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shopfloor_context_incidents_v1' + ) AS incidents_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1' + ) AS summary_view; + +\echo '=== 2. Política activa ===' + +SELECT * +FROM + mv_loss_intelligence + .odoo_shopfloor_health_policies +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CUTTER_SHOPFLOOR_HEALTH_V1'; + +\echo '=== 3. Salud del sincronizador ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_shopfloor_sync_health_v1; + +\echo '=== 4. Tres máquinas y una fila por máquina ===' + +SELECT + count(*) AS live_machine_rows, + count( + DISTINCT machine_id + ) AS distinct_machine_rows +FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1; + +\echo '=== 5. Estado vivo por máquina ===' + +SELECT + machine_id, + telemetry_fresh, + machine_running, + published_order_present, + published_order_coherent, + published_production_order, + published_workorder_id, + open_session_count, + context_key, + session_production_order, + odoo_workorder_id, + operator_name, + operator_candidate_name, + sync_health_status, + live_context_status, + live_severity, + recommended_action, + official_eligible +FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1 +ORDER BY machine_id; + +\echo '=== 6. Invariantes de sesiones ===' + +SELECT + count(*) FILTER ( + WHERE pending_has_time + ) AS pending_has_time_count, + count(*) FILTER ( + WHERE open_missing_start + ) AS open_missing_start_count, + count(*) FILTER ( + WHERE open_has_end + ) AS open_has_end_count, + count(*) FILTER ( + WHERE closed_missing_start + ) AS closed_missing_start_count, + count(*) FILTER ( + WHERE closed_missing_end + ) AS closed_missing_end_count, + count(*) FILTER ( + WHERE invalid_interval + ) AS invalid_interval_count, + count(*) FILTER ( + WHERE multiple_open_sessions + ) AS multiple_open_session_rows +FROM + mv_reports_ucepsa_prod + .li_shopfloor_session_data_quality_v1; + +\echo '=== 7. Identidades con namespace sin colisiones ===' + +WITH duplicated AS ( + SELECT + tenant, + site, + context_key, + count(*) AS n + FROM + mv_reports_ucepsa_prod + .li_context_identity_registry_v1 + GROUP BY + tenant, + site, + context_key + HAVING count(*) > 1 +) +SELECT count(*) AS context_key_collision_count +FROM duplicated; + +\echo '=== 8. Sesiones OPEN bajo vigilancia ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_shopfloor_open_session_watch_v1 +ORDER BY machine_id, session_started_at; + +\echo '=== 9. Incidencias vivas ===' + +SELECT + severity, + incident_scope, + incident_code, + machine_id, + context_key, + production_order, + odoo_workorder_id, + operator_name, + detail, + recommended_action, + official_eligible +FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incidents_v1 +ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'WARNING' THEN 2 + ELSE 3 + END, + machine_id NULLS FIRST, + incident_code; + +\echo '=== 10. Coherencia de severidades ===' + +SELECT + count(*) FILTER ( + WHERE live_context_status = + 'TEMPORARY_STOP_INSIDE_SESSION' + AND live_severity <> 'INFO' + ) AS temporary_stop_wrong_severity, + count(*) FILTER ( + WHERE live_context_status = + 'HEALTHY_OPEN_SESSION' + AND live_severity <> 'OK' + ) AS healthy_session_wrong_severity, + count(*) FILTER ( + WHERE live_context_status = + 'IDLE_NO_ACTIVE_CONTEXT' + AND live_severity <> 'OK' + ) AS idle_wrong_severity, + count(*) FILTER ( + WHERE official_eligible + ) AS official_live_rows +FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1; + +\echo '=== 11. Resumen ejecutivo ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_health_summary_v1; + +\echo '=== 12. Nada oficial ===' + +SELECT + ( + SELECT count(*) + FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incidents_v1 + WHERE official_eligible + ) AS official_incident_rows, + ( + SELECT count(*) + FROM + mv_reports_ucepsa_prod + .li_context_identity_registry_v1 + WHERE official_eligible + ) AS official_identity_rows, + ( + SELECT count(*) + FROM + mv_reports_ucepsa_prod + .li_operator_stop_queue_v2 + ) AS official_operator_queue_rows; diff --git a/ucepsa/edge-oee-demo/sql/versions/108_ucepsa_shopfloor_context_health_v035.sql b/ucepsa/edge-oee-demo/sql/versions/108_ucepsa_shopfloor_context_health_v035.sql new file mode 100644 index 0000000..a390535 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/108_ucepsa_shopfloor_context_health_v035.sql @@ -0,0 +1,1287 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.odoo_shopfloor_health_policies ( + tenant text NOT NULL, + site text NOT NULL, + policy_code text NOT NULL, + sync_expected_interval_s integer NOT NULL DEFAULT 15 + CHECK (sync_expected_interval_s > 0), + sync_delayed_after_s integer NOT NULL DEFAULT 45 + CHECK (sync_delayed_after_s > 0), + sync_stale_after_s integer NOT NULL DEFAULT 120 + CHECK (sync_stale_after_s > sync_delayed_after_s), + stale_open_session_s integer NOT NULL DEFAULT 43200 + CHECK (stale_open_session_s > 0), + future_start_tolerance_s integer NOT NULL DEFAULT 300 + CHECK (future_start_tolerance_s >= 0), + active boolean NOT NULL DEFAULT true, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ( + tenant, + site, + policy_code + ) +); + +COMMENT ON TABLE +mv_loss_intelligence.odoo_shopfloor_health_policies IS +'Umbrales configurables para supervisar sincronización, sesiones Shop Floor y coherencia máquina–Odoo–sesión.'; + +INSERT INTO +mv_loss_intelligence.odoo_shopfloor_health_policies ( + tenant, + site, + policy_code, + sync_expected_interval_s, + sync_delayed_after_s, + sync_stale_after_s, + stale_open_session_s, + future_start_tolerance_s, + active, + notes +) +VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_SHOPFLOOR_HEALTH_V1', + 15, + 45, + 120, + 43200, + 300, + true, + 'El sincronizador consulta cada 15 s. Se considera retrasado a partir de 45 s y obsoleto a partir de 120 s. Una sesión abierta más de 12 h requiere revisión.' +) +ON CONFLICT ( + tenant, + site, + policy_code +) +DO UPDATE SET + sync_expected_interval_s = + EXCLUDED.sync_expected_interval_s, + sync_delayed_after_s = + EXCLUDED.sync_delayed_after_s, + sync_stale_after_s = + EXCLUDED.sync_stale_after_s, + stale_open_session_s = + EXCLUDED.stale_open_session_s, + future_start_tolerance_s = + EXCLUDED.future_start_tolerance_s, + active = true, + notes = EXCLUDED.notes, + updated_at = now(); + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shopfloor_sync_health_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence + .odoo_shopfloor_health_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CUTTER_SHOPFLOOR_HEALTH_V1' + AND active + ORDER BY updated_at DESC + LIMIT 1 +), +sync_state AS ( + SELECT * + FROM + mv_loss_intelligence + .odoo_shopfloor_sync_state + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND sync_name = + 'odoo_shopfloor_sessions_v033' +) +SELECT + p.tenant, + p.site, + 'odoo_shopfloor_sessions_v033'::text + AS sync_name, + p.policy_code, + p.sync_expected_interval_s, + p.sync_delayed_after_s, + p.sync_stale_after_s, + s.last_success_at, + s.last_odoo_cursor_at, + s.last_processed_count, + NULLIF( + btrim(s.last_error), + '' + ) AS last_error, + CASE + WHEN s.last_success_at IS NULL + THEN NULL::numeric + ELSE ROUND( + GREATEST( + EXTRACT( + EPOCH FROM ( + now() - s.last_success_at + ) + ), + 0 + )::numeric, + 2 + ) + END AS seconds_since_success, + CASE + WHEN s.tenant IS NULL + THEN 'NOT_INITIALIZED' + WHEN NULLIF( + btrim(s.last_error), + '' + ) IS NOT NULL + THEN 'ERROR' + WHEN s.last_success_at IS NULL + THEN 'NOT_INITIALIZED' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_stale_after_s + ::double precision + ) + THEN 'STALE' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_delayed_after_s + ::double precision + ) + THEN 'DELAYED' + ELSE 'HEALTHY' + END AS sync_health_status, + CASE + WHEN s.tenant IS NULL + THEN 'CRITICAL' + WHEN NULLIF( + btrim(s.last_error), + '' + ) IS NOT NULL + THEN 'CRITICAL' + WHEN s.last_success_at IS NULL + THEN 'CRITICAL' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_stale_after_s + ::double precision + ) + THEN 'CRITICAL' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_delayed_after_s + ::double precision + ) + THEN 'WARNING' + ELSE 'OK' + END AS severity, + CASE + WHEN s.tenant IS NULL + THEN + 'No existe estado de sincronización Shop Floor.' + WHEN NULLIF( + btrim(s.last_error), + '' + ) IS NOT NULL + THEN + 'El sincronizador registró un error.' + WHEN s.last_success_at IS NULL + THEN + 'El sincronizador todavía no ha completado una iteración.' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_stale_after_s + ::double precision + ) + THEN + 'La sincronización está obsoleta y no debe utilizarse como contexto vivo.' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_delayed_after_s + ::double precision + ) + THEN + 'La sincronización lleva más tiempo del esperado sin completar una iteración.' + ELSE + 'Sincronización Shop Floor dentro de los umbrales.' + END AS detail, + CASE + WHEN s.tenant IS NULL + THEN + 'Comprobar que el contenedor mv_ucepsa_odoo_shopfloor_session_sync está desplegado y ejecutar una iteración.' + WHEN NULLIF( + btrim(s.last_error), + '' + ) IS NOT NULL + THEN + 'Revisar el log del sincronizador y resolver el último error antes de utilizar datos nuevos.' + WHEN s.last_success_at IS NULL + THEN + 'Esperar una iteración o revisar conectividad con Odoo y PostgreSQL.' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_stale_after_s + ::double precision + ) + THEN + 'Revisar estado y logs del contenedor; no promover contexto mientras siga STALE.' + WHEN now() - s.last_success_at + > make_interval( + secs => + p.sync_delayed_after_s + ::double precision + ) + THEN + 'Observar dos ciclos y revisar carga, Odoo o red si continúa DELAYED.' + ELSE + 'Sin acción.' + END AS recommended_action, + false AS official_eligible, + now() AS observed_at +FROM policy p +LEFT JOIN sync_state s + ON s.tenant = p.tenant + AND s.site = p.site; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shopfloor_sync_health_v1 IS +'Salud viva del sincronizador Odoo Shop Floor con umbrales configurables.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shopfloor_session_data_quality_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence + .odoo_shopfloor_health_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CUTTER_SHOPFLOOR_HEALTH_V1' + AND active + ORDER BY updated_at DESC + LIMIT 1 +), +base AS ( + SELECT + s.*, + ( + 'ODOO_SHOPFLOOR:WORKORDER:' + || s.odoo_workorder_id::text + ) AS context_key, + COUNT(*) FILTER ( + WHERE s.session_status = 'OPEN' + ) OVER ( + PARTITION BY + s.tenant, + s.site, + s.machine_id + ) AS open_session_count_machine + FROM + mv_reports_ucepsa_prod + .li_odoo_shopfloor_sessions_v1 s +), +flags AS ( + SELECT + b.*, + p.stale_open_session_s, + p.future_start_tolerance_s, + ( + b.session_status = 'PENDING' + AND ( + b.started_at IS NOT NULL + OR b.ended_at IS NOT NULL + ) + ) AS pending_has_time, + ( + b.session_status = 'OPEN' + AND b.started_at IS NULL + ) AS open_missing_start, + ( + b.session_status = 'OPEN' + AND b.ended_at IS NOT NULL + ) AS open_has_end, + ( + b.session_status = 'CLOSED' + AND b.started_at IS NULL + ) AS closed_missing_start, + ( + b.session_status = 'CLOSED' + AND b.ended_at IS NULL + ) AS closed_missing_end, + ( + b.started_at IS NOT NULL + AND b.ended_at IS NOT NULL + AND b.ended_at < b.started_at + ) AS invalid_interval, + ( + b.session_status = 'OPEN' + AND b.workorder_state + IS DISTINCT FROM 'progress' + ) + OR ( + b.session_status = 'CLOSED' + AND b.workorder_state + IS DISTINCT FROM 'done' + ) + OR ( + b.session_status = 'PENDING' + AND b.workorder_state IN ( + 'progress', + 'done' + ) + ) AS workorder_session_state_mismatch, + ( + b.session_status = 'OPEN' + AND b.open_session_count_machine > 1 + ) AS multiple_open_sessions, + ( + b.session_status = 'OPEN' + AND b.started_at IS NOT NULL + AND now() - b.started_at + > make_interval( + secs => + p.stale_open_session_s + ::double precision + ) + ) AS stale_open_session, + ( + b.started_at IS NOT NULL + AND b.started_at + > now() + + make_interval( + secs => + p.future_start_tolerance_s + ::double precision + ) + ) AS future_session_start + FROM base b + CROSS JOIN policy p +), +issues AS ( + SELECT + f.*, + array_remove( + ARRAY[ + CASE + WHEN f.pending_has_time + THEN 'PENDING_HAS_TIME' + END, + CASE + WHEN f.open_missing_start + THEN 'OPEN_MISSING_START' + END, + CASE + WHEN f.open_has_end + THEN 'OPEN_HAS_END' + END, + CASE + WHEN f.closed_missing_start + THEN 'CLOSED_MISSING_START' + END, + CASE + WHEN f.closed_missing_end + THEN 'CLOSED_MISSING_END' + END, + CASE + WHEN f.invalid_interval + THEN 'INVALID_INTERVAL' + END, + CASE + WHEN f.workorder_session_state_mismatch + THEN + 'WORKORDER_SESSION_STATE_MISMATCH' + END, + CASE + WHEN f.multiple_open_sessions + THEN 'MULTIPLE_OPEN_SESSIONS' + END, + CASE + WHEN f.stale_open_session + THEN 'STALE_OPEN_SESSION' + END, + CASE + WHEN f.future_session_start + THEN 'FUTURE_SESSION_START' + END + ]::text[], + NULL + ) AS issue_codes + FROM flags f +) +SELECT + i.shopfloor_session_id, + i.tenant, + i.site, + i.context_key, + i.machine_id, + i.production_chain_key, + i.production_order, + i.odoo_workorder_id, + i.workorder_state, + i.production_state, + i.session_status, + i.started_at, + i.ended_at, + i.operator_employee_id, + i.operator_name, + i.operator_candidate_employee_id, + i.operator_candidate_name, + i.operator_confirmation_status, + i.quantity_confirmation_status, + i.confidence, + i.context_time_usable, + i.individual_attribution_eligible, + i.open_session_count_machine, + i.pending_has_time, + i.open_missing_start, + i.open_has_end, + i.closed_missing_start, + i.closed_missing_end, + i.invalid_interval, + i.workorder_session_state_mismatch, + i.multiple_open_sessions, + i.stale_open_session, + i.future_session_start, + i.issue_codes, + cardinality(i.issue_codes) AS issue_count, + CASE + WHEN i.pending_has_time + OR i.open_missing_start + OR i.open_has_end + OR i.closed_missing_start + OR i.closed_missing_end + OR i.invalid_interval + OR i.multiple_open_sessions + THEN 'CRITICAL' + WHEN i.workorder_session_state_mismatch + OR i.stale_open_session + OR i.future_session_start + THEN 'WARNING' + ELSE 'OK' + END AS dq_status, + false AS official_eligible, + now() AS observed_at +FROM issues i; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shopfloor_session_data_quality_v1 IS +'Calidad temporal y semántica de cada sesión Odoo Shop Floor, incluida detección de sesiones OPEN duplicadas.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_identity_registry_v1 +AS +SELECT + s.tenant, + s.site, + ( + 'ODOO_SHOPFLOOR:WORKORDER:' + || s.odoo_workorder_id::text + ) AS context_key, + 'ODOO_SHOPFLOOR'::text + AS context_namespace, + 'mv_loss_intelligence.odoo_shopfloor_sessions'::text + AS source_table, + s.shopfloor_session_id::text + AS source_native_id, + s.machine_id, + 'ODOO_SHOPFLOOR'::text + AS source_type, + s.session_key AS source_ref, + s.production_order AS order_ref, + s.started_at, + s.ended_at, + s.session_status AS status, + s.confidence, + s.official_eligible +FROM + mv_reports_ucepsa_prod + .li_odoo_shopfloor_sessions_v1 s + +UNION ALL + +SELECT + p.tenant, + p.site, + ( + 'PRODUCTION_CONTEXT:' + || p.source_type + || ':' + || p.context_session_id::text + ) AS context_key, + 'PRODUCTION_CONTEXT'::text + AS context_namespace, + 'mv_loss_intelligence.production_context_sessions'::text + AS source_table, + p.context_session_id::text + AS source_native_id, + p.machine_id, + p.source_type, + p.source_ref, + p.external_order_ref AS order_ref, + p.started_at, + p.ended_at, + p.status, + p.confidence, + p.official_eligible +FROM + mv_loss_intelligence + .production_context_sessions p; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_identity_registry_v1 IS +'Registro de identidades con namespace para evitar colisiones entre IDs numéricos de Shop Floor y contextos manuales/legacy.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shopfloor_machine_live_context_v1 +AS +WITH machines AS ( + SELECT * + FROM ( + VALUES + ( + 'ucepsa'::text, + 'ucepsa_onpremise'::text, + 'CORT-00'::text, + 52::bigint + ), + ( + 'ucepsa'::text, + 'ucepsa_onpremise'::text, + 'CORT-01'::text, + 53::bigint + ), + ( + 'ucepsa'::text, + 'ucepsa_onpremise'::text, + 'CORT-02'::text, + 54::bigint + ) + ) AS x( + tenant, + site, + machine_id, + workcenter_id + ) +), +open_sessions AS ( + SELECT + s.*, + ( + 'ODOO_SHOPFLOOR:WORKORDER:' + || s.odoo_workorder_id::text + ) AS context_key, + COUNT(*) OVER ( + PARTITION BY + s.tenant, + s.site, + s.machine_id + ) AS open_session_count, + ROW_NUMBER() OVER ( + PARTITION BY + s.tenant, + s.site, + s.machine_id + ORDER BY + s.started_at DESC NULLS LAST, + s.shopfloor_session_id DESC + ) AS session_rank + FROM + mv_reports_ucepsa_prod + .li_odoo_shopfloor_sessions_v1 s + WHERE s.session_status = 'OPEN' +), +open_summary AS ( + SELECT + tenant, + site, + machine_id, + COUNT(*) AS open_session_count, + array_agg( + context_key + ORDER BY + started_at, + shopfloor_session_id + ) AS open_context_keys, + array_agg( + odoo_workorder_id + ORDER BY + started_at, + shopfloor_session_id + ) AS open_workorder_ids + FROM open_sessions + GROUP BY + tenant, + site, + machine_id +), +primary_open AS ( + SELECT * + FROM open_sessions + WHERE session_rank = 1 +), +facts AS ( + SELECT + m.tenant, + m.site, + m.machine_id, + m.workcenter_id, + mm.operational_state, + mm.migration_context_state, + mm.migration_context_label, + COALESCE( + mm.telemetry_fresh, + false + ) AS telemetry_fresh, + COALESCE( + mm.machine_running, + false + ) AS machine_running, + COALESCE( + o.order_present, + mm.order_present, + false + ) AS published_order_present, + COALESCE( + o.order_coherent, + mm.order_coherent, + false + ) AS published_order_coherent, + COALESCE( + o.production_order, + mm.production_order + ) AS published_production_order, + o.workorder_id + AS published_workorder_id, + o.workorder_state + AS published_workorder_state, + o.odoo_state + AS published_odoo_state, + o.mqtt_ts + AS published_mqtt_ts, + o.received_at + AS published_received_at, + COALESCE( + os.open_session_count, + 0 + ) AS open_session_count, + os.open_context_keys, + os.open_workorder_ids, + po.context_key, + po.shopfloor_session_id, + po.production_chain_key, + po.production_order + AS session_production_order, + po.odoo_production_id, + po.odoo_workorder_id, + po.started_at + AS session_started_at, + po.operator_employee_id, + po.operator_name, + po.operator_candidate_employee_id, + po.operator_candidate_name, + po.operator_confirmation_status, + po.quantity_confirmation_status, + po.confidence + AS session_confidence, + dq.dq_status + AS session_dq_status, + dq.issue_codes + AS session_dq_issue_codes, + sh.sync_health_status, + sh.severity AS sync_severity, + sh.seconds_since_success, + sh.last_error AS sync_last_error + FROM machines m + LEFT JOIN + mv_reports_ucepsa_prod + .machine_migration_context_state_v1 mm + ON mm.machine_id = m.machine_id + LEFT JOIN + mv_hot.odoo_order_state_current o + ON o.tenant = m.tenant + AND o.site = m.site + AND o.machine_id = m.machine_id + LEFT JOIN open_summary os + ON os.tenant = m.tenant + AND os.site = m.site + AND os.machine_id = m.machine_id + LEFT JOIN primary_open po + ON po.tenant = m.tenant + AND po.site = m.site + AND po.machine_id = m.machine_id + LEFT JOIN + mv_reports_ucepsa_prod + .li_shopfloor_session_data_quality_v1 dq + ON dq.tenant = m.tenant + AND dq.site = m.site + AND dq.shopfloor_session_id = + po.shopfloor_session_id + CROSS JOIN + mv_reports_ucepsa_prod + .li_shopfloor_sync_health_v1 sh +), +classified AS ( + SELECT + f.*, + CASE + WHEN f.open_session_count > 1 + THEN 'MULTIPLE_OPEN_SESSIONS' + WHEN f.session_dq_status = 'CRITICAL' + THEN + 'OPEN_SESSION_DATA_QUALITY_ERROR' + WHEN f.open_session_count = 1 + AND f.published_order_present + AND f.published_workorder_id + IS DISTINCT FROM + f.odoo_workorder_id::text + THEN 'OPEN_SESSION_ORDER_MISMATCH' + WHEN f.open_session_count = 1 + AND f.published_order_present + AND f.machine_running + THEN 'HEALTHY_OPEN_SESSION' + WHEN f.open_session_count = 1 + AND f.published_order_present + AND NOT f.machine_running + THEN 'TEMPORARY_STOP_INSIDE_SESSION' + WHEN f.open_session_count = 1 + AND NOT f.published_order_present + AND f.machine_running + THEN + 'RUNNING_SESSION_WITHOUT_PUBLISHED_ORDER' + WHEN f.open_session_count = 1 + AND NOT f.published_order_present + AND NOT f.machine_running + THEN + 'OPEN_SESSION_WITHOUT_PUBLISHED_ORDER' + WHEN f.open_session_count = 0 + AND f.published_order_present + AND f.machine_running + THEN + 'RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION' + WHEN f.open_session_count = 0 + AND f.published_order_present + AND NOT f.machine_running + THEN + 'ORDER_WITHOUT_SHOPFLOOR_SESSION' + WHEN f.open_session_count = 0 + AND NOT f.published_order_present + AND f.machine_running + THEN + 'RUNNING_WITHOUT_SHOPFLOOR_SESSION' + ELSE 'IDLE_NO_ACTIVE_CONTEXT' + END AS live_context_status + FROM facts f +) +SELECT + c.*, + CASE + WHEN c.live_context_status IN ( + 'MULTIPLE_OPEN_SESSIONS', + 'OPEN_SESSION_DATA_QUALITY_ERROR', + 'OPEN_SESSION_ORDER_MISMATCH' + ) + THEN 'CRITICAL' + WHEN c.live_context_status IN ( + 'RUNNING_SESSION_WITHOUT_PUBLISHED_ORDER', + 'OPEN_SESSION_WITHOUT_PUBLISHED_ORDER', + 'RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION', + 'ORDER_WITHOUT_SHOPFLOOR_SESSION', + 'RUNNING_WITHOUT_SHOPFLOOR_SESSION' + ) + THEN 'WARNING' + WHEN c.live_context_status = + 'TEMPORARY_STOP_INSIDE_SESSION' + THEN 'INFO' + ELSE 'OK' + END AS live_severity, + CASE + WHEN c.live_context_status = + 'MULTIPLE_OPEN_SESSIONS' + THEN + 'Cerrar o revisar el solape: una máquina no debe tener más de una sesión OPEN.' + WHEN c.live_context_status = + 'OPEN_SESSION_DATA_QUALITY_ERROR' + THEN + 'Revisar la sesión OPEN y sus invariantes temporales antes de utilizarla.' + WHEN c.live_context_status = + 'OPEN_SESSION_ORDER_MISMATCH' + THEN + 'Comparar el workorder publicado con la sesión OPEN; no promover atribución hasta resolver la discrepancia.' + WHEN c.live_context_status = + 'RUNNING_SESSION_WITHOUT_PUBLISHED_ORDER' + THEN + 'Comprobar si el workorder quedó en progress o el publicador Odoo dejó de verlo.' + WHEN c.live_context_status = + 'OPEN_SESSION_WITHOUT_PUBLISHED_ORDER' + THEN + 'Esperar un posible cambio breve; revisar si la sesión permanece OPEN sin orden publicada.' + WHEN c.live_context_status = + 'RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION' + THEN + 'Revisar el sincronizador y el workorder: existe orden publicada pero no sesión OPEN.' + WHEN c.live_context_status = + 'ORDER_WITHOUT_SHOPFLOOR_SESSION' + THEN + 'Comprobar si la orden está preparada pero todavía no se ha iniciado la sesión.' + WHEN c.live_context_status = + 'RUNNING_WITHOUT_SHOPFLOOR_SESSION' + THEN + 'Confirmar sistema anterior, prueba, material residual o inicio omitido en Shop Floor.' + WHEN c.live_context_status = + 'TEMPORARY_STOP_INSIDE_SESSION' + THEN + 'Sin acción inmediata; la parada física no cierra la sesión Odoo.' + WHEN c.live_context_status = + 'HEALTHY_OPEN_SESSION' + THEN + 'Sin acción.' + ELSE + 'Sin contexto activo; situación compatible con máquina inactiva.' + END AS recommended_action, + false AS official_eligible, + now() AS observed_at +FROM classified c; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shopfloor_machine_live_context_v1 IS +'Estado vivo por cortadora al cruzar máquina física, orden publicada, sesión Shop Floor y salud del sincronizador.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shopfloor_open_session_watch_v1 +AS +SELECT + l.tenant, + l.site, + l.machine_id, + l.context_key, + l.shopfloor_session_id, + l.production_chain_key, + l.session_production_order + AS production_order, + l.odoo_production_id, + l.odoo_workorder_id, + l.session_started_at, + ROUND( + EXTRACT( + EPOCH FROM ( + now() - l.session_started_at + ) + )::numeric / 60, + 2 + ) AS session_age_min, + l.operator_employee_id, + l.operator_name, + l.operator_candidate_employee_id, + l.operator_candidate_name, + l.operator_confirmation_status, + l.quantity_confirmation_status, + l.session_confidence, + l.session_dq_status, + l.session_dq_issue_codes, + l.machine_running, + l.telemetry_fresh, + l.published_order_present, + l.published_order_coherent, + l.published_production_order, + l.published_workorder_id, + l.live_context_status AS watch_status, + l.live_severity AS watch_severity, + stop.stop_id AS open_stop_id, + stop.started_at AS open_stop_started_at, + CASE + WHEN stop.started_at IS NULL + THEN NULL::numeric + ELSE ROUND( + EXTRACT( + EPOCH FROM ( + now() - stop.started_at + ) + )::numeric / 60, + 2 + ) + END AS open_stop_duration_min, + l.recommended_action, + false AS official_eligible, + now() AS observed_at +FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1 l +LEFT JOIN LATERAL ( + SELECT + s.id AS stop_id, + s.started_at + FROM mv_hot.edge_oee_machine_stops s + WHERE s.tenant = l.tenant + AND s.site = l.site + AND s.machine_id = l.machine_id + AND s.status = 'OPEN' + ORDER BY s.started_at DESC + LIMIT 1 +) stop ON true +WHERE l.open_session_count > 0; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shopfloor_open_session_watch_v1 IS +'Vigilancia de sesiones OPEN, incluida parada física vigente sin interpretar esa parada como cierre de sesión.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shopfloor_context_incidents_v1 +AS +WITH sync_incidents AS ( + SELECT + ( + 'SYNC:' + || s.sync_name + || ':' + || s.sync_health_status + ) AS incident_key, + 'SYNC'::text AS incident_scope, + s.observed_at, + s.severity, + ( + 'SHOPFLOOR_SYNC_' + || s.sync_health_status + ) AS incident_code, + NULL::text AS machine_id, + NULL::text AS context_key, + NULL::text AS production_order, + NULL::bigint AS odoo_workorder_id, + NULL::text AS operator_name, + s.detail, + s.recommended_action, + false AS official_eligible + FROM + mv_reports_ucepsa_prod + .li_shopfloor_sync_health_v1 s + WHERE s.sync_health_status <> 'HEALTHY' +), +machine_incidents AS ( + SELECT + ( + 'MACHINE:' + || l.machine_id + || ':' + || l.live_context_status + ) AS incident_key, + 'MACHINE'::text AS incident_scope, + l.observed_at, + l.live_severity AS severity, + l.live_context_status AS incident_code, + l.machine_id, + l.context_key, + COALESCE( + l.session_production_order, + l.published_production_order + ) AS production_order, + l.odoo_workorder_id, + COALESCE( + l.operator_name, + l.operator_candidate_name + ) AS operator_name, + ( + 'Estado físico=' + || COALESCE( + l.operational_state, + 'UNKNOWN' + ) + || '; orden publicada=' + || COALESCE( + l.published_production_order, + 'NINGUNA' + ) + || '; sesiones OPEN=' + || l.open_session_count::text + || '.' + ) AS detail, + l.recommended_action, + false AS official_eligible + FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1 l + WHERE l.live_severity IN ( + 'WARNING', + 'CRITICAL' + ) +), +session_incidents AS ( + SELECT + ( + 'SESSION:' + || d.context_key + || ':' + || issue.issue_code + ) AS incident_key, + 'SESSION'::text AS incident_scope, + d.observed_at, + CASE + WHEN issue.issue_code IN ( + 'PENDING_HAS_TIME', + 'OPEN_MISSING_START', + 'OPEN_HAS_END', + 'CLOSED_MISSING_START', + 'CLOSED_MISSING_END', + 'INVALID_INTERVAL', + 'MULTIPLE_OPEN_SESSIONS' + ) + THEN 'CRITICAL' + ELSE 'WARNING' + END AS severity, + issue.issue_code AS incident_code, + d.machine_id, + d.context_key, + d.production_order, + d.odoo_workorder_id, + COALESCE( + d.operator_name, + d.operator_candidate_name + ) AS operator_name, + ( + 'Sesión ' + || d.session_status + || ', workorder ' + || COALESCE( + d.workorder_state, + 'NULL' + ) + || ', inicio ' + || COALESCE( + d.started_at::text, + 'NULL' + ) + || ', fin ' + || COALESCE( + d.ended_at::text, + 'NULL' + ) + || '.' + ) AS detail, + CASE issue.issue_code + WHEN 'PENDING_HAS_TIME' + THEN + 'Mantener las fechas solo en la capa raw; una sesión PENDING no aporta tiempo productivo.' + WHEN 'OPEN_MISSING_START' + THEN + 'Reconstruir el inicio desde la transición a progress antes de utilizar la sesión.' + WHEN 'OPEN_HAS_END' + THEN + 'Eliminar el final hasta que el workorder pase a done.' + WHEN 'CLOSED_MISSING_START' + THEN + 'Revisar la transición de inicio del workorder.' + WHEN 'CLOSED_MISSING_END' + THEN + 'Revisar el cierre del workorder y la captura del sincronizador.' + WHEN 'INVALID_INTERVAL' + THEN + 'Bloquear la sesión y revisar las fechas Odoo.' + WHEN 'WORKORDER_SESSION_STATE_MISMATCH' + THEN + 'Refrescar el workorder y revisar la traducción state→session_status.' + WHEN 'MULTIPLE_OPEN_SESSIONS' + THEN + 'Resolver el solape de sesiones OPEN en la máquina.' + WHEN 'STALE_OPEN_SESSION' + THEN + 'Confirmar si el turno continúa o el workorder quedó abierto indebidamente.' + WHEN 'FUTURE_SESSION_START' + THEN + 'No usar la fecha futura como inicio real; revisar planificación de Odoo.' + ELSE + 'Revisar la evidencia de la sesión.' + END AS recommended_action, + false AS official_eligible + FROM + mv_reports_ucepsa_prod + .li_shopfloor_session_data_quality_v1 d + CROSS JOIN LATERAL + unnest(d.issue_codes) + AS issue(issue_code) + WHERE d.dq_status IN ( + 'WARNING', + 'CRITICAL' + ) +) +SELECT * FROM sync_incidents +UNION ALL +SELECT * FROM machine_incidents +UNION ALL +SELECT * FROM session_incidents; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shopfloor_context_incidents_v1 IS +'Lista viva de incidencias de sincronización, coherencia máquina–Odoo–sesión y calidad de datos.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1 +AS +WITH sync_summary AS ( + SELECT + max(sync_health_status) + AS sync_health_status, + max(severity) + AS sync_severity, + max(seconds_since_success) + AS seconds_since_sync_success + FROM + mv_reports_ucepsa_prod + .li_shopfloor_sync_health_v1 +), +machine_summary AS ( + SELECT + count( + DISTINCT machine_id + ) AS machine_count, + count( + DISTINCT machine_id + ) FILTER ( + WHERE live_severity = 'OK' + ) AS healthy_machine_count, + count( + DISTINCT machine_id + ) FILTER ( + WHERE live_severity = 'INFO' + ) AS info_machine_count, + count( + DISTINCT machine_id + ) FILTER ( + WHERE live_severity = 'WARNING' + ) AS warning_machine_count, + count( + DISTINCT machine_id + ) FILTER ( + WHERE live_severity = 'CRITICAL' + ) AS critical_machine_count + FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1 +), +incident_summary AS ( + SELECT + count(*) FILTER ( + WHERE severity = 'WARNING' + ) AS warning_incident_count, + count(*) FILTER ( + WHERE severity = 'CRITICAL' + ) AS critical_incident_count + FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incidents_v1 +), +session_summary AS ( + SELECT + count(*) FILTER ( + WHERE session_status = 'OPEN' + ) AS open_session_count, + count(*) FILTER ( + WHERE session_status = 'PENDING' + ) AS pending_session_count + FROM + mv_reports_ucepsa_prod + .li_odoo_shopfloor_sessions_v1 +) +SELECT + now() AS observed_at, + sy.sync_health_status, + sy.seconds_since_sync_success, + m.machine_count, + m.healthy_machine_count, + m.info_machine_count, + m.warning_machine_count, + m.critical_machine_count, + s.open_session_count, + s.pending_session_count, + i.warning_incident_count, + i.critical_incident_count, + CASE + WHEN sy.sync_severity = 'CRITICAL' + OR m.critical_machine_count > 0 + OR i.critical_incident_count > 0 + THEN 'CRITICAL' + WHEN sy.sync_severity = 'WARNING' + OR m.warning_machine_count > 0 + OR i.warning_incident_count > 0 + THEN 'WARNING' + ELSE 'HEALTHY' + END AS overall_health_status, + false AS official_eligible +FROM sync_summary sy +CROSS JOIN machine_summary m +CROSS JOIN incident_summary i +CROSS JOIN session_summary s; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1 IS +'Resumen ejecutivo vivo de salud Shop Floor para Grafana.'; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON + mv_loss_intelligence + .odoo_shopfloor_health_policies + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shopfloor_sync_health_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shopfloor_session_data_quality_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_identity_registry_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shopfloor_open_session_watch_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shopfloor_context_incidents_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shopfloor_context_health_summary_v1 + TO grafana_ucepsa_ro; + END IF; +END +$$; + +COMMIT;