From 33706d397a4ee37d720c0e69a85cfbd39e87f29b Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Tue, 21 Jul 2026 10:29:31 +0200 Subject: [PATCH] feat(ucepsa): add governance review ui shadow --- ...113_governance_review_ui_shadow_v0.3.14.md | 209 ++++ .../edge-oee-demo/governance-ui/.dockerignore | 6 + .../edge-oee-demo/governance-ui/.env.example | 9 + ucepsa/edge-oee-demo/governance-ui/Dockerfile | 25 + .../governance-ui/app/__init__.py | 0 .../edge-oee-demo/governance-ui/app/auth.py | 162 +++ ucepsa/edge-oee-demo/governance-ui/app/db.py | 34 + .../edge-oee-demo/governance-ui/app/main.py | 641 ++++++++++ .../governance-ui/app/review_service.py | 1030 +++++++++++++++++ .../governance-ui/app/settings.py | 220 ++++ .../governance-ui/app/static/style.css | 339 ++++++ .../governance-ui/app/templates/base.html | 33 + .../governance-ui/app/templates/decision.html | 158 +++ .../governance-ui/app/templates/history.html | 38 + .../governance-ui/app/templates/login.html | 23 + .../governance-ui/app/templates/queue.html | 61 + .../governance-ui/requirements.txt | 7 + .../tools/generate_password_hash.py | 33 + ...sa-shopfloor-context-health.dashboard.json | 14 +- ...oy_governance_review_ui_dashboard_v0314.sh | 301 +++++ .../ops/manage_governance_review_ui_v0314.sh | 93 ++ .../ops/smoke_governance_review_ui_v0314.sh | 27 + .../validate_governance_review_ui_v0314.sql | 121 ++ ...epsa_governance_review_ui_shadow_v0314.sql | 220 ++++ 24 files changed, 3799 insertions(+), 5 deletions(-) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/113_governance_review_ui_shadow_v0.3.14.md create mode 100644 ucepsa/edge-oee-demo/governance-ui/.dockerignore create mode 100644 ucepsa/edge-oee-demo/governance-ui/.env.example create mode 100644 ucepsa/edge-oee-demo/governance-ui/Dockerfile create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/__init__.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/auth.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/db.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/main.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/review_service.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/settings.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/static/style.css create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/base.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/history.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/login.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/requirements.txt create mode 100644 ucepsa/edge-oee-demo/governance-ui/tools/generate_password_hash.py create mode 100755 ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh create mode 100755 ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh create mode 100755 ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0314.sh create mode 100644 ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/117_ucepsa_governance_review_ui_shadow_v0314.sql 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 %} +
+
+ MESAVAULT + Revisión de gobernanza +
+ +
+ {% 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 %} +
+ + +
+ 1. Intervalos a los que se aplica la respuesta + {% for case in pending_cases %} + + {% endfor %} +

Desmarque un intervalo cuando la causa no sea la misma. Los demás seguirán pendientes.

+
+ +
+ 2. Qué ocurrió + {% for code, label in answer_options %} + + {% endfor %} +
+ + {% if decision.decision_family == "ODOO_ORDER_START_CONFLICT" %} +
+ 3. Orden válida + {% for order_ref, workorder_id in order_pairs %} + + {% endfor %} + +
+ {% endif %} + + {% if decision.decision_family == "UNCONFIRMED_ACTIVITY" %} +
+ 3. Contexto retrospectivo + +

+ Solo se admite para sistema antiguo, prueba/ajuste o mantenimiento. + No cubre los huecos entre intervalos y no se permite sobre casos todavía abiertos. +

+
+ {% endif %} + + + + + + +
+ {% 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.

+
+
+ +
+ + + + + + + + + + + + + + {% for action in actions %} + + + + + + + + + + {% endfor %} + +
FechaUsuarioDecisiónRespuestaCasosContextosEstado
{{ 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 }}
+
+{% 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 %} +
+

{{ settings.app_title }}

+

Acceso interno para revisar y resolver excepciones operativas.

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + + +
+
+{% 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;