feat(ucepsa): add two-stage governance context approval

This commit is contained in:
Victor Fraile Garcia 2026-07-21 16:45:13 +02:00
parent 33706d397a
commit c3eb98a91d
27 changed files with 3767 additions and 494 deletions

View File

@ -0,0 +1,230 @@
# UCEPSA — Governance Review UI en dos etapas SHADOW v0.3.15
## Objetivo
Separar claramente las dos responsabilidades:
```text
Producción
→ explica qué ocurrió realmente en planta.
MESAVAULT / responsable técnico
→ decide si esa explicación puede convertirse en contexto canónico.
```
Juan Pablo deja de ver y utilizar la casilla técnica de creación de contexto.
Cuando una respuesta permite proponer un contexto, la aplicación crea una
propuesta pendiente de aprobación técnica, no el contexto definitivo.
## Novedades
```text
respuesta de Producción
→ revisión de decisión, casos y episodios
→ propuesta técnica cuando procede
→ aprobación / rechazo / solicitud de aclaración
→ creación de contexto solo al aprobar
```
Incluye además:
- estados visibles en lenguaje normal;
- `En consulta` para los casos cuya respuesta todavía no se conoce;
- aclaraciones entre Producción y el revisor técnico;
- idempotencia mediante una clave única por formulario;
- auditoría unificada;
- pruebas de roles y traducciones;
- tres paneles nuevos de Grafana.
## Estados
### Decisión de Producción
```text
Pendiente
En consulta
Resuelta
```
### Propuesta de contexto
```text
Pendiente
Requiere aclaración
Aprobada
Rechazada
```
## Contextos que pueden proponerse
```text
Producción con sistema antiguo
→ LEGACY_ERP
Prueba, ajuste o preparación
→ TEST_SETUP
Mantenimiento
→ MAINTENANCE
```
La propuesta no significa que el contexto esté creado.
## Validaciones antes de aprobar
La aplicación comprueba:
```text
intervalo terminado;
inicio y fin conocidos;
máquina identificada;
sin solapamiento con una sesión Odoo;
sin solapamiento con otro contexto explícito;
un contexto exacto por caso;
official_eligible = false.
```
## Despliegue
No reiniciar la aplicación mientras Juan Pablo tenga un formulario abierto.
Esperar a que termine la acción o indicarle que recargue después del despliegue.
### Migración 118
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
Debe finalizar con:
```text
DO
COMMIT
```
### Instalar la aplicación conservando `.env`
```bash
INSTALLER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
"$INSTALLER"
```
### Construir y reiniciar
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh
"$MANAGER" build
TESTS=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh
"$TESTS"
"$MANAGER" restart
sleep 15
"$MANAGER" status
"$MANAGER" health
```
El cambio de `SESSION_SALT` cerrará las sesiones anteriores. Los usuarios
deben volver a iniciar sesión después del despliegue.
## Validación SQL
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.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_v2_rows = agenda_rows
invalid_workflow_status_rows = 0
duplicate_open_consultations = 0
duplicate_active_proposals = 0
approved_without_context = 0
invalid_approved_contexts = 0
direct_context_creation_actions = 0
duplicate_review_requests = 0
duplicate_proposal_action_requests = 0
official_consultation_rows = 0
official_proposal_rows = 0
official_proposal_action_rows = 0
official_operator_queue_rows = 0
```
## Prueba funcional sin crear contexto
1. Entrar como Juan Pablo.
2. Abrir una decisión pendiente.
3. Comprobar que no aparece ninguna casilla para crear contexto.
4. Marcar una decisión real como `En consulta` y comprobar la cola.
5. Volver a Pendiente o resolverla cuando exista respuesta.
6. Confirmar que una respuesta `Sistema antiguo`, `Prueba` o `Mantenimiento`
crea una propuesta técnica, no un contexto.
7. Entrar como Víctor.
8. Abrir `Aprobación técnica`.
9. Validar o solicitar aclaración.
## Aprobación técnica
Al aprobar:
```text
propuesta PENDING
→ comprobación de solapamientos
→ producción_context_sessions
→ propuesta APPROVED
→ auditoría
```
Al solicitar aclaración:
```text
propuesta NEEDS_CLARIFICATION
→ aparece en Aclaraciones para Producción
→ Producción responde
→ propuesta vuelve a PENDING
```
## Idempotencia
Cada formulario lleva un UUID único. Un doble clic, reenvío o recarga no debe
crear dos revisiones ni dos contextos.
Las tablas aplican restricciones únicas sobre:
```text
review request_id
consultation request_id
proposal action request_id
proposal activa por case_id
```
## Grafana
Se mantienen los 31 paneles previos y se añaden:
```text
32 · Contextos pendientes de aprobación
33 · Decisiones en consulta
34 · Actividad de la interfaz · 30 días
```
Los paneles de decisiones utilizan `li_context_governance_ui_queue_v2`.
## Regla operativa
```text
Juan Pablo responde la realidad de planta.
Víctor valida la coherencia técnica.
Ninguna respuesta de Producción crea por sí sola un contexto autoritativo.
```

View File

@ -14,6 +14,7 @@ RUN pip install --no-cache-dir \
COPY app /app/app COPY app /app/app
COPY tools /app/tools COPY tools /app/tools
COPY tests /app/tests
USER appuser USER appuser

View File

@ -10,10 +10,15 @@ from itsdangerous import BadSignature, SignatureExpired
from itsdangerous.url_safe import URLSafeTimedSerializer from itsdangerous.url_safe import URLSafeTimedSerializer
from .settings import UserConfig, get_settings from .settings import UserConfig, get_settings
from .policy import (
can_approve_context_roles,
can_manage_clarification_roles,
can_view_owner_roles,
)
SESSION_COOKIE = "mv_governance_session" SESSION_COOKIE = "mv_governance_session"
SESSION_SALT = "mv-governance-ui-v0314" SESSION_SALT = "mv-governance-ui-v0315"
@dataclass(frozen=True) @dataclass(frozen=True)
@ -134,22 +139,10 @@ def can_view_owner(
user: CurrentUser, user: CurrentUser,
review_owner: str, review_owner: str,
) -> bool: ) -> bool:
if "ADMIN" in user.roles: return can_view_owner_roles(
return True user.roles,
if review_owner == "PRODUCTION": review_owner,
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( def can_review_owner(
@ -160,3 +153,19 @@ def can_review_owner(
user, user,
review_owner, review_owner,
) )
def can_approve_context(
user: CurrentUser,
) -> bool:
return can_approve_context_roles(
user.roles
)
def can_manage_clarification(
user: CurrentUser,
) -> bool:
return can_manage_clarification_roles(
user.roles
)

View File

@ -20,7 +20,7 @@ def get_connection() -> Iterator[Connection]:
user=settings.pg_user, user=settings.pg_user,
password=settings.pg_password, password=settings.pg_password,
connect_timeout=10, connect_timeout=10,
application_name="governance_review_ui_v0314", application_name="governance_review_ui_v0315",
) )
try: try:
yield connection yield connection

View File

