feat(ucepsa): add governance review ui shadow
This commit is contained in:
parent
27a653eb42
commit
33706d397a
@ -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
|
||||||
|
```
|
||||||
6
ucepsa/edge-oee-demo/governance-ui/.dockerignore
Normal file
6
ucepsa/edge-oee-demo/governance-ui/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.hash-venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.git
|
||||||
9
ucepsa/edge-oee-demo/governance-ui/.env.example
Normal file
9
ucepsa/edge-oee-demo/governance-ui/.env.example
Normal file
@ -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"]}]
|
||||||
25
ucepsa/edge-oee-demo/governance-ui/Dockerfile
Normal file
25
ucepsa/edge-oee-demo/governance-ui/Dockerfile
Normal file
@ -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"]
|
||||||
0
ucepsa/edge-oee-demo/governance-ui/app/__init__.py
Normal file
0
ucepsa/edge-oee-demo/governance-ui/app/__init__.py
Normal file
162
ucepsa/edge-oee-demo/governance-ui/app/auth.py
Normal file
162
ucepsa/edge-oee-demo/governance-ui/app/auth.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
34
ucepsa/edge-oee-demo/governance-ui/app/db.py
Normal file
34
ucepsa/edge-oee-demo/governance-ui/app/db.py
Normal file
@ -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
|
||||||
|
)
|
||||||
641
ucepsa/edge-oee-demo/governance-ui/app/main.py
Normal file
641
ucepsa/edge-oee-demo/governance-ui/app/main.py
Normal file
@ -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(),
|
||||||
|
},
|
||||||
|
)
|
||||||
1030
ucepsa/edge-oee-demo/governance-ui/app/review_service.py
Normal file
1030
ucepsa/edge-oee-demo/governance-ui/app/review_service.py
Normal file
File diff suppressed because it is too large
Load Diff
220
ucepsa/edge-oee-demo/governance-ui/app/settings.py
Normal file
220
ucepsa/edge-oee-demo/governance-ui/app/settings.py
Normal file
@ -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)),
|
||||||
|
)
|
||||||
339
ucepsa/edge-oee-demo/governance-ui/app/static/style.css
Normal file
339
ucepsa/edge-oee-demo/governance-ui/app/static/style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
Normal file
33
ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ settings.app_title }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if user %}
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<a class="brand" href="/">MESAVAULT</a>
|
||||||
|
<span class="subtitle">Revisión de gobernanza</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Pendientes</a>
|
||||||
|
<a href="/history">Historial</a>
|
||||||
|
<span class="user">{{ user.display_name }}</span>
|
||||||
|
{% if not settings.write_enabled %}<span class="status">Solo lectura</span>{% endif %}
|
||||||
|
<form class="inline" method="post" action="/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<button class="link-button" type="submit">Salir</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
158
ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
Normal file
158
ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/">← Volver a pendientes</a>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="decision-detail">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ decision.machine_id or "GLOBAL" }}</span>
|
||||||
|
<span class="severity">{{ decision.max_severity }}</span>
|
||||||
|
<h1>{{ decision.decision_title }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="status-stack">
|
||||||
|
<span class="status">
|
||||||
|
Incidencia:
|
||||||
|
{% if decision.decision_status == "OPEN" %}Activa{% else %}Terminada{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="status">Revisión: {{ decision.review_status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-box">
|
||||||
|
<h2>Pregunta</h2>
|
||||||
|
<p>{{ decision.question_text }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Intervalos y casos</h2>
|
||||||
|
<div class="timeline">
|
||||||
|
{% for case in cases %}
|
||||||
|
<article class="timeline-item {% if case.case_review_status != 'PENDING' %}reviewed{% endif %}">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<strong>{{ case.first_seen_at|datetime_es }}</strong>
|
||||||
|
<span>→ {{ case.ended_at|datetime_es }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Caso {{ case.case_id }} · {{ case.case_title }}</h3>
|
||||||
|
<p>
|
||||||
|
Incidencia:
|
||||||
|
{% if case.case_status == "OPEN" %}activa{% else %}terminada{% endif %}
|
||||||
|
· Revisión: {{ case.case_review_status }}
|
||||||
|
</p>
|
||||||
|
{% if case.case_resolution_classification %}
|
||||||
|
<p><strong>Resolución previa:</strong> {{ case.case_resolution_classification }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if case.production_orders %}
|
||||||
|
<p><strong>Órdenes:</strong> {{ case.production_orders|join(", ") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Evidencia técnica</summary>
|
||||||
|
<div class="evidence-list">
|
||||||
|
{% for item in decision.episode_evidence %}
|
||||||
|
<article>
|
||||||
|
<strong>Episodio {{ item.episode_id }} · {{ item.incident_title }}</strong>
|
||||||
|
<p>{{ item.latest_detail or "Sin detalle adicional." }}</p>
|
||||||
|
<small>
|
||||||
|
{{ 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 %}
|
||||||
|
</small>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if not write_enabled %}
|
||||||
|
<div class="alert error">
|
||||||
|
La interfaz está en modo solo lectura. Puede validar la información, pero todavía no registrar respuestas.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_review and pending_cases %}
|
||||||
|
<form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>1. Intervalos a los que se aplica la respuesta</legend>
|
||||||
|
{% for case in pending_cases %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="case_ids" value="{{ case.case_id }}" checked>
|
||||||
|
<span>
|
||||||
|
Caso {{ case.case_id }}:
|
||||||
|
{{ case.first_seen_at|datetime_es }}
|
||||||
|
→ {{ case.ended_at|datetime_es }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<p class="help">Desmarque un intervalo cuando la causa no sea la misma. Los demás seguirán pendientes.</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>2. Qué ocurrió</legend>
|
||||||
|
{% for code, label in answer_options %}
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" name="answer_code" value="{{ code }}" required>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% if decision.decision_family == "ODOO_ORDER_START_CONFLICT" %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>3. Orden válida</legend>
|
||||||
|
{% for order_ref, workorder_id in order_pairs %}
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" name="selected_order" value="{{ order_ref }}|{{ workorder_id }}">
|
||||||
|
<span>{{ order_ref }} · WO {{ workorder_id }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="corrected_in_odoo" value="true">
|
||||||
|
<span>La incoherencia ya está corregida en Odoo</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if decision.decision_family == "UNCONFIRMED_ACTIVITY" %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>3. Contexto retrospectivo</legend>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="create_contexts" value="true">
|
||||||
|
<span>Crear un contexto exacto por cada intervalo seleccionado cuando la respuesta lo permita</span>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Observaciones
|
||||||
|
<textarea name="notes" rows="5" placeholder="Explique brevemente qué ocurrió y, cuando proceda, qué se corrigió."></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="check-row confirmation">
|
||||||
|
<input type="checkbox" name="confirmation" value="true" required>
|
||||||
|
<span>Confirmo que la respuesta refleja la realidad de planta para los intervalos seleccionados.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="primary" type="submit">Resolver selección</button>
|
||||||
|
</form>
|
||||||
|
{% elif not pending_cases %}
|
||||||
|
<div class="alert success">Esta decisión ya no tiene casos pendientes.</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Historial de acciones</h1>
|
||||||
|
<p>Acciones realizadas desde la interfaz durante los últimos 30 días.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Decisión</th>
|
||||||
|
<th>Respuesta</th>
|
||||||
|
<th>Casos</th>
|
||||||
|
<th>Contextos</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for action in actions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ action.created_at|datetime_es }}</td>
|
||||||
|
<td>{{ action.actor_display_name }}</td>
|
||||||
|
<td>{{ action.decision_group_key }}</td>
|
||||||
|
<td>{{ action.answer_code }}</td>
|
||||||
|
<td>{{ action.selected_case_ids|join(", ") }}</td>
|
||||||
|
<td>{{ action.created_context_session_ids|join(", ") if action.created_context_session_ids else "—" }}</td>
|
||||||
|
<td>{{ action.status }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
ucepsa/edge-oee-demo/governance-ui/app/templates/login.html
Normal file
23
ucepsa/edge-oee-demo/governance-ui/app/templates/login.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="login-card">
|
||||||
|
<h1>{{ settings.app_title }}</h1>
|
||||||
|
<p>Acceso interno para revisar y resolver excepciones operativas.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>
|
||||||
|
Usuario
|
||||||
|
<input name="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Contraseña
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Entrar</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
61
ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
Normal file
61
ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Decisiones pendientes</h1>
|
||||||
|
<p>Una tarjeta equivale a una pregunta humana. Los datos técnicos se conservan como evidencia.</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-badge">{{ rows|length }} pendientes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not rows %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No hay decisiones pendientes para su rol</h2>
|
||||||
|
<p>La cola se actualizará automáticamente cuando aparezca una excepción que requiera intervención humana.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for row in rows %}
|
||||||
|
<article class="decision-card severity-{{ row.max_severity|lower }}">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ row.machine_id or "GLOBAL" }}</span>
|
||||||
|
<span class="severity">{{ row.max_severity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-pair">
|
||||||
|
<span class="status">
|
||||||
|
Incidencia:
|
||||||
|
{% if row.incident_status == "ACTIVE" %}Activa{% else %}Terminada{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="status review-pending">Revisión: Pendiente</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{ row.decision_title }}</h2>
|
||||||
|
<p class="question">{{ row.question_text }}</p>
|
||||||
|
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Inicio</dt><dd>{{ row.first_seen_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Fin</dt><dd>{{ row.ended_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Responsable</dt><dd>{{ row.review_owner }}</dd></div>
|
||||||
|
<div><dt>Casos pendientes</dt><dd>{{ row.pending_root_case_count }}</dd></div>
|
||||||
|
<div><dt>Evidencias</dt><dd>{{ row.linked_episode_count }}</dd></div>
|
||||||
|
<div><dt>Atención</dt><dd>{{ row.attention_status }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if row.production_orders %}
|
||||||
|
<p><strong>Órdenes:</strong> {{ row.production_orders|join(", ") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="primary button" href="/decisions/{{ row.decision_group_key|urlencode }}">
|
||||||
|
Revisar
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
7
ucepsa/edge-oee-demo/governance-ui/requirements.txt
Normal file
7
ucepsa/edge-oee-demo/governance-ui/requirements.txt
Normal file
@ -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
|
||||||
@ -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())
|
||||||
@ -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,
|
"editable": true,
|
||||||
"fiscalYearStartMonth": 0,
|
"fiscalYearStartMonth": 0,
|
||||||
"graphTooltip": 1,
|
"graphTooltip": 1,
|
||||||
@ -2743,11 +2743,11 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"rawQuery": true,
|
"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"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Decisiones humanas pendientes",
|
"title": "Decisiones pendientes de revisión",
|
||||||
"type": "table"
|
"type": "table"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -3150,7 +3150,7 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"rawQuery": true,
|
"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"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -3672,7 +3672,11 @@
|
|||||||
"regression",
|
"regression",
|
||||||
"hardening",
|
"hardening",
|
||||||
"contracts",
|
"contracts",
|
||||||
"v0313"
|
"v0313",
|
||||||
|
"v03131",
|
||||||
|
"governance-ui",
|
||||||
|
"human-review",
|
||||||
|
"v0314"
|
||||||
],
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": []
|
"list": []
|
||||||
|
|||||||
301
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh
Executable file
301
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh
Executable file
@ -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
|
||||||
93
ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
Executable file
93
ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
Executable file
@ -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
|
||||||
27
ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0314.sh
Executable file
27
ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0314.sh
Executable file
@ -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"
|
||||||
121
ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql
Normal file
121
ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql
Normal file
@ -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;
|
||||||
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user