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,
|
||||
"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": []
|
||||
|
||||
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