@ -0,0 +1,68 @@
from __future__ import annotations
LABELS = {
"RECENT": "Reciente",
"ATTENTION": "Requiere atención",
"OVERDUE": "Vencida",
"APPLIED": "Aplicada",
"ERROR": "Error",
"PRODUCTION_REVIEW": "Revisión de Producción",
"CONSULTATION": "Consulta",
"CONTEXT_PROPOSAL": "Propuesta de contexto",
"MARK_IN_CONSULTATION": "Marcada En consulta",
"CLOSE_CONSULTATION": "Consulta cerrada",
"WARNING": "Aviso",
"CRITICAL": "Crítica",
"PENDING": "Pendiente",
"REVIEWED": "Resuelta",
"DISMISSED": "Descartada",
"NOT_REQUIRED": "No requiere revisión",
"OPEN": "Activa",
"CLOSED": "Terminada",
"ACTIVE": "Activa",
"ENDED": "Terminada",
"IN_CONSULTATION": "En consulta",
"PRODUCTION": "Producción",
"TECHNICAL": "Técnico",
"JOINT": "Conjunto",
"ADMIN": "Administrador",
"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",
"NOT_RELEVANT": "No era producción",
"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",
"LEGACY_ERP": "Sistema antiguo",
"TEST_SETUP": "Prueba o ajuste",
"APPROVED": "Aprobado",
"REJECTED": "Rechazado",
"NEEDS_CLARIFICATION": "Requiere aclaración",
"CANCELLED": "Cancelado",
"APPROVE": "Aprobar contexto",
"REJECT": "Rechazar propuesta",
"REQUEST_CLARIFICATION": "Solicitar aclaración",
"CLARIFICATION_RESPONSE": "Aclaración respondida",
}
def label(value: object) -> str:
if value is None:
return ""
raw = str(value)
return LABELS.get(
raw,
raw.replace("_", " ").capitalize(),
)

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
@ -23,6 +24,8 @@ from .auth import (
SESSION_COOKIE, SESSION_COOKIE,
authenticate, authenticate,
build_session_token, build_session_token,
can_approve_context,
can_manage_clarification,
can_review_owner, can_review_owner,
can_view_owner, can_view_owner,
read_current_user, read_current_user,
@ -30,10 +33,20 @@ from .auth import (
verify_csrf, verify_csrf,
) )
from .db import get_connection from .db import get_connection
from .labels import label
from .proposal_service import (
ProposalActionInput,
ProposalError,
approve_proposal,
change_proposal_status,
)
from .review_service import ( from .review_service import (
ConsultationInput,
ReviewError, ReviewError,
ReviewInput, ReviewInput,
apply_review, apply_production_review,
mark_in_consultation,
resume_from_consultation,
) )
from .settings import get_settings from .settings import get_settings
@ -42,7 +55,7 @@ BASE_DIR = Path(__file__).resolve().parent
app = FastAPI( app = FastAPI(
title="MESAVAULT Governance Review UI", title="MESAVAULT Governance Review UI",
version="0.3.14", version="0.3.15",
) )
app.mount( app.mount(
@ -72,11 +85,19 @@ def format_datetime(value: Any) -> str:
templates.env.filters[ templates.env.filters[
"datetime_es" "datetime_es"
] = format_datetime ] = format_datetime
templates.env.filters[
"label"
] = label
def visible_queue( def redirect_login() -> RedirectResponse:
user, return RedirectResponse(
) -> list[dict[str, Any]]: "/login",
status_code=status.HTTP_303_SEE_OTHER,
)
def visible_queue(user) -> list[dict[str, Any]]:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor( with connection.cursor(
cursor_factory=RealDictCursor, cursor_factory=RealDictCursor,
@ -86,7 +107,7 @@ def visible_queue(
SELECT * SELECT *
FROM FROM
mv_reports_ucepsa_prod mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v1 .li_context_governance_ui_queue_v2
ORDER BY agenda_rank ORDER BY agenda_rank
""" """
) )
@ -105,8 +126,8 @@ def visible_queue(
] ]
def recent_actions( def recent_activity(
limit: int = 50, limit: int = 100,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor( with connection.cursor(
@ -117,10 +138,11 @@ def recent_actions(
SELECT * SELECT *
FROM FROM
mv_reports_ucepsa_prod mv_reports_ucepsa_prod
.li_context_governance_ui_recent_actions_v1 .li_context_governance_ui_activity_v1
ORDER BY ORDER BY
created_at DESC, created_at DESC,
ui_action_id DESC activity_type,
activity_id DESC
LIMIT %s LIMIT %s
""", """,
(limit,), (limit,),
@ -136,6 +158,7 @@ def load_decision(
) -> tuple[ ) -> tuple[
dict[str, Any], dict[str, Any],
list[dict[str, Any]], list[dict[str, Any]],
dict[str, Any] | None,
]: ]:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor( with connection.cursor(
@ -177,80 +200,128 @@ def load_decision(
for row in cursor.fetchall() for row in cursor.fetchall()
] ]
return dict(decision), cases cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_consultation_current_v1
WHERE decision_group_key = %s
""",
(decision_group_key,),
)
consultation = cursor.fetchone()
return (
dict(decision),
cases,
dict(consultation)
if consultation
else None,
)
def answer_options( def answer_options(
decision_family: str, decision_family: str,
) -> list[tuple[str, str]]: ) -> list[tuple[str, str]]:
if decision_family == "UNCONFIRMED_ACTIVITY": if decision_family == "UNCONFIRMED_ACTIVITY":
return [ codes = [
( "AUTHORIZED_LEGACY_PRODUCTION",
"AUTHORIZED_LEGACY_PRODUCTION", "ODOO_START_OMITTED",
"Producción con el sistema antiguo", "TEST_OR_SETUP",
), "RESIDUAL_MATERIAL",
( "MAINTENANCE",
"ODOO_START_OMITTED", "NOT_PRODUCTION",
"Producción real, pero se olvidó iniciar Odoo", "OTHER",
),
(
"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",
),
] ]
elif decision_family == "ODOO_ORDER_START_CONFLICT":
if decision_family == "ODOO_ORDER_START_CONFLICT": codes = [
return [ "ODOO_DOUBLE_START_ONE_VALID",
( "ODOO_DOUBLE_START_BOTH_INVALID",
"ODOO_DOUBLE_START_ONE_VALID", "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
"Una orden era válida y la otra quedó abierta por error", "DATA_ISSUE",
), "OTHER",
( ]
"ODOO_DOUBLE_START_BOTH_INVALID", else:
"Ninguna de las órdenes era válida", codes = [
), "DATA_ISSUE",
( "OTHER",
"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 [ return [
( (code, label(code))
"DATA_ISSUE", for code in codes
"Incidencia de datos",
),
(
"OTHER",
"Otro",
),
] ]
def load_approval_queue() -> 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_context_approval_queue_v1
ORDER BY approval_rank
"""
)
return [
dict(row)
for row in cursor.fetchall()
]
def load_proposal(
proposal_id: int,
) -> 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_context_proposals_v1
WHERE proposal_id = %s
""",
(proposal_id,),
)
row = cursor.fetchone()
if not row:
raise HTTPException(
status_code=404,
detail="Propuesta no encontrada.",
)
return dict(row)
def load_clarifications() -> 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_context_clarification_queue_v1
ORDER BY
last_action_at,
proposal_id
"""
)
return [
dict(row)
for row in cursor.fetchall()
]
@app.get( @app.get(
"/health", "/health",
response_class=HTMLResponse, response_class=HTMLResponse,
@ -267,13 +338,11 @@ def health() -> str:
"/login", "/login",
response_class=HTMLResponse, response_class=HTMLResponse,
) )
def login_page( def login_page(request: Request):
request: Request,
):
if read_current_user(request): if read_current_user(request):
return RedirectResponse( return RedirectResponse(
"/", "/",
status_code=status.HTTP_303_SEE_OTHER, status_code=303,
) )
return templates.TemplateResponse( return templates.TemplateResponse(
@ -314,7 +383,7 @@ def login_submit(
response = RedirectResponse( response = RedirectResponse(
"/", "/",
status_code=status.HTTP_303_SEE_OTHER, status_code=303,
) )
response.set_cookie( response.set_cookie(
SESSION_COOKIE, SESSION_COOKIE,
@ -341,7 +410,7 @@ def logout(
response = RedirectResponse( response = RedirectResponse(
"/login", "/login",
status_code=status.HTTP_303_SEE_OTHER, status_code=303,
) )
response.delete_cookie( response.delete_cookie(
SESSION_COOKIE, SESSION_COOKIE,
@ -360,10 +429,7 @@ def queue_page(
): ):
user = read_current_user(request) user = read_current_user(request)
if user is None: if user is None:
return RedirectResponse( return redirect_login()
"/login",
status_code=status.HTTP_303_SEE_OTHER,
)
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@ -373,6 +439,12 @@ def queue_page(
"user": user, "user": user,
"rows": visible_queue(user), "rows": visible_queue(user),
"message": message, "message": message,
"can_approve":
can_approve_context(user),
"clarification_count":
len(load_clarifications())
if can_manage_clarification(user)
else 0,
}, },
) )
@ -388,12 +460,9 @@ def decision_page(
): ):
user = read_current_user(request) user = read_current_user(request)
if user is None: if user is None:
return RedirectResponse( return redirect_login()
"/login",
status_code=status.HTTP_303_SEE_OTHER,
)
decision, cases = load_decision( decision, cases, consultation = load_decision(
decision_group_key decision_group_key
) )
@ -441,6 +510,7 @@ def decision_page(
"decision": decision, "decision": decision,
"cases": cases, "cases": cases,
"pending_cases": pending_cases, "pending_cases": pending_cases,
"consultation": consultation,
"answer_options": answer_options( "answer_options": answer_options(
str( str(
decision[ decision[
@ -458,6 +528,12 @@ def decision_page(
), ),
"write_enabled": "write_enabled":
get_settings().write_enabled, get_settings().write_enabled,
"review_submission_key":
str(uuid.uuid4()),
"consultation_submission_key":
str(uuid.uuid4()),
"resume_submission_key":
str(uuid.uuid4()),
"error": error, "error": error,
}, },
) )
@ -470,19 +546,16 @@ def review_decision(
request: Request, request: Request,
decision_group_key: str, decision_group_key: str,
csrf_token: str = Form(...), csrf_token: str = Form(...),
submission_key: uuid.UUID = Form(...),
case_ids: list[int] = Form(default=[]), case_ids: list[int] = Form(default=[]),
answer_code: str = Form(...), answer_code: str = Form(...),
notes: str = Form(""), notes: str = Form(""),
selected_order: str = Form(""), selected_order: str = Form(""),
corrected_in_odoo: bool = Form(False), corrected_in_odoo: bool = Form(False),
create_contexts: bool = Form(False),
confirmation: bool = Form(False), confirmation: bool = Form(False),
): ):
user = require_user(request) user = require_user(request)
verify_csrf( verify_csrf(user, csrf_token)
user,
csrf_token,
)
if not get_settings().write_enabled: if not get_settings().write_enabled:
raise HTTPException( raise HTTPException(
@ -490,7 +563,7 @@ def review_decision(
detail="La interfaz está en modo solo lectura.", detail="La interfaz está en modo solo lectura.",
) )
decision, _ = load_decision( decision, _, _ = load_decision(
decision_group_key decision_group_key
) )
@ -538,6 +611,7 @@ def review_decision(
) )
review = ReviewInput( review = ReviewInput(
submission_key=submission_key,
decision_group_key= decision_group_key=
decision_group_key, decision_group_key,
selected_case_ids=tuple( selected_case_ids=tuple(
@ -556,14 +630,11 @@ def review_decision(
selected_workorder_id, selected_workorder_id,
corrected_in_odoo= corrected_in_odoo=
corrected_in_odoo, corrected_in_odoo,
create_contexts= confirmation=confirmation,
create_contexts,
confirmation=
confirmation,
) )
try: try:
result = apply_review( result = apply_production_review(
review=review, review=review,
user=user, user=user,
source_ip=( source_ip=(
@ -591,22 +662,18 @@ def review_decision(
) )
message = ( message = (
"Revisión registrada. " "Respuesta registrada. "
f"Acción UI {result['ui_action_id']}." f"Acción UI {result['ui_action_id']}."
) )
if result[ proposal_ids = result.get(
"created_context_session_ids" "context_proposal_ids"
]: ) or []
if proposal_ids:
message += ( message += (
" Contextos creados: " " Se han enviado "
+ ", ".join( f"{len(proposal_ids)} propuestas "
str(value) "a validación técnica."
for value in result[
"created_context_session_ids"
]
)
+ "."
) )
return RedirectResponse( return RedirectResponse(
@ -616,19 +683,383 @@ def review_decision(
) )
@app.post(
"/decisions/{decision_group_key:path}/consultation"
)
def consultation_submit(
request: Request,
decision_group_key: str,
csrf_token: str = Form(...),
submission_key: uuid.UUID = Form(...),
reason: str = Form(...),
follow_up_with: str = Form(""),
):
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 gestionar esta decisión.",
)
try:
mark_in_consultation(
ConsultationInput(
submission_key=
submission_key,
decision_group_key=
decision_group_key,
reason=reason,
follow_up_with=
follow_up_with,
),
user,
)
except ReviewError as exc:
return RedirectResponse(
"/decisions/"
+ quote(
decision_group_key,
safe="",
)
+ "?error="
+ quote(str(exc)),
status_code=303,
)
return RedirectResponse(
"/?message="
+ quote(
"La decisión queda marcada En consulta."
),
status_code=303,
)
@app.post(
"/decisions/{decision_group_key:path}/resume"
)
def consultation_resume(
request: Request,
decision_group_key: str,
csrf_token: str = Form(...),
submission_key: uuid.UUID = Form(...),
):
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.",
)
try:
resume_from_consultation(
submission_key,
decision_group_key,
user,
)
except ReviewError as exc:
return RedirectResponse(
"/decisions/"
+ quote(
decision_group_key,
safe="",
)
+ "?error="
+ quote(str(exc)),
status_code=303,
)
return RedirectResponse(
"/decisions/"
+ quote(
decision_group_key,
safe="",
),
status_code=303,
)
@app.get(
"/approvals",
response_class=HTMLResponse,
)
def approvals_page(
request: Request,
message: str | None = None,
):
user = read_current_user(request)
if user is None:
return redirect_login()
if not can_approve_context(user):
raise HTTPException(
status_code=403,
detail="No tiene permisos de aprobación técnica.",
)
return templates.TemplateResponse(
request=request,
name="approvals.html",
context={
"settings": get_settings(),
"user": user,
"proposals": load_approval_queue(),
"message": message,
},
)
@app.get(
"/approvals/{proposal_id}",
response_class=HTMLResponse,
)
def approval_detail(
request: Request,
proposal_id: int,
error: str | None = None,
):
user = read_current_user(request)
if user is None:
return redirect_login()
if not can_approve_context(user):
raise HTTPException(
status_code=403,
detail="No tiene permisos de aprobación técnica.",
)
return templates.TemplateResponse(
request=request,
name="approval_detail.html",
context={
"settings": get_settings(),
"user": user,
"proposal": load_proposal(
proposal_id
),
"write_enabled":
get_settings().write_enabled,
"submission_key":
str(uuid.uuid4()),
"error": error,
},
)
@app.post(
"/approvals/{proposal_id}/{action_code}"
)
def approval_action(
request: Request,
proposal_id: int,
action_code: str,
csrf_token: str = Form(...),
submission_key: uuid.UUID = Form(...),
notes: str = Form(""),
):
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.",
)
if not can_approve_context(user):
raise HTTPException(
status_code=403,
detail="No tiene permisos de aprobación técnica.",
)
normalized = action_code.upper()
if normalized not in {
"APPROVE",
"REJECT",
"REQUEST_CLARIFICATION",
}:
raise HTTPException(
status_code=404,
detail="Acción no permitida.",
)
action = ProposalActionInput(
submission_key=submission_key,
proposal_id=proposal_id,
action_code=normalized,
notes=notes,
)
try:
if normalized == "APPROVE":
result = approve_proposal(
action,
user,
(
request.client.host
if request.client
else None
),
request.headers.get(
"user-agent"
),
)
message = (
"Contexto aprobado y creado: "
f"{result['context_session_id']}."
)
else:
result = change_proposal_status(
action,
user,
(
request.client.host
if request.client
else None
),
request.headers.get(
"user-agent"
),
)
message = (
"Propuesta actualizada: "
+ label(
result[
"resulting_status"
]
)
+ "."
)
except ProposalError as exc:
return RedirectResponse(
f"/approvals/{proposal_id}?error="
+ quote(str(exc)),
status_code=303,
)
return RedirectResponse(
"/approvals?message="
+ quote(message),
status_code=303,
)
@app.get(
"/clarifications",
response_class=HTMLResponse,
)
def clarifications_page(
request: Request,
message: str | None = None,
):
user = read_current_user(request)
if user is None:
return redirect_login()
if not can_manage_clarification(user):
raise HTTPException(
status_code=403,
detail="No tiene acceso a aclaraciones de Producción.",
)
return templates.TemplateResponse(
request=request,
name="clarifications.html",
context={
"settings": get_settings(),
"user": user,
"proposals": load_clarifications(),
"submission_keys": {
int(row["proposal_id"]):
str(uuid.uuid4())
for row in load_clarifications()
},
"message": message,
"write_enabled":
get_settings().write_enabled,
},
)
@app.post(
"/clarifications/{proposal_id}/respond"
)
def clarification_respond(
request: Request,
proposal_id: int,
csrf_token: str = Form(...),
submission_key: uuid.UUID = Form(...),
notes: str = Form(...),
):
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.",
)
if not can_manage_clarification(user):
raise HTTPException(
status_code=403,
detail="No puede responder esta aclaración.",
)
try:
change_proposal_status(
ProposalActionInput(
submission_key=submission_key,
proposal_id=proposal_id,
action_code=
"CLARIFICATION_RESPONSE",
notes=notes,
),
user,
(
request.client.host
if request.client
else None
),
request.headers.get(
"user-agent"
),
)
except ProposalError as exc:
return RedirectResponse(
"/clarifications?message="
+ quote(str(exc)),
status_code=303,
)
return RedirectResponse(
"/clarifications?message="
+ quote(
"Aclaración enviada a revisión técnica."
),
status_code=303,
)
@app.get( @app.get(
"/history", "/history",
response_class=HTMLResponse, response_class=HTMLResponse,
) )
def history_page( def history_page(request: Request):
request: Request,
):
user = read_current_user(request) user = read_current_user(request)
if user is None: if user is None:
return RedirectResponse( return redirect_login()
"/login",
status_code=303,
)
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@ -636,6 +1067,6 @@ def history_page(
context={ context={
"settings": get_settings(), "settings": get_settings(),
"user": user, "user": user,
"actions": recent_actions(), "activities": recent_activity(),
}, },
) )

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from typing import AbstractSet
def can_view_owner_roles(
roles: AbstractSet[str],
review_owner: str,
) -> bool:
if "ADMIN" in roles:
return True
if review_owner == "PRODUCTION":
return "PRODUCTION" in roles
if review_owner == "TECHNICAL":
return "TECHNICAL" in roles
if review_owner == "JOINT":
return bool(
roles
& {
"PRODUCTION",
"TECHNICAL",
"JOINT",
}
)
return False
def can_approve_context_roles(
roles: AbstractSet[str],
) -> bool:
return bool(
roles
& {
"TECHNICAL",
"ADMIN",
}
)
def can_manage_clarification_roles(
roles: AbstractSet[str],
) -> bool:
return bool(
roles
& {
"PRODUCTION",
"ADMIN",
}
)

View File

@ -0,0 +1,597 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from psycopg2.extras import Json, RealDictCursor
from .auth import CurrentUser
from .db import get_connection
from .settings import get_settings
@dataclass(frozen=True)
class ProposalActionInput:
submission_key: uuid.UUID
proposal_id: int
action_code: str
notes: str
class ProposalError(RuntimeError):
pass
def json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {
str(key): json_safe(item)
for key, item in value.items()
}
if isinstance(value, (list, tuple, set)):
return [
json_safe(item)
for item in value
]
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def fetch_proposal_for_update(
cursor: RealDictCursor,
proposal_id: int,
) -> dict[str, Any]:
cursor.execute(
"""
SELECT p.*
FROM
mv_loss_intelligence
.context_governance_context_proposals raw
JOIN
mv_reports_ucepsa_prod
.li_context_governance_context_proposals_v1 p
ON p.proposal_id = raw.proposal_id
WHERE raw.proposal_id = %s
FOR UPDATE OF raw
""",
(proposal_id,),
)
row = cursor.fetchone()
if not row:
raise ProposalError(
"La propuesta no existe."
)
return dict(row)
def existing_action(
cursor: RealDictCursor,
settings,
submission_key: uuid.UUID,
) -> dict[str, Any] | None:
cursor.execute(
"""
SELECT *
FROM
mv_loss_intelligence
.context_governance_context_proposal_actions
WHERE tenant = %s
AND site = %s
AND request_id = %s
""",
(
settings.tenant,
settings.site,
str(submission_key),
),
)
row = cursor.fetchone()
return dict(row) if row else None
def count_authoritative_overlaps(
cursor: RealDictCursor,
proposal: dict[str, Any],
) -> int:
cursor.execute(
"""
SELECT COUNT(*) AS overlap_count
FROM (
SELECT 1
FROM
mv_loss_intelligence
.production_context_sessions s
WHERE s.machine_id = %s
AND s.started_at < %s
AND COALESCE(
s.ended_at,
'infinity'::timestamptz
) > %s
UNION ALL
SELECT 1
FROM
mv_loss_intelligence
.odoo_shopfloor_sessions o
WHERE o.machine_id = %s
AND o.started_at IS NOT NULL
AND o.started_at < %s
AND COALESCE(
o.ended_at,
'infinity'::timestamptz
) > %s
AND o.session_status IN (
'OPEN',
'CLOSED'
)
) overlaps
""",
(
proposal["machine_id"],
proposal["ended_at"],
proposal["first_seen_at"],
proposal["machine_id"],
proposal["ended_at"],
proposal["first_seen_at"],
),
)
return int(
cursor.fetchone()["overlap_count"]
)
def insert_proposal_action(
cursor: RealDictCursor,
settings,
action: ProposalActionInput,
proposal: dict[str, Any],
user: CurrentUser,
resulting_status: str,
context_session_id: int | None,
source_ip: str | None,
user_agent: str | None,
) -> int:
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.context_governance_context_proposal_actions (
tenant,
site,
request_id,
proposal_id,
action_code,
previous_status,
resulting_status,
actor_username,
actor_display_name,
actor_roles,
notes,
context_session_id,
source_ip,
user_agent,
official_eligible
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s, false
)
RETURNING proposal_action_id
""",
(
settings.tenant,
settings.site,
str(action.submission_key),
action.proposal_id,
action.action_code,
proposal["proposal_status"],
resulting_status,
user.username,
user.display_name,
sorted(user.roles),
action.notes.strip() or None,
context_session_id,
source_ip,
user_agent,
),
)
return int(
cursor.fetchone()[
"proposal_action_id"
]
)
def approve_proposal(
action: ProposalActionInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
settings = get_settings()
with get_connection() as connection:
connection.autocommit = False
try:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT pg_advisory_xact_lock(
hashtext(%s)
)
""",
(
(
f"{settings.tenant}:"
f"{settings.site}:"
f"{action.submission_key}"
),
),
)
replay = existing_action(
cursor,
settings,
action.submission_key,
)
if replay:
connection.rollback()
return {
"idempotent_replay": True,
"proposal_action_id":
replay[
"proposal_action_id"
],
"context_session_id":
replay[
"context_session_id"
],
}
proposal = fetch_proposal_for_update(
cursor,
action.proposal_id,
)
if proposal["proposal_status"] != "PENDING":
raise ProposalError(
"Solo pueden aprobarse propuestas pendientes."
)
if (
proposal["case_status"] !=
"CLOSED"
or proposal["ended_at"] is None
):
raise ProposalError(
"El intervalo todavía no ha terminado."
)
overlap_count = count_authoritative_overlaps(
cursor,
proposal,
)
if overlap_count > 0:
raise ProposalError(
"El intervalo se solapa con otro contexto autoritativo. "
"La propuesta no puede aprobarse."
)
source_ref = (
"GOVERNANCE_PROPOSAL:"
f"{proposal['proposal_id']}"
)
cursor.execute(
"""
SELECT context_session_id
FROM
mv_loss_intelligence
.production_context_sessions
WHERE source_ref = %s
""",
(source_ref,),
)
existing_context = cursor.fetchone()
if existing_context:
context_session_id = int(
existing_context[
"context_session_id"
]
)
else:
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.production_context_sessions (
tenant,
site,
machine_id,
source_type,
source_ref,
started_at,
ended_at,
status,
authorization_status,
confidence,
official_eligible,
authorized_by,
authorized_at,
closed_by,
notes,
evidence_json
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, 'CLOSED',
'AUTHORIZED', 'MEDIUM', false,
%s, now(), %s, %s, %s
)
RETURNING context_session_id
""",
(
settings.tenant,
settings.site,
proposal["machine_id"],
proposal[
"proposed_source_type"
],
source_ref,
proposal["first_seen_at"],
proposal["ended_at"],
user.display_name,
user.display_name,
action.notes.strip()
or proposal.get(
"technical_review_notes"
)
or "Aprobación técnica desde Governance Review UI.",
Json({
"created_with":
"governance_review_ui_v0315",
"proposal_id":
proposal["proposal_id"],
"proposal_key":
proposal["proposal_key"],
"decision_group_key":
proposal[
"decision_group_key"
],
"case_id":
proposal["case_id"],
"answer_code":
proposal["answer_code"],
"proposed_by":
proposal[
"proposed_by_display_name"
],
"approved_by":
user.display_name,
"deployment_mode":
"SHADOW",
}),
),
)
context_session_id = int(
cursor.fetchone()[
"context_session_id"
]
)
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_context_proposals
SET
proposal_status = 'APPROVED',
technical_review_by_username = %s,
technical_review_by_display_name = %s,
technical_review_at = now(),
technical_review_notes = %s,
context_session_id = %s,
updated_at = now()
WHERE proposal_id = %s
""",
(
user.username,
user.display_name,
action.notes.strip() or None,
context_session_id,
action.proposal_id,
),
)
proposal_action_id = insert_proposal_action(
cursor,
settings,
action,
proposal,
user,
"APPROVED",
context_session_id,
source_ip,
user_agent,
)
connection.commit()
return {
"idempotent_replay": False,
"proposal_action_id":
proposal_action_id,
"context_session_id":
context_session_id,
}
except Exception:
connection.rollback()
raise
def change_proposal_status(
action: ProposalActionInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
settings = get_settings()
target_status = {
"REJECT": "REJECTED",
"REQUEST_CLARIFICATION":
"NEEDS_CLARIFICATION",
"CLARIFICATION_RESPONSE":
"PENDING",
}.get(action.action_code)
if target_status is None:
raise ProposalError(
"Acción de propuesta no permitida."
)
if not action.notes.strip():
raise ProposalError(
"Debe escribir una explicación."
)
with get_connection() as connection:
connection.autocommit = False
try:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT pg_advisory_xact_lock(
hashtext(%s)
)
""",
(
(
f"{settings.tenant}:"
f"{settings.site}:"
f"{action.submission_key}"
),
),
)
replay = existing_action(
cursor,
settings,
action.submission_key,
)
if replay:
connection.rollback()
return {
"idempotent_replay": True,
"proposal_action_id":
replay[
"proposal_action_id"
],
"resulting_status":
replay[
"resulting_status"
],
}
proposal = fetch_proposal_for_update(
cursor,
action.proposal_id,
)
expected_status = (
"NEEDS_CLARIFICATION"
if action.action_code ==
"CLARIFICATION_RESPONSE"
else "PENDING"
)
if proposal["proposal_status"] != expected_status:
raise ProposalError(
"La propuesta ha cambiado de estado. Recargue la página."
)
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_context_proposals
SET
proposal_status = %s,
technical_review_by_username =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_by_username
ELSE %s
END,
technical_review_by_display_name =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_by_display_name
ELSE %s
END,
technical_review_at =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_at
ELSE now()
END,
technical_review_notes =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_notes
ELSE %s
END,
updated_at = now()
WHERE proposal_id = %s
""",
(
target_status,
action.action_code,
user.username,
action.action_code,
user.display_name,
action.action_code,
action.action_code,
action.notes.strip(),
action.proposal_id,
),
)
proposal_action_id = insert_proposal_action(
cursor,
settings,
action,
proposal,
user,
target_status,
None,
source_ip,
user_agent,
)
connection.commit()
return {
"idempotent_replay": False,
"proposal_action_id":
proposal_action_id,
"resulting_status":
target_status,
}
except Exception:
connection.rollback()
raise

File diff suppressed because it is too large Load Diff

View File

@ -337,3 +337,41 @@ th { color: var(--muted); }
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.secondary {
background: #52616f;
color: var(--text);
font-weight: 700;
}
.danger-button {
background: var(--danger);
color: #160604;
font-weight: 800;
}
.info-box,
.consultation-box {
background: rgba(91, 168, 255, .10);
border: 1px solid rgba(91, 168, 255, .35);
border-radius: 10px;
padding: 1rem;
margin: 1rem 0;
}
.consultation-box {
background: rgba(240, 165, 58, .10);
border-color: rgba(240, 165, 58, .35);
}
.consultation-form {
border-color: rgba(240, 165, 58, .45);
}
.compact-section {
margin-bottom: 1rem;
}
.topbar nav {
flex-wrap: wrap;
}

View File

@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block content %}
<a class="back" href="/approvals">← Volver a aprobación técnica</a>
{% if error %}
<div class="alert error">{{ error }}</div>
{% endif %}
<section class="decision-detail">
<div class="detail-heading">
<div>
<span class="machine">{{ proposal.machine_id }}</span>
<span class="status">{{ proposal.proposal_status|label }}</span>
<h1>Propuesta de contexto: {{ proposal.proposed_source_type|label }}</h1>
</div>
</div>
<div class="question-box">
<h2>Respuesta aportada por Producción</h2>
<p><strong>{{ proposal.answer_code|label }}</strong></p>
<p>Respondido por {{ proposal.proposed_by_display_name }} el {{ proposal.proposed_at|datetime_es }}</p>
</div>
<dl class="facts">
<div><dt>Inicio exacto</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
<div><dt>Fin exacto</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
<div><dt>Estado del intervalo</dt><dd>{{ proposal.case_status|label }}</dd></div>
<div><dt>Estado de revisión</dt><dd>{{ proposal.case_review_status|label }}</dd></div>
<div><dt>Órdenes</dt><dd>{{ proposal.production_orders|join(", ") if proposal.production_orders else "—" }}</dd></div>
<div><dt>Workorders</dt><dd>{{ proposal.odoo_workorder_ids|join(", ") if proposal.odoo_workorder_ids else "—" }}</dd></div>
</dl>
{% if proposal.last_action_notes %}
<div class="consultation-box">
<strong>Último intercambio</strong>
<p>{{ proposal.last_action_notes }}</p>
<small>{{ proposal.last_action_by }} · {{ proposal.last_action_at|datetime_es }}</small>
</div>
{% endif %}
<div class="info-box">
<strong>Validaciones al aprobar</strong>
<p>La aplicación comprobará que el intervalo ha terminado y que no se solapa con Odoo ni con otro contexto autoritativo. El contexto se creará en SHADOW y con elegibilidad oficial desactivada.</p>
</div>
{% if not write_enabled %}
<div class="alert error">La interfaz está en modo solo lectura.</div>
{% elif proposal.proposal_status == 'PENDING' %}
<form method="post" action="/approvals/{{ proposal.proposal_id }}/approve" class="review-form">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_key }}">
<label>
Observaciones técnicas
<textarea name="notes" rows="4" placeholder="Ejemplo: intervalo cerrado, sin solapamientos y coherente con la respuesta de Producción."></textarea>
</label>
<button class="primary" type="submit">Aprobar y crear contexto</button>
</form>
<form method="post" action="/approvals/{{ proposal.proposal_id }}/request_clarification" class="review-form">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_key }}">
<label>
Aclaración necesaria
<textarea name="notes" rows="4" required placeholder="Indique qué dato debe aclarar Producción."></textarea>
</label>
<button class="secondary" type="submit">Solicitar aclaración</button>
</form>
<form method="post" action="/approvals/{{ proposal.proposal_id }}/reject" class="review-form">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_key }}">
<label>
Motivo del rechazo
<textarea name="notes" rows="4" required placeholder="Explique por qué no debe crearse el contexto."></textarea>
</label>
<button class="danger-button" type="submit">Rechazar propuesta</button>
</form>
{% else %}
<div class="alert success">La propuesta ya no está pendiente: {{ proposal.proposal_status|label }}.</div>
{% endif %}
</section>
{% endblock %}

View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Aprobación técnica de contextos</h1>
<p>Producción ya explicó qué ocurrió. Esta cola valida si la respuesta puede convertirse en contexto canónico sin crear solapamientos.</p>
</div>
<div class="summary-badge">{{ proposals|length }} propuestas</div>
</div>
{% if message %}
<div class="alert success">{{ message }}</div>
{% endif %}
{% if not proposals %}
<div class="empty-state">
<h2>No hay propuestas pendientes</h2>
<p>Las respuestas de Producción que no necesitan contexto no aparecen aquí.</p>
</div>
{% endif %}
<div class="card-grid">
{% for proposal in proposals %}
<article class="decision-card">
<div class="card-head">
<div>
<span class="machine">{{ proposal.machine_id }}</span>
<span class="status">{{ proposal.proposal_status|label }}</span>
</div>
</div>
<h2>{{ proposal.proposed_source_type|label }}</h2>
<p><strong>Respuesta de Producción:</strong> {{ proposal.answer_code|label }}</p>
<dl class="facts">
<div><dt>Inicio</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
<div><dt>Fin</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
<div><dt>Propuesto por</dt><dd>{{ proposal.proposed_by_display_name }}</dd></div>
<div><dt>Caso</dt><dd>{{ proposal.case_id }}</dd></div>
</dl>
{% if proposal.last_action_notes %}
<div class="consultation-box">
<strong>Último intercambio</strong>
<p>{{ proposal.last_action_notes }}</p>
</div>
{% endif %}
<a class="primary button" href="/approvals/{{ proposal.proposal_id }}">Revisar propuesta</a>
</article>
{% endfor %}
</div>
{% endblock %}

View File

@ -15,9 +15,17 @@
</div> </div>
<nav> <nav>
<a href="/">Pendientes</a> <a href="/">Pendientes</a>
{% if 'TECHNICAL' in user.roles or 'ADMIN' in user.roles %}
<a href="/approvals">Aprobación técnica</a>
{% endif %}
{% if 'PRODUCTION' in user.roles or 'ADMIN' in user.roles %}
<a href="/clarifications">Aclaraciones</a>
{% endif %}
<a href="/history">Historial</a> <a href="/history">Historial</a>
<span class="user">{{ user.display_name }}</span> <span class="user">{{ user.display_name }}</span>
{% if not settings.write_enabled %}<span class="status">Solo lectura</span>{% endif %} {% if not settings.write_enabled %}
<span class="status">Solo lectura</span>
{% endif %}
<form class="inline" method="post" action="/logout"> <form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="link-button" type="submit">Salir</button> <button class="link-button" type="submit">Salir</button>

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Aclaraciones solicitadas</h1>
<p>El revisor técnico necesita un dato adicional antes de aprobar o rechazar el contexto propuesto.</p>
</div>
<div class="summary-badge">{{ proposals|length }} pendientes</div>
</div>
{% if message %}
<div class="alert success">{{ message }}</div>
{% endif %}
{% if not proposals %}
<div class="empty-state">
<h2>No hay aclaraciones pendientes</h2>
</div>
{% endif %}
{% for proposal in proposals %}
<section class="decision-detail compact-section">
<div class="detail-heading">
<div>
<span class="machine">{{ proposal.machine_id }}</span>
<h2>{{ proposal.proposed_source_type|label }}</h2>
</div>
<span class="status">{{ proposal.proposal_status|label }}</span>
</div>
<p><strong>Pregunta técnica:</strong> {{ proposal.last_action_notes }}</p>
<p><strong>Intervalo:</strong> {{ proposal.first_seen_at|datetime_es }} → {{ proposal.ended_at|datetime_es }}</p>
{% if write_enabled %}
<form method="post" action="/clarifications/{{ proposal.proposal_id }}/respond">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ submission_keys[proposal.proposal_id] }}">
<label>
Respuesta o aclaración
<textarea name="notes" rows="4" required></textarea>
</label>
<button class="primary" type="submit">Enviar aclaración</button>
</form>
{% endif %}
</section>
{% endfor %}
{% endblock %}

