diff --git a/ucepsa/edge-oee-demo/docs/runbooks/113_governance_review_ui_shadow_v0.3.14.md b/ucepsa/edge-oee-demo/docs/runbooks/113_governance_review_ui_shadow_v0.3.14.md
new file mode 100644
index 0000000..c65599f
--- /dev/null
+++ b/ucepsa/edge-oee-demo/docs/runbooks/113_governance_review_ui_shadow_v0.3.14.md
@@ -0,0 +1,209 @@
+# UCEPSA — Governance Review UI SHADOW v0.3.14
+
+## Objetivo
+
+Dar a Juan Pablo una interfaz de trabajo, separada de Grafana, para resolver
+excepciones sin interpretar SQL, hashes ni códigos técnicos.
+
+```text
+Grafana
+→ supervisión y auditoría
+
+Governance Review UI
+→ revisar y resolver
+```
+
+## URL prevista
+
+```text
+http://17.126.1.70:8091
+```
+
+La aplicación incluye autenticación, sesión firmada, CSRF, roles y auditoría.
+
+## Roles
+
+```text
+PRODUCTION
+→ decisiones de Producción y conjuntas
+
+TECHNICAL
+→ decisiones técnicas y conjuntas
+
+ADMIN
+→ todas las decisiones
+```
+
+## Funciones
+
+```text
+cola por decisión humana
+estado de incidencia separado de estado de revisión
+línea temporal por intervalos
+evidencia técnica desplegable
+formularios específicos por familia
+selección de uno o varios casos
+revisión en cascada de casos y episodios
+auditoría por usuario
+historial de acciones
+```
+
+## Contextos retrospectivos
+
+La creación es opcional y exige confirmación explícita.
+
+Solo se permite para:
+
+```text
+AUTHORIZED_LEGACY_PRODUCTION → LEGACY_ERP
+TEST_OR_SETUP → TEST_SETUP
+MAINTENANCE → MAINTENANCE
+```
+
+Condiciones:
+
+```text
+caso terminado
+ended_at conocido
+sin solapamiento con Odoo u otro contexto autoritativo
+un contexto exacto por caso
+sin cubrir huecos
+official_eligible = false
+```
+
+## Lo que NO hace
+
+```text
+no cambia órdenes Odoo
+no cierra workorders
+no cambia cantidades
+no edita telemetría raw
+no activa el Ledger oficial
+```
+
+## Despliegue SQL
+
+```bash
+FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/117_ucepsa_governance_review_ui_shadow_v0314.sql
+
+docker exec -i mv_ucepsa_postgres_hot sh -lc \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ < "$FILE"
+```
+
+## Copiar aplicación al runtime
+
+```bash
+TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
+RUNTIME=/srv/mesavault/edge-oee-ucepsa
+
+rm -rf "$RUNTIME/governance-ui"
+mkdir -p "$RUNTIME/governance-ui"
+
+cp -a \
+ "$TARGET/governance-ui/." \
+ "$RUNTIME/governance-ui/"
+```
+
+## Construir imagen y crear hashes
+
+```bash
+MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
+
+"$MANAGER" build
+```
+
+Generar un hash para cada usuario:
+
+```bash
+docker run --rm -it \
+ --entrypoint python \
+ mesavault/ucepsa-governance-ui:0.3.14 \
+ /app/tools/generate_password_hash.py
+```
+
+Guardar únicamente los hashes en `.env`.
+
+## Configuración
+
+```bash
+cd /srv/mesavault/edge-oee-ucepsa/governance-ui
+
+cp .env.example .env
+chmod 600 .env
+nano .env
+```
+
+Generar el secreto:
+
+```bash
+python3 - <<'PY'
+import secrets
+print(secrets.token_urlsafe(48))
+PY
+```
+
+## Arranque
+
+```bash
+MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
+
+"$MANAGER" deploy
+sleep 20
+"$MANAGER" status
+"$MANAGER" health
+```
+
+## Validación SQL
+
+```bash
+VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.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
+ui_queue_rows = agenda_rows
+invalid_ui_status_rows = 0
+duplicate_ui_decisions = 0
+pending_cases_without_timeline = 0
+official_ui_action_rows = 0
+official_ui_queue_rows = 0
+official_operator_queue_rows = 0
+```
+
+## Primera prueba
+
+1. Entrar como Víctor.
+2. Abrir una decisión pendiente.
+3. Comprobar la línea temporal y la evidencia.
+4. No resolver todavía una decisión real.
+5. Comprobar `/health`.
+6. Validar que Grafana sigue en solo lectura.
+
+La primera escritura real debe ser una respuesta confirmada por Juan Pablo.
+
+
+## Activación en dos pasos
+
+La variable inicial debe permanecer:
+
+```text
+GOVERNANCE_UI_WRITE_ENABLED=false
+```
+
+Primero se valida login, roles, cola, timeline y evidencia. Cuando Juan Pablo
+acepte la interfaz:
+
+```bash
+sed -i \
+ 's/^GOVERNANCE_UI_WRITE_ENABLED=false/GOVERNANCE_UI_WRITE_ENABLED=true/' \
+ /srv/mesavault/edge-oee-ucepsa/governance-ui/.env
+
+MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
+"$MANAGER" restart
+```
diff --git a/ucepsa/edge-oee-demo/governance-ui/.dockerignore b/ucepsa/edge-oee-demo/governance-ui/.dockerignore
new file mode 100644
index 0000000..bf41d73
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/.dockerignore
@@ -0,0 +1,6 @@
+.env
+.hash-venv
+__pycache__
+*.pyc
+.pytest_cache
+.git
diff --git a/ucepsa/edge-oee-demo/governance-ui/.env.example b/ucepsa/edge-oee-demo/governance-ui/.env.example
new file mode 100644
index 0000000..34ba1d7
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/.env.example
@@ -0,0 +1,9 @@
+GOVERNANCE_UI_TITLE=MESAVAULT · UCEPSA · Revisión de gobernanza
+GOVERNANCE_UI_SESSION_SECRET=CAMBIAR_POR_UN_SECRETO_LARGO
+GOVERNANCE_UI_SESSION_MAX_AGE_S=28800
+GOVERNANCE_UI_COOKIE_SECURE=false
+GOVERNANCE_UI_WRITE_ENABLED=false
+
+# Generar cada password_hash con:
+# python tools/generate_password_hash.py
+GOVERNANCE_UI_USERS_JSON=[{"username":"juanpablo","display_name":"Juan Pablo","password_hash":"$2b$12$CAMBIAR","roles":["PRODUCTION"]},{"username":"victor","display_name":"Víctor Fraile","password_hash":"$2b$12$CAMBIAR","roles":["ADMIN","TECHNICAL","JOINT"]}]
diff --git a/ucepsa/edge-oee-demo/governance-ui/Dockerfile b/ucepsa/edge-oee-demo/governance-ui/Dockerfile
new file mode 100644
index 0000000..2d7b208
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/Dockerfile
@@ -0,0 +1,25 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+RUN useradd --create-home --uid 10001 appuser
+
+COPY requirements.txt /app/requirements.txt
+
+RUN pip install --no-cache-dir \
+ -r /app/requirements.txt
+
+COPY app /app/app
+COPY tools /app/tools
+
+USER appuser
+
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/__init__.py b/ucepsa/edge-oee-demo/governance-ui/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/auth.py b/ucepsa/edge-oee-demo/governance-ui/app/auth.py
new file mode 100644
index 0000000..c834c47
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/auth.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+import secrets
+from dataclasses import dataclass
+from typing import FrozenSet
+
+import bcrypt
+from fastapi import HTTPException, Request, status
+from itsdangerous import BadSignature, SignatureExpired
+from itsdangerous.url_safe import URLSafeTimedSerializer
+
+from .settings import UserConfig, get_settings
+
+
+SESSION_COOKIE = "mv_governance_session"
+SESSION_SALT = "mv-governance-ui-v0314"
+
+
+@dataclass(frozen=True)
+class CurrentUser:
+ username: str
+ display_name: str
+ roles: FrozenSet[str]
+ csrf_token: str
+
+
+def serializer() -> URLSafeTimedSerializer:
+ settings = get_settings()
+ return URLSafeTimedSerializer(
+ secret_key=settings.session_secret,
+ salt=SESSION_SALT,
+ )
+
+
+def authenticate(
+ username: str,
+ password: str,
+) -> UserConfig | None:
+ settings = get_settings()
+ user = settings.users.get(username)
+
+ if user is None:
+ return None
+
+ try:
+ valid = bcrypt.checkpw(
+ password.encode("utf-8"),
+ user.password_hash.encode("utf-8"),
+ )
+ except ValueError:
+ return None
+
+ return user if valid else None
+
+
+def build_session_token(user: UserConfig) -> str:
+ payload = {
+ "username": user.username,
+ "display_name": user.display_name,
+ "roles": sorted(user.roles),
+ "csrf_token": secrets.token_urlsafe(32),
+ }
+ return serializer().dumps(payload)
+
+
+def read_current_user(
+ request: Request,
+) -> CurrentUser | None:
+ token = request.cookies.get(SESSION_COOKIE)
+ if not token:
+ return None
+
+ settings = get_settings()
+
+ try:
+ payload = serializer().loads(
+ token,
+ max_age=settings.session_max_age_s,
+ )
+ except (
+ BadSignature,
+ SignatureExpired,
+ ):
+ return None
+
+ username = str(payload.get("username", ""))
+ configured = settings.users.get(username)
+
+ if configured is None:
+ return None
+
+ csrf_token = str(
+ payload.get("csrf_token", "")
+ )
+ if not csrf_token:
+ return None
+
+ return CurrentUser(
+ username=configured.username,
+ display_name=configured.display_name,
+ roles=configured.roles,
+ csrf_token=csrf_token,
+ )
+
+
+def require_user(request: Request) -> CurrentUser:
+ user = read_current_user(request)
+ if user is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Sesión no válida.",
+ )
+ return user
+
+
+def verify_csrf(
+ user: CurrentUser,
+ submitted_token: str,
+) -> None:
+ if (
+ not submitted_token
+ or not secrets.compare_digest(
+ user.csrf_token,
+ submitted_token,
+ )
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Token CSRF no válido.",
+ )
+
+
+def can_view_owner(
+ user: CurrentUser,
+ review_owner: str,
+) -> bool:
+ if "ADMIN" in user.roles:
+ return True
+ if review_owner == "PRODUCTION":
+ return "PRODUCTION" in user.roles
+ if review_owner == "TECHNICAL":
+ return "TECHNICAL" in user.roles
+ if review_owner == "JOINT":
+ return bool(
+ user.roles
+ & {
+ "PRODUCTION",
+ "TECHNICAL",
+ "JOINT",
+ }
+ )
+ return False
+
+
+def can_review_owner(
+ user: CurrentUser,
+ review_owner: str,
+) -> bool:
+ return can_view_owner(
+ user,
+ review_owner,
+ )
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/db.py b/ucepsa/edge-oee-demo/governance-ui/app/db.py
new file mode 100644
index 0000000..70f2a1b
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/db.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import Iterator
+
+import psycopg2
+from psycopg2.extensions import connection as Connection
+from psycopg2.extras import RealDictCursor
+
+from .settings import get_settings
+
+
+@contextmanager
+def get_connection() -> Iterator[Connection]:
+ settings = get_settings()
+ connection = psycopg2.connect(
+ host=settings.pg_host,
+ port=settings.pg_port,
+ dbname=settings.pg_database,
+ user=settings.pg_user,
+ password=settings.pg_password,
+ connect_timeout=10,
+ application_name="governance_review_ui_v0314",
+ )
+ try:
+ yield connection
+ finally:
+ connection.close()
+
+
+def dict_cursor(connection: Connection) -> RealDictCursor:
+ return connection.cursor(
+ cursor_factory=RealDictCursor
+ )
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/main.py b/ucepsa/edge-oee-demo/governance-ui/app/main.py
new file mode 100644
index 0000000..5b668d7
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/main.py
@@ -0,0 +1,641 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+
+from fastapi import (
+ FastAPI,
+ Form,
+ HTTPException,
+ Request,
+ status,
+)
+from fastapi.responses import (
+ HTMLResponse,
+ RedirectResponse,
+)
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from psycopg2.extras import RealDictCursor
+
+from .auth import (
+ SESSION_COOKIE,
+ authenticate,
+ build_session_token,
+ can_review_owner,
+ can_view_owner,
+ read_current_user,
+ require_user,
+ verify_csrf,
+)
+from .db import get_connection
+from .review_service import (
+ ReviewError,
+ ReviewInput,
+ apply_review,
+)
+from .settings import get_settings
+
+
+BASE_DIR = Path(__file__).resolve().parent
+
+app = FastAPI(
+ title="MESAVAULT Governance Review UI",
+ version="0.3.14",
+)
+
+app.mount(
+ "/static",
+ StaticFiles(
+ directory=BASE_DIR / "static"
+ ),
+ name="static",
+)
+
+templates = Jinja2Templates(
+ directory=BASE_DIR / "templates"
+)
+
+
+def format_datetime(value: Any) -> str:
+ if value is None:
+ return "—"
+ try:
+ return value.strftime(
+ "%d/%m/%Y %H:%M:%S"
+ )
+ except AttributeError:
+ return str(value)
+
+
+templates.env.filters[
+ "datetime_es"
+] = format_datetime
+
+
+def visible_queue(
+ user,
+) -> list[dict[str, Any]]:
+ with get_connection() as connection:
+ with connection.cursor(
+ cursor_factory=RealDictCursor,
+ ) as cursor:
+ cursor.execute(
+ """
+ SELECT *
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_queue_v1
+ ORDER BY agenda_rank
+ """
+ )
+ rows = [
+ dict(row)
+ for row in cursor.fetchall()
+ ]
+
+ return [
+ row
+ for row in rows
+ if can_view_owner(
+ user,
+ str(row["review_owner"]),
+ )
+ ]
+
+
+def recent_actions(
+ limit: int = 50,
+) -> list[dict[str, Any]]:
+ with get_connection() as connection:
+ with connection.cursor(
+ cursor_factory=RealDictCursor,
+ ) as cursor:
+ cursor.execute(
+ """
+ SELECT *
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_recent_actions_v1
+ ORDER BY
+ created_at DESC,
+ ui_action_id DESC
+ LIMIT %s
+ """,
+ (limit,),
+ )
+ return [
+ dict(row)
+ for row in cursor.fetchall()
+ ]
+
+
+def load_decision(
+ decision_group_key: str,
+) -> tuple[
+ dict[str, Any],
+ list[dict[str, Any]],
+]:
+ with get_connection() as connection:
+ with connection.cursor(
+ cursor_factory=RealDictCursor,
+ ) as cursor:
+ cursor.execute(
+ """
+ SELECT *
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_decision_evidence_pack_v1
+ WHERE decision_group_key = %s
+ """,
+ (decision_group_key,),
+ )
+ decision = cursor.fetchone()
+
+ if not decision:
+ raise HTTPException(
+ status_code=404,
+ detail="Decisión no encontrada.",
+ )
+
+ cursor.execute(
+ """
+ SELECT *
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_case_timeline_v1
+ WHERE decision_group_key = %s
+ ORDER BY
+ first_seen_at,
+ case_id
+ """,
+ (decision_group_key,),
+ )
+ cases = [
+ dict(row)
+ for row in cursor.fetchall()
+ ]
+
+ return dict(decision), cases
+
+
+def answer_options(
+ decision_family: str,
+) -> list[tuple[str, str]]:
+ if decision_family == "UNCONFIRMED_ACTIVITY":
+ return [
+ (
+ "AUTHORIZED_LEGACY_PRODUCTION",
+ "Producción con el sistema antiguo",
+ ),
+ (
+ "ODOO_START_OMITTED",
+ "Producción real, pero se olvidó iniciar Odoo",
+ ),
+ (
+ "TEST_OR_SETUP",
+ "Prueba, ajuste o preparación",
+ ),
+ (
+ "RESIDUAL_MATERIAL",
+ "Material residual o vaciado de máquina",
+ ),
+ (
+ "MAINTENANCE",
+ "Mantenimiento",
+ ),
+ (
+ "NOT_PRODUCTION",
+ "No era producción",
+ ),
+ (
+ "OTHER",
+ "Otro",
+ ),
+ ]
+
+ if decision_family == "ODOO_ORDER_START_CONFLICT":
+ return [
+ (
+ "ODOO_DOUBLE_START_ONE_VALID",
+ "Una orden era válida y la otra quedó abierta por error",
+ ),
+ (
+ "ODOO_DOUBLE_START_BOTH_INVALID",
+ "Ninguna de las órdenes era válida",
+ ),
+ (
+ "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
+ "Eran órdenes consecutivas y la anterior no se cerró",
+ ),
+ (
+ "DATA_ISSUE",
+ "Incidencia de datos o configuración",
+ ),
+ (
+ "OTHER",
+ "Otro",
+ ),
+ ]
+
+ return [
+ (
+ "DATA_ISSUE",
+ "Incidencia de datos",
+ ),
+ (
+ "OTHER",
+ "Otro",
+ ),
+ ]
+
+
+@app.get(
+ "/health",
+ response_class=HTMLResponse,
+)
+def health() -> str:
+ with get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT 1")
+ cursor.fetchone()
+ return "ok"
+
+
+@app.get(
+ "/login",
+ response_class=HTMLResponse,
+)
+def login_page(
+ request: Request,
+):
+ if read_current_user(request):
+ return RedirectResponse(
+ "/",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+ return templates.TemplateResponse(
+ request=request,
+ name="login.html",
+ context={
+ "settings": get_settings(),
+ "error": None,
+ },
+ )
+
+
+@app.post(
+ "/login",
+ response_class=HTMLResponse,
+)
+def login_submit(
+ request: Request,
+ username: str = Form(...),
+ password: str = Form(...),
+):
+ user = authenticate(
+ username.strip(),
+ password,
+ )
+
+ if user is None:
+ return templates.TemplateResponse(
+ request=request,
+ name="login.html",
+ status_code=401,
+ context={
+ "settings": get_settings(),
+ "error":
+ "Usuario o contraseña incorrectos.",
+ },
+ )
+
+ response = RedirectResponse(
+ "/",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+ response.set_cookie(
+ SESSION_COOKIE,
+ build_session_token(user),
+ max_age=get_settings().session_max_age_s,
+ httponly=True,
+ secure=get_settings().cookie_secure,
+ samesite="lax",
+ path="/",
+ )
+ return response
+
+
+@app.post("/logout")
+def logout(
+ request: Request,
+ csrf_token: str = Form(...),
+):
+ user = require_user(request)
+ verify_csrf(
+ user,
+ csrf_token,
+ )
+
+ response = RedirectResponse(
+ "/login",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+ response.delete_cookie(
+ SESSION_COOKIE,
+ path="/",
+ )
+ return response
+
+
+@app.get(
+ "/",
+ response_class=HTMLResponse,
+)
+def queue_page(
+ request: Request,
+ message: str | None = None,
+):
+ user = read_current_user(request)
+ if user is None:
+ return RedirectResponse(
+ "/login",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+ return templates.TemplateResponse(
+ request=request,
+ name="queue.html",
+ context={
+ "settings": get_settings(),
+ "user": user,
+ "rows": visible_queue(user),
+ "message": message,
+ },
+ )
+
+
+@app.get(
+ "/decisions/{decision_group_key:path}",
+ response_class=HTMLResponse,
+)
+def decision_page(
+ request: Request,
+ decision_group_key: str,
+ error: str | None = None,
+):
+ user = read_current_user(request)
+ if user is None:
+ return RedirectResponse(
+ "/login",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+ decision, cases = load_decision(
+ decision_group_key
+ )
+
+ if not can_view_owner(
+ user,
+ str(decision["review_owner"]),
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="No tiene acceso a esta decisión.",
+ )
+
+ pending_cases = [
+ case
+ for case in cases
+ if case["case_review_status"] ==
+ "PENDING"
+ ]
+
+ order_pairs = sorted({
+ (
+ str(item["production_order"]),
+ int(item["odoo_workorder_id"]),
+ )
+ for item in (
+ decision.get(
+ "episode_evidence"
+ )
+ or []
+ )
+ if item.get(
+ "production_order"
+ )
+ and item.get(
+ "odoo_workorder_id"
+ ) is not None
+ })
+
+ return templates.TemplateResponse(
+ request=request,
+ name="decision.html",
+ context={
+ "settings": get_settings(),
+ "user": user,
+ "decision": decision,
+ "cases": cases,
+ "pending_cases": pending_cases,
+ "answer_options": answer_options(
+ str(
+ decision[
+ "decision_family"
+ ]
+ )
+ ),
+ "order_pairs": order_pairs,
+ "can_review": (
+ get_settings().write_enabled
+ and can_review_owner(
+ user,
+ str(decision["review_owner"]),
+ )
+ ),
+ "write_enabled":
+ get_settings().write_enabled,
+ "error": error,
+ },
+ )
+
+
+@app.post(
+ "/decisions/{decision_group_key:path}/review"
+)
+def review_decision(
+ request: Request,
+ decision_group_key: str,
+ csrf_token: str = Form(...),
+ case_ids: list[int] = Form(default=[]),
+ answer_code: str = Form(...),
+ notes: str = Form(""),
+ selected_order: str = Form(""),
+ corrected_in_odoo: bool = Form(False),
+ create_contexts: bool = Form(False),
+ confirmation: bool = Form(False),
+):
+ user = require_user(request)
+ verify_csrf(
+ user,
+ csrf_token,
+ )
+
+ if not get_settings().write_enabled:
+ raise HTTPException(
+ status_code=503,
+ detail="La interfaz está en modo solo lectura.",
+ )
+
+ decision, _ = load_decision(
+ decision_group_key
+ )
+
+ if not can_review_owner(
+ user,
+ str(decision["review_owner"]),
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="No puede resolver esta decisión.",
+ )
+
+ selected_order_ref = None
+ selected_workorder_id = None
+
+ if selected_order:
+ try:
+ selected_order_ref, raw_id = (
+ selected_order.split(
+ "|",
+ 1,
+ )
+ )
+ selected_workorder_id = int(
+ raw_id
+ )
+ except (
+ ValueError,
+ TypeError,
+ ):
+ target = (
+ "/decisions/"
+ + quote(
+ decision_group_key,
+ safe="",
+ )
+ + "?error="
+ + quote(
+ "Orden seleccionada no válida."
+ )
+ )
+ return RedirectResponse(
+ target,
+ status_code=303,
+ )
+
+ review = ReviewInput(
+ decision_group_key=
+ decision_group_key,
+ selected_case_ids=tuple(
+ sorted(
+ set(
+ int(case_id)
+ for case_id in case_ids
+ )
+ )
+ ),
+ answer_code=answer_code,
+ notes=notes.strip(),
+ selected_order_ref=
+ selected_order_ref,
+ selected_workorder_id=
+ selected_workorder_id,
+ corrected_in_odoo=
+ corrected_in_odoo,
+ create_contexts=
+ create_contexts,
+ confirmation=
+ confirmation,
+ )
+
+ try:
+ result = apply_review(
+ review=review,
+ user=user,
+ source_ip=(
+ request.client.host
+ if request.client
+ else None
+ ),
+ user_agent=request.headers.get(
+ "user-agent"
+ ),
+ )
+ except ReviewError as exc:
+ target = (
+ "/decisions/"
+ + quote(
+ decision_group_key,
+ safe="",
+ )
+ + "?error="
+ + quote(str(exc))
+ )
+ return RedirectResponse(
+ target,
+ status_code=303,
+ )
+
+ message = (
+ "Revisión registrada. "
+ f"Acción UI {result['ui_action_id']}."
+ )
+
+ if result[
+ "created_context_session_ids"
+ ]:
+ message += (
+ " Contextos creados: "
+ + ", ".join(
+ str(value)
+ for value in result[
+ "created_context_session_ids"
+ ]
+ )
+ + "."
+ )
+
+ return RedirectResponse(
+ "/?message="
+ + quote(message),
+ status_code=303,
+ )
+
+
+@app.get(
+ "/history",
+ response_class=HTMLResponse,
+)
+def history_page(
+ request: Request,
+):
+ user = read_current_user(request)
+ if user is None:
+ return RedirectResponse(
+ "/login",
+ status_code=303,
+ )
+
+ return templates.TemplateResponse(
+ request=request,
+ name="history.html",
+ context={
+ "settings": get_settings(),
+ "user": user,
+ "actions": recent_actions(),
+ },
+ )
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/review_service.py b/ucepsa/edge-oee-demo/governance-ui/app/review_service.py
new file mode 100644
index 0000000..ff45dfd
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/review_service.py
@@ -0,0 +1,1030 @@
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass
+from typing import Any
+
+from psycopg2.extras import Json, RealDictCursor
+
+from .auth import CurrentUser
+from .db import get_connection
+from .settings import get_settings
+
+
+ALLOWED_ANSWER_CODES = {
+ "AUTHORIZED_LEGACY_PRODUCTION",
+ "ODOO_START_OMITTED",
+ "TEST_OR_SETUP",
+ "RESIDUAL_MATERIAL",
+ "MAINTENANCE",
+ "NOT_PRODUCTION",
+ "ODOO_DOUBLE_START_ONE_VALID",
+ "ODOO_DOUBLE_START_BOTH_INVALID",
+ "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
+ "DATA_ISSUE",
+ "OTHER",
+}
+
+MAPPED_CLASSIFICATION = {
+ "AUTHORIZED_LEGACY_PRODUCTION":
+ "AUTHORIZED_LEGACY_PRODUCTION",
+ "ODOO_START_OMITTED":
+ "ODOO_START_OMITTED",
+ "TEST_OR_SETUP":
+ "TEST_OR_SETUP",
+ "RESIDUAL_MATERIAL":
+ "RESIDUAL_MATERIAL",
+ "MAINTENANCE":
+ "OTHER",
+ "NOT_PRODUCTION":
+ "NOT_RELEVANT",
+ "ODOO_DOUBLE_START_ONE_VALID":
+ "ODOO_DOUBLE_START_ONE_VALID",
+ "ODOO_DOUBLE_START_BOTH_INVALID":
+ "ODOO_DOUBLE_START_BOTH_INVALID",
+ "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED":
+ "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
+ "DATA_ISSUE":
+ "DATA_ISSUE",
+ "OTHER":
+ "OTHER",
+}
+
+CONTEXT_SOURCE_TYPE = {
+ "AUTHORIZED_LEGACY_PRODUCTION":
+ "LEGACY_ERP",
+ "TEST_OR_SETUP":
+ "TEST_SETUP",
+ "MAINTENANCE":
+ "MAINTENANCE",
+}
+
+
+@dataclass(frozen=True)
+class ReviewInput:
+ decision_group_key: str
+ selected_case_ids: tuple[int, ...]
+ answer_code: str
+ notes: str
+ selected_order_ref: str | None
+ selected_workorder_id: int | None
+ corrected_in_odoo: bool
+ create_contexts: bool
+ confirmation: bool
+
+
+class ReviewError(RuntimeError):
+ pass
+
+
+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 hasattr(value, "isoformat"):
+ return value.isoformat()
+ return value
+
+
+def fetch_decision(
+ cursor: RealDictCursor,
+ decision_group_key: str,
+) -> dict[str, Any]:
+ cursor.execute(
+ """
+ SELECT *
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_decision_evidence_pack_v1
+ WHERE decision_group_key = %s
+ """,
+ (decision_group_key,),
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ raise ReviewError(
+ "La decisión ya no existe."
+ )
+
+ return dict(row)
+
+
+def fetch_cases_for_update(
+ cursor: RealDictCursor,
+ decision_group_key: str,
+) -> list[dict[str, Any]]:
+ cursor.execute(
+ """
+ SELECT
+ c.*
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_decision_group_cases_v1 c
+ JOIN
+ mv_loss_intelligence
+ .context_review_cases raw
+ ON raw.case_id =
+ c.case_id
+ WHERE c.decision_group_key = %s
+ ORDER BY c.case_id
+ FOR UPDATE OF raw
+ """,
+ (decision_group_key,),
+ )
+ return [
+ dict(row)
+ for row in cursor.fetchall()
+ ]
+
+
+def validate_input(
+ decision: dict[str, Any],
+ all_cases: list[dict[str, Any]],
+ review: ReviewInput,
+) -> tuple[
+ list[dict[str, Any]],
+ list[dict[str, Any]],
+ str,
+]:
+ if not review.confirmation:
+ raise ReviewError(
+ "Debe confirmar que la respuesta refleja la realidad de planta."
+ )
+
+ if review.answer_code not in ALLOWED_ANSWER_CODES:
+ raise ReviewError(
+ "Código de respuesta no permitido."
+ )
+
+ pending_cases = [
+ case
+ for case in all_cases
+ if case["case_review_status"] ==
+ "PENDING"
+ ]
+
+ if not pending_cases:
+ raise ReviewError(
+ "La decisión ya no tiene casos pendientes."
+ )
+
+ selected_ids = set(
+ review.selected_case_ids
+ )
+ pending_ids = {
+ int(case["case_id"])
+ for case in pending_cases
+ }
+
+ if not selected_ids:
+ raise ReviewError(
+ "Debe seleccionar al menos un intervalo o caso."
+ )
+
+ if not selected_ids.issubset(
+ pending_ids
+ ):
+ raise ReviewError(
+ "La selección contiene casos que ya no están pendientes."
+ )
+
+ selected_cases = [
+ case
+ for case in pending_cases
+ if int(case["case_id"])
+ in selected_ids
+ ]
+
+ family = str(
+ decision["decision_family"]
+ )
+
+ if family == "ODOO_ORDER_START_CONFLICT":
+ allowed = {
+ "ODOO_DOUBLE_START_ONE_VALID",
+ "ODOO_DOUBLE_START_BOTH_INVALID",
+ "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
+ "DATA_ISSUE",
+ "OTHER",
+ }
+
+ if review.answer_code not in allowed:
+ raise ReviewError(
+ "La respuesta no corresponde a un conflicto de arranque Odoo."
+ )
+
+ if (
+ review.answer_code ==
+ "ODOO_DOUBLE_START_ONE_VALID"
+ ):
+ if (
+ not review.selected_order_ref
+ or review.selected_workorder_id
+ is None
+ ):
+ raise ReviewError(
+ "Debe seleccionar la orden y el workorder válidos."
+ )
+
+ valid_pairs = {
+ (
+ str(item["production_order"]),
+ int(item["odoo_workorder_id"]),
+ )
+ for item in (
+ decision.get(
+ "episode_evidence"
+ )
+ or []
+ )
+ if item.get(
+ "production_order"
+ )
+ and item.get(
+ "odoo_workorder_id"
+ ) is not None
+ }
+
+ if (
+ review.selected_order_ref,
+ review.selected_workorder_id,
+ ) not in valid_pairs:
+ raise ReviewError(
+ "La orden seleccionada no pertenece a la evidencia."
+ )
+
+ if not review.corrected_in_odoo:
+ raise ReviewError(
+ "Debe confirmar que la incoherencia ya fue corregida en Odoo."
+ )
+
+ elif family == "UNCONFIRMED_ACTIVITY":
+ allowed = {
+ "AUTHORIZED_LEGACY_PRODUCTION",
+ "ODOO_START_OMITTED",
+ "TEST_OR_SETUP",
+ "RESIDUAL_MATERIAL",
+ "MAINTENANCE",
+ "NOT_PRODUCTION",
+ "OTHER",
+ }
+
+ if review.answer_code not in allowed:
+ raise ReviewError(
+ "La respuesta no corresponde a actividad sin contexto."
+ )
+
+ if review.create_contexts:
+ if (
+ review.answer_code
+ not in CONTEXT_SOURCE_TYPE
+ ):
+ raise ReviewError(
+ "Esta respuesta no permite crear contextos retrospectivos."
+ )
+
+ for case in selected_cases:
+ if (
+ case["case_status"] != "CLOSED"
+ or case["ended_at"] is None
+ ):
+ raise ReviewError(
+ "Solo pueden crearse contextos para intervalos terminados."
+ )
+
+ if not case["machine_id"]:
+ raise ReviewError(
+ "El caso no tiene máquina asociada."
+ )
+
+ mapped = MAPPED_CLASSIFICATION[
+ review.answer_code
+ ]
+
+ return (
+ pending_cases,
+ selected_cases,
+ mapped,
+ )
+
+
+def insert_contexts(
+ cursor: RealDictCursor,
+ decision: dict[str, Any],
+ selected_cases: list[dict[str, Any]],
+ review: ReviewInput,
+ user: CurrentUser,
+ request_id: uuid.UUID,
+) -> list[int]:
+ if not review.create_contexts:
+ return []
+
+ source_type = CONTEXT_SOURCE_TYPE[
+ review.answer_code
+ ]
+ settings = get_settings()
+ created_ids: list[int] = []
+
+ for case in selected_cases:
+ case_id = int(case["case_id"])
+ source_ref = (
+ f"GOVERNANCE_UI_CASE:{case_id}:"
+ f"{review.answer_code}"
+ )
+
+ cursor.execute(
+ """
+ SELECT context_session_id
+ FROM
+ mv_loss_intelligence
+ .production_context_sessions
+ WHERE source_ref = %s
+ """,
+ (source_ref,),
+ )
+ existing = cursor.fetchone()
+
+ if existing:
+ created_ids.append(
+ int(
+ existing[
+ "context_session_id"
+ ]
+ )
+ )
+ continue
+
+ cursor.execute(
+ """
+ SELECT COUNT(*) AS overlap_count
+ FROM (
+ SELECT 1
+ FROM
+ mv_loss_intelligence
+ .production_context_sessions s
+ WHERE s.machine_id = %s
+ AND s.started_at < %s
+ AND COALESCE(
+ s.ended_at,
+ 'infinity'::timestamptz
+ ) > %s
+
+ UNION ALL
+
+ SELECT 1
+ FROM
+ mv_loss_intelligence
+ .odoo_shopfloor_sessions o
+ WHERE o.machine_id = %s
+ AND o.started_at IS NOT NULL
+ AND o.started_at < %s
+ AND COALESCE(
+ o.ended_at,
+ 'infinity'::timestamptz
+ ) > %s
+ AND o.session_status IN (
+ 'OPEN',
+ 'CLOSED'
+ )
+ ) overlaps
+ """,
+ (
+ case["machine_id"],
+ case["ended_at"],
+ case["first_seen_at"],
+ case["machine_id"],
+ case["ended_at"],
+ case["first_seen_at"],
+ ),
+ )
+
+ overlap_count = int(
+ cursor.fetchone()["overlap_count"]
+ )
+
+ if overlap_count > 0:
+ raise ReviewError(
+ f"El caso {case_id} se solapa con otro contexto autoritativo. "
+ "No se ha creado ningún contexto."
+ )
+
+ cursor.execute(
+ """
+ INSERT INTO
+ mv_loss_intelligence
+ .production_context_sessions (
+ tenant,
+ site,
+ machine_id,
+ source_type,
+ source_ref,
+ started_at,
+ ended_at,
+ status,
+ authorization_status,
+ confidence,
+ official_eligible,
+ authorized_by,
+ authorized_at,
+ closed_by,
+ notes,
+ evidence_json
+ )
+ VALUES (
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ 'CLOSED',
+ 'AUTHORIZED',
+ 'MEDIUM',
+ false,
+ %s,
+ now(),
+ %s,
+ %s,
+ %s
+ )
+ RETURNING context_session_id
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ case["machine_id"],
+ source_type,
+ source_ref,
+ case["first_seen_at"],
+ case["ended_at"],
+ user.display_name,
+ user.display_name,
+ review.notes,
+ Json({
+ "created_with":
+ "governance_review_ui_v0314",
+ "request_id":
+ str(request_id),
+ "decision_group_key":
+ decision[
+ "decision_group_key"
+ ],
+ "case_id":
+ case_id,
+ "answer_code":
+ review.answer_code,
+ "retrospective":
+ True,
+ "deployment_mode":
+ "SHADOW",
+ }),
+ ),
+ )
+ created_ids.append(
+ int(
+ cursor.fetchone()[
+ "context_session_id"
+ ]
+ )
+ )
+
+ return created_ids
+
+
+def insert_case_and_episode_reviews(
+ cursor: RealDictCursor,
+ decision: dict[str, Any],
+ selected_cases: list[dict[str, Any]],
+ mapped_classification: str,
+ review: ReviewInput,
+ user: CurrentUser,
+ request_id: uuid.UUID,
+) -> tuple[list[int], list[int]]:
+ case_review_ids: list[int] = []
+ episode_review_ids: list[int] = []
+
+ new_status = (
+ "DISMISSED"
+ if mapped_classification ==
+ "NOT_RELEVANT"
+ else "REVIEWED"
+ )
+
+ for case in selected_cases:
+ case_id = int(case["case_id"])
+
+ 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_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,
+ mapped_classification,
+ review.selected_order_ref,
+ review.selected_workorder_id,
+ user.display_name,
+ review.notes,
+ Json({
+ "source":
+ "governance_review_ui_v0314",
+ "request_id":
+ str(request_id),
+ "decision_group_key":
+ decision[
+ "decision_group_key"
+ ],
+ "answer_code":
+ review.answer_code,
+ }),
+ ),
+ )
+ case_review_ids.append(
+ int(
+ 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_id,),
+ )
+ episodes = [
+ dict(row)
+ for row in cursor.fetchall()
+ ]
+
+ episode_classification = (
+ "DATA_ISSUE"
+ if mapped_classification in {
+ "ODOO_DOUBLE_START_ONE_VALID",
+ "ODOO_DOUBLE_START_BOTH_INVALID",
+ "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
+ }
+ else mapped_classification
+ )
+
+ 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"
+ ],
+ })
+ ),
+ new_status,
+ episode_classification,
+ user.display_name,
+ review.notes,
+ Json({
+ "source":
+ "governance_review_ui_v0314",
+ "request_id":
+ str(request_id),
+ "decision_group_key":
+ decision[
+ "decision_group_key"
+ ],
+ "case_id":
+ case_id,
+ "answer_code":
+ review.answer_code,
+ }),
+ ),
+ )
+ episode_review_ids.append(
+ int(
+ cursor.fetchone()[
+ "review_id"
+ ]
+ )
+ )
+
+ return (
+ case_review_ids,
+ episode_review_ids,
+ )
+
+
+def apply_review(
+ review: ReviewInput,
+ user: CurrentUser,
+ source_ip: str | None,
+ user_agent: str | None,
+) -> dict[str, Any]:
+ request_id = uuid.uuid4()
+ settings = get_settings()
+
+ with get_connection() as connection:
+ connection.autocommit = False
+
+ try:
+ with connection.cursor(
+ cursor_factory=RealDictCursor,
+ ) as cursor:
+ cursor.execute(
+ """
+ SELECT pg_advisory_xact_lock(
+ hashtext(%s)
+ )
+ """,
+ (
+ (
+ f"{settings.tenant}:"
+ f"{settings.site}:"
+ f"{review.decision_group_key}"
+ ),
+ ),
+ )
+
+ decision = fetch_decision(
+ cursor,
+ review.decision_group_key,
+ )
+ all_cases = fetch_cases_for_update(
+ cursor,
+ review.decision_group_key,
+ )
+
+ (
+ pending_cases,
+ selected_cases,
+ mapped_classification,
+ ) = validate_input(
+ decision,
+ all_cases,
+ review,
+ )
+
+ previous_state = {
+ "decision": decision,
+ "cases": all_cases,
+ }
+
+ created_context_ids = insert_contexts(
+ cursor,
+ decision,
+ selected_cases,
+ review,
+ user,
+ request_id,
+ )
+
+ pending_ids = {
+ int(case["case_id"])
+ for case in pending_cases
+ }
+ selected_ids = {
+ int(case["case_id"])
+ for case in selected_cases
+ }
+ all_pending_selected = (
+ pending_ids == selected_ids
+ )
+
+ decision_review_id = None
+
+ if all_pending_selected:
+ new_status = (
+ "DISMISSED"
+ if mapped_classification ==
+ "NOT_RELEVANT"
+ else "REVIEWED"
+ )
+
+ 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 review_id
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ review.decision_group_key,
+ Json(
+ json_safe(decision)
+ ),
+ new_status,
+ mapped_classification,
+ review.selected_order_ref,
+ review.selected_workorder_id,
+ user.display_name,
+ review.notes,
+ Json({
+ "source":
+ "governance_review_ui_v0314",
+ "request_id":
+ str(request_id),
+ "answer_code":
+ review.answer_code,
+ "selected_case_ids":
+ list(
+ review.selected_case_ids
+ ),
+ }),
+ ),
+ )
+ decision_review_id = int(
+ cursor.fetchone()[
+ "review_id"
+ ]
+ )
+
+ (
+ case_review_ids,
+ episode_review_ids,
+ ) = insert_case_and_episode_reviews(
+ cursor,
+ decision,
+ selected_cases,
+ mapped_classification,
+ review,
+ user,
+ request_id,
+ )
+
+ resulting_decision = fetch_decision(
+ cursor,
+ review.decision_group_key,
+ )
+
+ resulting_state = {
+ "decision":
+ resulting_decision,
+ "decision_review_id":
+ decision_review_id,
+ "case_review_ids":
+ case_review_ids,
+ "episode_review_ids":
+ episode_review_ids,
+ "created_context_session_ids":
+ created_context_ids,
+ }
+
+ cursor.execute(
+ """
+ INSERT INTO
+ mv_loss_intelligence
+ .context_governance_ui_actions (
+ tenant,
+ site,
+ request_id,
+ decision_group_key,
+ selected_case_ids,
+ action_scope,
+ answer_code,
+ mapped_classification,
+ selected_order_ref,
+ selected_workorder_id,
+ corrected_in_odoo,
+ create_contexts,
+ created_context_session_ids,
+ actor_username,
+ actor_display_name,
+ actor_roles,
+ notes,
+ previous_state,
+ resulting_state,
+ status,
+ source_ip,
+ user_agent,
+ official_eligible
+ )
+ VALUES (
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ 'APPLIED',
+ %s,
+ %s,
+ false
+ )
+ RETURNING ui_action_id
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ str(request_id),
+ review.decision_group_key,
+ list(review.selected_case_ids),
+ (
+ "ALL_PENDING_CASES"
+ if all_pending_selected
+ else "SELECTED_CASES"
+ ),
+ review.answer_code,
+ mapped_classification,
+ review.selected_order_ref,
+ review.selected_workorder_id,
+ review.corrected_in_odoo,
+ review.create_contexts,
+ created_context_ids,
+ user.username,
+ user.display_name,
+ sorted(user.roles),
+ review.notes,
+ Json(
+ json_safe(
+ previous_state
+ )
+ ),
+ Json(
+ json_safe(
+ resulting_state
+ )
+ ),
+ source_ip,
+ user_agent,
+ ),
+ )
+ ui_action_id = int(
+ cursor.fetchone()[
+ "ui_action_id"
+ ]
+ )
+
+ connection.commit()
+
+ return {
+ "ui_action_id":
+ ui_action_id,
+ "request_id":
+ str(request_id),
+ "decision_review_id":
+ decision_review_id,
+ "case_review_ids":
+ case_review_ids,
+ "episode_review_ids":
+ episode_review_ids,
+ "created_context_session_ids":
+ created_context_ids,
+ "all_pending_cases_selected":
+ all_pending_selected,
+ }
+
+ except Exception:
+ connection.rollback()
+ raise
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/settings.py b/ucepsa/edge-oee-demo/governance-ui/app/settings.py
new file mode 100644
index 0000000..fa7d0ed
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/settings.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from functools import lru_cache
+from typing import FrozenSet
+
+
+@dataclass(frozen=True)
+class UserConfig:
+ username: str
+ display_name: str
+ password_hash: str
+ roles: FrozenSet[str]
+
+
+@dataclass(frozen=True)
+class Settings:
+ app_title: str
+ tenant: str
+ site: str
+ pg_host: str
+ pg_port: int
+ pg_database: str
+ pg_user: str
+ pg_password: str
+ session_secret: str
+ session_max_age_s: int
+ cookie_secure: bool
+ write_enabled: bool
+ users: dict[str, UserConfig]
+
+
+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 parse_bool(value: str | None, default: bool = False) -> bool:
+ if value is None:
+ return default
+ return value.strip().lower() in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ }
+
+
+def load_users(raw: str) -> dict[str, UserConfig]:
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError(
+ "GOVERNANCE_UI_USERS_JSON no contiene JSON válido."
+ ) from exc
+
+ if not isinstance(payload, list) or not payload:
+ raise RuntimeError(
+ "GOVERNANCE_UI_USERS_JSON debe ser una lista no vacía."
+ )
+
+ users: dict[str, UserConfig] = {}
+
+ for item in payload:
+ if not isinstance(item, dict):
+ raise RuntimeError(
+ "Cada usuario debe ser un objeto JSON."
+ )
+
+ username = str(item.get("username", "")).strip()
+ display_name = str(
+ item.get("display_name", "")
+ ).strip()
+ password_hash = str(
+ item.get("password_hash", "")
+ ).strip()
+ roles_raw = item.get("roles", [])
+
+ if (
+ not username
+ or not display_name
+ or not password_hash
+ or not isinstance(roles_raw, list)
+ ):
+ raise RuntimeError(
+ "Cada usuario requiere username, display_name, "
+ "password_hash y roles."
+ )
+
+ roles = frozenset(
+ str(role).strip().upper()
+ for role in roles_raw
+ if str(role).strip()
+ )
+
+ allowed_roles = {
+ "PRODUCTION",
+ "TECHNICAL",
+ "JOINT",
+ "ADMIN",
+ }
+
+ if not roles or not roles.issubset(allowed_roles):
+ raise RuntimeError(
+ f"Roles inválidos para {username!r}: {sorted(roles)}"
+ )
+
+ if username in users:
+ raise RuntimeError(
+ f"Usuario duplicado: {username!r}"
+ )
+
+ users[username] = UserConfig(
+ username=username,
+ display_name=display_name,
+ password_hash=password_hash,
+ roles=roles,
+ )
+
+ return users
+
+
+@lru_cache(maxsize=1)
+def get_settings() -> Settings:
+ database = env_first(
+ "PGDATABASE",
+ "POSTGRES_DB",
+ )
+ user = env_first(
+ "PGUSER",
+ "POSTGRES_USER",
+ )
+ password = env_first(
+ "PGPASSWORD",
+ "POSTGRES_PASSWORD",
+ )
+ session_secret = env_first(
+ "GOVERNANCE_UI_SESSION_SECRET"
+ )
+ users_json = env_first(
+ "GOVERNANCE_UI_USERS_JSON"
+ )
+
+ missing = [
+ name
+ for name, value in (
+ ("PGDATABASE/POSTGRES_DB", database),
+ ("PGUSER/POSTGRES_USER", user),
+ ("PGPASSWORD/POSTGRES_PASSWORD", password),
+ ("GOVERNANCE_UI_SESSION_SECRET", session_secret),
+ ("GOVERNANCE_UI_USERS_JSON", users_json),
+ )
+ if not value
+ ]
+
+ if missing:
+ raise RuntimeError(
+ "Faltan variables: " + ", ".join(missing)
+ )
+
+ return Settings(
+ app_title=env_first(
+ "GOVERNANCE_UI_TITLE",
+ default="MESAVAULT · Revisión de gobernanza",
+ )
+ or "MESAVAULT · Revisión de gobernanza",
+ tenant=env_first(
+ "TENANT_OVERRIDE",
+ "TENANT",
+ default="ucepsa",
+ )
+ or "ucepsa",
+ site=env_first(
+ "SITE_OVERRIDE",
+ "SITE",
+ default="ucepsa_onpremise",
+ )
+ or "ucepsa_onpremise",
+ pg_host=env_first(
+ "PGHOST",
+ default="mv_ucepsa_postgres_hot",
+ )
+ or "mv_ucepsa_postgres_hot",
+ pg_port=int(
+ env_first(
+ "PGPORT",
+ default="5432",
+ )
+ or "5432"
+ ),
+ pg_database=str(database),
+ pg_user=str(user),
+ pg_password=str(password),
+ session_secret=str(session_secret),
+ session_max_age_s=int(
+ env_first(
+ "GOVERNANCE_UI_SESSION_MAX_AGE_S",
+ default="28800",
+ )
+ or "28800"
+ ),
+ cookie_secure=parse_bool(
+ env_first(
+ "GOVERNANCE_UI_COOKIE_SECURE",
+ default="false",
+ )
+ ),
+ write_enabled=parse_bool(
+ env_first(
+ "GOVERNANCE_UI_WRITE_ENABLED",
+ default="false",
+ )
+ ),
+ users=load_users(str(users_json)),
+ )
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/static/style.css b/ucepsa/edge-oee-demo/governance-ui/app/static/style.css
new file mode 100644
index 0000000..5c8844d
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/static/style.css
@@ -0,0 +1,339 @@
+:root {
+ color-scheme: dark;
+ --bg: #0f141a;
+ --panel: #171e26;
+ --panel-2: #202936;
+ --text: #eef3f8;
+ --muted: #9eabb8;
+ --line: #344050;
+ --primary: #5ba8ff;
+ --success: #47c78a;
+ --warning: #f0a53a;
+ --danger: #ef6461;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: Inter, system-ui, -apple-system, Segoe UI, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+}
+
+a { color: var(--primary); }
+
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 2rem;
+ background: #0a0f14;
+ border-bottom: 1px solid var(--line);
+ position: sticky;
+ top: 0;
+ z-index: 5;
+}
+
+.brand {
+ color: var(--text);
+ font-weight: 800;
+ text-decoration: none;
+ letter-spacing: .04em;
+}
+
+.subtitle {
+ color: var(--muted);
+ margin-left: .7rem;
+}
+
+.topbar nav {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.topbar nav a { text-decoration: none; }
+.user { color: var(--muted); }
+
+.container {
+ width: min(1400px, calc(100% - 2rem));
+ margin: 2rem auto 4rem;
+}
+
+.page-heading,
+.detail-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+ margin-bottom: 1.5rem;
+}
+
+h1, h2, h3 { margin-top: 0; }
+p { line-height: 1.55; }
+
+.summary-badge,
+.machine,
+.severity,
+.status {
+ display: inline-block;
+ border-radius: 999px;
+ padding: .35rem .7rem;
+ background: var(--panel-2);
+ font-size: .88rem;
+}
+
+.machine { font-weight: 800; }
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
+ gap: 1rem;
+}
+
+.decision-card,
+.decision-detail,
+.login-card {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 1.25rem;
+}
+
+.decision-card.severity-critical {
+ border-left: 5px solid var(--danger);
+}
+
+.decision-card.severity-warning {
+ border-left: 5px solid var(--warning);
+}
+
+.card-head,
+.status-pair,
+.status-stack {
+ display: flex;
+ gap: .5rem;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+
+.question { font-size: 1.08rem; }
+
+.facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: .75rem;
+}
+
+.facts div {
+ background: var(--panel-2);
+ border-radius: 10px;
+ padding: .7rem;
+}
+
+.facts dt {
+ color: var(--muted);
+ font-size: .8rem;
+}
+
+.facts dd { margin: .25rem 0 0; }
+
+.button,
+button {
+ border: 0;
+ border-radius: 9px;
+ padding: .72rem 1rem;
+ font: inherit;
+ cursor: pointer;
+}
+
+.primary {
+ background: var(--primary);
+ color: #06111f;
+ font-weight: 800;
+ text-decoration: none;
+}
+
+.link-button {
+ color: var(--primary);
+ background: transparent;
+ padding: 0;
+}
+
+.inline { display: inline; }
+
+.alert {
+ padding: .9rem 1rem;
+ border-radius: 10px;
+ margin-bottom: 1rem;
+}
+
+.alert.error {
+ background: rgba(239, 100, 97, .16);
+ border: 1px solid var(--danger);
+}
+
+.alert.success {
+ background: rgba(71, 199, 138, .16);
+ border: 1px solid var(--success);
+}
+
+.empty-state {
+ text-align: center;
+ padding: 4rem 1rem;
+ color: var(--muted);
+}
+
+.login-card {
+ max-width: 460px;
+ margin: 7rem auto;
+}
+
+label {
+ display: block;
+ margin: 1rem 0;
+}
+
+input,
+textarea,
+select {
+ width: 100%;
+ margin-top: .4rem;
+ padding: .75rem;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #0e151d;
+ color: var(--text);
+ font: inherit;
+}
+
+input[type="checkbox"],
+input[type="radio"] {
+ width: auto;
+ margin: 0;
+}
+
+fieldset {
+ margin: 1.25rem 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 1rem;
+}
+
+legend {
+ padding: 0 .5rem;
+ font-weight: 800;
+}
+
+.check-row,
+.radio-row {
+ display: flex;
+ align-items: flex-start;
+ gap: .7rem;
+ margin: .65rem 0;
+}
+
+.help,
+small { color: var(--muted); }
+
+.timeline {
+ display: grid;
+ gap: .75rem;
+}
+
+.timeline-item {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ gap: 1rem;
+ background: var(--panel-2);
+ border-radius: 10px;
+ padding: 1rem;
+ border-left: 4px solid var(--warning);
+}
+
+.timeline-item.reviewed {
+ border-left-color: var(--success);
+ opacity: .82;
+}
+
+.timeline-time {
+ display: flex;
+ flex-direction: column;
+ gap: .35rem;
+}
+
+.question-box {
+ background: var(--panel-2);
+ border-radius: 12px;
+ padding: 1rem;
+ margin: 1rem 0 1.5rem;
+}
+
+details {
+ margin: 1.5rem 0;
+ background: var(--panel-2);
+ padding: 1rem;
+ border-radius: 10px;
+}
+
+details summary {
+ cursor: pointer;
+ font-weight: 800;
+}
+
+.evidence-list article {
+ border-top: 1px solid var(--line);
+ padding: 1rem 0;
+}
+
+.review-form {
+ border-top: 1px solid var(--line);
+ margin-top: 2rem;
+ padding-top: 1rem;
+}
+
+.confirmation {
+ background: rgba(240, 165, 58, .12);
+ padding: 1rem;
+ border-radius: 10px;
+}
+
+.table-wrap { overflow-x: auto; }
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--panel);
+}
+
+th, td {
+ padding: .75rem;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ vertical-align: top;
+}
+
+th { color: var(--muted); }
+.back { display: inline-block; margin-bottom: 1rem; }
+
+@media (max-width: 760px) {
+ .topbar,
+ .page-heading,
+ .detail-heading {
+ flex-direction: column;
+ }
+
+ .timeline-item {
+ grid-template-columns: 1fr;
+ }
+
+ .facts {
+ grid-template-columns: 1fr;
+ }
+
+ .card-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
new file mode 100644
index 0000000..1930876
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ {{ settings.app_title }}
+
+
+
+ {% if user %}
+
+
+
+
+ {% endif %}
+
+
+ {% block content %}{% endblock %}
+
+
+
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
new file mode 100644
index 0000000..ffc9277
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
@@ -0,0 +1,158 @@
+{% extends "base.html" %}
+{% block content %}
+← Volver a pendientes
+
+{% if error %}
+{{ error }}
+{% endif %}
+
+
+
+
+ {{ decision.machine_id or "GLOBAL" }}
+ {{ decision.max_severity }}
+
{{ decision.decision_title }}
+
+
+
+ Incidencia:
+ {% if decision.decision_status == "OPEN" %}Activa{% else %}Terminada{% endif %}
+
+ Revisión: {{ decision.review_status }}
+
+
+
+
+
Pregunta
+
{{ decision.question_text }}
+
+
+
+ Intervalos y casos
+
+ {% for case in cases %}
+
+
+ {{ case.first_seen_at|datetime_es }}
+ → {{ case.ended_at|datetime_es }}
+
+
+
Caso {{ case.case_id }} · {{ case.case_title }}
+
+ Incidencia:
+ {% if case.case_status == "OPEN" %}activa{% else %}terminada{% endif %}
+ · Revisión: {{ case.case_review_status }}
+
+ {% if case.case_resolution_classification %}
+
Resolución previa: {{ case.case_resolution_classification }}
+ {% endif %}
+ {% if case.production_orders %}
+
Órdenes: {{ case.production_orders|join(", ") }}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+
+ Evidencia técnica
+
+ {% for item in decision.episode_evidence %}
+
+ Episodio {{ item.episode_id }} · {{ item.incident_title }}
+ {{ item.latest_detail or "Sin detalle adicional." }}
+
+ {{ item.first_seen_at }} → {{ item.ended_at or "abierto" }}
+ {% if item.production_order %} · {{ item.production_order }}{% endif %}
+ {% if item.operator_name %} · {{ item.operator_name }}{% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% if not write_enabled %}
+
+ La interfaz está en modo solo lectura. Puede validar la información, pero todavía no registrar respuestas.
+
+ {% endif %}
+
+ {% if can_review and pending_cases %}
+
+ {% elif not pending_cases %}
+ Esta decisión ya no tiene casos pendientes.
+ {% else %}
+ Su rol puede consultar esta decisión, pero no resolverla.
+ {% endif %}
+
+{% endblock %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/history.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/history.html
new file mode 100644
index 0000000..b79d2ee
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/history.html
@@ -0,0 +1,38 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+
Historial de acciones
+
Acciones realizadas desde la interfaz durante los últimos 30 días.
+
+
+
+
+
+
+
+ | Fecha |
+ Usuario |
+ Decisión |
+ Respuesta |
+ Casos |
+ Contextos |
+ Estado |
+
+
+
+ {% for action in actions %}
+
+ | {{ action.created_at|datetime_es }} |
+ {{ action.actor_display_name }} |
+ {{ action.decision_group_key }} |
+ {{ action.answer_code }} |
+ {{ action.selected_case_ids|join(", ") }} |
+ {{ action.created_context_session_ids|join(", ") if action.created_context_session_ids else "—" }} |
+ {{ action.status }} |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/login.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/login.html
new file mode 100644
index 0000000..0c089ec
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/login.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+{% block content %}
+
+{% endblock %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
new file mode 100644
index 0000000..5661df6
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
@@ -0,0 +1,61 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+
Decisiones pendientes
+
Una tarjeta equivale a una pregunta humana. Los datos técnicos se conservan como evidencia.
+
+
{{ rows|length }} pendientes
+
+
+{% if message %}
+{{ message }}
+{% endif %}
+
+{% if not rows %}
+
+
No hay decisiones pendientes para su rol
+
La cola se actualizará automáticamente cuando aparezca una excepción que requiera intervención humana.
+
+{% endif %}
+
+
+{% for row in rows %}
+
+
+
+ {{ row.machine_id or "GLOBAL" }}
+ {{ row.max_severity }}
+
+
+
+ Incidencia:
+ {% if row.incident_status == "ACTIVE" %}Activa{% else %}Terminada{% endif %}
+
+ Revisión: Pendiente
+
+
+
+ {{ row.decision_title }}
+ {{ row.question_text }}
+
+
+ - Inicio
- {{ row.first_seen_at|datetime_es }}
+ - Fin
- {{ row.ended_at|datetime_es }}
+ - Responsable
- {{ row.review_owner }}
+ - Casos pendientes
- {{ row.pending_root_case_count }}
+ - Evidencias
- {{ row.linked_episode_count }}
+ - Atención
- {{ row.attention_status }}
+
+
+ {% if row.production_orders %}
+ Órdenes: {{ row.production_orders|join(", ") }}
+ {% endif %}
+
+
+ Revisar
+
+
+{% endfor %}
+
+{% endblock %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/requirements.txt b/ucepsa/edge-oee-demo/governance-ui/requirements.txt
new file mode 100644
index 0000000..657dcb4
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/requirements.txt
@@ -0,0 +1,7 @@
+fastapi>=0.115,<1
+uvicorn[standard]>=0.34,<1
+jinja2>=3.1,<4
+psycopg2-binary>=2.9,<3
+python-multipart>=0.0.20,<1
+itsdangerous>=2.2,<3
+bcrypt>=4.1,<5
diff --git a/ucepsa/edge-oee-demo/governance-ui/tools/generate_password_hash.py b/ucepsa/edge-oee-demo/governance-ui/tools/generate_password_hash.py
new file mode 100644
index 0000000..a6b67f6
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/tools/generate_password_hash.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import getpass
+
+import bcrypt
+
+
+def main() -> int:
+ first = getpass.getpass("Contraseña: ")
+ second = getpass.getpass("Repetir contraseña: ")
+
+ if first != second:
+ raise SystemExit(
+ "Las contraseñas no coinciden."
+ )
+
+ if len(first) < 12:
+ raise SystemExit(
+ "Use al menos 12 caracteres."
+ )
+
+ password_hash = bcrypt.hashpw(
+ first.encode("utf-8"),
+ bcrypt.gensalt(rounds=12),
+ ).decode("utf-8")
+
+ print(password_hash)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
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 c97f097..854035d 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, gobernanza diaria y regresión funcional. La suite protege contratos históricos, arbitraje, agrupación, agenda y guardas oficiales sin modificar datos productivos.",
+ "description": "Supervisión SHADOW y acceso a la cola de revisión. Los paneles distinguen incidencia activa/terminada de revisión pendiente/resuelta. La escritura se realiza únicamente mediante la Governance Review UI v0.3.14.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
@@ -2743,11 +2743,11 @@
"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",
+ "rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n CASE review_status\n WHEN 'PENDING' THEN 'Pendiente'\n WHEN 'REVIEWED' THEN 'Resuelta'\n ELSE review_status\n END AS \"Revisión\",\n max_severity AS \"Severidad\",\n decision_title AS \"Pregunta operativa\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\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 ended_at AS \"Fin\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v1\nORDER BY agenda_rank\nLIMIT 50",
"refId": "A"
}
],
- "title": "Decisiones humanas pendientes",
+ "title": "Decisiones pendientes de revisión",
"type": "table"
},
{
@@ -3150,7 +3150,7 @@
"editorMode": "code",
"format": "table",
"rawQuery": true,
- "rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n max_severity AS \"Severidad\",\n attention_status AS \"Atención\",\n machine_id AS \"Máquina\",\n decision_title AS \"Decisión\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n pending_age_hours AS \"Antigüedad h\",\n review_due_at AS \"Límite\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_daily_agenda_v1\nORDER BY agenda_rank\nLIMIT 50",
+ "rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n max_severity AS \"Severidad\",\n attention_status AS \"Atención\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n 'Pendiente' AS \"Revisión\",\n decision_title AS \"Decisión\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n pending_age_hours AS \"Antigüedad h\",\n review_due_at AS \"Límite\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v1\nORDER BY agenda_rank\nLIMIT 50",
"refId": "A"
}
],
@@ -3672,7 +3672,11 @@
"regression",
"hardening",
"contracts",
- "v0313"
+ "v0313",
+ "v03131",
+ "governance-ui",
+ "human-review",
+ "v0314"
],
"templating": {
"list": []
diff --git a/ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh b/ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh
new file mode 100755
index 0000000..c8c2797
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh
@@ -0,0 +1,301 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ACTION="${1:-deploy}"
+GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
+REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
+DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
+FOLDER_UID="${REGRESSION_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 governance review UI "
+ "SHADOW v0.3.14"
+ ),
+ },
+ )
+ 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", [])) != 31:
+ errors.append(
+ "El dashboard no tiene 31 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 = {
+ 29: "li_context_regression_latest_run_v1",
+ 30: "li_context_regression_failures_v1",
+ 31: "li_context_regression_run_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}"
+ )
+
+ governance_expected = {
+ 23: "li_context_governance_ui_queue_v1",
+ 26: "li_context_governance_ui_queue_v1",
+ }
+
+ for panel_id, fragment in governance_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 (
+ '"Incidencia"' not in sql
+ or '"Revisión"' not in sql
+ ):
+ errors.append(
+ f"Panel {panel_id} no separa Incidencia y Revisión"
+ )
+
+ 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"),
+ "regression_panels": {
+ "latest_run": 29 in panels,
+ "issues": 30 in panels,
+ "history": 31 in panels,
+ },
+ "governance_ui_panels": {
+ "pending_review": 23 in panels,
+ "daily_agenda": 26 in panels,
+ "semantic_status_split": all(
+ (
+ '"Incidencia"' in (
+ panels.get(panel_id, {})
+ .get("targets", [{}])[0]
+ .get("rawSql", "")
+ )
+ and '"Revisión"' in (
+ panels.get(panel_id, {})
+ .get("targets", [{}])[0]
+ .get("rawSql", "")
+ )
+ )
+ for panel_id in (23, 26)
+ ),
+ },
+ "errors":
+ errors,
+ }
+
+ print(json.dumps(
+ output,
+ ensure_ascii=False,
+ indent=2,
+ ))
+
+ if errors:
+ raise SystemExit(1)
+
+else:
+ raise SystemExit(
+ "Uso: deploy_governance_review_ui_dashboard_v0314.sh "
+ "{deploy|validate}"
+ )
+PY
diff --git a/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh b/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
new file mode 100755
index 0000000..eacfbf6
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ACTION="${1:-status}"
+RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
+APP_ROOT="${GOVERNANCE_UI_APP_ROOT:-$RUNTIME_ROOT/governance-ui}"
+CONTAINER="${GOVERNANCE_UI_CONTAINER:-mv_ucepsa_governance_ui}"
+IMAGE="${GOVERNANCE_UI_IMAGE:-mesavault/ucepsa-governance-ui:0.3.14}"
+NETWORK="${GOVERNANCE_UI_NETWORK:-mv_ucepsa_net}"
+HOST_IP="${GOVERNANCE_UI_HOST_IP:-0.0.0.0}"
+HOST_PORT="${GOVERNANCE_UI_HOST_PORT:-8091}"
+BASE_ENV="${GOVERNANCE_UI_BASE_ENV:-$RUNTIME_ROOT/.env}"
+UI_ENV="${GOVERNANCE_UI_ENV:-$APP_ROOT/.env}"
+
+require_build_files() {
+ test -f "$APP_ROOT/Dockerfile"
+ test -f "$APP_ROOT/requirements.txt"
+}
+
+require_runtime_files() {
+ require_build_files
+ test -f "$UI_ENV"
+ test -f "$BASE_ENV"
+}
+
+build_image() {
+ require_build_files
+ docker build \
+ -t "$IMAGE" \
+ "$APP_ROOT"
+}
+
+start_container() {
+ require_runtime_files
+ docker rm -f "$CONTAINER" \
+ >/dev/null 2>&1 || true
+
+ docker run -d \
+ --name "$CONTAINER" \
+ --restart unless-stopped \
+ --network "$NETWORK" \
+ --env-file "$BASE_ENV" \
+ --env-file "$UI_ENV" \
+ -e "PGHOST=mv_ucepsa_postgres_hot" \
+ -e "TENANT_OVERRIDE=ucepsa" \
+ -e "SITE_OVERRIDE=ucepsa_onpremise" \
+ -p "${HOST_IP}:${HOST_PORT}:8080" \
+ "$IMAGE"
+
+ docker ps \
+ --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \
+ | grep -E "$CONTAINER|NAMES"
+}
+
+case "$ACTION" in
+ build)
+ build_image
+ ;;
+ start)
+ start_container
+ ;;
+ deploy)
+ build_image
+ start_container
+ ;;
+ restart)
+ docker rm -f "$CONTAINER" \
+ >/dev/null 2>&1 || true
+ start_container
+ ;;
+ stop)
+ docker stop "$CONTAINER"
+ ;;
+ status)
+ docker ps -a \
+ --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \
+ | grep -E "$CONTAINER|NAMES"
+ ;;
+ logs)
+ docker logs --tail 200 -f "$CONTAINER"
+ ;;
+ health)
+ curl -fsS \
+ "http://127.0.0.1:${HOST_PORT}/health"
+ echo
+ ;;
+ *)
+ echo \
+ "Uso: $0 {build|start|deploy|restart|stop|status|logs|health}" \
+ >&2
+ exit 2
+ ;;
+esac
diff --git a/ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0314.sh b/ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0314.sh
new file mode 100755
index 0000000..679929b
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0314.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${GOVERNANCE_UI_HOST_PORT:-8091}"
+BASE_URL="${GOVERNANCE_UI_BASE_URL:-http://127.0.0.1:${PORT}}"
+
+echo "=== health ==="
+curl -fsS "${BASE_URL}/health"
+echo
+
+echo "=== login page ==="
+curl -fsS "${BASE_URL}/login" \
+ | grep -F "Revisión de gobernanza" \
+ >/dev/null
+
+echo "=== protected queue redirects to login ==="
+STATUS="$(
+ curl -sS -o /dev/null -w '%{http_code}' \
+ "${BASE_URL}/"
+)"
+
+if [[ "$STATUS" != "307" && "$STATUS" != "303" ]]; then
+ echo "ERROR: la cola sin sesión devolvió HTTP $STATUS" >&2
+ exit 1
+fi
+
+echo "Smoke test OK"
diff --git a/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql b/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql
new file mode 100644
index 0000000..9ddfc4e
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql
@@ -0,0 +1,121 @@
+\pset pager off
+
+\echo '=== 1. Objetos v0.3.14 ==='
+
+SELECT
+ to_regclass(
+ 'mv_loss_intelligence.context_governance_ui_actions'
+ ) AS actions_table,
+ to_regclass(
+ 'mv_reports_ucepsa_prod.li_context_governance_ui_queue_v1'
+ ) AS queue_view,
+ to_regclass(
+ 'mv_reports_ucepsa_prod.li_context_governance_ui_case_timeline_v1'
+ ) AS timeline_view,
+ to_regclass(
+ 'mv_reports_ucepsa_prod.li_context_governance_ui_recent_actions_v1'
+ ) AS actions_view;
+
+\echo '=== 2. Cola UI igual a la agenda diaria ==='
+
+SELECT
+ (
+ SELECT COUNT(*)
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_queue_v1
+ ) AS ui_queue_rows,
+ (
+ SELECT COUNT(*)
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_daily_agenda_v1
+ ) AS agenda_rows;
+
+\echo '=== 3. Separación semántica de estados ==='
+
+SELECT COUNT(*) AS invalid_ui_status_rows
+FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_queue_v1
+WHERE incident_status NOT IN (
+ 'ACTIVE',
+ 'ENDED'
+)
+ OR review_status <> 'PENDING';
+
+\echo '=== 4. Una decisión una sola vez ==='
+
+SELECT COUNT(*) AS duplicate_ui_decisions
+FROM (
+ SELECT
+ decision_group_key,
+ COUNT(*) AS n
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_queue_v1
+ GROUP BY decision_group_key
+ HAVING COUNT(*) > 1
+) duplicated;
+
+\echo '=== 5. Timeline contiene todos los casos pendientes ==='
+
+SELECT COUNT(*) AS pending_cases_without_timeline
+FROM
+ mv_reports_ucepsa_prod
+ .li_context_decision_group_cases_v1 c
+WHERE c.case_review_status =
+ 'PENDING'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_case_timeline_v1 t
+ WHERE t.decision_group_key =
+ c.decision_group_key
+ AND t.case_id =
+ c.case_id
+ );
+
+\echo '=== 6. Auditoría UI no oficial ==='
+
+SELECT
+ (
+ SELECT COUNT(*)
+ FROM
+ mv_loss_intelligence
+ .context_governance_ui_actions
+ WHERE official_eligible
+ ) AS official_ui_action_rows,
+ (
+ SELECT COUNT(*)
+ FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_queue_v1
+ WHERE official_eligible
+ ) AS official_ui_queue_rows,
+ (
+ SELECT COUNT(*)
+ FROM
+ mv_reports_ucepsa_prod
+ .li_operator_stop_queue_v2
+ ) AS official_operator_queue_rows;
+
+\echo '=== 7. Acciones registradas ==='
+
+SELECT
+ ui_action_id,
+ decision_group_key,
+ selected_case_ids,
+ answer_code,
+ mapped_classification,
+ actor_display_name,
+ create_contexts,
+ created_context_session_ids,
+ status,
+ created_at
+FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_ui_recent_actions_v1
+ORDER BY created_at DESC
+LIMIT 20;
diff --git a/ucepsa/edge-oee-demo/sql/versions/117_ucepsa_governance_review_ui_shadow_v0314.sql b/ucepsa/edge-oee-demo/sql/versions/117_ucepsa_governance_review_ui_shadow_v0314.sql
new file mode 100644
index 0000000..779e5a8
--- /dev/null
+++ b/ucepsa/edge-oee-demo/sql/versions/117_ucepsa_governance_review_ui_shadow_v0314.sql
@@ -0,0 +1,220 @@
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS
+mv_loss_intelligence.context_governance_ui_actions (
+ ui_action_id bigserial PRIMARY KEY,
+ tenant text NOT NULL,
+ site text NOT NULL,
+ request_id uuid NOT NULL,
+ decision_group_key text NOT NULL,
+ selected_case_ids bigint[] NOT NULL
+ CHECK (cardinality(selected_case_ids) > 0),
+ action_scope text NOT NULL
+ CHECK (
+ action_scope IN (
+ 'ALL_PENDING_CASES',
+ 'SELECTED_CASES'
+ )
+ ),
+ answer_code text NOT NULL,
+ mapped_classification text NOT NULL,
+ selected_order_ref text,
+ selected_workorder_id bigint,
+ corrected_in_odoo boolean NOT NULL DEFAULT false,
+ create_contexts boolean NOT NULL DEFAULT false,
+ created_context_session_ids bigint[] NOT NULL
+ DEFAULT ARRAY[]::bigint[],
+ actor_username text NOT NULL,
+ actor_display_name text NOT NULL,
+ actor_roles text[] NOT NULL
+ DEFAULT ARRAY[]::text[],
+ notes text,
+ previous_state jsonb NOT NULL,
+ resulting_state jsonb NOT NULL,
+ status text NOT NULL
+ CHECK (
+ status IN (
+ 'APPLIED',
+ 'REJECTED',
+ 'ERROR'
+ )
+ ),
+ error_detail text,
+ source_ip inet,
+ user_agent text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ official_eligible boolean NOT NULL DEFAULT false
+ CHECK (official_eligible = false),
+ CONSTRAINT ux_context_governance_ui_request
+ UNIQUE (tenant, site, request_id)
+);
+
+CREATE INDEX IF NOT EXISTS
+idx_context_governance_ui_actions_decision
+ON
+mv_loss_intelligence.context_governance_ui_actions (
+ tenant,
+ site,
+ decision_group_key,
+ created_at DESC
+);
+
+CREATE INDEX IF NOT EXISTS
+idx_context_governance_ui_actions_actor
+ON
+mv_loss_intelligence.context_governance_ui_actions (
+ tenant,
+ site,
+ actor_username,
+ created_at DESC
+);
+
+COMMENT ON TABLE
+mv_loss_intelligence.context_governance_ui_actions IS
+'Auditoría SHADOW de cada acción realizada desde la interfaz de revisión. No modifica Odoo ni habilita el Ledger oficial.';
+
+CREATE OR REPLACE VIEW
+mv_reports_ucepsa_prod.li_context_governance_ui_queue_v1
+AS
+SELECT
+ a.agenda_rank,
+ a.decision_group_key,
+ a.machine_id,
+ CASE a.decision_status
+ WHEN 'OPEN' THEN 'ACTIVE'
+ ELSE 'ENDED'
+ END AS incident_status,
+ a.review_status,
+ a.max_severity,
+ a.decision_family,
+ a.decision_title,
+ a.question_text,
+ a.recommended_action,
+ a.review_owner,
+ a.first_seen_at,
+ a.last_seen_at,
+ a.ended_at,
+ a.pending_age_hours,
+ a.attention_status,
+ a.review_due_at,
+ a.pending_root_case_count,
+ a.reviewed_root_case_count,
+ a.linked_root_case_count,
+ a.linked_episode_count,
+ a.root_case_ids,
+ a.episode_ids,
+ a.production_orders,
+ a.odoo_workorder_ids,
+ a.operator_names,
+ a.known_resolution_classifications,
+ a.official_eligible
+FROM
+ mv_reports_ucepsa_prod
+ .li_context_governance_daily_agenda_v1 a;
+
+COMMENT ON VIEW
+mv_reports_ucepsa_prod.li_context_governance_ui_queue_v1 IS
+'Cola de la interfaz: separa estado de incidencia y estado de revisión para evitar confundir terminada con resuelta.';
+
+CREATE OR REPLACE VIEW
+mv_reports_ucepsa_prod.li_context_governance_ui_case_timeline_v1
+AS
+SELECT
+ d.decision_group_key,
+ d.decision_family,
+ d.machine_id,
+ d.decision_status,
+ d.decision_review_status,
+ d.decision_case_role,
+ d.case_id,
+ d.case_family,
+ d.case_title,
+ d.case_status,
+ d.case_severity,
+ d.first_seen_at,
+ d.last_seen_at,
+ d.ended_at,
+ d.case_duration_min,
+ d.linked_evidence_count,
+ d.episode_ids,
+ d.production_orders,
+ d.odoo_workorder_ids,
+ d.case_review_owner,
+ d.case_review_status,
+ d.case_resolution_classification,
+ d.case_selected_order_ref,
+ d.case_selected_workorder_id,
+ d.case_reviewed_by,
+ d.case_reviewed_at,
+ d.case_review_notes,
+ d.official_eligible
+FROM
+ mv_reports_ucepsa_prod
+ .li_context_decision_group_cases_v1 d;
+
+COMMENT ON VIEW
+mv_reports_ucepsa_prod.li_context_governance_ui_case_timeline_v1 IS
+'Línea temporal de casos raíz mostrada en la interfaz de revisión.';
+
+CREATE OR REPLACE VIEW
+mv_reports_ucepsa_prod.li_context_governance_ui_recent_actions_v1
+AS
+SELECT
+ a.ui_action_id,
+ a.request_id,
+ a.decision_group_key,
+ a.selected_case_ids,
+ a.action_scope,
+ a.answer_code,
+ a.mapped_classification,
+ a.selected_order_ref,
+ a.selected_workorder_id,
+ a.corrected_in_odoo,
+ a.create_contexts,
+ a.created_context_session_ids,
+ a.actor_username,
+ a.actor_display_name,
+ a.actor_roles,
+ a.notes,
+ a.status,
+ a.error_detail,
+ a.source_ip,
+ a.created_at,
+ a.official_eligible
+FROM
+ mv_loss_intelligence.context_governance_ui_actions a
+WHERE a.created_at >= now() - interval '30 days'
+ORDER BY
+ a.created_at DESC,
+ a.ui_action_id DESC;
+
+COMMENT ON VIEW
+mv_reports_ucepsa_prod.li_context_governance_ui_recent_actions_v1 IS
+'Historial de acciones de la interfaz durante los últimos 30 días.';
+
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM pg_roles
+ WHERE rolname = 'grafana_ucepsa_ro'
+ ) THEN
+ GRANT SELECT
+ ON mv_reports_ucepsa_prod
+ .li_context_governance_ui_queue_v1
+ TO grafana_ucepsa_ro;
+
+ GRANT SELECT
+ ON mv_reports_ucepsa_prod
+ .li_context_governance_ui_case_timeline_v1
+ TO grafana_ucepsa_ro;
+
+ GRANT SELECT
+ ON mv_reports_ucepsa_prod
+ .li_context_governance_ui_recent_actions_v1
+ TO grafana_ucepsa_ro;
+ END IF;
+END
+$$;
+
+COMMIT;