From 72c57f4ce97aa570fa933bb669b03f13572a449f Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Mon, 20 Jul 2026 17:56:33 +0200 Subject: [PATCH] feat(ucepsa): consolidate root cases into human decisions shadow --- ..._context_decision_groups_shadow_v0.3.11.md | 160 +++ ...sa-shopfloor-context-health.dashboard.json | 387 +++++- ...context_decision_groups_dashboard_v0311.sh | 275 ++++ .../manage_context_decision_groups_v0311.sh | 47 + ...validate_context_decision_groups_v0311.sql | 221 +++ ...a_context_decision_groups_shadow_v0311.sql | 1218 +++++++++++++++++ .../tools/context_decision_group_admin.py | 650 +++++++++ 7 files changed, 2952 insertions(+), 6 deletions(-) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/110_context_decision_groups_shadow_v0.3.11.md create mode 100755 ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh create mode 100755 ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh create mode 100644 ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql create mode 100755 ucepsa/edge-oee-demo/tools/context_decision_group_admin.py diff --git a/ucepsa/edge-oee-demo/docs/runbooks/110_context_decision_groups_shadow_v0.3.11.md b/ucepsa/edge-oee-demo/docs/runbooks/110_context_decision_groups_shadow_v0.3.11.md new file mode 100644 index 0000000..ca4886b --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/110_context_decision_groups_shadow_v0.3.11.md @@ -0,0 +1,160 @@ +# UCEPSA — Decisiones humanas consolidadas SHADOW v0.3.11 + +## Objetivo + +Reducir la carga de revisión: + +```text +10 episodios técnicos +→ 7 casos raíz +→ aproximadamente 3 decisiones humanas +``` + +No se elimina ningún intervalo, caso o evidencia. + +## Agrupación inicial + +### Actividad sin contexto + +```text +misma máquina ++ mismo día local Europe/Madrid ++ separación <= 15 min +→ una decisión humana +``` + +### Conflictos de arranque Odoo + +```text +MULTIPLE_OPEN_SESSIONS ++ ORDER_WITHOUT_SESSION ++ misma máquina ++ separación <= 5 min +→ una decisión humana +``` + +No se inventan turnos horarios porque todavía no existe un calendario de turnos +confirmado. El día local actúa como frontera conservadora. + +## Casos esperados + +```text +CORT-00 casos 1/2/3/4 +→ una decisión sobre sistema antiguo + +CORT-00 casos 5/6 +→ una decisión WH/MO/00660 vs WH/MO/00706 + +CORT-02 casos 7/8 +→ una decisión sobre actividad posterior sin sesión +``` + +## Revisión + +La revisión de una decisión: + +- registra una única respuesta humana; +- revisa únicamente los casos raíz pendientes; +- revisa únicamente los episodios pendientes; +- conserva las revisiones ya existentes; +- no modifica Odoo; +- no crea contextos retrospectivos automáticamente; +- no habilita el Ledger oficial. + +## Despliegue + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$FILE" +``` + +No hay servicio nuevo: la agrupación es determinista mediante vistas SQL. + +## Runtime + +```bash +RUNTIME=/srv/mesavault/edge-oee-ucepsa +TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo + +install -m 0644 \ + "$TARGET/tools/context_decision_group_admin.py" \ + "$RUNTIME/tools/" +``` + +## Validación + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.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 +root_case_assignment_duplicates = 0 +recent_root_cases_without_decision_group = 0 + +decision_group_count_1_2_3_4 = 1 +linked_case_count_1_2_3_4 = 4 + +decision_group_count_5_6 = 1 +linked_case_count_5_6 = 2 + +decision_group_count_7_8 = 1 +linked_case_count_7_8 = 2 + +decision_group_count_exceeds_root_case_count = 0 +ready_v3_gate_mismatches = 0 + +official_decision_group_rows = 0 +official_gate_rows = 0 +official_operator_queue_rows = 0 +``` + +## Consola + +```bash +MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh + +"$MANAGER" list --limit 50 +"$MANAGER" show \ + --decision-group-key 'DECISION:...' +``` + +No revisar el grupo de doble arranque hasta recibir la respuesta de Juan Pablo. + +Cuando responda: + +```bash +"$MANAGER" review \ + --decision-group-key 'DECISION:ODOO_ORDER_START_CONFLICT:CORT-00:20260720:5' \ + --classification ODOO_DOUBLE_START_ONE_VALID \ + --selected-order-ref "WH/MO/00706" \ + --selected-workorder-id 698 \ + --reviewed-by "Juan Pablo" \ + --notes "Confirma la orden real y la orden abierta por error." +``` + +## Dashboard + +Se mantiene el mismo UID y se añaden: + +```text +Decisiones humanas pendientes +Casos raíz incluidos en cada decisión +Historial de decisiones humanas +``` + +Readiness v3 compara: + +```text +evidencias pendientes +casos raíz pendientes +decisiones humanas pendientes +``` 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 71aa26a..b372fc6 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 sesiones, incidencias, arbitraje, readiness y casos raíz. Producción revisa una decisión por problema real mientras las evidencias técnicas se conservan para auditoría.", + "description": "Supervisión SHADOW de sesiones, incidencias, arbitraje, casos raíz y decisiones humanas consolidadas. La carga de revisión se mide por preguntas operativas, conservando la auditoría de casos y evidencias.", "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, @@ -2045,11 +2045,11 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n machine_id AS \"Máquina\",\n observed_activity_hours AS \"Horas observadas\",\n confirmed_context_pct AS \"% confirmado\",\n unconfirmed_context_pct AS \"% sin confirmar\",\n pending_root_case_count AS \"Casos raíz pendientes\",\n pending_review_count AS \"Evidencias pendientes\",\n oldest_pending_root_case_hours\n AS \"Antigüedad caso h\",\n recent_conflict_minutes AS \"Conflicto min\",\n critical_session_dq_count AS \"DQ crítica\",\n readiness_status AS \"Readiness\",\n next_action AS \"Siguiente acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_canonical_context_readiness_gate_v2\nORDER BY machine_id", + "rawSql": "SELECT\n machine_id AS \"Máquina\",\n observed_activity_hours AS \"Horas observadas\",\n confirmed_context_pct AS \"% confirmado\",\n unconfirmed_context_pct AS \"% sin confirmar\",\n pending_decision_group_count\n AS \"Decisiones pendientes\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n pending_review_count\n AS \"Evidencias pendientes\",\n oldest_pending_decision_group_hours\n AS \"Antigüedad decisión h\",\n recent_conflict_minutes AS \"Conflicto min\",\n critical_session_dq_count AS \"DQ crítica\",\n readiness_status AS \"Readiness\",\n next_action AS \"Siguiente acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_canonical_context_readiness_gate_v3\nORDER BY machine_id", "refId": "A" } ], - "title": "Readiness por máquina · casos raíz", + "title": "Readiness por máquina · decisiones humanas", "type": "table" }, { @@ -2166,7 +2166,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n machine_id AS \"Máquina\",\n blocker_severity AS \"Severidad\",\n blocker_code AS \"Criterio\",\n current_value AS \"Valor actual\",\n target_value AS \"Objetivo\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_blockers_v2\nORDER BY\n CASE blocker_severity\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n machine_id,\n blocker_code", + "rawSql": "SELECT\n machine_id AS \"Máquina\",\n blocker_severity AS \"Severidad\",\n blocker_code AS \"Criterio\",\n current_value AS \"Valor actual\",\n target_value AS \"Objetivo\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_blockers_v3\nORDER BY\n CASE blocker_severity\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n machine_id,\n blocker_code", "refId": "A" } ], @@ -2231,7 +2231,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n overall_readiness_status AS \"Estado global\",\n machine_count AS \"Máquinas\",\n ready_machine_count AS \"Ready SHADOW\",\n technical_blocked_machine_count\n AS \"Bloqueo técnico\",\n insufficient_observation_machine_count\n AS \"Falta observación\",\n review_backlog_machine_count\n AS \"Revisión pendiente\",\n coverage_not_ready_machine_count\n AS \"Cobertura insuficiente\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n blocker_count AS \"Bloqueos\",\n critical_blocker_count AS \"Bloqueos críticos\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_readiness_summary_v2", + "rawSql": "SELECT\n overall_readiness_status AS \"Estado global\",\n machine_count AS \"Máquinas\",\n ready_machine_count AS \"Ready SHADOW\",\n technical_blocked_machine_count\n AS \"Bloqueo técnico\",\n insufficient_observation_machine_count\n AS \"Falta observación\",\n review_backlog_machine_count\n AS \"Revisión pendiente\",\n coverage_not_ready_machine_count\n AS \"Cobertura insuficiente\",\n pending_decision_group_count\n AS \"Decisiones pendientes\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n pending_episode_review_count\n AS \"Evidencias pendientes\",\n blocker_count AS \"Bloqueos\",\n critical_blocker_count AS \"Bloqueos críticos\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_readiness_summary_v3", "refId": "A" } ], @@ -2609,6 +2609,378 @@ ], "title": "Historial de casos raíz · ú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": "Severidad" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "WARNING": { + "text": "WARNING", + "color": "orange", + "index": 0 + }, + "CRITICAL": { + "text": "CRITICAL", + "color": "red", + "index": 1 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Pregunta operativa" + }, + "properties": [ + { + "id": "custom.width", + "value": 400 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Pregunta" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Acción" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 157 + }, + "id": 23, + "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 backlog_rank AS \"Prioridad\",\n decision_group_key AS \"Decisión\",\n CASE decision_status\n WHEN 'OPEN' THEN 'Abierta'\n ELSE 'Cerrada'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Pregunta operativa\",\n array_to_string(\n root_case_ids,\n ', '\n ) AS \"Casos raíz\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n pending_root_case_count\n AS \"Casos pendientes\",\n reviewed_root_case_count\n AS \"Casos revisados\",\n array_to_string(\n known_resolution_classifications,\n ', '\n ) AS \"Resolución conocida\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n first_seen_at AS \"Inicio\",\n decision_duration_min AS \"Duración min\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_decision_group_backlog_v1\nORDER BY backlog_rank\nLIMIT 50", + "refId": "A" + } + ], + "title": "Decisiones humanas pendientes", + "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": "Severidad" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "WARNING": { + "text": "WARNING", + "color": "orange", + "index": 0 + }, + "CRITICAL": { + "text": "CRITICAL", + "color": "red", + "index": 1 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Caso" + }, + "properties": [ + { + "id": "custom.width", + "value": 400 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 168 + }, + "id": 24, + "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 decision_group_key AS \"Decisión\",\n decision_case_role AS \"Rol\",\n case_id AS \"Caso raíz\",\n case_title AS \"Caso\",\n case_status AS \"Estado\",\n case_severity AS \"Severidad\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n case_duration_min AS \"Duración min\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n linked_evidence_count AS \"Evidencias\",\n case_review_status AS \"Revisión\",\n COALESCE(\n case_resolution_classification,\n '—'\n ) AS \"Resolución\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_decision_group_cases_v1\nWHERE decision_review_status =\n 'PENDING'\nORDER BY\n decision_group_key,\n case_id\nLIMIT 200", + "refId": "A" + } + ], + "title": "Casos raíz incluidos en cada decisión", + "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": "Severidad" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "WARNING": { + "text": "WARNING", + "color": "orange", + "index": 0 + }, + "CRITICAL": { + "text": "CRITICAL", + "color": "red", + "index": 1 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Pregunta operativa" + }, + "properties": [ + { + "id": "custom.width", + "value": 420 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 178 + }, + "id": 25, + "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 decision_group_key AS \"Decisión\",\n CASE decision_status\n WHEN 'OPEN' THEN 'Abierta'\n ELSE 'Cerrada'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Pregunta operativa\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n decision_duration_min AS \"Duración min\",\n linked_root_case_count AS \"Casos raíz\",\n pending_root_case_count AS \"Pendientes\",\n review_status AS \"Revisión\",\n COALESCE(\n resolution_classification,\n '—'\n ) AS \"Resolución\",\n COALESCE(\n selected_order_ref,\n '—'\n ) AS \"Orden válida\",\n COALESCE(\n selected_workorder_id::text,\n '—'\n ) AS \"WO válido\",\n COALESCE(\n reviewed_by,\n '—'\n ) AS \"Revisado por\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_decision_group_history_v1\nWHERE first_seen_at >=\n now() - interval '7 days'\nORDER BY first_seen_at DESC\nLIMIT 100", + "refId": "A" + } + ], + "title": "Historial de decisiones humanas · últimos 7 días", + "type": "table" } ], "refresh": "15s", @@ -2630,7 +3002,10 @@ "v039", "root-cases", "human-governance", - "v0310" + "v0310", + "decision-groups", + "temporal-consolidation", + "v0311" ], "templating": { "list": [] diff --git a/ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh b/ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh new file mode 100755 index 0000000..c4bd32b --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-deploy}" +GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}" +REPO_ROOT="${DECISION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}" +DASHBOARD_FILE="${DECISION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}" +FOLDER_UID="${DECISION_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 + echo "ERROR: falta GRAFANA_API_TOKEN." >&2 + 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 decision groups " + "SHADOW v0.3.11" + ), + }, + ) + 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", [])) != 25: + errors.append( + "El dashboard no tiene 25 paneles" + ) + + if dashboard.get("refresh") != "15s": + errors.append( + "El refresco no está en 15s" + ) + + if datasource_uids != [datasource_uid]: + errors.append( + f"Datasource inesperado: {datasource_uids!r}" + ) + + expected = { + 23: "li_context_decision_group_backlog_v1", + 24: "li_context_decision_group_cases_v1", + 25: "li_context_decision_group_history_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}" + ) + + readiness_sql = ( + panels.get(17, {}) + .get("targets", [{}])[0] + .get("rawSql", "") + ) + if ( + "li_canonical_context_readiness_gate_v3" + not in readiness_sql + ): + errors.append( + "El panel readiness no usa gate v3" + ) + + 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"), + "decision_group_panels": { + "backlog": 23 in panels, + "root_cases": 24 in panels, + "history": 25 in panels, + "readiness_v3": + "li_canonical_context_readiness_gate_v3" + in readiness_sql, + }, + "errors": + errors, + } + + print(json.dumps( + output, + ensure_ascii=False, + indent=2, + )) + + if errors: + raise SystemExit(1) + +else: + raise SystemExit( + "Uso: deploy_context_decision_groups_dashboard_v0311.sh " + "{deploy|validate}" + ) +PY diff --git a/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh b/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh new file mode 100755 index 0000000..5b9a703 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-list}" +ROOT="${CONTEXT_DECISION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}" +NETWORK="${CONTEXT_DECISION_NETWORK:-mv_ucepsa_net}" +POSTGRES_CONTAINER="${CONTEXT_DECISION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}" +TENANT_OVERRIDE="${CONTEXT_DECISION_TENANT_OVERRIDE:-ucepsa}" +SITE_OVERRIDE="${CONTEXT_DECISION_SITE_OVERRIDE:-ucepsa_onpremise}" +PGHOST_OVERRIDE="${CONTEXT_DECISION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}" + +cd "$ROOT" + +IMAGE="$( + docker inspect \ + mv_ucepsa_odoo_order_state_pg_sink \ + --format '{{.Config.Image}}' +)" + +PGDATABASE_OVERRIDE="${CONTEXT_DECISION_PGDATABASE_OVERRIDE:-$( + docker inspect "$POSTGRES_CONTAINER" \ + --format '{{range .Config.Env}}{{println .}}{{end}}' \ + | sed -n 's/^POSTGRES_DB=//p' \ + | head -n 1 +)}" + +if [[ -z "$PGDATABASE_OVERRIDE" ]]; then + echo "ERROR: no se pudo determinar POSTGRES_DB." >&2 + exit 1 +fi + +shift || true + +docker run --rm -i \ + --network "$NETWORK" \ + --env-file .env \ + -e "TENANT_OVERRIDE=$TENANT_OVERRIDE" \ + -e "SITE_OVERRIDE=$SITE_OVERRIDE" \ + -e "PGHOST=$PGHOST_OVERRIDE" \ + -e "PGDATABASE=$PGDATABASE_OVERRIDE" \ + -v "$ROOT:/app:ro" \ + -w /app \ + --entrypoint python \ + "$IMAGE" \ + tools/context_decision_group_admin.py \ + "$ACTION" \ + "$@" diff --git a/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.sql b/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.sql new file mode 100644 index 0000000..7fe7f44 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.sql @@ -0,0 +1,221 @@ +\pset pager off + +\echo '=== 1. Objetos v0.3.11 ===' + +SELECT + to_regclass( + 'mv_loss_intelligence.context_decision_group_policies' + ) AS policy_table, + to_regclass( + 'mv_loss_intelligence.context_decision_group_reviews' + ) AS reviews_table, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_decision_group_case_assignments_v1' + ) AS assignments_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_decision_group_history_v1' + ) AS history_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_decision_group_backlog_v1' + ) AS backlog_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_decision_group_cases_v1' + ) AS cases_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v3' + ) AS readiness_v3_view; + +\echo '=== 2. Política de consolidación ===' + +SELECT + source_case_family, + decision_family, + max_gap_s, + require_same_local_day, + review_owner, + timezone_name, + active +FROM + mv_loss_intelligence + .context_decision_group_policies +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CONTEXT_DECISION_GROUPS_V1' +ORDER BY source_case_family; + +\echo '=== 3. Cada caso raíz aparece una sola vez ===' + +SELECT COUNT(*) AS root_case_assignment_duplicates +FROM ( + SELECT + case_id, + COUNT(*) AS n + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + GROUP BY case_id + HAVING COUNT(*) > 1 +) duplicated; + +\echo '=== 4. Casos recientes sin decisión consolidada ===' + +SELECT COUNT(*) AS recent_root_cases_without_decision_group +FROM + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 c +WHERE c.review_required + AND ( + c.case_status = 'OPEN' + OR c.first_seen_at >= + now() - interval '30 days' + ) + AND NOT EXISTS ( + SELECT 1 + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 a + WHERE a.case_id = + c.case_id + ); + +\echo '=== 5. CORT-00 actividad sin contexto: casos 1/2/3/4 ===' + +SELECT + COUNT( + DISTINCT decision_group_key + ) AS decision_group_count_1_2_3_4, + COUNT(*) AS linked_case_count_1_2_3_4 +FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 +WHERE case_id IN ( + 1, + 2, + 3, + 4 +); + +\echo '=== 6. CORT-00 doble arranque: casos 5/6 ===' + +SELECT + COUNT( + DISTINCT decision_group_key + ) AS decision_group_count_5_6, + COUNT(*) AS linked_case_count_5_6 +FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 +WHERE case_id IN ( + 5, + 6 +); + +\echo '=== 7. CORT-02 actividad posterior: casos 7/8 ===' + +SELECT + COUNT( + DISTINCT decision_group_key + ) AS decision_group_count_7_8, + COUNT(*) AS linked_case_count_7_8 +FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 +WHERE case_id IN ( + 7, + 8 +); + +\echo '=== 8. Decisiones humanas pendientes ===' + +SELECT + backlog_rank, + decision_group_key, + decision_status, + max_severity, + machine_id, + decision_family, + decision_title, + root_case_ids, + production_orders, + odoo_workorder_ids, + pending_root_case_count, + reviewed_root_case_count, + known_resolution_classifications, + review_owner, + review_status, + question_text +FROM + mv_reports_ucepsa_prod + .li_context_decision_group_backlog_v1 +ORDER BY backlog_rank +LIMIT 50; + +\echo '=== 9. Comparación de carga humana ===' + +SELECT + machine_id, + pending_review_count + AS pending_episode_review_count, + pending_root_case_count, + pending_decision_group_count, + pending_critical_decision_group_count, + oldest_pending_decision_group_hours, + technical_gate_pass, + review_gate_pass, + readiness_status, + next_action, + ready_for_official_ledger, + official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 +ORDER BY machine_id; + +\echo '=== 10. Consolidación nunca aumenta la carga humana ===' + +SELECT COUNT(*) AS decision_group_count_exceeds_root_case_count +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 +WHERE pending_decision_group_count > + pending_root_case_count; + +\echo '=== 11. READY v3 coherente ===' + +SELECT COUNT(*) AS ready_v3_gate_mismatches +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 +WHERE readiness_status = + 'READY_FOR_CONTROLLED_SHADOW' + AND NOT ( + technical_gate_pass + AND observation_gate_pass + AND review_gate_pass + AND coverage_gate_pass + ); + +\echo '=== 12. Nada oficial ===' + +SELECT + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 + WHERE official_eligible + ) AS official_decision_group_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 + WHERE ready_for_official_ledger + OR official_ledger_eligible + ) AS official_gate_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/113_ucepsa_context_decision_groups_shadow_v0311.sql b/ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql new file mode 100644 index 0000000..fa56001 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql @@ -0,0 +1,1218 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_decision_group_policies ( + tenant text NOT NULL, + site text NOT NULL, + policy_code text NOT NULL, + source_case_family text NOT NULL, + decision_family text NOT NULL, + max_gap_s integer NOT NULL + CHECK (max_gap_s >= 0), + require_same_local_day boolean NOT NULL DEFAULT true, + review_owner text NOT NULL + CHECK ( + review_owner IN ( + 'PRODUCTION', + 'TECHNICAL', + 'JOINT' + ) + ), + timezone_name text NOT NULL DEFAULT 'Europe/Madrid', + history_lookback_days integer NOT NULL DEFAULT 30 + CHECK (history_lookback_days > 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, + source_case_family + ) +); + +COMMENT ON TABLE +mv_loss_intelligence.context_decision_group_policies IS +'Política SHADOW para consolidar casos raíz próximos en una única pregunta humana. No elimina casos ni evidencias.'; + +INSERT INTO +mv_loss_intelligence.context_decision_group_policies ( + tenant, + site, + policy_code, + source_case_family, + decision_family, + max_gap_s, + require_same_local_day, + review_owner, + timezone_name, + history_lookback_days, + active, + notes +) +VALUES +( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_DECISION_GROUPS_V1', + 'UNCONFIRMED_ACTIVITY', + 'UNCONFIRMED_ACTIVITY', + 900, + true, + 'PRODUCTION', + 'Europe/Madrid', + 30, + true, + 'Agrupa intervalos próximos de actividad sin contexto en la misma máquina y día local.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_DECISION_GROUPS_V1', + 'MULTIPLE_OPEN_SESSIONS', + 'ODOO_ORDER_START_CONFLICT', + 300, + true, + 'JOINT', + 'Europe/Madrid', + 30, + true, + 'Agrupa el doble inicio con evidencias Odoo próximas.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_DECISION_GROUPS_V1', + 'ORDER_WITHOUT_SESSION', + 'ODOO_ORDER_START_CONFLICT', + 300, + true, + 'JOINT', + 'Europe/Madrid', + 30, + true, + 'Una transición breve de orden sin sesión puede pertenecer al mismo doble arranque.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_DECISION_GROUPS_V1', + 'ORDER_SESSION_MISMATCH', + 'ODOO_ORDER_START_CONFLICT', + 300, + true, + 'JOINT', + 'Europe/Madrid', + 30, + true, + 'Agrupa discrepancias de orden y sesión cercanas.' +), +( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_DECISION_GROUPS_V1', + 'SYNC_HEALTH', + 'SYNC_HEALTH', + 300, + true, + 'TECHNICAL', + 'Europe/Madrid', + 30, + true, + 'Agrupa incidencias técnicas de sincronización próximas.' +) +ON CONFLICT ( + tenant, + site, + policy_code, + source_case_family +) +DO UPDATE SET + decision_family = + EXCLUDED.decision_family, + max_gap_s = + EXCLUDED.max_gap_s, + require_same_local_day = + EXCLUDED.require_same_local_day, + review_owner = + EXCLUDED.review_owner, + timezone_name = + EXCLUDED.timezone_name, + history_lookback_days = + EXCLUDED.history_lookback_days, + active = true, + notes = + EXCLUDED.notes, + updated_at = now(); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_decision_group_reviews ( + review_id bigserial PRIMARY KEY, + tenant text NOT NULL, + site text NOT NULL, + decision_group_key text NOT NULL, + previous_values jsonb NOT NULL, + new_review_status text NOT NULL + CHECK ( + new_review_status IN ( + 'REVIEWED', + 'DISMISSED' + ) + ), + resolution_classification text NOT NULL + CHECK ( + resolution_classification IN ( + 'ODOO_DOUBLE_START_ONE_VALID', + 'ODOO_DOUBLE_START_BOTH_INVALID', + 'ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED', + 'AUTHORIZED_LEGACY_PRODUCTION', + 'TEST_OR_SETUP', + 'RESIDUAL_MATERIAL', + 'ODOO_START_OMITTED', + 'DATA_ISSUE', + 'NOT_RELEVANT', + 'OTHER' + ) + ), + selected_order_ref text, + selected_workorder_id bigint, + reviewed_by text NOT NULL, + reviewed_at timestamptz NOT NULL DEFAULT now(), + notes text, + evidence_json jsonb NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS +idx_context_decision_group_reviews_key +ON +mv_loss_intelligence.context_decision_group_reviews ( + tenant, + site, + decision_group_key, + reviewed_at DESC, + review_id DESC +); + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_decision_group_case_assignments_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence + .context_decision_group_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CONTEXT_DECISION_GROUPS_V1' + AND active +), +base AS ( + SELECT + c.*, + COALESCE( + p.decision_family, + c.case_family + ) AS decision_family, + COALESCE( + p.max_gap_s, + 0 + ) AS max_gap_s, + COALESCE( + p.require_same_local_day, + true + ) AS require_same_local_day, + COALESCE( + p.review_owner, + c.review_owner + ) AS decision_review_owner, + COALESCE( + p.timezone_name, + 'Europe/Madrid' + ) AS timezone_name, + COALESCE( + p.history_lookback_days, + 30 + ) AS history_lookback_days, + COALESCE( + c.ended_at, + c.last_seen_at + ) AS effective_case_end + FROM + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 c + LEFT JOIN policy p + ON p.source_case_family = + c.case_family + WHERE c.review_required + AND ( + c.case_status = 'OPEN' + OR c.first_seen_at >= + now() - interval '30 days' + ) +), +with_local_day AS ( + SELECT + b.*, + CASE + WHEN b.require_same_local_day + THEN ( + b.first_seen_at + AT TIME ZONE b.timezone_name + )::date + ELSE DATE '1970-01-01' + END AS grouping_local_day + FROM base b +), +ordered AS ( + SELECT + l.*, + MAX(l.effective_case_end) OVER ( + PARTITION BY + l.machine_id, + l.decision_family, + l.grouping_local_day + ORDER BY + l.first_seen_at, + l.case_id + ROWS BETWEEN + UNBOUNDED PRECEDING + AND 1 PRECEDING + ) AS prior_max_end + FROM with_local_day l +), +flags AS ( + SELECT + o.*, + CASE + WHEN o.prior_max_end IS NULL + THEN 1 + WHEN o.first_seen_at > + o.prior_max_end + + ( + o.max_gap_s + * interval '1 second' + ) + THEN 1 + ELSE 0 + END AS new_group_flag + FROM ordered o +), +numbered AS ( + SELECT + f.*, + SUM(f.new_group_flag) OVER ( + PARTITION BY + f.machine_id, + f.decision_family, + f.grouping_local_day + ORDER BY + f.first_seen_at, + f.case_id + ROWS BETWEEN + UNBOUNDED PRECEDING + AND CURRENT ROW + ) AS decision_group_number + FROM flags f +), +identified AS ( + SELECT + n.*, + MIN(n.case_id) OVER ( + PARTITION BY + n.machine_id, + n.decision_family, + n.grouping_local_day, + n.decision_group_number + ) AS first_case_id, + ROW_NUMBER() OVER ( + PARTITION BY + n.machine_id, + n.decision_family, + n.grouping_local_day, + n.decision_group_number + ORDER BY + n.first_seen_at, + n.case_id + ) AS case_sequence + FROM numbered n +) +SELECT + i.*, + ( + 'DECISION:' + || i.decision_family + || ':' + || COALESCE( + i.machine_id, + 'GLOBAL' + ) + || ':' + || to_char( + i.grouping_local_day, + 'YYYYMMDD' + ) + || ':' + || i.first_case_id::text + ) AS decision_group_key, + CASE + WHEN i.case_sequence = 1 + THEN 'PRIMARY_CASE' + WHEN i.review_status IN ( + 'REVIEWED', + 'DISMISSED' + ) + THEN 'KNOWN_RESOLUTION' + ELSE 'RELATED_CASE' + END AS decision_case_role +FROM identified i; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_decision_group_case_assignments_v1 IS +'Asignación determinista de casos raíz a decisiones humanas mediante máquina, familia, día local y proximidad temporal.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_decision_group_history_v1 +AS +WITH grouped AS ( + SELECT + a.tenant, + a.site, + a.decision_group_key, + a.decision_family, + a.machine_id, + a.grouping_local_day + AS local_day, + MAX(a.decision_review_owner) + AS review_owner, + COUNT(*) AS linked_root_case_count, + ARRAY_AGG( + a.case_id + ORDER BY a.case_id + ) AS root_case_ids, + ARRAY_AGG( + a.decision_case_role + ORDER BY a.case_id + ) AS case_roles, + COUNT(*) FILTER ( + WHERE a.case_status = + 'OPEN' + ) AS open_root_case_count, + COUNT(*) FILTER ( + WHERE a.review_status = + 'PENDING' + ) AS pending_root_case_count, + COUNT(*) FILTER ( + WHERE a.review_status IN ( + 'REVIEWED', + 'DISMISSED' + ) + ) AS reviewed_root_case_count, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT a.resolution_classification + ORDER BY a.resolution_classification + ), + NULL + ), + ARRAY[]::text[] + ) AS known_resolution_classifications, + CASE + WHEN BOOL_OR( + a.max_severity = + 'CRITICAL' + ) + THEN 'CRITICAL' + ELSE 'WARNING' + END AS max_severity, + MIN(a.first_seen_at) + AS first_seen_at, + MAX(a.last_seen_at) + AS last_seen_at, + CASE + WHEN BOOL_OR( + a.case_status = + 'OPEN' + ) + THEN NULL::timestamptz + ELSE MAX( + COALESCE( + a.ended_at, + a.last_seen_at + ) + ) + END AS ended_at + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 a + GROUP BY + a.tenant, + a.site, + a.decision_group_key, + a.decision_family, + a.machine_id, + a.grouping_local_day +), +order_rollup AS ( + SELECT + a.tenant, + a.site, + a.decision_group_key, + COALESCE( + ARRAY_AGG( + DISTINCT o.order_ref + ORDER BY o.order_ref + ) FILTER ( + WHERE o.order_ref IS NOT NULL + ), + ARRAY[]::text[] + ) AS production_orders + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 a + LEFT JOIN LATERAL + unnest(a.production_orders) + AS o(order_ref) + ON true + GROUP BY + a.tenant, + a.site, + a.decision_group_key +), +workorder_rollup AS ( + SELECT + a.tenant, + a.site, + a.decision_group_key, + COALESCE( + ARRAY_AGG( + DISTINCT w.workorder_id + ORDER BY w.workorder_id + ) FILTER ( + WHERE w.workorder_id IS NOT NULL + ), + ARRAY[]::bigint[] + ) AS odoo_workorder_ids + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 a + LEFT JOIN LATERAL + unnest(a.odoo_workorder_ids) + AS w(workorder_id) + ON true + GROUP BY + a.tenant, + a.site, + a.decision_group_key +), +latest_review AS ( + SELECT DISTINCT ON ( + tenant, + site, + decision_group_key + ) + tenant, + site, + decision_group_key, + new_review_status, + resolution_classification, + selected_order_ref, + selected_workorder_id, + reviewed_by, + reviewed_at, + notes + FROM + mv_loss_intelligence + .context_decision_group_reviews + ORDER BY + tenant, + site, + decision_group_key, + reviewed_at DESC, + review_id DESC +) +SELECT + g.tenant, + g.site, + g.decision_group_key, + g.decision_family, + g.machine_id, + g.local_day, + CASE g.decision_family + WHEN 'UNCONFIRMED_ACTIVITY' + THEN + 'Actividad sin contexto en varios intervalos' + WHEN 'ODOO_ORDER_START_CONFLICT' + THEN + 'Arranque Odoo con órdenes o sesiones incompatibles' + WHEN 'SYNC_HEALTH' + THEN + 'Incidencias de sincronización relacionadas' + ELSE + initcap( + replace( + g.decision_family, + '_', + ' ' + ) + ) + END AS decision_title, + CASE + WHEN g.decision_family = + 'UNCONFIRMED_ACTIVITY' + AND g.pending_root_case_count > 0 + AND g.known_resolution_classifications = + ARRAY[ + 'AUTHORIZED_LEGACY_PRODUCTION' + ]::text[] + THEN + '¿Confirma que los demás intervalos también correspondían al sistema antiguo?' + WHEN g.decision_family = + 'UNCONFIRMED_ACTIVITY' + THEN + '¿Los intervalos próximos corresponden a la misma causa: sistema antiguo, prueba o ajuste, material residual o inicio omitido?' + WHEN g.decision_family = + 'ODOO_ORDER_START_CONFLICT' + THEN + '¿Cuál era la orden realmente ejecutada y qué debe hacerse con las demás?' + ELSE + '¿Los casos próximos corresponden a una misma causa operativa?' + END AS question_text, + CASE g.decision_family + WHEN 'UNCONFIRMED_ACTIVITY' + THEN + 'Confirmar una sola causa y aplicarla únicamente a los intervalos relacionados.' + WHEN 'ODOO_ORDER_START_CONFLICT' + THEN + 'Confirmar la orden real y corregir en Odoo las órdenes o sesiones erróneas.' + WHEN 'SYNC_HEALTH' + THEN + 'Resolver la causa técnica y documentarla una sola vez.' + ELSE + 'Revisar los casos relacionados y registrar una única decisión.' + END AS recommended_action, + CASE + WHEN g.open_root_case_count > 0 + THEN 'OPEN' + ELSE 'CLOSED' + END AS decision_status, + g.max_severity, + g.first_seen_at, + g.last_seen_at, + g.ended_at, + ROUND( + EXTRACT( + EPOCH FROM ( + COALESCE( + g.ended_at, + now() + ) + - g.first_seen_at + ) + )::numeric / 60.0, + 2 + ) AS decision_duration_min, + g.linked_root_case_count, + g.root_case_ids, + g.case_roles, + COALESCE( + o.production_orders, + ARRAY[]::text[] + ) AS production_orders, + COALESCE( + w.odoo_workorder_ids, + ARRAY[]::bigint[] + ) AS odoo_workorder_ids, + g.open_root_case_count, + g.pending_root_case_count, + g.reviewed_root_case_count, + g.known_resolution_classifications, + g.review_owner, + ( + g.pending_root_case_count > 0 + ) AS review_required, + CASE + WHEN g.pending_root_case_count > 0 + THEN 'PENDING' + ELSE COALESCE( + r.new_review_status, + CASE + WHEN g.reviewed_root_case_count > 0 + THEN 'REVIEWED' + ELSE 'NOT_REQUIRED' + END + ) + END AS review_status, + r.resolution_classification, + r.selected_order_ref, + r.selected_workorder_id, + r.reviewed_by, + r.reviewed_at, + r.notes AS review_notes, + false AS official_eligible +FROM grouped g +LEFT JOIN order_rollup o + ON o.tenant = + g.tenant + AND o.site = + g.site + AND o.decision_group_key = + g.decision_group_key +LEFT JOIN workorder_rollup w + ON w.tenant = + g.tenant + AND w.site = + g.site + AND w.decision_group_key = + g.decision_group_key +LEFT JOIN latest_review r + ON r.tenant = + g.tenant + AND r.site = + g.site + AND r.decision_group_key = + g.decision_group_key; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_decision_group_history_v1 IS +'Historial SHADOW de decisiones humanas consolidadas. Varios casos raíz pueden requerir una sola respuesta.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_decision_group_backlog_v1 +AS +SELECT + ROW_NUMBER() OVER ( + ORDER BY + CASE max_severity + WHEN 'CRITICAL' THEN 1 + ELSE 2 + END, + CASE decision_status + WHEN 'OPEN' THEN 1 + ELSE 2 + END, + first_seen_at + ) AS backlog_rank, + h.* +FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 h +WHERE h.review_required + AND h.review_status = + 'PENDING'; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_decision_group_backlog_v1 IS +'Cola humana consolidada: cuenta preguntas operativas pendientes, no episodios ni casos técnicos.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_decision_group_cases_v1 +AS +SELECT + d.decision_group_key, + d.decision_family, + d.decision_title, + d.machine_id, + d.decision_status, + d.max_severity + AS decision_severity, + d.review_status + AS decision_review_status, + a.decision_case_role, + c.case_id, + c.case_reference, + c.case_family, + c.root_incident_code, + c.case_title, + c.question_text + AS case_question_text, + c.recommended_action + AS case_recommended_action, + c.case_status, + c.max_severity + AS case_severity, + c.first_seen_at, + c.last_seen_at, + c.ended_at, + c.case_duration_min, + c.linked_evidence_count, + c.episode_ids, + c.production_orders, + c.odoo_workorder_ids, + c.open_evidence_count, + c.pending_episode_review_count, + c.review_owner + AS case_review_owner, + c.review_status + AS case_review_status, + c.resolution_classification + AS case_resolution_classification, + c.selected_order_ref + AS case_selected_order_ref, + c.selected_workorder_id + AS case_selected_workorder_id, + c.reviewed_by + AS case_reviewed_by, + c.reviewed_at + AS case_reviewed_at, + c.review_notes + AS case_review_notes, + c.official_eligible +FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 d +JOIN + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 a + ON a.tenant = + d.tenant + AND a.site = + d.site + AND a.decision_group_key = + d.decision_group_key +JOIN + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 c + ON c.case_id = + a.case_id; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_decision_group_cases_v1 IS +'Casos raíz incluidos en cada decisión consolidada.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_metrics_v3 +AS +WITH decisions AS ( + SELECT + machine_id, + COUNT(*) AS pending_decision_group_count, + COUNT(*) FILTER ( + WHERE max_severity = + 'CRITICAL' + ) AS pending_critical_decision_group_count, + MIN(first_seen_at) + AS oldest_pending_decision_group_at + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_backlog_v1 + WHERE machine_id IS NOT NULL + GROUP BY machine_id +) +SELECT + m.*, + COALESCE( + d.pending_decision_group_count, + 0 + ) AS pending_decision_group_count, + COALESCE( + d.pending_critical_decision_group_count, + 0 + ) AS pending_critical_decision_group_count, + d.oldest_pending_decision_group_at, + ROUND( + CASE + WHEN d.oldest_pending_decision_group_at + IS NULL + THEN 0 + ELSE EXTRACT( + EPOCH FROM ( + now() + - d.oldest_pending_decision_group_at + ) + )::numeric / 3600.0 + END, + 2 + ) AS oldest_pending_decision_group_hours +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_metrics_v2 m +LEFT JOIN decisions d + ON d.machine_id = + m.machine_id; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_metrics_v3 IS +'Readiness v3: conserva episodios y casos raíz, pero mide la carga humana mediante decisiones consolidadas.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v3 +AS +SELECT + m.*, + ( + ( + NOT m.require_sync_healthy + OR m.sync_health_status = + 'HEALTHY' + ) + AND ( + NOT m.require_incident_collector_healthy + OR m.collector_health_status = + 'HEALTHY' + ) + AND m.builder_health_status = + 'HEALTHY' + AND m.critical_session_dq_count <= + m.maximum_critical_session_dq_count + AND m.recent_conflict_minutes <= + m.maximum_conflict_minutes + AND m.live_canonical_health_status <> + 'CRITICAL' + ) AS technical_gate_pass, + ( + m.observed_activity_hours >= + m.minimum_observed_activity_hours + ) AS observation_gate_pass, + ( + m.pending_decision_group_count <= + m.maximum_pending_review_count + AND m.oldest_pending_decision_group_hours <= + m.maximum_oldest_pending_review_hours + ) AS review_gate_pass, + ( + m.confirmed_context_pct >= + m.minimum_confirmed_context_pct + AND m.unconfirmed_context_pct <= + m.maximum_unconfirmed_context_pct + ) AS coverage_gate_pass, + CASE + WHEN ( + ( + m.require_sync_healthy + AND m.sync_health_status <> + 'HEALTHY' + ) + OR ( + m.require_incident_collector_healthy + AND m.collector_health_status <> + 'HEALTHY' + ) + OR m.builder_health_status <> + 'HEALTHY' + OR m.critical_session_dq_count > + m.maximum_critical_session_dq_count + OR m.recent_conflict_minutes > + m.maximum_conflict_minutes + OR m.live_canonical_health_status = + 'CRITICAL' + ) + THEN 'TECHNICAL_BLOCKED' + WHEN m.observed_activity_hours < + m.minimum_observed_activity_hours + THEN 'INSUFFICIENT_OBSERVATION' + WHEN ( + m.pending_decision_group_count > + m.maximum_pending_review_count + OR m.oldest_pending_decision_group_hours > + m.maximum_oldest_pending_review_hours + ) + THEN 'REVIEW_BACKLOG' + WHEN ( + m.confirmed_context_pct < + m.minimum_confirmed_context_pct + OR m.unconfirmed_context_pct > + m.maximum_unconfirmed_context_pct + ) + THEN 'COVERAGE_NOT_READY' + ELSE 'READY_FOR_CONTROLLED_SHADOW' + END AS readiness_status, + CASE + WHEN ( + ( + m.require_sync_healthy + AND m.sync_health_status <> + 'HEALTHY' + ) + OR ( + m.require_incident_collector_healthy + AND m.collector_health_status <> + 'HEALTHY' + ) + ) + THEN + 'Restablecer la salud de sincronización y del colector.' + WHEN m.builder_health_status <> + 'HEALTHY' + THEN + 'Restablecer el constructor de casos raíz.' + WHEN m.critical_session_dq_count > + m.maximum_critical_session_dq_count + THEN + 'Resolver los errores críticos de sesiones Shop Floor.' + WHEN m.recent_conflict_minutes > + m.maximum_conflict_minutes + OR m.live_canonical_health_status = + 'CRITICAL' + THEN + 'Resolver los conflictos de contexto antes de continuar.' + WHEN m.observed_activity_hours < + m.minimum_observed_activity_hours + THEN + 'Acumular más horas reales de observación.' + WHEN m.pending_decision_group_count > + m.maximum_pending_review_count + THEN + 'Resolver las decisiones humanas pendientes.' + WHEN m.oldest_pending_decision_group_hours > + m.maximum_oldest_pending_review_hours + THEN + 'Revisar la decisión pendiente más antigua.' + WHEN m.confirmed_context_pct < + m.minimum_confirmed_context_pct + THEN + 'Aumentar el uso correcto de Shop Floor o autorizar contextos reales.' + WHEN m.unconfirmed_context_pct > + m.maximum_unconfirmed_context_pct + THEN + 'Reducir actividad física sin contexto confirmado.' + ELSE + 'La máquina cumple los criterios SHADOW configurados.' + END AS next_action, + CASE + WHEN ( + ( + m.require_sync_healthy + AND m.sync_health_status <> + 'HEALTHY' + ) + OR ( + m.require_incident_collector_healthy + AND m.collector_health_status <> + 'HEALTHY' + ) + OR m.builder_health_status <> + 'HEALTHY' + OR m.critical_session_dq_count > + m.maximum_critical_session_dq_count + OR m.recent_conflict_minutes > + m.maximum_conflict_minutes + OR m.live_canonical_health_status = + 'CRITICAL' + ) + THEN 1 + WHEN m.observed_activity_hours < + m.minimum_observed_activity_hours + THEN 2 + WHEN ( + m.pending_decision_group_count > + m.maximum_pending_review_count + OR m.oldest_pending_decision_group_hours > + m.maximum_oldest_pending_review_hours + ) + THEN 3 + WHEN ( + m.confirmed_context_pct < + m.minimum_confirmed_context_pct + OR m.unconfirmed_context_pct > + m.maximum_unconfirmed_context_pct + ) + THEN 4 + ELSE 5 + END AS readiness_stage, + false AS ready_for_official_ledger +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_metrics_v3 m; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v3 IS +'Gate v3: la intervención humana se mide por decisiones consolidadas.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_rollout_blockers_v3 +AS +SELECT * +FROM + mv_reports_ucepsa_prod + .li_context_rollout_blockers_v2 +WHERE blocker_code NOT IN ( + 'PENDING_ROOT_CASE_COUNT', + 'OLDEST_PENDING_ROOT_CASE_HOURS' +) + +UNION ALL + +SELECT + g.machine_id, + 'PENDING_DECISION_GROUP_COUNT'::text, + 'WARNING'::text, + g.pending_decision_group_count::text, + g.maximum_pending_review_count::text, + 'Resolver las decisiones humanas pendientes.'::text, + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 g +WHERE g.pending_decision_group_count > + g.maximum_pending_review_count + +UNION ALL + +SELECT + g.machine_id, + 'OLDEST_PENDING_DECISION_GROUP_HOURS', + 'WARNING', + g.oldest_pending_decision_group_hours::text, + g.maximum_oldest_pending_review_hours::text, + 'Revisar la decisión pendiente más antigua.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 g +WHERE g.oldest_pending_decision_group_hours > + g.maximum_oldest_pending_review_hours; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_rollout_blockers_v3 IS +'Bloqueos v3: la carga humana se expresa mediante decisiones consolidadas.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_rollout_readiness_summary_v3 +AS +WITH gate AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 +), +blockers AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_rollout_blockers_v3 +) +SELECT + now() AS observed_at, + COUNT(*) AS machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'READY_FOR_CONTROLLED_SHADOW' + ) AS ready_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'TECHNICAL_BLOCKED' + ) AS technical_blocked_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'INSUFFICIENT_OBSERVATION' + ) AS insufficient_observation_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'REVIEW_BACKLOG' + ) AS review_backlog_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'COVERAGE_NOT_READY' + ) AS coverage_not_ready_machine_count, + COALESCE( + SUM(pending_review_count), + 0 + ) AS pending_episode_review_count, + COALESCE( + SUM(pending_root_case_count), + 0 + ) AS pending_root_case_count, + COALESCE( + SUM(pending_decision_group_count), + 0 + ) AS pending_decision_group_count, + ( + SELECT COUNT(*) + FROM blockers + ) AS blocker_count, + ( + SELECT COUNT(*) + FROM blockers + WHERE blocker_severity = + 'CRITICAL' + ) AS critical_blocker_count, + CASE + WHEN COUNT(*) FILTER ( + WHERE readiness_status = + 'TECHNICAL_BLOCKED' + ) > 0 + THEN 'TECHNICAL_BLOCKED' + WHEN COUNT(*) FILTER ( + WHERE readiness_status = + 'READY_FOR_CONTROLLED_SHADOW' + ) = COUNT(*) + THEN 'READY_FOR_CONTROLLED_SHADOW' + ELSE 'NOT_READY' + END AS overall_readiness_status, + false AS ready_for_official_ledger, + false AS official_ledger_eligible +FROM gate; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_rollout_readiness_summary_v3 IS +'Resumen v3: compara evidencias, casos raíz y decisiones humanas pendientes.'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = + 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON + mv_loss_intelligence.context_decision_group_policies + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_decision_group_backlog_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_decision_group_cases_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_readiness_metrics_v3 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_rollout_blockers_v3 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_rollout_readiness_summary_v3 + TO grafana_ucepsa_ro; + END IF; +END +$$; + +COMMIT; diff --git a/ucepsa/edge-oee-demo/tools/context_decision_group_admin.py b/ucepsa/edge-oee-demo/tools/context_decision_group_admin.py new file mode 100755 index 0000000..99ce08a --- /dev/null +++ b/ucepsa/edge-oee-demo/tools/context_decision_group_admin.py @@ -0,0 +1,650 @@ +#!/usr/bin/env python3 +"""Administración de decisiones humanas consolidadas v0.3.11.""" + +from __future__ import annotations + +import argparse +import json +import os +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +import psycopg2 +from psycopg2.extras import Json, RealDictCursor + + +CLASSIFICATIONS = ( + "ODOO_DOUBLE_START_ONE_VALID", + "ODOO_DOUBLE_START_BOTH_INVALID", + "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED", + "AUTHORIZED_LEGACY_PRODUCTION", + "TEST_OR_SETUP", + "RESIDUAL_MATERIAL", + "ODOO_START_OMITTED", + "DATA_ISSUE", + "NOT_RELEVANT", + "OTHER", +) + + +def env_first(*names: str, default: str | None = None) -> str | None: + for name in names: + value = os.getenv(name) + if value not in (None, ""): + return value + return default + + +def connect(): + database = env_first("PGDATABASE", "POSTGRES_DB") + user = env_first("PGUSER", "POSTGRES_USER") + password = env_first("PGPASSWORD", "POSTGRES_PASSWORD") + + missing = [ + name + for name, value in ( + ("PGDATABASE", database), + ("PGUSER", user), + ("PGPASSWORD", password), + ) + if not value + ] + if missing: + raise RuntimeError( + "Faltan variables: " + ", ".join(missing) + ) + + return psycopg2.connect( + host=env_first( + "PGHOST", + default="mv_ucepsa_postgres_hot", + ), + port=int( + env_first("PGPORT", default="5432") + or "5432" + ), + dbname=database, + user=user, + password=password, + connect_timeout=10, + application_name=( + "context_decision_group_admin_v0311" + ), + ) + + +def json_safe(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): json_safe(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple, set)): + return [ + json_safe(item) + for item in value + ] + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + return value + + +def print_json(value: Any) -> None: + print( + json.dumps( + json_safe(value), + ensure_ascii=False, + indent=2, + ) + ) + + +def list_groups(limit: int) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT + backlog_rank, + decision_group_key, + decision_status, + max_severity, + first_seen_at, + last_seen_at, + decision_duration_min, + machine_id, + decision_title, + root_case_ids, + production_orders, + odoo_workorder_ids, + linked_root_case_count, + pending_root_case_count, + reviewed_root_case_count, + known_resolution_classifications, + review_owner, + review_status, + question_text, + recommended_action + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_backlog_v1 + ORDER BY backlog_rank + LIMIT %s + """, + (limit,), + ) + print_json( + [dict(row) for row in cursor.fetchall()] + ) + + +def show_group(decision_group_key: str) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 + WHERE decision_group_key = %s + """, + (decision_group_key,), + ) + group = cursor.fetchone() + if not group: + raise RuntimeError( + "No existe la decisión " + f"{decision_group_key!r}" + ) + + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_cases_v1 + WHERE decision_group_key = %s + ORDER BY case_id + """, + (decision_group_key,), + ) + cases = [ + dict(row) + for row in cursor.fetchall() + ] + + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_decision_group_reviews + WHERE tenant = %s + AND site = %s + AND decision_group_key = %s + ORDER BY reviewed_at, review_id + """, + ( + group["tenant"], + group["site"], + decision_group_key, + ), + ) + reviews = [ + dict(row) + for row in cursor.fetchall() + ] + + print_json({ + "decision_group": dict(group), + "root_cases": cases, + "reviews": reviews, + }) + + +def episode_classification( + classification: str, +) -> str: + mapping = { + "ODOO_DOUBLE_START_ONE_VALID": + "DATA_ISSUE", + "ODOO_DOUBLE_START_BOTH_INVALID": + "DATA_ISSUE", + "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED": + "DATA_ISSUE", + "AUTHORIZED_LEGACY_PRODUCTION": + "AUTHORIZED_LEGACY_PRODUCTION", + "TEST_OR_SETUP": + "TEST_OR_SETUP", + "RESIDUAL_MATERIAL": + "RESIDUAL_MATERIAL", + "ODOO_START_OMITTED": + "ODOO_START_OMITTED", + "DATA_ISSUE": + "DATA_ISSUE", + "NOT_RELEVANT": + "NOT_RELEVANT", + "OTHER": + "OTHER", + } + return mapping[classification] + + +def review_group( + decision_group_key: str, + classification: str, + reviewed_by: str, + notes: str | None, + selected_order_ref: str | None, + selected_workorder_id: int | None, +) -> None: + if ( + classification == + "ODOO_DOUBLE_START_ONE_VALID" + and ( + not selected_order_ref + or selected_workorder_id is None + ) + ): + raise RuntimeError( + "ODOO_DOUBLE_START_ONE_VALID exige " + "--selected-order-ref y " + "--selected-workorder-id." + ) + + new_status = ( + "DISMISSED" + if classification == "NOT_RELEVANT" + else "REVIEWED" + ) + episode_status = new_status + episode_class = episode_classification( + classification + ) + + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 + WHERE decision_group_key = %s + """, + (decision_group_key,), + ) + group = cursor.fetchone() + if not group: + raise RuntimeError( + "No existe la decisión " + f"{decision_group_key!r}" + ) + + if group["review_status"] != "PENDING": + raise RuntimeError( + "La decisión ya no está pendiente: " + f"{group['review_status']}" + ) + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_decision_group_reviews ( + tenant, + site, + decision_group_key, + previous_values, + new_review_status, + resolution_classification, + selected_order_ref, + selected_workorder_id, + reviewed_by, + notes, + evidence_json + ) + VALUES ( + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s + ) + RETURNING * + """, + ( + group["tenant"], + group["site"], + decision_group_key, + Json(json_safe(dict(group))), + new_status, + classification, + selected_order_ref, + selected_workorder_id, + reviewed_by, + notes, + Json({ + "source": + "context_decision_group_admin_v0311", + "cascade_to_root_cases": + True, + "cascade_to_episodes": + True, + }), + ), + ) + group_review = dict( + cursor.fetchone() + ) + + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_cases_v1 + WHERE decision_group_key = %s + ORDER BY case_id + """, + (decision_group_key,), + ) + cases = [ + dict(row) + for row in cursor.fetchall() + ] + + cascaded_cases = [] + cascaded_episodes = [] + + for case in cases: + if ( + case["case_review_status"] + != "PENDING" + ): + continue + + case_notes = ( + f"Decisión consolidada " + f"{decision_group_key}: " + f"{notes or classification}. " + f"Orden seleccionada: " + f"{selected_order_ref or '—'}; " + f"workorder seleccionado: " + f"{selected_workorder_id or '—'}." + ) + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_review_case_reviews ( + case_id, + previous_values, + new_review_status, + resolution_classification, + selected_order_ref, + selected_workorder_id, + reviewed_by, + notes, + evidence_json + ) + VALUES ( + %s, %s, %s, %s, + %s, %s, %s, %s, %s + ) + RETURNING review_id + """, + ( + case["case_id"], + Json(json_safe({ + "review_status": + case[ + "case_review_status" + ], + "resolution_classification": + case[ + "case_resolution_classification" + ], + "reviewed_by": + case["case_reviewed_by"], + "reviewed_at": + case["case_reviewed_at"], + "review_notes": + case["case_review_notes"], + })), + new_status, + classification, + selected_order_ref, + selected_workorder_id, + reviewed_by, + case_notes, + Json({ + "source_decision_group_key": + decision_group_key, + }), + ), + ) + cascaded_cases.append({ + "case_id": case["case_id"], + "review_id": + cursor.fetchone()["review_id"], + }) + + cursor.execute( + """ + SELECT h.* + FROM + mv_loss_intelligence + .context_review_case_evidence e + JOIN + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_history_v1 h + ON h.episode_id = + e.episode_id + WHERE e.case_id = %s + ORDER BY h.episode_id + """, + (case["case_id"],), + ) + episodes = [ + dict(row) + for row in cursor.fetchall() + ] + + for episode in episodes: + if ( + episode["review_status"] + != "PENDING" + ): + continue + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .shopfloor_context_incident_reviews ( + episode_id, + previous_values, + new_review_status, + review_classification, + reviewed_by, + notes, + evidence_json + ) + VALUES ( + %s, %s, %s, %s, + %s, %s, %s + ) + RETURNING review_id + """, + ( + episode["episode_id"], + Json(json_safe({ + "review_status": + episode[ + "review_status" + ], + "review_classification": + episode[ + "review_classification" + ], + "reviewed_by": + episode[ + "reviewed_by" + ], + "reviewed_at": + episode[ + "reviewed_at" + ], + "review_notes": + episode[ + "review_notes" + ], + })), + episode_status, + episode_class, + reviewed_by, + case_notes, + Json({ + "source_decision_group_key": + decision_group_key, + "source_case_id": + case["case_id"], + "root_classification": + classification, + }), + ), + ) + cascaded_episodes.append({ + "episode_id": + episode["episode_id"], + "review_id": + cursor.fetchone()[ + "review_id" + ], + }) + + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 + WHERE decision_group_key = %s + """, + (decision_group_key,), + ) + current = dict(cursor.fetchone()) + + print_json({ + "decision_group_review": + group_review, + "decision_group": + current, + "cascaded_root_case_reviews": + cascaded_cases, + "cascaded_episode_reviews": + cascaded_episodes, + "warning": ( + "La decisión consolida la revisión. " + "No modifica Odoo, no crea contextos " + "retrospectivos automáticamente y no " + "habilita el Ledger oficial." + ), + }) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Administración de decisiones " + "humanas consolidadas." + ) + ) + sub = parser.add_subparsers( + dest="command", + required=True, + ) + + list_cmd = sub.add_parser("list") + list_cmd.add_argument( + "--limit", + type=int, + default=50, + ) + + show_cmd = sub.add_parser("show") + show_cmd.add_argument( + "--decision-group-key", + required=True, + ) + + review_cmd = sub.add_parser("review") + review_cmd.add_argument( + "--decision-group-key", + required=True, + ) + review_cmd.add_argument( + "--classification", + choices=CLASSIFICATIONS, + required=True, + ) + review_cmd.add_argument( + "--reviewed-by", + required=True, + ) + review_cmd.add_argument("--notes") + review_cmd.add_argument( + "--selected-order-ref" + ) + review_cmd.add_argument( + "--selected-workorder-id", + type=int, + ) + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.command == "list": + list_groups(args.limit) + return 0 + + if args.command == "show": + show_group( + args.decision_group_key + ) + return 0 + + if args.command == "review": + review_group( + decision_group_key= + args.decision_group_key, + classification= + args.classification, + reviewed_by= + args.reviewed_by, + notes= + args.notes, + selected_order_ref= + args.selected_order_ref, + selected_workorder_id= + args.selected_workorder_id, + ) + return 0 + + raise RuntimeError("Comando no soportado") + + +if __name__ == "__main__": + raise SystemExit(main())