View File

@ -10,15 +10,12 @@
<div class="detail-heading"> <div class="detail-heading">
<div> <div>
<span class="machine">{{ decision.machine_id or "GLOBAL" }}</span> <span class="machine">{{ decision.machine_id or "GLOBAL" }}</span>
<span class="severity">{{ decision.max_severity }}</span> <span class="severity">{{ decision.max_severity|label }}</span>
<h1>{{ decision.decision_title }}</h1> <h1>{{ decision.decision_title }}</h1>
</div> </div>
<div class="status-stack"> <div class="status-stack">
<span class="status"> <span class="status">Incidencia: {{ decision.decision_status|label }}</span>
Incidencia: <span class="status">Revisión: {{ decision.review_status|label }}</span>
{% if decision.decision_status == "OPEN" %}Activa{% else %}Terminada{% endif %}
</span>
<span class="status">Revisión: {{ decision.review_status }}</span>
</div> </div>
</div> </div>
@ -27,6 +24,25 @@
<p>{{ decision.question_text }}</p> <p>{{ decision.question_text }}</p>
</div> </div>
{% if consultation %}
<div class="consultation-box">
<h2>En consulta</h2>
<p>{{ consultation.reason }}</p>
{% if consultation.follow_up_with %}
<p><strong>Consultar con:</strong> {{ consultation.follow_up_with }}</p>
{% endif %}
<small>Marcado por {{ consultation.created_by_display_name }} el {{ consultation.created_at|datetime_es }}</small>
{% if write_enabled %}
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/resume">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ resume_submission_key }}">
<button class="secondary" type="submit">Ya tengo la información</button>
</form>
{% endif %}
</div>
{% endif %}
<section> <section>
<h2>Intervalos y casos</h2> <h2>Intervalos y casos</h2>
<div class="timeline"> <div class="timeline">
@ -39,12 +55,11 @@
<div> <div>
<h3>Caso {{ case.case_id }} · {{ case.case_title }}</h3> <h3>Caso {{ case.case_id }} · {{ case.case_title }}</h3>
<p> <p>
Incidencia: Incidencia: {{ case.case_status|label }}
{% if case.case_status == "OPEN" %}activa{% else %}terminada{% endif %} · Revisión: {{ case.case_review_status|label }}
· Revisión: {{ case.case_review_status }}
</p> </p>
{% if case.case_resolution_classification %} {% if case.case_resolution_classification %}
<p><strong>Resolución previa:</strong> {{ case.case_resolution_classification }}</p> <p><strong>Periodo ya aclarado:</strong> {{ case.case_resolution_classification|label }}</p>
{% endif %} {% endif %}
{% if case.production_orders %} {% if case.production_orders %}
<p><strong>Órdenes:</strong> {{ case.production_orders|join(", ") }}</p> <p><strong>Órdenes:</strong> {{ case.production_orders|join(", ") }}</p>
@ -79,8 +94,28 @@
{% endif %} {% endif %}
{% if can_review and pending_cases %} {% if can_review and pending_cases %}
{% if not consultation %}
<details class="consultation-form">
<summary>No conozco todavía la respuesta</summary>
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/consultation">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ consultation_submission_key }}">
<label>
Qué información falta
<textarea name="reason" rows="3" required placeholder="Ejemplo: preguntar a la operaria del turno de tarde qué estaba haciendo la máquina."></textarea>
</label>
<label>
A quién hay que consultar
<input name="follow_up_with" placeholder="Ejemplo: María / responsable del turno anterior">
</label>
<button class="secondary" type="submit">Marcar En consulta</button>
</form>
</details>
{% endif %}
<form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review"> <form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="submission_key" value="{{ review_submission_key }}">
<fieldset> <fieldset>
<legend>1. Intervalos a los que se aplica la respuesta</legend> <legend>1. Intervalos a los que se aplica la respuesta</legend>
@ -99,10 +134,10 @@
<fieldset> <fieldset>
<legend>2. Qué ocurrió</legend> <legend>2. Qué ocurrió</legend>
{% for code, label in answer_options %} {% for code, text in answer_options %}
<label class="radio-row"> <label class="radio-row">
<input type="radio" name="answer_code" value="{{ code }}" required> <input type="radio" name="answer_code" value="{{ code }}" required>
<span>{{ label }}</span> <span>{{ text }}</span>
</label> </label>
{% endfor %} {% endfor %}
</fieldset> </fieldset>
@ -123,19 +158,10 @@
</fieldset> </fieldset>
{% endif %} {% endif %}
{% if decision.decision_family == "UNCONFIRMED_ACTIVITY" %} <div class="info-box">
<fieldset> <strong>Qué ocurrirá después</strong>
<legend>3. Contexto retrospectivo</legend> <p>Su respuesta cerrará la pregunta de Producción. Cuando la respuesta permita crear un contexto histórico, se enviará una propuesta a validación técnica. Usted no crea ni aprueba contextos.</p>
<label class="check-row"> </div>
<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> <label>
Observaciones Observaciones
@ -151,7 +177,7 @@
</form> </form>
{% elif not pending_cases %} {% elif not pending_cases %}
<div class="alert success">Esta decisión ya no tiene casos pendientes.</div> <div class="alert success">Esta decisión ya no tiene casos pendientes.</div>
{% else %} {% elif write_enabled %}
<div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div> <div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div>
{% endif %} {% endif %}
</section> </section>

