diff --git a/ucepsa/edge-oee-demo/docs/runbooks/106_canonical_context_arbitration_shadow_v0.3.8.md b/ucepsa/edge-oee-demo/docs/runbooks/106_canonical_context_arbitration_shadow_v0.3.8.md new file mode 100644 index 0000000..cf45c81 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/106_canonical_context_arbitration_shadow_v0.3.8.md @@ -0,0 +1,186 @@ +# UCEPSA — Arbitraje canónico de contexto SHADOW v0.3.8 + +## Objetivo + +Convertir varias fuentes posibles de contexto en una decisión única y +reproducible por tramo temporal. + +```text +una fuente autoritativa +→ contexto canónico + +cero fuentes autoritativas + actividad +→ UNCONFIRMED_CONTEXT + +más de una fuente autoritativa +→ CONTEXT_CONFLICT +``` + +La versión continúa íntegramente en SHADOW. + +## Fuentes autoritativas + +```text +ODOO_SHOPFLOOR +LEGACY_ERP autorizado +MANUAL_AUTHORIZED +MAINTENANCE +TEST_SETUP +``` + +## Evidencias auxiliares + +```text +ODOO_ORDER_CONTEXT +INFERRED_SHADOW +``` + +Una orden publicada o una inferencia nunca se promueven por sí solas a contexto +canónico. + +## Beneficios operativos + +### Una sola respuesta por tramo + +Antes, una consulta podía usar orden Odoo, otra la sesión Shop Floor y otra el +contexto legacy. Ahora todas pueden consultar la misma clave canónica. + +Ejemplo: + +```text +CORT-02 +ODOO_SHOPFLOOR:WORKORDER:340 +``` + +### Evita doble contabilización + +Si alguien abre un contexto legacy mientras continúa una sesión Shop Floor: + +```text +Shop Floor + legacy +→ CONTEXT_CONFLICT +→ ningún paro es elegible +``` + +El sistema no aplica una prioridad silenciosa. + +### Protege frente a falsas pérdidas + +CORT-00 en marcha sin contexto explícito permanece: + +```text +UNCONFIRMED_CONTEXT +``` + +No se inventa una orden, un operario ni una pérdida económica. + +### Permite convivir durante la migración + +Una orden fabricada con el sistema anterior puede usar un contexto +`LEGACY_ERP` autorizado sin obligar a fingir que pasó por Shop Floor. + +### Simplifica el producto + +Las capas posteriores dejan de implementar reglas diferentes para Odoo, +legacy y manual. Consultan: + +```text +canonical_context_key +canonical_context_source +canonical_context_status +``` + +### Facilita la activación futura + +La misma capa permite medir cobertura y conflictos antes de decidir si puede +alimentar el Loss Ledger oficial. + +## Nuevos objetos + +```text +context_arbitration_source_policies + +li_canonical_context_candidates_v1 +li_canonical_context_segments_v1 +li_context_arbitration_conflicts_v1 +li_machine_canonical_context_live_v1 +li_canonical_context_coverage_v1 +li_stop_canonical_context_slices_v1 +li_stop_canonical_context_shadow_v1 +li_canonical_context_diagnostics_v1 +``` + +## Despliegue + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/110_ucepsa_canonical_context_arbitration_shadow_v038.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$FILE" +``` + +No es necesario detener ningún servicio. + +## Validación + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_canonical_context_arbitration_v038.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$VALIDATION" +``` + +Valores obligatorios: + +```text +candidate_key_collisions = 0 +invalid_segment_intervals = 0 +negative_segment_durations = 0 +one_candidate_without_canonical_key = 0 +ambiguous_with_canonical_key = 0 +supporting_promoted_to_canonical = 0 +conflict_without_review = 0 +live_machine_rows = 3 +distinct_live_machines = 3 +official_live_rows = 0 +stop_duration_overflow_count = 0 +open_stops_marked_eligible = 0 +nonproductive_stops_marked_eligible = 0 +ambiguous_stops_marked_eligible = 0 +official_canonical_stop_rows = 0 +official_canonical_segment_rows = 0 +official_operator_queue_rows = 0 +``` + +## Lectura esperada + +Mientras la situación actual continúe: + +```text +CORT-00 +→ UNCONFIRMED_CONTEXT + +CORT-01 +→ NO_ACTIVE_CONTEXT + +CORT-02 +→ ODOO_SHOPFLOOR +→ ODOO_SHOPFLOOR:WORKORDER:340 +``` + +Si las máquinas cambian durante la validación, los estados pueden variar, pero +las invariantes deben mantenerse. + +## Dashboard + +Se amplía el dashboard existente con: + +```text +Contexto canónico vivo por máquina +Conflictos de arbitraje · últimas 24 horas +Cobertura de contexto canónico · últimos 30 días +``` + +El refresco queda fijado en 15 segundos. diff --git a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json index e97645d..3abe94a 100644 --- a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json +++ b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json @@ -15,7 +15,7 @@ } ] }, - "description": "Supervisión SHADOW de máquina física, orden Odoo, sesión Shop Floor, sincronizador, calidad temporal e historial persistente de incidencias. No alimenta el Ledger oficial.", + "description": "Supervisión SHADOW de máquina, Odoo Shop Floor, contextos explícitos, histórico de incidencias y arbitraje canónico. Cero fuentes autoritativas queda sin confirmar y más de una queda bloqueada por conflicto. No alimenta el Ledger oficial.", "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, @@ -1614,9 +1614,315 @@ ], "title": "Resumen diario de incidencias · últimos 7 días", "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Salud" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "OK": { + "text": "OK", + "color": "green", + "index": 0 + }, + "INFO": { + "text": "INFO", + "color": "blue", + "index": 1 + }, + "WARNING": { + "text": "WARNING", + "color": "orange", + "index": 2 + }, + "CRITICAL": { + "text": "CRITICAL", + "color": "red", + "index": 3 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Contexto canónico" + }, + "properties": [ + { + "id": "custom.width", + "value": 290 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Acción" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 71 + }, + "id": 14, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n machine_id AS \"Máquina\",\n CASE\n WHEN machine_running THEN 'Sí'\n ELSE 'No'\n END AS \"Marcha\",\n CASE canonical_context_status\n WHEN 'ODOO_SHOPFLOOR'\n THEN 'Odoo Shop Floor'\n WHEN 'LEGACY_ERP'\n THEN 'Producción legacy autorizada'\n WHEN 'MANUAL_AUTHORIZED'\n THEN 'Producción manual autorizada'\n WHEN 'MAINTENANCE'\n THEN 'Mantenimiento'\n WHEN 'TEST_SETUP'\n THEN 'Prueba / ajuste'\n WHEN 'UNCONFIRMED_CONTEXT'\n THEN 'Actividad sin contexto confirmado'\n WHEN 'CONTEXT_CONFLICT'\n THEN 'Conflicto de contextos'\n WHEN 'NO_ACTIVE_CONTEXT'\n THEN 'Sin contexto activo'\n ELSE canonical_context_status\n END AS \"Contexto canónico\",\n COALESCE(\n canonical_order_ref,\n '—'\n ) AS \"Orden\",\n COALESCE(\n canonical_odoo_workorder_id::text,\n '—'\n ) AS \"Workorder\",\n COALESCE(\n operator_name,\n operator_candidate_name,\n '—'\n ) AS \"Operario\",\n authoritative_candidate_count\n AS \"Fuentes autoritativas\",\n order_alignment_status\n AS \"Coherencia Odoo\",\n canonical_health_status\n AS \"Salud\",\n recommended_action\n AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_machine_canonical_context_live_v1\nORDER BY machine_id", + "refId": "A" + } + ], + "title": "Contexto canónico vivo por máquina", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Tipo de conflicto" + }, + "properties": [ + { + "id": "custom.width", + "value": 330 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Acción" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 81 + }, + "id": 15, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n segment_from AS \"Inicio\",\n segment_to AS \"Fin\",\n ROUND(\n segment_duration_s / 60.0,\n 2\n ) AS \"Duración min\",\n machine_id AS \"Máquina\",\n conflict_type AS \"Tipo de conflicto\",\n array_to_string(\n authoritative_context_sources,\n ', '\n ) AS \"Fuentes\",\n array_to_string(\n authoritative_context_keys,\n ', '\n ) AS \"Claves\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_arbitration_conflicts_v1\nWHERE segment_to >=\n now() - interval '24 hours'\nORDER BY segment_from DESC\nLIMIT 100", + "refId": "A" + } + ], + "title": "Conflictos de arbitraje · últimas 24 horas", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Contexto" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 90 + }, + "id": 16, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n machine_id AS \"Máquina\",\n CASE canonical_context_status\n WHEN 'ODOO_SHOPFLOOR'\n THEN 'Odoo Shop Floor'\n WHEN 'LEGACY_ERP'\n THEN 'Legacy autorizada'\n WHEN 'MANUAL_AUTHORIZED'\n THEN 'Manual autorizada'\n WHEN 'MAINTENANCE'\n THEN 'Mantenimiento'\n WHEN 'TEST_SETUP'\n THEN 'Prueba / ajuste'\n WHEN 'UNCONFIRMED_CONTEXT'\n THEN 'Sin confirmar'\n WHEN 'CONTEXT_CONFLICT'\n THEN 'Conflicto'\n ELSE canonical_context_status\n END AS \"Contexto\",\n segment_count AS \"Segmentos\",\n review_segment_count AS \"A revisar\",\n hours AS \"Horas\",\n machine_activity_pct AS \"% actividad\"\nFROM\n mv_reports_ucepsa_prod\n .li_canonical_context_coverage_v1\nORDER BY\n machine_id,\n hours DESC", + "refId": "A" + } + ], + "title": "Cobertura de contexto canónico · últimos 30 días", + "type": "table" } ], - "refresh": "5s", + "refresh": "15s", "schemaVersion": 42, "tags": [ "mesavault", @@ -1626,7 +1932,10 @@ "shadow", "presentation-v0362", "incident-history", - "v037" + "v037", + "canonical-context", + "arbitration", + "v038" ], "templating": { "list": [] diff --git a/ucepsa/edge-oee-demo/ops/deploy_canonical_context_arbitration_dashboard_v038.sh b/ucepsa/edge-oee-demo/ops/deploy_canonical_context_arbitration_dashboard_v038.sh new file mode 100755 index 0000000..cf358a4 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/deploy_canonical_context_arbitration_dashboard_v038.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-deploy}" +GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}" +REPO_ROOT="${CANONICAL_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}" +DASHBOARD_FILE="${CANONICAL_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}" +FOLDER_UID="${CANONICAL_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}" +DASHBOARD_UID="ucepsa-shopfloor-context-health" +DATASOURCE_UID="bfnbcasbm6hhca" + +resolve_url() { + if [[ -n "${GRAFANA_URL:-}" ]]; then + printf '%s' "$GRAFANA_URL" + return + fi + + local port_line host_port container_ip + + port_line="$( + docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \ + | head -n 1 || true + )" + + if [[ -n "$port_line" ]]; then + host_port="${port_line##*:}" + printf 'http://127.0.0.1:%s' "$host_port" + return + fi + + container_ip="$( + docker inspect "$GRAFANA_CONTAINER" \ + --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' + )" + + if [[ -z "$container_ip" ]]; then + echo "ERROR: no se pudo resolver Grafana." >&2 + exit 1 + fi + + printf 'http://%s:3000' "$container_ip" +} + +if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then + cat >&2 <<'MSG' +ERROR: falta GRAFANA_API_TOKEN. + + read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN + echo + export GRAFANA_API_TOKEN +MSG + exit 1 +fi + +export GRAFANA_URL_RESOLVED +GRAFANA_URL_RESOLVED="$(resolve_url)" + +python3 - \ + "$ACTION" \ + "$DASHBOARD_FILE" \ + "$FOLDER_UID" \ + "$DASHBOARD_UID" \ + "$DATASOURCE_UID" <<'PY' +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +( + action, + dashboard_file, + folder_uid, + dashboard_uid, + datasource_uid, +) = sys.argv[1:] + +base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/") +token = os.environ["GRAFANA_API_TOKEN"] + + +def request(method, path, payload=None): + body = None + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {token}", + } + + if payload is not None: + body = json.dumps( + payload, + ensure_ascii=False, + ).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request( + base_url + path, + data=body, + headers=headers, + method=method, + ) + + try: + with urllib.request.urlopen( + req, + timeout=20, + ) as response: + raw = response.read() + return ( + json.loads(raw.decode("utf-8")) + if raw + else None + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode( + "utf-8", + errors="replace", + ) + raise RuntimeError( + f"Grafana API {method} {path}: " + f"HTTP {exc.code}: {detail}" + ) from exc + + +health = request("GET", "/api/health") +if not health or health.get("database") != "ok": + raise RuntimeError( + f"Grafana no está saludable: {health!r}" + ) + +if action == "deploy": + with open( + dashboard_file, + encoding="utf-8", + ) as handle: + dashboard = json.load(handle) + + result = request( + "POST", + "/api/dashboards/db", + { + "dashboard": dashboard, + "folderUid": folder_uid, + "overwrite": True, + "message": ( + "MESAVAULT canonical context " + "arbitration SHADOW v0.3.8" + ), + }, + ) + + print(json.dumps( + { + "status": "deployed", + "dashboard_uid": dashboard_uid, + "folder_uid": folder_uid, + "response": result, + }, + ensure_ascii=False, + indent=2, + )) + +elif action == "validate": + result = request( + "GET", + ( + "/api/dashboards/uid/" + + urllib.parse.quote(dashboard_uid) + ), + ) + dashboard = result["dashboard"] + panels = { + panel.get("id"): panel + for panel in dashboard.get("panels", []) + } + datasource_uids = sorted({ + panel.get("datasource", {}).get("uid") + for panel in dashboard.get("panels", []) + if isinstance( + panel.get("datasource"), + dict, + ) + and panel.get("datasource", {}).get("uid") + }) + + errors = [] + + if len(dashboard.get("panels", [])) != 16: + errors.append( + "El dashboard no tiene 16 paneles" + ) + + if datasource_uids != [datasource_uid]: + errors.append( + f"Datasource inesperado: {datasource_uids!r}" + ) + + expected = { + 14: "li_machine_canonical_context_live_v1", + 15: "li_context_arbitration_conflicts_v1", + 16: "li_canonical_context_coverage_v1", + } + + for panel_id, fragment in expected.items(): + sql = ( + panels.get(panel_id, {}) + .get("targets", [{}])[0] + .get("rawSql", "") + ) + if fragment not in sql: + errors.append( + f"Panel {panel_id} no consulta {fragment}" + ) + + if dashboard.get("refresh") != "15s": + errors.append( + "El refresco no está alineado a 15s" + ) + + output = { + "status": "ok" if not errors else "error", + "dashboard_uid": dashboard.get("uid"), + "folder_uid": result.get("meta", {}).get( + "folderUid" + ), + "panel_count": len( + dashboard.get("panels", []) + ), + "refresh": dashboard.get("refresh"), + "datasource_uids": datasource_uids, + "version": dashboard.get("version"), + "url": result.get("meta", {}).get("url"), + "canonical_panels": { + "live": 14 in panels, + "conflicts": 15 in panels, + "coverage": 16 in panels, + }, + "errors": errors, + } + + print(json.dumps( + output, + ensure_ascii=False, + indent=2, + )) + + if errors: + raise SystemExit(1) + +else: + raise SystemExit( + "Uso: " + "deploy_canonical_context_arbitration_dashboard_v038.sh " + "{deploy|validate}" + ) +PY diff --git a/ucepsa/edge-oee-demo/ops/validate_canonical_context_arbitration_v038.sql b/ucepsa/edge-oee-demo/ops/validate_canonical_context_arbitration_v038.sql new file mode 100644 index 0000000..f65a10c --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_canonical_context_arbitration_v038.sql @@ -0,0 +1,268 @@ +\pset pager off + +\echo '=== 1. Objetos v0.3.8 ===' + +SELECT + to_regclass( + 'mv_loss_intelligence.context_arbitration_source_policies' + ) AS policy_table, + to_regclass( + 'mv_reports_ucepsa_prod.li_canonical_context_candidates_v1' + ) AS candidates_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_canonical_context_segments_v1' + ) AS segments_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_arbitration_conflicts_v1' + ) AS conflicts_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_machine_canonical_context_live_v1' + ) AS live_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_canonical_context_coverage_v1' + ) AS coverage_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_canonical_context_slices_v1' + ) AS stop_slices_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_canonical_context_shadow_v1' + ) AS stop_shadow_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_canonical_context_diagnostics_v1' + ) AS diagnostics_view; + +\echo '=== 2. Política de fuentes ===' + +SELECT + source_type, + candidate_role, + source_rank, + productive_context, + active +FROM + mv_loss_intelligence + .context_arbitration_source_policies +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CUTTER_CANONICAL_CONTEXT_V1' +ORDER BY source_rank; + +\echo '=== 3. Claves de candidato sin colisiones ===' + +WITH duplicated AS ( + SELECT + tenant, + site, + context_key, + COUNT(*) AS n + FROM + mv_reports_ucepsa_prod + .li_canonical_context_candidates_v1 + GROUP BY + tenant, + site, + context_key + HAVING COUNT(*) > 1 +) +SELECT COUNT(*) AS candidate_key_collisions +FROM duplicated; + +\echo '=== 4. Invariantes temporales de segmentos ===' + +SELECT + COUNT(*) FILTER ( + WHERE segment_to <= segment_from + ) AS invalid_segment_intervals, + COUNT(*) FILTER ( + WHERE segment_duration_s < 0 + ) AS negative_segment_durations +FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1; + +\echo '=== 5. Semántica canónica ===' + +SELECT + COUNT(*) FILTER ( + WHERE authoritative_candidate_count = 1 + AND canonical_context_key IS NULL + ) AS one_candidate_without_canonical_key, + COUNT(*) FILTER ( + WHERE authoritative_candidate_count <> 1 + AND canonical_context_key IS NOT NULL + ) AS ambiguous_with_canonical_key, + COUNT(*) FILTER ( + WHERE canonical_context_source IN ( + 'ODOO_ORDER_CONTEXT', + 'INFERRED_SHADOW' + ) + ) AS supporting_promoted_to_canonical, + COUNT(*) FILTER ( + WHERE canonical_context_status = + 'CONTEXT_CONFLICT' + AND NOT review_required + ) AS conflict_without_review +FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1; + +\echo '=== 6. Una fila viva por máquina ===' + +SELECT + COUNT(*) AS live_machine_rows, + COUNT( + DISTINCT machine_id + ) AS distinct_live_machines, + COUNT(*) FILTER ( + WHERE official_ledger_eligible + ) AS official_live_rows +FROM + mv_reports_ucepsa_prod + .li_machine_canonical_context_live_v1; + +\echo '=== 7. Estado vivo actual ===' + +SELECT + machine_id, + telemetry_fresh, + machine_running, + published_production_order, + published_workorder_id, + authoritative_candidate_count, + authoritative_context_sources, + canonical_context_key, + canonical_context_source, + canonical_context_status, + canonical_order_ref, + canonical_odoo_workorder_id, + operator_name, + operator_candidate_name, + order_alignment_status, + canonical_health_status, + recommended_action, + official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_machine_canonical_context_live_v1 +ORDER BY machine_id; + +\echo '=== 8. Candidatos históricos conocidos ===' + +SELECT + source_type, + candidate_role, + context_key, + machine_id, + order_ref, + odoo_workorder_id, + context_from, + context_to, + context_record_status, + authorization_status, + operator_name, + operator_candidate_name, + official_eligible +FROM + mv_reports_ucepsa_prod + .li_canonical_context_candidates_v1 +WHERE odoo_workorder_id IN ( + 87, + 810, + 340 + ) + OR production_context_session_id = 1 +ORDER BY + context_from, + source_rank, + context_key; + +\echo '=== 9. Conflictos de arbitraje ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_context_arbitration_conflicts_v1 +WHERE segment_to >= + now() - interval '24 hours' +ORDER BY segment_from DESC +LIMIT 30; + +\echo '=== 10. Duración de slices no supera el paro raw ===' + +WITH totals AS ( + SELECT + source_stop_id, + MAX(raw_duration_s) + AS raw_duration_s, + SUM(segment_duration_s) + AS sliced_duration_s + FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_slices_v1 + GROUP BY source_stop_id +) +SELECT COUNT(*) AS stop_duration_overflow_count +FROM totals +WHERE sliced_duration_s > + raw_duration_s + 1::numeric; + +\echo '=== 11. Paros abiertos y contextos no productivos no elegibles ===' + +SELECT + COUNT(*) FILTER ( + WHERE source_status = 'OPEN' + AND technically_eligible + ) AS open_stops_marked_eligible, + COUNT(*) FILTER ( + WHERE NOT canonical_productive_context + AND technically_eligible + ) AS nonproductive_stops_marked_eligible, + COUNT(*) FILTER ( + WHERE authoritative_candidate_count <> 1 + AND technically_eligible + ) AS ambiguous_stops_marked_eligible +FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_shadow_v1; + +\echo '=== 12. Nada oficial ===' + +SELECT + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_shadow_v1 + WHERE official_ledger_eligible + ) AS official_canonical_stop_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 + WHERE official_ledger_eligible + ) AS official_canonical_segment_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_operator_stop_queue_v2 + ) AS official_operator_queue_rows; + +\echo '=== 13. Diagnóstico 24 horas ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_canonical_context_diagnostics_v1; + +\echo '=== 14. Cobertura canónica 30 días ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_canonical_context_coverage_v1 +ORDER BY + machine_id, + hours DESC; diff --git a/ucepsa/edge-oee-demo/sql/versions/110_ucepsa_canonical_context_arbitration_shadow_v038.sql b/ucepsa/edge-oee-demo/sql/versions/110_ucepsa_canonical_context_arbitration_shadow_v038.sql new file mode 100644 index 0000000..7cea121 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/110_ucepsa_canonical_context_arbitration_shadow_v038.sql @@ -0,0 +1,2874 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_arbitration_source_policies ( + tenant text NOT NULL, + site text NOT NULL, + policy_code text NOT NULL, + source_type text NOT NULL, + candidate_role text NOT NULL + CHECK ( + candidate_role IN ( + 'AUTHORITATIVE', + 'SUPPORTING' + ) + ), + source_rank integer NOT NULL + CHECK (source_rank > 0), + productive_context boolean NOT NULL, + 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, + source_type + ) +); + +COMMENT ON TABLE +mv_loss_intelligence.context_arbitration_source_policies IS +'Política SHADOW que distingue fuentes autoritativas de evidencias auxiliares para arbitrar contexto productivo sin elegir silenciosamente entre fuentes solapadas.'; + +INSERT INTO +mv_loss_intelligence.context_arbitration_source_policies ( + tenant, + site, + policy_code, + source_type, + candidate_role, + source_rank, + productive_context, + active, + notes +) +VALUES +( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CANONICAL_CONTEXT_V1', + 'ODOO_SHOPFLOOR', + 'AUTHORITATIVE', + 10, + true, + true, + 'Sesión real de Shop Floor. Autoritativa cuando el intervalo es utilizable.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CANONICAL_CONTEXT_V1', + 'LEGACY_ERP', + 'AUTHORITATIVE', + 20, + true, + true, + 'Producción legacy válida únicamente si ha sido autorizada explícitamente.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CANONICAL_CONTEXT_V1', + 'MANUAL_AUTHORIZED', + 'AUTHORITATIVE', + 30, + true, + true, + 'Producción manual autorizada explícitamente.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CANONICAL_CONTEXT_V1', + 'MAINTENANCE', + 'AUTHORITATIVE', + 40, + false, + true, + 'Contexto explícito no productivo.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CANONICAL_CONTEXT_V1', + 'TEST_SETUP', + 'AUTHORITATIVE', + 50, + false, + true, + 'Prueba o ajuste explícito; no es producción.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CANONICAL_CONTEXT_V1', + 'ODOO_ORDER_CONTEXT', + 'SUPPORTING', + 90, + false, + true, + 'La orden publicada es evidencia auxiliar. Nunca se convierte por sí sola en contexto canónico.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CANONICAL_CONTEXT_V1', + 'INFERRED_SHADOW', + 'SUPPORTING', + 100, + false, + true, + 'La inferencia solo ayuda a revisar. Nunca es contexto canónico.' +) +ON CONFLICT ( + tenant, + site, + policy_code, + source_type +) +DO UPDATE SET + candidate_role = + EXCLUDED.candidate_role, + source_rank = + EXCLUDED.source_rank, + productive_context = + EXCLUDED.productive_context, + active = true, + notes = + EXCLUDED.notes, + updated_at = now(); + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_candidates_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence + .context_arbitration_source_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CUTTER_CANONICAL_CONTEXT_V1' + AND active +), +shopfloor AS ( + SELECT + s.tenant, + s.site, + s.machine_id, + ( + 'ODOO_SHOPFLOOR:WORKORDER:' + || s.odoo_workorder_id::text + ) AS context_key, + 'ODOO_SHOPFLOOR'::text + AS source_type, + p.candidate_role, + p.source_rank, + p.productive_context, + 'mv_loss_intelligence.odoo_shopfloor_sessions'::text + AS source_table, + s.shopfloor_session_id::text + AS source_native_id, + s.shopfloor_session_id, + NULL::bigint + AS production_context_session_id, + s.effective_from + AS context_from, + s.effective_to + AS context_to, + COALESCE( + s.effective_to, + now() + ) AS effective_context_to, + s.effective_to IS NULL + AS context_open_ended, + s.status + AS context_record_status, + s.authorization_status, + s.confidence, + s.context_time_usable, + s.individual_attribution_eligible, + s.external_order_ref + AS order_ref, + s.odoo_production_id, + s.odoo_workorder_id, + s.product_id, + s.product_default_code, + s.product_name, + s.operator_employee_id, + s.operator_name, + s.operator_candidate_employee_id, + s.operator_candidate_name, + s.operator_confirmation_status, + s.quantity_confirmation_status, + s.official_eligible + AS source_official_eligible, + false AS official_eligible, + 'Sesión Odoo Shop Floor utilizable.'::text + AS notes, + s.evidence_json + FROM + mv_reports_ucepsa_prod + .li_odoo_shopfloor_context_intervals_v1 s + JOIN policy p + ON p.source_type = + 'ODOO_SHOPFLOOR' + WHERE s.context_time_usable + AND s.status IN ( + 'OPEN', + 'CLOSED' + ) + AND s.effective_from IS NOT NULL + AND COALESCE( + s.effective_to, + now() + ) > s.effective_from +), +explicit_contexts AS ( + SELECT + s.tenant, + s.site, + s.machine_id, + ( + 'PRODUCTION_CONTEXT:' + || s.source_type + || ':' + || s.context_session_id::text + ) AS context_key, + s.source_type, + p.candidate_role, + p.source_rank, + p.productive_context, + 'mv_loss_intelligence.production_context_sessions'::text + AS source_table, + s.context_session_id::text + AS source_native_id, + NULL::bigint + AS shopfloor_session_id, + s.context_session_id + AS production_context_session_id, + s.started_at + AS context_from, + s.ended_at + AS context_to, + COALESCE( + s.ended_at, + now() + ) AS effective_context_to, + s.ended_at IS NULL + AS context_open_ended, + s.status + AS context_record_status, + s.authorization_status, + s.confidence, + true AS context_time_usable, + ( + s.operator_employee_id IS NOT NULL + AND s.operator_name IS NOT NULL + AND s.authorization_status IN ( + 'AUTHORIZED', + 'AUTO_CONFIRMED' + ) + ) AS individual_attribution_eligible, + s.external_order_ref + AS order_ref, + s.odoo_production_id, + s.odoo_workorder_id, + s.product_id, + s.product_default_code, + s.product_name, + s.operator_employee_id, + s.operator_name, + NULL::bigint + AS operator_candidate_employee_id, + NULL::text + AS operator_candidate_name, + CASE + WHEN s.operator_employee_id IS NOT NULL + AND s.operator_name IS NOT NULL + THEN 'CONFIRMED_HUMAN' + ELSE 'UNRESOLVED' + END AS operator_confirmation_status, + 'UNRESOLVED'::text + AS quantity_confirmation_status, + s.official_eligible + AS source_official_eligible, + false AS official_eligible, + s.notes, + s.evidence_json + FROM + mv_loss_intelligence + .production_context_sessions s + JOIN policy p + ON p.source_type = + s.source_type + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND s.source_type IN ( + 'LEGACY_ERP', + 'MANUAL_AUTHORIZED', + 'MAINTENANCE', + 'TEST_SETUP', + 'INFERRED_SHADOW' + ) + AND s.status IN ( + 'OPEN', + 'CLOSED' + ) + AND COALESCE( + s.ended_at, + now() + ) > s.started_at + AND ( + p.candidate_role = + 'SUPPORTING' + OR s.authorization_status IN ( + 'AUTHORIZED', + 'AUTO_CONFIRMED' + ) + ) +), +order_evidence AS ( + SELECT + o.tenant, + o.site, + o.machine_id, + ( + 'ODOO_ORDER_CONTEXT:' + || o.machine_id + || ':' + || o.id::text + ) AS context_key, + 'ODOO_ORDER_CONTEXT'::text + AS source_type, + p.candidate_role, + p.source_rank, + p.productive_context, + 'mv_reports_ucepsa_prod.li_order_state_intervals_v1'::text + AS source_table, + o.id::text + AS source_native_id, + NULL::bigint + AS shopfloor_session_id, + NULL::bigint + AS production_context_session_id, + o.effective_from + AS context_from, + o.effective_to + AS context_to, + COALESCE( + o.effective_to, + now() + ) AS effective_context_to, + o.effective_to IS NULL + AS context_open_ended, + CASE + WHEN o.effective_to IS NULL + THEN 'OPEN' + ELSE 'CLOSED' + END AS context_record_status, + 'AUTO_CONFIRMED'::text + AS authorization_status, + 'HIGH'::text + AS confidence, + true AS context_time_usable, + false AS individual_attribution_eligible, + o.production_order + AS order_ref, + o.production_id + AS odoo_production_id, + NULLIF( + o.workorder_id, + '' + )::bigint + AS odoo_workorder_id, + o.product_id, + NULL::text + AS product_default_code, + o.product + AS product_name, + NULL::bigint + AS operator_employee_id, + NULL::text + AS operator_name, + NULL::bigint + AS operator_candidate_employee_id, + NULL::text + AS operator_candidate_name, + 'UNRESOLVED'::text + AS operator_confirmation_status, + 'UNRESOLVED'::text + AS quantity_confirmation_status, + false AS source_official_eligible, + false AS official_eligible, + 'Orden Odoo coherente usada únicamente como evidencia auxiliar.'::text + AS notes, + jsonb_build_object( + 'order_state_event_id', + o.id, + 'odoo_state', + o.odoo_state, + 'workorder_state', + o.workorder_state, + 'qty_production', + o.qty_production + ) AS evidence_json + FROM + mv_reports_ucepsa_prod + .li_order_state_intervals_v1 o + JOIN policy p + ON p.source_type = + 'ODOO_ORDER_CONTEXT' + WHERE o.tenant = 'ucepsa' + AND o.site = 'ucepsa_onpremise' + AND o.order_present + AND o.order_coherent + AND COALESCE( + o.effective_to, + now() + ) > o.effective_from +) +SELECT * FROM shopfloor +UNION ALL +SELECT * FROM explicit_contexts +UNION ALL +SELECT * FROM order_evidence; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_candidates_v1 IS +'Inventario SHADOW de fuentes de contexto. Solo AUTHORITATIVE puede convertirse en contexto canónico; orden Odoo e inferencia son evidencias SUPPORTING.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_segments_v1 +AS +WITH activities AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_machine_running_activity_v1 +), +candidates AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_canonical_context_candidates_v1 +), +candidate_edges AS ( + SELECT + a.tenant, + a.site, + a.machine_id, + a.activity_island_id, + GREATEST( + a.activity_from, + c.context_from + ) AS boundary_at + FROM activities a + JOIN candidates c + ON c.tenant = a.tenant + AND c.site = a.site + AND c.machine_id = a.machine_id + AND a.activity_to > + c.context_from + AND a.activity_from < + c.effective_context_to + + UNION ALL + + SELECT + a.tenant, + a.site, + a.machine_id, + a.activity_island_id, + LEAST( + a.activity_to, + c.effective_context_to + ) AS boundary_at + FROM activities a + JOIN candidates c + ON c.tenant = a.tenant + AND c.site = a.site + AND c.machine_id = a.machine_id + AND a.activity_to > + c.context_from + AND a.activity_from < + c.effective_context_to +), +boundaries AS ( + SELECT + a.tenant, + a.site, + a.machine_id, + a.asset, + a.activity_island_id, + a.activity_source_key, + a.activity_from, + a.activity_to, + a.activity_duration_s, + a.activity_from + AS boundary_at + FROM activities a + + UNION ALL + + SELECT + a.tenant, + a.site, + a.machine_id, + a.asset, + a.activity_island_id, + a.activity_source_key, + a.activity_from, + a.activity_to, + a.activity_duration_s, + a.activity_to + AS boundary_at + FROM activities a + + UNION ALL + + SELECT + a.tenant, + a.site, + a.machine_id, + a.asset, + a.activity_island_id, + a.activity_source_key, + a.activity_from, + a.activity_to, + a.activity_duration_s, + e.boundary_at + FROM activities a + JOIN candidate_edges e + ON e.tenant = a.tenant + AND e.site = a.site + AND e.machine_id = a.machine_id + AND e.activity_island_id = + a.activity_island_id +), +distinct_boundaries AS ( + SELECT DISTINCT + tenant, + site, + machine_id, + asset, + activity_island_id, + activity_source_key, + activity_from, + activity_to, + activity_duration_s, + boundary_at + FROM boundaries +), +segmented AS ( + SELECT + b.*, + LEAD( + b.boundary_at + ) OVER ( + PARTITION BY + b.tenant, + b.site, + b.machine_id, + b.activity_island_id + ORDER BY + b.boundary_at + ) AS segment_to + FROM distinct_boundaries b +), +segments AS ( + SELECT + tenant, + site, + machine_id, + asset, + activity_island_id, + activity_source_key, + activity_from, + activity_to, + activity_duration_s, + boundary_at + AS segment_from, + segment_to + FROM segmented + WHERE segment_to IS NOT NULL + AND segment_to > + boundary_at +), +candidate_matches AS ( + SELECT + s.tenant, + s.site, + s.machine_id, + s.activity_island_id, + s.segment_from, + s.segment_to, + c.* + FROM segments s + JOIN candidates c + ON c.tenant = s.tenant + AND c.site = s.site + AND c.machine_id = s.machine_id + AND s.segment_to > + c.context_from + AND s.segment_from < + c.effective_context_to +), +authoritative_matches AS ( + SELECT + m.*, + ROW_NUMBER() OVER ( + PARTITION BY + m.tenant, + m.site, + m.machine_id, + m.activity_island_id, + m.segment_from, + m.segment_to + ORDER BY + m.source_rank, + m.context_key + ) AS authoritative_rank + FROM candidate_matches m + WHERE m.candidate_role = + 'AUTHORITATIVE' +), +candidate_summary AS ( + SELECT + m.tenant, + m.site, + m.machine_id, + m.activity_island_id, + m.segment_from, + m.segment_to, + COUNT( + DISTINCT m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'AUTHORITATIVE' + ) AS authoritative_candidate_count, + COUNT( + DISTINCT m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'SUPPORTING' + ) AS supporting_candidate_count, + COUNT( + DISTINCT m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'AUTHORITATIVE' + AND m.source_type = + 'ODOO_SHOPFLOOR' + ) AS authoritative_shopfloor_count, + COALESCE( + ARRAY_AGG( + DISTINCT m.context_key + ORDER BY m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'AUTHORITATIVE' + ), + ARRAY[]::text[] + ) AS authoritative_context_keys, + COALESCE( + ARRAY_AGG( + DISTINCT m.source_type + ORDER BY m.source_type + ) FILTER ( + WHERE m.candidate_role = + 'AUTHORITATIVE' + ), + ARRAY[]::text[] + ) AS authoritative_context_sources, + COALESCE( + ARRAY_AGG( + DISTINCT m.context_key + ORDER BY m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'SUPPORTING' + ), + ARRAY[]::text[] + ) AS supporting_context_keys, + COALESCE( + ARRAY_AGG( + DISTINCT m.source_type + ORDER BY m.source_type + ) FILTER ( + WHERE m.candidate_role = + 'SUPPORTING' + ), + ARRAY[]::text[] + ) AS supporting_context_sources, + COUNT( + DISTINCT m.context_key + ) FILTER ( + WHERE m.source_type = + 'ODOO_ORDER_CONTEXT' + ) AS supporting_order_count, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT m.odoo_workorder_id + ORDER BY m.odoo_workorder_id + ) FILTER ( + WHERE m.source_type = + 'ODOO_ORDER_CONTEXT' + ), + NULL + ), + ARRAY[]::bigint[] + ) AS supporting_order_workorder_ids, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT m.order_ref + ORDER BY m.order_ref + ) FILTER ( + WHERE m.source_type = + 'ODOO_ORDER_CONTEXT' + ), + NULL + ), + ARRAY[]::text[] + ) AS supporting_order_refs + FROM candidate_matches m + GROUP BY + m.tenant, + m.site, + m.machine_id, + m.activity_island_id, + m.segment_from, + m.segment_to +), +primary_authoritative AS ( + SELECT * + FROM authoritative_matches + WHERE authoritative_rank = 1 +), +enriched AS ( + SELECT + s.*, + COALESCE( + cs.authoritative_candidate_count, + 0 + ) AS authoritative_candidate_count, + COALESCE( + cs.supporting_candidate_count, + 0 + ) AS supporting_candidate_count, + COALESCE( + cs.authoritative_shopfloor_count, + 0 + ) AS authoritative_shopfloor_count, + COALESCE( + cs.authoritative_context_keys, + ARRAY[]::text[] + ) AS authoritative_context_keys, + COALESCE( + cs.authoritative_context_sources, + ARRAY[]::text[] + ) AS authoritative_context_sources, + COALESCE( + cs.supporting_context_keys, + ARRAY[]::text[] + ) AS supporting_context_keys, + COALESCE( + cs.supporting_context_sources, + ARRAY[]::text[] + ) AS supporting_context_sources, + COALESCE( + cs.supporting_order_count, + 0 + ) AS supporting_order_count, + COALESCE( + cs.supporting_order_workorder_ids, + ARRAY[]::bigint[] + ) AS supporting_order_workorder_ids, + COALESCE( + cs.supporting_order_refs, + ARRAY[]::text[] + ) AS supporting_order_refs, + pa.context_key + AS diagnostic_primary_context_key, + pa.source_type + AS diagnostic_primary_source, + pa.source_rank + AS diagnostic_primary_rank, + pa.shopfloor_session_id, + pa.production_context_session_id, + pa.context_from, + pa.context_to, + pa.context_open_ended, + pa.context_record_status, + pa.authorization_status, + pa.confidence, + pa.productive_context, + pa.individual_attribution_eligible, + pa.order_ref, + pa.odoo_production_id, + pa.odoo_workorder_id, + pa.product_id, + pa.product_default_code, + pa.product_name, + pa.operator_employee_id, + pa.operator_name, + pa.operator_candidate_employee_id, + pa.operator_candidate_name, + pa.operator_confirmation_status, + pa.quantity_confirmation_status, + pa.evidence_json + FROM segments s + LEFT JOIN candidate_summary cs + ON cs.tenant = s.tenant + AND cs.site = s.site + AND cs.machine_id = s.machine_id + AND cs.activity_island_id = + s.activity_island_id + AND cs.segment_from = + s.segment_from + AND cs.segment_to = + s.segment_to + LEFT JOIN primary_authoritative pa + ON pa.tenant = s.tenant + AND pa.site = s.site + AND pa.machine_id = s.machine_id + AND pa.activity_island_id = + s.activity_island_id + AND pa.segment_from = + s.segment_from + AND pa.segment_to = + s.segment_to +), +classified AS ( + SELECT + e.*, + CASE + WHEN e.authoritative_candidate_count = 0 + THEN 'UNCONFIRMED_CONTEXT' + WHEN e.authoritative_candidate_count > 1 + THEN 'CONTEXT_CONFLICT' + WHEN e.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + THEN 'ODOO_SHOPFLOOR' + WHEN e.diagnostic_primary_source = + 'LEGACY_ERP' + THEN 'LEGACY_ERP' + WHEN e.diagnostic_primary_source = + 'MANUAL_AUTHORIZED' + THEN 'MANUAL_AUTHORIZED' + WHEN e.diagnostic_primary_source = + 'MAINTENANCE' + THEN 'MAINTENANCE' + WHEN e.diagnostic_primary_source = + 'TEST_SETUP' + THEN 'TEST_SETUP' + ELSE 'UNSUPPORTED_CONTEXT' + END AS canonical_context_status + FROM enriched e +) +SELECT + c.tenant, + c.site, + c.machine_id, + c.asset, + c.activity_island_id, + c.activity_source_key, + c.activity_from, + c.activity_to, + c.activity_duration_s, + c.segment_from, + c.segment_to, + EXTRACT( + EPOCH FROM ( + c.segment_to + - c.segment_from + ) + )::numeric AS segment_duration_s, + c.authoritative_candidate_count, + c.supporting_candidate_count, + c.authoritative_shopfloor_count, + c.authoritative_context_keys, + c.authoritative_context_sources, + c.supporting_context_keys, + c.supporting_context_sources, + c.supporting_order_count, + c.supporting_order_workorder_ids, + c.supporting_order_refs, + c.diagnostic_primary_context_key, + c.diagnostic_primary_source, + c.diagnostic_primary_rank, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.diagnostic_primary_context_key + ELSE NULL::text + END AS canonical_context_key, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.diagnostic_primary_source + ELSE NULL::text + END AS canonical_context_source, + c.canonical_context_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.shopfloor_session_id + ELSE NULL::bigint + END AS shopfloor_session_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.production_context_session_id + ELSE NULL::bigint + END AS production_context_session_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.context_from + ELSE NULL::timestamptz + END AS canonical_context_from, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.context_to + ELSE NULL::timestamptz + END AS canonical_context_to, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.context_open_ended + ELSE false + END AS canonical_context_open_ended, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.context_record_status + ELSE NULL::text + END AS canonical_record_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.authorization_status + ELSE NULL::text + END AS canonical_authorization_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.confidence + ELSE NULL::text + END AS canonical_confidence, + ( + c.authoritative_candidate_count = 1 + AND COALESCE( + c.productive_context, + false + ) + ) AS canonical_productive_context, + ( + c.authoritative_candidate_count = 1 + AND COALESCE( + c.individual_attribution_eligible, + false + ) + ) AS individual_attribution_eligible, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.order_ref + ELSE NULL::text + END AS order_ref, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.odoo_production_id + ELSE NULL::bigint + END AS odoo_production_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.odoo_workorder_id + ELSE NULL::bigint + END AS odoo_workorder_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.product_id + ELSE NULL::bigint + END AS product_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.product_default_code + ELSE NULL::text + END AS product_default_code, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.product_name + ELSE NULL::text + END AS product_name, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_employee_id + ELSE NULL::bigint + END AS operator_employee_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_name + ELSE NULL::text + END AS operator_name, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_candidate_employee_id + ELSE NULL::bigint + END AS operator_candidate_employee_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_candidate_name + ELSE NULL::text + END AS operator_candidate_name, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_confirmation_status + ELSE NULL::text + END AS operator_confirmation_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.quantity_confirmation_status + ELSE NULL::text + END AS quantity_confirmation_status, + CASE + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND c.supporting_order_count = 0 + THEN 'NO_ORDER_EVIDENCE' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND ( + ( + c.odoo_workorder_id IS NOT NULL + AND c.odoo_workorder_id = ANY( + c.supporting_order_workorder_ids + ) + ) + OR ( + c.order_ref IS NOT NULL + AND c.order_ref = ANY( + c.supporting_order_refs + ) + ) + ) + THEN 'MATCH' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND c.supporting_order_count > 0 + THEN 'MISMATCH' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source IN ( + 'LEGACY_ERP', + 'MANUAL_AUTHORIZED', + 'MAINTENANCE', + 'TEST_SETUP' + ) + AND c.supporting_order_count > 0 + THEN 'ORDER_EVIDENCE_WITH_EXPLICIT_CONTEXT' + WHEN c.authoritative_candidate_count = 0 + AND c.supporting_order_count > 0 + THEN 'ORDER_EVIDENCE_ONLY' + ELSE 'NONE' + END AS order_evidence_status, + ( + c.authoritative_candidate_count = 0 + OR c.authoritative_candidate_count > 1 + ) AS review_required, + CASE + WHEN c.canonical_context_status = + 'UNCONFIRMED_CONTEXT' + THEN + 'La máquina tuvo actividad física, pero ninguna fuente autoritativa cubre el tramo.' + WHEN c.canonical_context_status = + 'CONTEXT_CONFLICT' + THEN + 'Más de una fuente autoritativa cubre el mismo tramo. Se bloquea el arbitraje.' + WHEN c.canonical_context_status = + 'ODOO_SHOPFLOOR' + THEN + 'La sesión Shop Floor es la única fuente autoritativa del tramo.' + WHEN c.canonical_context_status IN ( + 'LEGACY_ERP', + 'MANUAL_AUTHORIZED' + ) + THEN + 'Un contexto productivo explícitamente autorizado es la única fuente autoritativa.' + WHEN c.canonical_context_status IN ( + 'MAINTENANCE', + 'TEST_SETUP' + ) + THEN + 'El tramo tiene un contexto explícito no productivo.' + ELSE + 'El tipo de contexto no está soportado por la política canónica.' + END AS arbitration_reason, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.evidence_json + ELSE NULL::jsonb + END AS canonical_evidence_json, + false AS official_ledger_eligible +FROM classified c; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_segments_v1 IS +'Actividad física segmentada mediante arbitraje SHADOW. Solo una fuente AUTHORITATIVE produce contexto canónico; cero queda sin confirmar y más de una queda en conflicto.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_arbitration_conflicts_v1 +AS +SELECT + ( + 'CANONICAL_CONTEXT_CONFLICT:' + || machine_id + || ':' + || to_char( + segment_from + AT TIME ZONE 'UTC', + 'YYYYMMDDHH24MISSUS' + ) + ) AS conflict_key, + tenant, + site, + machine_id, + segment_from, + segment_to, + segment_duration_s, + authoritative_candidate_count, + authoritative_shopfloor_count, + authoritative_context_keys, + authoritative_context_sources, + supporting_context_keys, + supporting_context_sources, + CASE + WHEN authoritative_shopfloor_count > 1 + THEN + 'MULTIPLE_ODOO_SHOPFLOOR_SESSIONS' + WHEN authoritative_shopfloor_count = 1 + AND authoritative_candidate_count > 1 + THEN + 'ODOO_SHOPFLOOR_VS_EXPLICIT_CONTEXT' + ELSE + 'MULTIPLE_EXPLICIT_CONTEXTS' + END AS conflict_type, + 'CRITICAL'::text AS severity, + 'Revisar y cerrar o corregir el contexto solapado. Ningún paro del tramo debe promoverse mientras exista el conflicto.'::text + AS recommended_action, + true AS review_required, + false AS official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 +WHERE canonical_context_status = + 'CONTEXT_CONFLICT'; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_arbitration_conflicts_v1 IS +'Conflictos históricos de arbitraje sobre actividad física. El sistema no aplica prioridades silenciosas cuando hay más de una fuente autoritativa.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_machine_canonical_context_live_v1 +AS +WITH machines AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_shopfloor_machine_live_context_v1 +), +active_authoritative AS ( + SELECT + c.*, + ROW_NUMBER() OVER ( + PARTITION BY + c.tenant, + c.site, + c.machine_id + ORDER BY + c.source_rank, + c.context_key + ) AS source_row_rank + FROM + mv_reports_ucepsa_prod + .li_canonical_context_candidates_v1 c + WHERE c.candidate_role = + 'AUTHORITATIVE' + AND now() >= + c.context_from + AND ( + c.context_to IS NULL + OR now() < + c.context_to + ) +), +active_summary AS ( + SELECT + tenant, + site, + machine_id, + COUNT( + DISTINCT context_key + ) AS authoritative_candidate_count, + COUNT( + DISTINCT context_key + ) FILTER ( + WHERE source_type = + 'ODOO_SHOPFLOOR' + ) AS authoritative_shopfloor_count, + ARRAY_AGG( + DISTINCT context_key + ORDER BY context_key + ) AS authoritative_context_keys, + ARRAY_AGG( + DISTINCT source_type + ORDER BY source_type + ) AS authoritative_context_sources + FROM active_authoritative + GROUP BY + tenant, + site, + machine_id +), +primary_authoritative AS ( + SELECT * + FROM active_authoritative + WHERE source_row_rank = 1 +), +enriched AS ( + SELECT + m.*, + COALESCE( + s.authoritative_candidate_count, + 0 + ) AS authoritative_candidate_count, + COALESCE( + s.authoritative_shopfloor_count, + 0 + ) AS authoritative_shopfloor_count, + COALESCE( + s.authoritative_context_keys, + ARRAY[]::text[] + ) AS authoritative_context_keys, + COALESCE( + s.authoritative_context_sources, + ARRAY[]::text[] + ) AS authoritative_context_sources, + p.context_key + AS diagnostic_primary_context_key, + p.source_type + AS diagnostic_primary_source, + p.productive_context, + p.shopfloor_session_id + AS canonical_shopfloor_session_id, + p.production_context_session_id + AS canonical_production_context_session_id, + p.context_from + AS canonical_context_from, + p.context_to + AS canonical_context_to, + p.context_record_status + AS canonical_record_status, + p.authorization_status + AS canonical_authorization_status, + p.confidence + AS canonical_confidence, + p.order_ref + AS canonical_order_ref, + p.odoo_production_id + AS canonical_odoo_production_id, + p.odoo_workorder_id + AS canonical_odoo_workorder_id, + p.product_id + AS canonical_product_id, + p.product_name + AS canonical_product_name, + p.operator_employee_id, + p.operator_name, + p.operator_candidate_employee_id, + p.operator_candidate_name, + p.operator_confirmation_status, + p.quantity_confirmation_status, + p.individual_attribution_eligible + FROM machines m + LEFT JOIN active_summary s + ON s.tenant = m.tenant + AND s.site = m.site + AND s.machine_id = m.machine_id + LEFT JOIN primary_authoritative p + ON p.tenant = m.tenant + AND p.site = m.site + AND p.machine_id = m.machine_id +), +classified AS ( + SELECT + e.*, + CASE + WHEN e.authoritative_candidate_count > 1 + THEN 'CONTEXT_CONFLICT' + WHEN e.authoritative_candidate_count = 1 + THEN e.diagnostic_primary_source + WHEN e.machine_running + THEN 'UNCONFIRMED_CONTEXT' + ELSE 'NO_ACTIVE_CONTEXT' + END AS canonical_context_status + FROM enriched e +), +aligned AS ( + SELECT + c.*, + CASE + WHEN c.authoritative_candidate_count > 1 + THEN 'NOT_EVALUATED' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND NOT c.published_order_present + THEN 'MISSING_PUBLISHED_ORDER' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND c.published_order_present + AND ( + c.published_workorder_id = + c.canonical_odoo_workorder_id::text + OR c.published_production_order = + c.canonical_order_ref + ) + THEN 'MATCH' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND c.published_order_present + THEN 'MISMATCH' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source IN ( + 'LEGACY_ERP', + 'MANUAL_AUTHORIZED', + 'MAINTENANCE', + 'TEST_SETUP' + ) + AND c.published_order_present + THEN + 'ORDER_PRESENT_WITH_EXPLICIT_CONTEXT' + WHEN c.authoritative_candidate_count = 0 + AND c.published_order_present + THEN + 'ORDER_ONLY_NO_CANONICAL_CONTEXT' + ELSE 'NONE' + END AS order_alignment_status + FROM classified c +) +SELECT + a.tenant, + a.site, + a.machine_id, + a.workcenter_id, + a.telemetry_fresh, + a.machine_running, + a.operational_state, + a.migration_context_state, + a.published_order_present, + a.published_order_coherent, + a.published_production_order, + a.published_workorder_id, + a.open_session_count, + a.sync_health_status, + a.seconds_since_success, + a.authoritative_candidate_count, + a.authoritative_shopfloor_count, + a.authoritative_context_keys, + a.authoritative_context_sources, + a.diagnostic_primary_context_key, + a.diagnostic_primary_source, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.diagnostic_primary_context_key + ELSE NULL::text + END AS canonical_context_key, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.diagnostic_primary_source + ELSE NULL::text + END AS canonical_context_source, + a.canonical_context_status, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_shopfloor_session_id + ELSE NULL::bigint + END AS canonical_shopfloor_session_id, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_production_context_session_id + ELSE NULL::bigint + END AS canonical_production_context_session_id, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_context_from + ELSE NULL::timestamptz + END AS canonical_context_from, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_context_to + ELSE NULL::timestamptz + END AS canonical_context_to, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_record_status + ELSE NULL::text + END AS canonical_record_status, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_authorization_status + ELSE NULL::text + END AS canonical_authorization_status, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_confidence + ELSE NULL::text + END AS canonical_confidence, + ( + a.authoritative_candidate_count = 1 + AND COALESCE( + a.productive_context, + false + ) + ) AS canonical_productive_context, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_order_ref + ELSE NULL::text + END AS canonical_order_ref, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_odoo_production_id + ELSE NULL::bigint + END AS canonical_odoo_production_id, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_odoo_workorder_id + ELSE NULL::bigint + END AS canonical_odoo_workorder_id, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_product_id + ELSE NULL::bigint + END AS canonical_product_id, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.canonical_product_name + ELSE NULL::text + END AS canonical_product_name, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.operator_employee_id + ELSE NULL::bigint + END AS operator_employee_id, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.operator_name + ELSE NULL::text + END AS operator_name, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.operator_candidate_employee_id + ELSE NULL::bigint + END AS operator_candidate_employee_id, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.operator_candidate_name + ELSE NULL::text + END AS operator_candidate_name, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.operator_confirmation_status + ELSE NULL::text + END AS operator_confirmation_status, + CASE + WHEN a.authoritative_candidate_count = 1 + THEN a.quantity_confirmation_status + ELSE NULL::text + END AS quantity_confirmation_status, + ( + a.authoritative_candidate_count = 1 + AND COALESCE( + a.individual_attribution_eligible, + false + ) + ) AS individual_attribution_eligible, + a.order_alignment_status, + CASE + WHEN a.canonical_context_status = + 'CONTEXT_CONFLICT' + THEN 'CRITICAL' + WHEN a.order_alignment_status = + 'MISMATCH' + THEN 'CRITICAL' + WHEN a.canonical_context_status = + 'UNCONFIRMED_CONTEXT' + THEN 'WARNING' + WHEN a.order_alignment_status IN ( + 'MISSING_PUBLISHED_ORDER', + 'ORDER_PRESENT_WITH_EXPLICIT_CONTEXT', + 'ORDER_ONLY_NO_CANONICAL_CONTEXT' + ) + THEN 'WARNING' + WHEN a.canonical_context_status IN ( + 'MAINTENANCE', + 'TEST_SETUP' + ) + THEN 'INFO' + ELSE 'OK' + END AS canonical_health_status, + CASE + WHEN a.canonical_context_status = + 'CONTEXT_CONFLICT' + THEN + 'Resolver el solape antes de atribuir paros o producción.' + WHEN a.order_alignment_status = + 'MISMATCH' + THEN + 'La orden publicada no coincide con la sesión canónica. Revisar Odoo y el sincronizador.' + WHEN a.canonical_context_status = + 'UNCONFIRMED_CONTEXT' + THEN + 'Confirmar si la actividad corresponde a legacy, producción manual, prueba o mantenimiento.' + WHEN a.order_alignment_status = + 'MISSING_PUBLISHED_ORDER' + THEN + 'La sesión Shop Floor sigue abierta, pero no existe orden publicada. Revisar el publicador.' + WHEN a.order_alignment_status = + 'ORDER_PRESENT_WITH_EXPLICIT_CONTEXT' + THEN + 'Existe orden Odoo además del contexto explícito. Revisar antes de usar económicamente.' + WHEN a.order_alignment_status = + 'ORDER_ONLY_NO_CANONICAL_CONTEXT' + THEN + 'La orden publicada no basta para crear contexto canónico; comprobar el inicio de Shop Floor.' + ELSE 'Sin acción.' + END AS recommended_action, + false AS official_ledger_eligible, + now() AS observed_at +FROM aligned a; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_machine_canonical_context_live_v1 IS +'Una fila viva por cortadora con el resultado del arbitraje canónico SHADOW. Distingue ausencia de actividad, actividad sin contexto y conflicto de fuentes.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_coverage_v1 +AS +WITH recent AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 + WHERE segment_to >= + now() - interval '30 days' +) +SELECT + machine_id, + canonical_context_status, + canonical_context_source, + canonical_productive_context, + COUNT(*) AS segment_count, + COUNT(*) FILTER ( + WHERE review_required + ) AS review_segment_count, + ROUND( + SUM(segment_duration_s) + / 60.0, + 2 + ) AS minutes, + ROUND( + SUM(segment_duration_s) + / 3600.0, + 2 + ) AS hours, + ROUND( + ( + 100.0 + * SUM(segment_duration_s) + / NULLIF( + SUM( + SUM(segment_duration_s) + ) OVER ( + PARTITION BY machine_id + ), + 0 + ) + )::numeric, + 2 + ) AS machine_activity_pct, + false AS official_ledger_eligible +FROM recent +GROUP BY + machine_id, + canonical_context_status, + canonical_context_source, + canonical_productive_context +ORDER BY + machine_id, + hours DESC; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_coverage_v1 IS +'Cobertura de actividad física por resultado del arbitraje canónico durante los últimos 30 días.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_canonical_context_slices_v1 +AS +WITH raw_stops AS ( + SELECT + s.id AS source_stop_id, + s.tenant, + s.site, + s.vertical, + s.asset, + s.machine_id, + s.order_id + AS source_order_id, + s.started_at + AS raw_started_at, + s.ended_at + AS raw_ended_at, + COALESCE( + s.ended_at, + now() + ) AS raw_effective_end, + EXTRACT( + EPOCH FROM ( + COALESCE( + s.ended_at, + now() + ) + - s.started_at + ) + )::numeric AS raw_duration_s, + s.status + AS source_status, + s.classification_status, + s.cause_code, + s.operator_note, + s.value_hour_eur, + s.start_event_name, + s.end_event_name, + s.start_payload_json, + s.end_payload_json + FROM + mv_hot.edge_oee_machine_stops s + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND s.machine_id IS NOT NULL + AND COALESCE( + s.ended_at, + now() + ) > s.started_at +), +candidates AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_canonical_context_candidates_v1 +), +telemetry AS ( + SELECT + tenant, + site, + machine_id, + telemetry_island_id, + coverage_from, + coverage_to + FROM + mv_reports_ucepsa_prod + .li_telemetry_coverage_v1 +), +candidate_edges AS ( + SELECT + s.source_stop_id, + GREATEST( + s.raw_started_at, + c.context_from + ) AS boundary_at + FROM raw_stops s + JOIN candidates c + ON c.tenant = s.tenant + AND c.site = s.site + AND c.machine_id = s.machine_id + AND s.raw_effective_end > + c.context_from + AND s.raw_started_at < + c.effective_context_to + + UNION ALL + + SELECT + s.source_stop_id, + LEAST( + s.raw_effective_end, + c.effective_context_to + ) AS boundary_at + FROM raw_stops s + JOIN candidates c + ON c.tenant = s.tenant + AND c.site = s.site + AND c.machine_id = s.machine_id + AND s.raw_effective_end > + c.context_from + AND s.raw_started_at < + c.effective_context_to +), +telemetry_edges AS ( + SELECT + s.source_stop_id, + GREATEST( + s.raw_started_at, + t.coverage_from + ) AS boundary_at + FROM raw_stops s + JOIN telemetry t + ON t.tenant = s.tenant + AND t.site = s.site + AND t.machine_id = s.machine_id + AND s.raw_effective_end > + t.coverage_from + AND s.raw_started_at < + t.coverage_to + + UNION ALL + + SELECT + s.source_stop_id, + LEAST( + s.raw_effective_end, + t.coverage_to + ) AS boundary_at + FROM raw_stops s + JOIN telemetry t + ON t.tenant = s.tenant + AND t.site = s.site + AND t.machine_id = s.machine_id + AND s.raw_effective_end > + t.coverage_from + AND s.raw_started_at < + t.coverage_to +), +boundaries AS ( + SELECT + source_stop_id, + raw_started_at + AS boundary_at + FROM raw_stops + + UNION ALL + + SELECT + source_stop_id, + raw_effective_end + AS boundary_at + FROM raw_stops + + UNION ALL + + SELECT + source_stop_id, + boundary_at + FROM candidate_edges + + UNION ALL + + SELECT + source_stop_id, + boundary_at + FROM telemetry_edges +), +distinct_boundaries AS ( + SELECT DISTINCT + source_stop_id, + boundary_at + FROM boundaries +), +segmented AS ( + SELECT + source_stop_id, + boundary_at + AS segment_from, + LEAD( + boundary_at + ) OVER ( + PARTITION BY + source_stop_id + ORDER BY + boundary_at + ) AS segment_to + FROM distinct_boundaries +), +segments AS ( + SELECT + source_stop_id, + segment_from, + segment_to + FROM segmented + WHERE segment_to IS NOT NULL + AND segment_to > + segment_from +), +candidate_matches AS ( + SELECT + s.source_stop_id, + s.segment_from, + s.segment_to, + c.* + FROM segments s + JOIN raw_stops r + ON r.source_stop_id = + s.source_stop_id + JOIN candidates c + ON c.tenant = r.tenant + AND c.site = r.site + AND c.machine_id = r.machine_id + AND s.segment_to > + c.context_from + AND s.segment_from < + c.effective_context_to +), +authoritative_matches AS ( + SELECT + m.*, + ROW_NUMBER() OVER ( + PARTITION BY + m.source_stop_id, + m.segment_from, + m.segment_to + ORDER BY + m.source_rank, + m.context_key + ) AS authoritative_rank + FROM candidate_matches m + WHERE m.candidate_role = + 'AUTHORITATIVE' +), +candidate_summary AS ( + SELECT + m.source_stop_id, + m.segment_from, + m.segment_to, + COUNT( + DISTINCT m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'AUTHORITATIVE' + ) AS authoritative_candidate_count, + COUNT( + DISTINCT m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'SUPPORTING' + ) AS supporting_candidate_count, + COALESCE( + ARRAY_AGG( + DISTINCT m.context_key + ORDER BY m.context_key + ) FILTER ( + WHERE m.candidate_role = + 'AUTHORITATIVE' + ), + ARRAY[]::text[] + ) AS authoritative_context_keys, + COALESCE( + ARRAY_AGG( + DISTINCT m.source_type + ORDER BY m.source_type + ) FILTER ( + WHERE m.candidate_role = + 'AUTHORITATIVE' + ), + ARRAY[]::text[] + ) AS authoritative_context_sources, + COALESCE( + ARRAY_AGG( + DISTINCT m.source_type + ORDER BY m.source_type + ) FILTER ( + WHERE m.candidate_role = + 'SUPPORTING' + ), + ARRAY[]::text[] + ) AS supporting_context_sources, + COUNT( + DISTINCT m.context_key + ) FILTER ( + WHERE m.source_type = + 'ODOO_ORDER_CONTEXT' + ) AS supporting_order_count, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT m.odoo_workorder_id + ORDER BY m.odoo_workorder_id + ) FILTER ( + WHERE m.source_type = + 'ODOO_ORDER_CONTEXT' + ), + NULL + ), + ARRAY[]::bigint[] + ) AS supporting_order_workorder_ids, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT m.order_ref + ORDER BY m.order_ref + ) FILTER ( + WHERE m.source_type = + 'ODOO_ORDER_CONTEXT' + ), + NULL + ), + ARRAY[]::text[] + ) AS supporting_order_refs + FROM candidate_matches m + GROUP BY + m.source_stop_id, + m.segment_from, + m.segment_to +), +primary_authoritative AS ( + SELECT * + FROM authoritative_matches + WHERE authoritative_rank = 1 +), +telemetry_matches AS ( + SELECT + s.source_stop_id, + s.segment_from, + s.segment_to, + t.telemetry_island_id, + t.coverage_from, + t.coverage_to + FROM segments s + JOIN raw_stops r + ON r.source_stop_id = + s.source_stop_id + JOIN telemetry t + ON t.tenant = r.tenant + AND t.site = r.site + AND t.machine_id = r.machine_id + AND s.segment_to > + t.coverage_from + AND s.segment_from < + t.coverage_to +), +telemetry_summary AS ( + SELECT + source_stop_id, + segment_from, + segment_to, + COUNT( + DISTINCT telemetry_island_id + ) AS telemetry_island_count, + MIN(coverage_from) + AS first_telemetry_coverage_from, + MAX(coverage_to) + AS last_telemetry_coverage_to + FROM telemetry_matches + GROUP BY + source_stop_id, + segment_from, + segment_to +), +enriched AS ( + SELECT + r.*, + s.segment_from, + s.segment_to, + EXTRACT( + EPOCH FROM ( + s.segment_to + - s.segment_from + ) + )::numeric AS segment_duration_s, + COALESCE( + cs.authoritative_candidate_count, + 0 + ) AS authoritative_candidate_count, + COALESCE( + cs.supporting_candidate_count, + 0 + ) AS supporting_candidate_count, + COALESCE( + cs.authoritative_context_keys, + ARRAY[]::text[] + ) AS authoritative_context_keys, + COALESCE( + cs.authoritative_context_sources, + ARRAY[]::text[] + ) AS authoritative_context_sources, + COALESCE( + cs.supporting_context_sources, + ARRAY[]::text[] + ) AS supporting_context_sources, + COALESCE( + cs.supporting_order_count, + 0 + ) AS supporting_order_count, + COALESCE( + cs.supporting_order_workorder_ids, + ARRAY[]::bigint[] + ) AS supporting_order_workorder_ids, + COALESCE( + cs.supporting_order_refs, + ARRAY[]::text[] + ) AS supporting_order_refs, + pa.context_key + AS diagnostic_primary_context_key, + pa.source_type + AS diagnostic_primary_source, + pa.shopfloor_session_id, + pa.production_context_session_id, + pa.context_from, + pa.context_to, + pa.context_record_status, + pa.authorization_status, + pa.confidence, + pa.productive_context, + pa.individual_attribution_eligible, + pa.order_ref, + pa.odoo_production_id, + pa.odoo_workorder_id, + pa.product_id, + pa.product_default_code, + pa.product_name, + pa.operator_employee_id, + pa.operator_name, + pa.operator_candidate_employee_id, + pa.operator_candidate_name, + pa.operator_confirmation_status, + pa.quantity_confirmation_status, + pa.evidence_json, + COALESCE( + ts.telemetry_island_count, + 0 + ) AS telemetry_island_count, + COALESCE( + ts.telemetry_island_count, + 0 + ) > 0 AS telemetry_covered, + ts.first_telemetry_coverage_from, + ts.last_telemetry_coverage_to + FROM segments s + JOIN raw_stops r + ON r.source_stop_id = + s.source_stop_id + LEFT JOIN candidate_summary cs + ON cs.source_stop_id = + s.source_stop_id + AND cs.segment_from = + s.segment_from + AND cs.segment_to = + s.segment_to + LEFT JOIN primary_authoritative pa + ON pa.source_stop_id = + s.source_stop_id + AND pa.segment_from = + s.segment_from + AND pa.segment_to = + s.segment_to + LEFT JOIN telemetry_summary ts + ON ts.source_stop_id = + s.source_stop_id + AND ts.segment_from = + s.segment_from + AND ts.segment_to = + s.segment_to +), +classified AS ( + SELECT + e.*, + CASE + WHEN e.authoritative_candidate_count = 0 + THEN 'UNCONFIRMED_CONTEXT' + WHEN e.authoritative_candidate_count > 1 + THEN 'CONTEXT_CONFLICT' + WHEN e.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + THEN 'ODOO_SHOPFLOOR' + WHEN e.diagnostic_primary_source = + 'LEGACY_ERP' + THEN 'LEGACY_ERP' + WHEN e.diagnostic_primary_source = + 'MANUAL_AUTHORIZED' + THEN 'MANUAL_AUTHORIZED' + WHEN e.diagnostic_primary_source = + 'MAINTENANCE' + THEN 'MAINTENANCE' + WHEN e.diagnostic_primary_source = + 'TEST_SETUP' + THEN 'TEST_SETUP' + ELSE 'UNSUPPORTED_CONTEXT' + END AS canonical_context_status + FROM enriched e +) +SELECT + c.source_stop_id, + c.tenant, + c.site, + c.vertical, + c.asset, + c.machine_id, + c.source_order_id, + c.raw_started_at, + c.raw_ended_at, + c.raw_effective_end, + c.raw_duration_s, + c.source_status, + c.classification_status, + c.cause_code, + c.operator_note, + c.value_hour_eur, + c.start_event_name, + c.end_event_name, + c.start_payload_json, + c.end_payload_json, + c.segment_from, + c.segment_to, + c.segment_duration_s, + c.authoritative_candidate_count, + c.supporting_candidate_count, + c.authoritative_context_keys, + c.authoritative_context_sources, + c.supporting_context_sources, + c.supporting_order_count, + c.supporting_order_workorder_ids, + c.supporting_order_refs, + c.diagnostic_primary_context_key, + c.diagnostic_primary_source, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.diagnostic_primary_context_key + ELSE NULL::text + END AS canonical_context_key, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.diagnostic_primary_source + ELSE NULL::text + END AS canonical_context_source, + c.canonical_context_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.shopfloor_session_id + ELSE NULL::bigint + END AS shopfloor_session_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.production_context_session_id + ELSE NULL::bigint + END AS production_context_session_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.context_from + ELSE NULL::timestamptz + END AS canonical_context_from, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.context_to + ELSE NULL::timestamptz + END AS canonical_context_to, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.context_record_status + ELSE NULL::text + END AS canonical_record_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.authorization_status + ELSE NULL::text + END AS canonical_authorization_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.confidence + ELSE NULL::text + END AS canonical_confidence, + ( + c.authoritative_candidate_count = 1 + AND COALESCE( + c.productive_context, + false + ) + ) AS canonical_productive_context, + ( + c.authoritative_candidate_count = 1 + AND COALESCE( + c.individual_attribution_eligible, + false + ) + ) AS individual_attribution_eligible, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.order_ref + ELSE NULL::text + END AS order_ref, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.odoo_production_id + ELSE NULL::bigint + END AS odoo_production_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.odoo_workorder_id + ELSE NULL::bigint + END AS odoo_workorder_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.product_id + ELSE NULL::bigint + END AS product_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.product_default_code + ELSE NULL::text + END AS product_default_code, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.product_name + ELSE NULL::text + END AS product_name, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_employee_id + ELSE NULL::bigint + END AS operator_employee_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_name + ELSE NULL::text + END AS operator_name, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_candidate_employee_id + ELSE NULL::bigint + END AS operator_candidate_employee_id, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_candidate_name + ELSE NULL::text + END AS operator_candidate_name, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.operator_confirmation_status + ELSE NULL::text + END AS operator_confirmation_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.quantity_confirmation_status + ELSE NULL::text + END AS quantity_confirmation_status, + c.telemetry_island_count, + c.telemetry_covered, + c.first_telemetry_coverage_from, + c.last_telemetry_coverage_to, + CASE + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND c.supporting_order_count = 0 + THEN 'NO_ORDER_EVIDENCE' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND ( + ( + c.odoo_workorder_id IS NOT NULL + AND c.odoo_workorder_id = ANY( + c.supporting_order_workorder_ids + ) + ) + OR ( + c.order_ref IS NOT NULL + AND c.order_ref = ANY( + c.supporting_order_refs + ) + ) + ) + THEN 'MATCH' + WHEN c.authoritative_candidate_count = 1 + AND c.diagnostic_primary_source = + 'ODOO_SHOPFLOOR' + AND c.supporting_order_count > 0 + THEN 'MISMATCH' + WHEN c.authoritative_candidate_count = 0 + AND c.supporting_order_count > 0 + THEN 'ORDER_EVIDENCE_ONLY' + ELSE 'NONE' + END AS order_evidence_status, + CASE + WHEN c.authoritative_candidate_count = 1 + THEN c.evidence_json + ELSE NULL::jsonb + END AS canonical_evidence_json, + false AS official_ledger_eligible +FROM classified c; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_canonical_context_slices_v1 IS +'Cada paro físico dividido por límites de contexto autoritativo, evidencia auxiliar y telemetría. No modifica las vistas de paros existentes.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_canonical_context_shadow_v1 +AS +WITH rollup AS ( + SELECT + s.source_stop_id, + s.tenant, + s.site, + MAX(s.vertical) + AS vertical, + MAX(s.asset) + AS asset, + s.machine_id, + MAX(s.source_order_id) + AS source_order_id, + MAX(s.raw_started_at) + AS raw_started_at, + MAX(s.raw_ended_at) + AS raw_ended_at, + MAX(s.raw_effective_end) + AS raw_effective_end, + MAX(s.raw_duration_s) + AS raw_duration_s, + MAX(s.source_status) + AS source_status, + MAX(s.classification_status) + AS classification_status, + MAX(s.cause_code) + AS cause_code, + MAX(s.operator_note) + AS operator_note, + MAX(s.value_hour_eur) + AS value_hour_eur, + MAX(s.start_event_name) + AS start_event_name, + MAX(s.end_event_name) + AS end_event_name, + s.authoritative_candidate_count, + s.supporting_candidate_count, + s.authoritative_context_keys, + s.authoritative_context_sources, + s.supporting_context_sources, + s.canonical_context_status, + s.canonical_context_key, + s.canonical_context_source, + s.shopfloor_session_id, + s.production_context_session_id, + MIN(s.canonical_context_from) + AS canonical_context_from, + MAX(s.canonical_context_to) + AS canonical_context_to, + s.canonical_record_status, + s.canonical_authorization_status, + s.canonical_confidence, + s.canonical_productive_context, + s.individual_attribution_eligible, + s.order_ref, + s.odoo_production_id, + s.odoo_workorder_id, + s.product_id, + s.product_default_code, + s.product_name, + s.operator_employee_id, + s.operator_name, + s.operator_candidate_employee_id, + s.operator_candidate_name, + s.operator_confirmation_status, + s.quantity_confirmation_status, + s.order_evidence_status, + MIN(s.segment_from) + AS started_at, + MAX(s.segment_to) + AS ended_at, + SUM(s.segment_duration_s) + AS canonical_segment_duration_s, + SUM( + CASE + WHEN s.telemetry_covered + THEN s.segment_duration_s + ELSE 0::numeric + END + ) AS telemetry_covered_duration_s, + COUNT(*) AS slice_count, + COUNT(*) FILTER ( + WHERE s.telemetry_covered + ) AS telemetry_slice_count, + MIN(s.first_telemetry_coverage_from) + AS first_telemetry_coverage_from, + MAX(s.last_telemetry_coverage_to) + AS last_telemetry_coverage_to, + ( + JSONB_AGG( + s.canonical_evidence_json + ORDER BY s.segment_from + ) FILTER ( + WHERE s.canonical_evidence_json + IS NOT NULL + ) + )->0 AS canonical_evidence_json + FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_slices_v1 s + GROUP BY + s.source_stop_id, + s.tenant, + s.site, + s.machine_id, + s.authoritative_candidate_count, + s.supporting_candidate_count, + s.authoritative_context_keys, + s.authoritative_context_sources, + s.supporting_context_sources, + s.canonical_context_status, + s.canonical_context_key, + s.canonical_context_source, + s.shopfloor_session_id, + s.production_context_session_id, + s.canonical_record_status, + s.canonical_authorization_status, + s.canonical_confidence, + s.canonical_productive_context, + s.individual_attribution_eligible, + s.order_ref, + s.odoo_production_id, + s.odoo_workorder_id, + s.product_id, + s.product_default_code, + s.product_name, + s.operator_employee_id, + s.operator_name, + s.operator_candidate_employee_id, + s.operator_candidate_name, + s.operator_confirmation_status, + s.quantity_confirmation_status, + s.order_evidence_status +), +with_boundaries AS ( + SELECT + r.*, + COUNT(*) OVER ( + PARTITION BY + r.source_stop_id + ) > 1 AS crosses_canonical_boundary + FROM rollup r +), +enriched AS ( + SELECT + r.*, + COALESCE( + p.noise_below_s, + 5 + ) AS noise_below_s, + COALESCE( + p.aggregate_below_s, + 300 + ) AS aggregate_below_s, + COALESCE( + p.operator_individual_from_s, + 300 + ) AS operator_individual_from_s, + COALESCE( + p.aggregation_window_min, + 15 + ) AS aggregation_window_min, + c.cause_name, + c.is_planned, + COALESCE( + ep.parameter_value, + r.value_hour_eur, + 36 + ) AS applied_capacity_eur_h, + COALESCE( + ep.confidence, + 'LOW' + ) AS capacity_cost_confidence, + CASE + WHEN r.authoritative_candidate_count = 1 + AND r.canonical_productive_context + THEN + r.telemetry_covered_duration_s + ELSE 0::numeric + END AS eligible_duration_s + FROM with_boundaries r + LEFT JOIN + mv_hot.edge_oee_stop_causes c + ON c.cause_code = + r.cause_code + LEFT JOIN LATERAL ( + SELECT x.* + FROM + mv_loss_intelligence + .loss_policies x + WHERE x.tenant = r.tenant + AND x.site = r.site + AND x.policy_code = + 'CUTTER_STOPS_V1' + AND x.is_active + ORDER BY x.id DESC + LIMIT 1 + ) p ON true + LEFT JOIN LATERAL ( + SELECT x.* + FROM + mv_loss_intelligence + .economic_parameters x + WHERE x.tenant = r.tenant + AND x.site = r.site + AND x.parameter_code = + 'CAPACITY_HOUR_EUR' + AND x.is_active + AND ( + x.machine_id IS NULL + OR x.machine_id = + r.machine_id + ) + AND ( + x.product_id IS NULL + OR x.product_id = + r.product_id + ) + AND r.started_at >= + x.valid_from + AND ( + x.valid_to IS NULL + OR r.started_at < + x.valid_to + ) + ORDER BY + ( + x.machine_id IS NOT NULL + ) DESC, + ( + x.product_id IS NOT NULL + ) DESC, + x.valid_from DESC, + x.id DESC + LIMIT 1 + ) ep ON true +), +classified AS ( + SELECT + e.*, + CASE + WHEN e.source_status = + 'OPEN' + THEN 'OPEN_MONITORING' + WHEN e.authoritative_candidate_count = 0 + THEN + 'BLOCKED_NO_CANONICAL_CONTEXT' + WHEN e.authoritative_candidate_count > 1 + THEN + 'BLOCKED_CONTEXT_CONFLICT' + WHEN NOT e.canonical_productive_context + THEN + 'EXCLUDED_NON_PRODUCTION_CONTEXT' + WHEN e.eligible_duration_s <= 0 + THEN + 'BLOCKED_NO_TELEMETRY' + WHEN e.eligible_duration_s < + e.noise_below_s + THEN 'EXCLUDED_NOISE' + WHEN e.eligible_duration_s < + e.operator_individual_from_s + THEN 'CANDIDATE_GROUP' + ELSE 'CANDIDATE_INDIVIDUAL' + END AS eligibility_status, + ( + e.source_status = 'CLOSED' + AND e.authoritative_candidate_count = 1 + AND e.canonical_productive_context + AND e.eligible_duration_s >= + e.noise_below_s + ) AS technically_eligible, + CASE + WHEN e.authoritative_candidate_count <> 1 + OR NOT e.canonical_productive_context + THEN 'NOT_APPLICABLE' + WHEN e.individual_attribution_eligible + THEN 'CONFIRMED_OPERATOR' + WHEN e.operator_candidate_employee_id + IS NOT NULL + THEN + 'PENDING_OPERATOR_CANDIDATE' + ELSE 'UNRESOLVED_OPERATOR' + END AS operator_attribution_status + FROM enriched e +) +SELECT + c.source_stop_id, + c.tenant, + c.site, + c.vertical, + c.asset, + c.machine_id, + c.source_order_id, + c.raw_started_at, + c.raw_ended_at, + c.raw_effective_end, + c.raw_duration_s, + c.started_at, + c.ended_at, + c.canonical_segment_duration_s, + c.telemetry_covered_duration_s, + c.eligible_duration_s, + ROUND( + c.eligible_duration_s + / 60.0, + 2 + ) AS eligible_duration_min, + GREATEST( + c.canonical_segment_duration_s + - c.eligible_duration_s, + 0::numeric + ) AS excluded_duration_s, + ROUND( + GREATEST( + c.canonical_segment_duration_s + - c.eligible_duration_s, + 0::numeric + ) / 60.0, + 2 + ) AS excluded_duration_min, + c.crosses_canonical_boundary, + c.slice_count, + c.telemetry_slice_count, + c.first_telemetry_coverage_from, + c.last_telemetry_coverage_to, + c.source_status, + c.classification_status, + c.cause_code, + c.cause_name, + c.is_planned, + c.operator_note, + c.start_event_name, + c.end_event_name, + c.authoritative_candidate_count, + c.supporting_candidate_count, + c.authoritative_context_keys, + c.authoritative_context_sources, + c.supporting_context_sources, + c.canonical_context_status, + c.canonical_context_key, + c.canonical_context_source, + c.shopfloor_session_id, + c.production_context_session_id, + c.canonical_context_from, + c.canonical_context_to, + c.canonical_record_status, + c.canonical_authorization_status, + c.canonical_confidence, + c.canonical_productive_context, + c.order_evidence_status, + c.order_ref, + c.odoo_production_id, + c.odoo_workorder_id, + c.product_id, + c.product_default_code, + c.product_name, + c.operator_employee_id, + c.operator_name, + c.operator_candidate_employee_id, + c.operator_candidate_name, + c.operator_confirmation_status, + c.quantity_confirmation_status, + c.operator_attribution_status, + c.applied_capacity_eur_h, + c.capacity_cost_confidence, + CASE + WHEN c.technically_eligible + THEN ROUND( + c.eligible_duration_s + / 3600.0 + * c.applied_capacity_eur_h, + 2 + ) + ELSE NULL::numeric + END AS shadow_capacity_loss_eur, + c.eligibility_status, + c.technically_eligible, + false AS official_ledger_eligible, + CASE + WHEN c.eligibility_status = + 'OPEN_MONITORING' + THEN + 'El paro continúa abierto.' + WHEN c.eligibility_status = + 'BLOCKED_NO_CANONICAL_CONTEXT' + THEN + 'No existe una fuente autoritativa única para el tramo.' + WHEN c.eligibility_status = + 'BLOCKED_CONTEXT_CONFLICT' + THEN + 'Más de una fuente autoritativa cubre el tramo.' + WHEN c.eligibility_status = + 'EXCLUDED_NON_PRODUCTION_CONTEXT' + THEN + 'El contexto canónico es mantenimiento o prueba.' + WHEN c.eligibility_status = + 'BLOCKED_NO_TELEMETRY' + THEN + 'No existe cobertura de telemetría válida.' + WHEN c.eligibility_status = + 'EXCLUDED_NOISE' + THEN + 'Duración inferior al umbral de ruido.' + WHEN c.eligibility_status IN ( + 'CANDIDATE_GROUP', + 'CANDIDATE_INDIVIDUAL' + ) + THEN + 'Candidato técnico SHADOW con contexto canónico único.' + ELSE + 'Tramo bloqueado por la política de arbitraje.' + END AS blocking_reason, + jsonb_build_object( + 'source_stop_id', + c.source_stop_id, + 'canonical_context_status', + c.canonical_context_status, + 'canonical_context_key', + c.canonical_context_key, + 'canonical_context_source', + c.canonical_context_source, + 'authoritative_candidate_count', + c.authoritative_candidate_count, + 'authoritative_context_keys', + c.authoritative_context_keys, + 'supporting_context_sources', + c.supporting_context_sources, + 'order_evidence_status', + c.order_evidence_status, + 'crosses_canonical_boundary', + c.crosses_canonical_boundary, + 'canonical_evidence', + c.canonical_evidence_json + ) AS evidence_json +FROM classified c; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_canonical_context_shadow_v1 IS +'Paros físicos cruzados con telemetría y contexto canónico SHADOW. Acepta Shop Floor o contexto explícito autorizado, bloquea conflicto y nunca alimenta la cola oficial.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_diagnostics_v1 +AS +SELECT + machine_id, + canonical_context_status, + canonical_context_source, + eligibility_status, + operator_attribution_status, + COUNT(*) AS row_count, + COUNT( + DISTINCT source_stop_id + ) AS source_stop_count, + ROUND( + SUM(canonical_segment_duration_s) + / 60.0, + 2 + ) AS observed_minutes, + ROUND( + SUM(eligible_duration_s) + / 60.0, + 2 + ) AS eligible_minutes, + ROUND( + SUM( + COALESCE( + shadow_capacity_loss_eur, + 0 + ) + ), + 2 + ) AS shadow_capacity_loss_eur, + false AS official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_shadow_v1 +WHERE raw_started_at >= + now() - interval '24 hours' +GROUP BY + machine_id, + canonical_context_status, + canonical_context_source, + eligibility_status, + operator_attribution_status +ORDER BY + machine_id, + canonical_context_status, + eligibility_status; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_diagnostics_v1 IS +'Diagnóstico de 24 horas del cruce paro × telemetría × contexto canónico SHADOW.'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = + 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON + mv_loss_intelligence + .context_arbitration_source_policies + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_candidates_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_arbitration_conflicts_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_machine_canonical_context_live_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_coverage_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_stop_canonical_context_slices_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_stop_canonical_context_shadow_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_diagnostics_v1 + TO grafana_ucepsa_ro; + END IF; +END +$$; + +COMMIT;