View File

@ -2,8 +2,8 @@
{% block content %} {% block content %}
<div class="page-heading"> <div class="page-heading">
<div> <div>
<h1>Historial de acciones</h1> <h1>Historial de actividad</h1>
<p>Acciones realizadas desde la interfaz durante los últimos 30 días.</p> <p>Revisiones de Producción, consultas y decisiones técnicas durante los últimos 30 días.</p>
</div> </div>
</div> </div>
@ -12,24 +12,24 @@
<thead> <thead>
<tr> <tr>
<th>Fecha</th> <th>Fecha</th>
<th>Tipo</th>
<th>Usuario</th> <th>Usuario</th>
<th>Decisión</th> <th>Decisión</th>
<th>Respuesta</th> <th>Acción</th>
<th>Casos</th>
<th>Contextos</th>
<th>Estado</th> <th>Estado</th>
<th>Notas</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for action in actions %} {% for activity in activities %}
<tr> <tr>
<td>{{ action.created_at|datetime_es }}</td> <td>{{ activity.created_at|datetime_es }}</td>
<td>{{ action.actor_display_name }}</td> <td>{{ activity.activity_type|label }}</td>
<td>{{ action.decision_group_key }}</td> <td>{{ activity.actor_display_name }}</td>
<td>{{ action.answer_code }}</td> <td>{{ activity.decision_group_key }}</td>
<td>{{ action.selected_case_ids|join(", ") }}</td> <td>{{ activity.action_code|label }}</td>
<td>{{ action.created_context_session_ids|join(", ") if action.created_context_session_ids else "—" }}</td> <td>{{ activity.status|label }}</td>
<td>{{ action.status }}</td> <td>{{ activity.notes or "—" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -3,7 +3,7 @@
<div class="page-heading"> <div class="page-heading">
<div> <div>
<h1>Decisiones pendientes</h1> <h1>Decisiones pendientes</h1>
<p>Una tarjeta equivale a una pregunta humana. Los datos técnicos se conservan como evidencia.</p> <p>Una tarjeta equivale a una pregunta humana. Producción explica lo ocurrido; la creación de contexto se valida después por separado.</p>
</div> </div>
<div class="summary-badge">{{ rows|length }} pendientes</div> <div class="summary-badge">{{ rows|length }} pendientes</div>
</div> </div>
@ -15,7 +15,7 @@
{% if not rows %} {% if not rows %}
<div class="empty-state"> <div class="empty-state">
<h2>No hay decisiones pendientes para su rol</h2> <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> <p>La cola se actualizará automáticamente cuando aparezca una excepción que necesite una explicación humana.</p>
</div> </div>
{% endif %} {% endif %}
@ -25,27 +25,34 @@
<div class="card-head"> <div class="card-head">
<div> <div>
<span class="machine">{{ row.machine_id or "GLOBAL" }}</span> <span class="machine">{{ row.machine_id or "GLOBAL" }}</span>
<span class="severity">{{ row.max_severity }}</span> <span class="severity">{{ row.max_severity|label }}</span>
</div> </div>
<div class="status-pair"> <div class="status-pair">
<span class="status"> <span class="status">Incidencia: {{ row.incident_status|label }}</span>
Incidencia: <span class="status">Revisión: {{ row.workflow_status|label }}</span>
{% if row.incident_status == "ACTIVE" %}Activa{% else %}Terminada{% endif %}
</span>
<span class="status review-pending">Revisión: Pendiente</span>
</div> </div>
</div> </div>
<h2>{{ row.decision_title }}</h2> <h2>{{ row.decision_title }}</h2>
<p class="question">{{ row.question_text }}</p> <p class="question">{{ row.question_text }}</p>
{% if row.workflow_status == 'IN_CONSULTATION' %}
<div class="consultation-box">
<strong>En consulta</strong>
<p>{{ row.consultation_reason }}</p>
{% if row.follow_up_with %}
<small>Consultar con: {{ row.follow_up_with }}</small>
{% endif %}
</div>
{% endif %}
<dl class="facts"> <dl class="facts">
<div><dt>Inicio</dt><dd>{{ row.first_seen_at|datetime_es }}</dd></div> <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>Fin</dt><dd>{{ row.ended_at|datetime_es }}</dd></div>
<div><dt>Responsable</dt><dd>{{ row.review_owner }}</dd></div> <div><dt>Responsable</dt><dd>{{ row.review_owner|label }}</dd></div>
<div><dt>Casos pendientes</dt><dd>{{ row.pending_root_case_count }}</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>Evidencias</dt><dd>{{ row.linked_episode_count }}</dd></div>
<div><dt>Atención</dt><dd>{{ row.attention_status }}</dd></div> <div><dt>Atención</dt><dd>{{ row.attention_status|label }}</dd></div>
</dl> </dl>
{% if row.production_orders %} {% if row.production_orders %}

View File

@ -5,3 +5,4 @@ psycopg2-binary>=2.9,<3
python-multipart>=0.0.20,<1 python-multipart>=0.0.20,<1
itsdangerous>=2.2,<3 itsdangerous>=2.2,<3
bcrypt>=4.1,<5 bcrypt>=4.1,<5
pytest>=8,<9

View File

@ -0,0 +1,81 @@
from app.labels import label
from app.policy import (
can_approve_context_roles,
can_manage_clarification_roles,
can_view_owner_roles,
)
def test_production_permissions() -> None:
roles = {"PRODUCTION"}
assert can_view_owner_roles(
roles,
"PRODUCTION",
)
assert can_view_owner_roles(
roles,
"JOINT",
)
assert not can_view_owner_roles(
roles,
"TECHNICAL",
)
assert not can_approve_context_roles(
roles
)
assert can_manage_clarification_roles(
roles
)
def test_technical_permissions() -> None:
roles = {"TECHNICAL"}
assert can_view_owner_roles(
roles,
"TECHNICAL",
)
assert can_view_owner_roles(
roles,
"JOINT",
)
assert not can_view_owner_roles(
roles,
"PRODUCTION",
)
assert can_approve_context_roles(
roles
)
assert not can_manage_clarification_roles(
roles
)
def test_admin_permissions() -> None:
roles = {"ADMIN"}
for owner in (
"PRODUCTION",
"TECHNICAL",
"JOINT",
):
assert can_view_owner_roles(
roles,
owner,
)
assert can_approve_context_roles(
roles
)
assert can_manage_clarification_roles(
roles
)
def test_human_labels() -> None:
assert label("WARNING") == "Aviso"
assert label("PENDING") == "Pendiente"
assert (
label(
"AUTHORIZED_LEGACY_PRODUCTION"
)
== "Producción con el sistema antiguo"
)
assert label("IN_CONSULTATION") == "En consulta"

View File

@ -15,7 +15,7 @@
} }
] ]
}, },
"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.", "description": "Supervisión SHADOW con revisión humana en dos etapas: Producción explica lo ocurrido y MESAVAULT aprueba por separado cualquier contexto retrospectivo. Incluye En consulta, idempotencia y auditoría.",
"editable": true, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 1, "graphTooltip": 1,
@ -2743,7 +2743,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"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", "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 workflow_status\n WHEN 'IN_CONSULTATION' THEN 'En consulta'\n ELSE 'Pendiente'\n END AS \"Revisión\",\n CASE max_severity\n WHEN 'CRITICAL' THEN 'Crítica'\n ELSE 'Aviso'\n END 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(production_orders, ', ') AS \"Órdenes\",\n array_to_string(odoo_workorder_ids, ', ') 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 consultation_reason AS \"Consulta pendiente\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v2\nORDER BY agenda_rank\nLIMIT 50",
"refId": "A" "refId": "A"
} }
], ],
@ -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 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", "rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n CASE max_severity\n WHEN 'CRITICAL' THEN 'Crítica'\n ELSE 'Aviso'\n END 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 CASE workflow_status\n WHEN 'IN_CONSULTATION' THEN 'En consulta'\n ELSE 'Pendiente'\n END 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 consultation_reason AS \"Consulta pendiente\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v2\nORDER BY agenda_rank\nLIMIT 50",
"refId": "A" "refId": "A"
} }
], ],
@ -3640,6 +3640,201 @@
], ],
"title": "Histórico de regresión · 30 días", "title": "Histórico de regresión · 30 días",
"type": "table" "type": "table"
},
{
"datasource": {
"type": "postgres",
"uid": "bfnbcasbm6hhca"
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"filterable": true,
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 246
},
"id": 32,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "11.0.0",
"targets": [
{
"datasource": {
"type": "postgres",
"uid": "bfnbcasbm6hhca"
},
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT\n approval_rank AS \"Prioridad\",\n proposal_id AS \"Propuesta\",\n machine_id AS \"Máquina\",\n CASE proposed_source_type\n WHEN 'LEGACY_ERP' THEN 'Sistema antiguo'\n WHEN 'TEST_SETUP' THEN 'Prueba o ajuste'\n WHEN 'MAINTENANCE' THEN 'Mantenimiento'\n ELSE proposed_source_type\n END AS \"Contexto propuesto\",\n CASE proposal_status\n WHEN 'PENDING' THEN 'Pendiente'\n WHEN 'NEEDS_CLARIFICATION' THEN 'Requiere aclaración'\n ELSE proposal_status\n END AS \"Estado\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n proposed_by_display_name AS \"Propuesto por\",\n case_id AS \"Caso\",\n last_action_notes AS \"Último intercambio\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_context_approval_queue_v1\nORDER BY approval_rank\nLIMIT 100",
"refId": "A"
}
],
"title": "Contextos pendientes de aprobación",
"type": "table"
},
{
"datasource": {
"type": "postgres",
"uid": "bfnbcasbm6hhca"
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"filterable": true,
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 256
},
"id": 33,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "11.0.0",
"targets": [
{
"datasource": {
"type": "postgres",
"uid": "bfnbcasbm6hhca"
},
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Decisión\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n consultation_reason AS \"Información pendiente\",\n follow_up_with AS \"Consultar con\",\n consultation_created_by AS \"Marcado por\",\n consultation_created_at AS \"Desde\",\n first_seen_at AS \"Inicio del caso\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v2\nWHERE workflow_status = 'IN_CONSULTATION'\nORDER BY agenda_rank\nLIMIT 100",
"refId": "A"
}
],
"title": "Decisiones en consulta",
"type": "table"
},
{
"datasource": {
"type": "postgres",
"uid": "bfnbcasbm6hhca"
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"filterable": true,
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 266
},
"id": 34,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "11.0.0",
"targets": [
{
"datasource": {
"type": "postgres",
"uid": "bfnbcasbm6hhca"
},
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT\n created_at AS \"Fecha\",\n activity_type AS \"Tipo\",\n actor_display_name AS \"Usuario\",\n decision_group_key AS \"Decisión\",\n proposal_id AS \"Propuesta\",\n action_code AS \"Acción\",\n status AS \"Estado\",\n notes AS \"Notas\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_activity_v1\nORDER BY created_at DESC\nLIMIT 200",
"refId": "A"
}
],
"title": "Actividad de la interfaz · 30 días",
"type": "table"
} }
], ],
"refresh": "15s", "refresh": "15s",
@ -3676,7 +3871,10 @@
"v03131", "v03131",
"governance-ui", "governance-ui",
"human-review", "human-review",
"v0314" "v0314",
"two-stage-approval",
"consultation",
"v0315"
], ],
"templating": { "templating": {
"list": [] "list": []

View File

@ -0,0 +1,323 @@
#!/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.15"
),
},
)
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", [])) != 34:
errors.append(
"El dashboard no tiene 34 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",
}
two_stage_expected = {
32: "li_context_governance_context_approval_queue_v1",
33: "li_context_governance_ui_queue_v2",
34: "li_context_governance_ui_activity_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_v2",
26: "li_context_governance_ui_queue_v2",
}
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"
)
for panel_id, fragment in two_stage_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}"
)
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)
),
},
"two_stage_panels": {
"approval_queue": 32 in panels,
"consultations": 33 in panels,
"activity": 34 in panels,
},
"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_v0315.sh "
"{deploy|validate}"
)
PY

View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_ROOT="${GOVERNANCE_UI_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
SOURCE="$TARGET_ROOT/governance-ui"
DEST="$RUNTIME_ROOT/governance-ui"
TMP_ENV=""
if [[ ! -f "$SOURCE/Dockerfile" ]]; then
echo "ERROR: no existe $SOURCE/Dockerfile" >&2
exit 1
fi
if [[ -f "$DEST/.env" ]]; then
TMP_ENV="$(mktemp)"
cp "$DEST/.env" "$TMP_ENV"
chmod 600 "$TMP_ENV"
fi
rm -rf "$DEST"
mkdir -p "$DEST"
cp -a "$SOURCE/." "$DEST/"
if [[ -n "$TMP_ENV" ]]; then
cp "$TMP_ENV" "$DEST/.env"
chmod 600 "$DEST/.env"
rm -f "$TMP_ENV"
echo "Configuración .env preservada."
else
echo "AVISO: no existía .env previo; configure $DEST/.env antes de arrancar." >&2
fi
find "$DEST" -type d -name '__pycache__' -prune -exec rm -rf {} +
echo "Governance Review UI v0.3.15 instalada en $DEST"

View 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.15}"
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

View 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"

View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${GOVERNANCE_UI_IMAGE:-mesavault/ucepsa-governance-ui:0.3.15}"
docker run --rm \
--entrypoint pytest \
"$IMAGE" \
-q /app/tests

View File

@ -0,0 +1,225 @@
\pset pager off
\echo '=== 1. Objetos v0.3.15 ==='
SELECT
to_regclass(
'mv_loss_intelligence.context_governance_ui_consultations'
) AS consultations_table,
to_regclass(
'mv_loss_intelligence.context_governance_context_proposals'
) AS proposals_table,
to_regclass(
'mv_loss_intelligence.context_governance_context_proposal_actions'
) AS proposal_actions_table,
to_regclass(
'mv_reports_ucepsa_prod.li_context_governance_ui_queue_v2'
) AS queue_v2_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_governance_context_approval_queue_v1'
) AS approval_queue_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_governance_context_clarification_queue_v1'
) AS clarification_queue_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_governance_ui_activity_v1'
) AS activity_view;
\echo '=== 2. Cola v2 conserva la agenda ==='
SELECT
(
SELECT COUNT(*)
FROM mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v2
) AS ui_v2_rows,
(
SELECT COUNT(*)
FROM mv_reports_ucepsa_prod
.li_context_governance_daily_agenda_v1
) AS agenda_rows;
\echo '=== 3. Workflow visible válido ==='
SELECT COUNT(*) AS invalid_workflow_status_rows
FROM mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v2
WHERE workflow_status NOT IN (
'PENDING',
'IN_CONSULTATION'
)
OR review_status <> 'PENDING';
\echo '=== 4. Una consulta abierta por decisión ==='
SELECT COUNT(*) AS duplicate_open_consultations
FROM (
SELECT
tenant,
site,
decision_group_key,
COUNT(*) AS n
FROM mv_loss_intelligence
.context_governance_ui_consultations
WHERE status = 'OPEN'
GROUP BY
tenant,
site,
decision_group_key
HAVING COUNT(*) > 1
) duplicated;
\echo '=== 5. Una propuesta activa por caso ==='
SELECT COUNT(*) AS duplicate_active_proposals
FROM (
SELECT
tenant,
site,
case_id,
COUNT(*) AS n
FROM mv_loss_intelligence
.context_governance_context_proposals
WHERE proposal_status IN (
'PENDING',
'NEEDS_CLARIFICATION'
)
GROUP BY
tenant,
site,
case_id
HAVING COUNT(*) > 1
) duplicated;
\echo '=== 6. Propuestas aprobadas tienen contexto ==='
SELECT COUNT(*) AS approved_without_context
FROM mv_loss_intelligence
.context_governance_context_proposals
WHERE proposal_status = 'APPROVED'
AND context_session_id IS NULL;
\echo '=== 7. Contextos aprobados conservan SHADOW ==='
SELECT COUNT(*) AS invalid_approved_contexts
FROM mv_loss_intelligence
.context_governance_context_proposals p
JOIN mv_loss_intelligence
.production_context_sessions s
ON s.context_session_id = p.context_session_id
WHERE p.proposal_status = 'APPROVED'
AND (
s.source_ref <>
'GOVERNANCE_PROPOSAL:'
|| p.proposal_id::text
OR s.source_type <>
p.proposed_source_type
OR s.official_eligible
OR p.official_eligible
);
\echo '=== 8. Producción no crea contextos directamente ==='
SELECT COUNT(*) AS direct_context_creation_actions
FROM mv_loss_intelligence
.context_governance_ui_actions
WHERE create_contexts
OR cardinality(
created_context_session_ids
) > 0;
\echo '=== 9. Idempotencia de requests ==='
SELECT
(
SELECT COUNT(*)
FROM (
SELECT
tenant,
site,
request_id,
COUNT(*) AS n
FROM mv_loss_intelligence
.context_governance_ui_actions
GROUP BY tenant, site, request_id
HAVING COUNT(*) > 1
) x
) AS duplicate_review_requests,
(
SELECT COUNT(*)
FROM (
SELECT
tenant,
site,
request_id,
COUNT(*) AS n
FROM mv_loss_intelligence
.context_governance_context_proposal_actions
GROUP BY tenant, site, request_id
HAVING COUNT(*) > 1
) x
) AS duplicate_proposal_action_requests;
\echo '=== 10. Colas de trabajo ==='
SELECT
(
SELECT COUNT(*)
FROM mv_reports_ucepsa_prod
.li_context_governance_context_approval_queue_v1
WHERE proposal_status = 'PENDING'
) AS technical_pending_count,
(
SELECT COUNT(*)
FROM mv_reports_ucepsa_prod
.li_context_governance_context_clarification_queue_v1
) AS clarification_count,
(
SELECT COUNT(*)
FROM mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v2
WHERE workflow_status = 'IN_CONSULTATION'
) AS in_consultation_count;
\echo '=== 11. Nada oficial ==='
SELECT
(
SELECT COUNT(*)
FROM mv_loss_intelligence
.context_governance_ui_consultations
WHERE official_eligible
) AS official_consultation_rows,
(
SELECT COUNT(*)
FROM mv_loss_intelligence
.context_governance_context_proposals
WHERE official_eligible
) AS official_proposal_rows,
(
SELECT COUNT(*)
FROM mv_loss_intelligence
.context_governance_context_proposal_actions
WHERE official_eligible
) AS official_proposal_action_rows,
(
SELECT COUNT(*)
FROM mv_reports_ucepsa_prod
.li_operator_stop_queue_v2
) AS official_operator_queue_rows;
\echo '=== 12. Actividad UI reciente ==='
SELECT
activity_type,
decision_group_key,
proposal_id,
actor_display_name,
action_code,
status,
notes,
created_at
FROM mv_reports_ucepsa_prod
.li_context_governance_ui_activity_v1
ORDER BY created_at DESC
LIMIT 30;

View File

@ -0,0 +1,411 @@
BEGIN;
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.context_governance_ui_consultations (
consultation_id bigserial PRIMARY KEY,
tenant text NOT NULL,
site text NOT NULL,
request_id uuid NOT NULL,
decision_group_key text NOT NULL,
status text NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('OPEN', 'CLOSED')),
reason text NOT NULL,
follow_up_with text,
created_by_username text NOT NULL,
created_by_display_name text NOT NULL,
created_by_roles text[] NOT NULL DEFAULT ARRAY[]::text[],
created_at timestamptz NOT NULL DEFAULT now(),
closed_by_username text,
closed_by_display_name text,
closed_at timestamptz,
close_reason text,
official_eligible boolean NOT NULL DEFAULT false
CHECK (official_eligible = false),
CONSTRAINT ux_context_governance_consultation_request
UNIQUE (tenant, site, request_id),
CONSTRAINT ck_context_governance_consultation_close
CHECK (
(status = 'OPEN' AND closed_at IS NULL)
OR
(status = 'CLOSED' AND closed_at IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS
ux_context_governance_open_consultation
ON mv_loss_intelligence.context_governance_ui_consultations (
tenant,
site,
decision_group_key
)
WHERE status = 'OPEN';
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.context_governance_context_proposals (
proposal_id bigserial PRIMARY KEY,
tenant text NOT NULL,
site text NOT NULL,
proposal_key text NOT NULL,
request_id uuid NOT NULL,
decision_group_key text NOT NULL,
case_id bigint NOT NULL
REFERENCES mv_loss_intelligence.context_review_cases (case_id)
ON DELETE RESTRICT,
answer_code text NOT NULL,
mapped_classification text NOT NULL,
proposed_source_type text NOT NULL
CHECK (
proposed_source_type IN (
'LEGACY_ERP',
'TEST_SETUP',
'MAINTENANCE'
)
),
proposed_by_username text NOT NULL,
proposed_by_display_name text NOT NULL,
proposed_at timestamptz NOT NULL DEFAULT now(),
proposal_status text NOT NULL DEFAULT 'PENDING'
CHECK (
proposal_status IN (
'PENDING',
'APPROVED',
'REJECTED',
'NEEDS_CLARIFICATION',
'CANCELLED'
)
),
technical_review_by_username text,
technical_review_by_display_name text,
technical_review_at timestamptz,
technical_review_notes text,
context_session_id bigint
REFERENCES mv_loss_intelligence.production_context_sessions (
context_session_id
)
ON DELETE RESTRICT,
source_evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
official_eligible boolean NOT NULL DEFAULT false
CHECK (official_eligible = false),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ux_context_governance_proposal_key
UNIQUE (tenant, site, proposal_key),
CONSTRAINT ux_context_governance_proposal_request_case
UNIQUE (tenant, site, request_id, case_id),
CONSTRAINT ck_context_governance_proposal_approved
CHECK (
proposal_status <> 'APPROVED'
OR context_session_id IS NOT NULL
)
);
CREATE UNIQUE INDEX IF NOT EXISTS
ux_context_governance_active_proposal_case
ON mv_loss_intelligence.context_governance_context_proposals (
tenant,
site,
case_id
)
WHERE proposal_status IN (
'PENDING',
'NEEDS_CLARIFICATION'
);
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.context_governance_context_proposal_actions (
proposal_action_id bigserial PRIMARY KEY,
tenant text NOT NULL,
site text NOT NULL,
request_id uuid NOT NULL,
proposal_id bigint NOT NULL
REFERENCES mv_loss_intelligence.context_governance_context_proposals (
proposal_id
)
ON DELETE CASCADE,
action_code text NOT NULL
CHECK (
action_code IN (
'APPROVE',
'REJECT',
'REQUEST_CLARIFICATION',
'CLARIFICATION_RESPONSE'
)
),
previous_status text NOT NULL,
resulting_status text NOT NULL,
actor_username text NOT NULL,
actor_display_name text NOT NULL,
actor_roles text[] NOT NULL DEFAULT ARRAY[]::text[],
notes text,
context_session_id bigint,
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_proposal_action_request
UNIQUE (tenant, site, request_id)
);
CREATE INDEX IF NOT EXISTS
idx_context_governance_proposals_status
ON mv_loss_intelligence.context_governance_context_proposals (
tenant,
site,
proposal_status,
proposed_at
);
CREATE INDEX IF NOT EXISTS
idx_context_governance_proposal_actions
ON mv_loss_intelligence.context_governance_context_proposal_actions (
proposal_id,
created_at DESC
);
COMMENT ON TABLE
mv_loss_intelligence.context_governance_ui_consultations IS
'Estado En consulta de una decisión pendiente. Conserva la pregunta abierta sin inventar una clasificación.';
COMMENT ON TABLE
mv_loss_intelligence.context_governance_context_proposals IS
'Propuestas SHADOW de contexto retrospectivo creadas a partir de una respuesta de Producción. Requieren aprobación técnica independiente.';
COMMENT ON TABLE
mv_loss_intelligence.context_governance_context_proposal_actions IS
'Auditoría de aprobación, rechazo y aclaraciones de propuestas de contexto.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_consultation_current_v1
AS
SELECT DISTINCT ON (
tenant,
site,
decision_group_key
)
consultation_id,
tenant,
site,
decision_group_key,
status,
reason,
follow_up_with,
created_by_username,
created_by_display_name,
created_by_roles,
created_at,
official_eligible
FROM mv_loss_intelligence.context_governance_ui_consultations
WHERE status = 'OPEN'
ORDER BY
tenant,
site,
decision_group_key,
created_at DESC,
consultation_id DESC;
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_queue_v2
AS
SELECT
q.*,
CASE
WHEN c.consultation_id IS NOT NULL
THEN 'IN_CONSULTATION'
ELSE 'PENDING'
END AS workflow_status,
c.consultation_id,
c.reason AS consultation_reason,
c.follow_up_with,
c.created_by_display_name AS consultation_created_by,
c.created_at AS consultation_created_at
FROM mv_reports_ucepsa_prod.li_context_governance_ui_queue_v1 q
LEFT JOIN
mv_reports_ucepsa_prod.li_context_governance_ui_consultation_current_v1 c
ON c.tenant = 'ucepsa'
AND c.site = 'ucepsa_onpremise'
AND c.decision_group_key = q.decision_group_key;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_queue_v2 IS
'Cola UI v2 con estado Pendiente o En consulta, separado del estado físico de la incidencia.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_context_proposals_v1
AS
WITH last_action AS (
SELECT DISTINCT ON (proposal_id)
proposal_id,
action_code,
notes AS last_action_notes,
actor_display_name AS last_action_by,
created_at AS last_action_at
FROM mv_loss_intelligence.context_governance_context_proposal_actions
ORDER BY
proposal_id,
created_at DESC,
proposal_action_id DESC
)
SELECT
p.proposal_id,
p.proposal_key,
p.tenant,
p.site,
p.decision_group_key,
p.case_id,
t.machine_id,
t.case_title,
t.case_status,
t.case_review_status,
t.first_seen_at,
t.last_seen_at,
t.ended_at,
t.case_duration_min,
t.production_orders,
t.odoo_workorder_ids,
p.answer_code,
p.mapped_classification,
p.proposed_source_type,
p.proposed_by_username,
p.proposed_by_display_name,
p.proposed_at,
p.proposal_status,
p.technical_review_by_username,
p.technical_review_by_display_name,
p.technical_review_at,
p.technical_review_notes,
p.context_session_id,
a.action_code AS last_action_code,
a.last_action_notes,
a.last_action_by,
a.last_action_at,
p.source_evidence,
p.official_eligible,
p.created_at,
p.updated_at
FROM mv_loss_intelligence.context_governance_context_proposals p
JOIN mv_reports_ucepsa_prod.li_context_governance_ui_case_timeline_v1 t
ON t.case_id = p.case_id
LEFT JOIN last_action a
ON a.proposal_id = p.proposal_id;
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_context_approval_queue_v1
AS
SELECT
ROW_NUMBER() OVER (
ORDER BY
CASE proposal_status
WHEN 'NEEDS_CLARIFICATION' THEN 2
ELSE 1
END,
proposed_at,
proposal_id
) AS approval_rank,
p.*
FROM mv_reports_ucepsa_prod.li_context_governance_context_proposals_v1 p
WHERE proposal_status IN (
'PENDING',
'NEEDS_CLARIFICATION'
);
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_context_clarification_queue_v1
AS
SELECT *
FROM mv_reports_ucepsa_prod.li_context_governance_context_proposals_v1
WHERE proposal_status = 'NEEDS_CLARIFICATION';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_activity_v1
AS
SELECT
'PRODUCTION_REVIEW'::text AS activity_type,
a.ui_action_id::bigint AS activity_id,
a.request_id,
a.decision_group_key,
NULL::bigint AS proposal_id,
a.actor_username,
a.actor_display_name,
a.answer_code AS action_code,
a.status,
a.notes,
a.created_at,
a.official_eligible
FROM mv_loss_intelligence.context_governance_ui_actions a
UNION ALL
SELECT
'CONSULTATION'::text,
c.consultation_id,
c.request_id,
c.decision_group_key,
NULL::bigint,
c.created_by_username,
c.created_by_display_name,
CASE c.status
WHEN 'OPEN' THEN 'MARK_IN_CONSULTATION'
ELSE 'CLOSE_CONSULTATION'
END,
c.status,
c.reason,
c.created_at,
c.official_eligible
FROM mv_loss_intelligence.context_governance_ui_consultations c
UNION ALL
SELECT
'CONTEXT_PROPOSAL'::text,
x.proposal_action_id,
x.request_id,
p.decision_group_key,
x.proposal_id,
x.actor_username,
x.actor_display_name,
x.action_code,
x.resulting_status,
x.notes,
x.created_at,
x.official_eligible
FROM mv_loss_intelligence.context_governance_context_proposal_actions x
JOIN mv_loss_intelligence.context_governance_context_proposals p
ON p.proposal_id = x.proposal_id;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_context_proposals_v1 IS
'Propuestas de contexto con el intervalo actual del caso y el último estado de revisión técnica.';
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_context_approval_queue_v1 IS
'Cola técnica de contextos propuestos por respuestas de Producción.';
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_context_clarification_queue_v1 IS
'Aclaraciones solicitadas por el revisor técnico a Producción.';
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_activity_v1 IS
'Actividad unificada de revisiones, consultas y aprobación de contextos.';
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_consultation_current_v1,
mv_reports_ucepsa_prod.li_context_governance_ui_queue_v2,
mv_reports_ucepsa_prod.li_context_governance_context_proposals_v1,
mv_reports_ucepsa_prod.li_context_governance_context_approval_queue_v1,
mv_reports_ucepsa_prod.li_context_governance_context_clarification_queue_v1,
mv_reports_ucepsa_prod.li_context_governance_ui_activity_v1
TO grafana_ucepsa_ro;
END IF;
END
$$;
COMMIT;