diff --git a/ucepsa/edge-oee-demo/docs/runbooks/114_governance_review_ui_two_stage_shadow_v0.3.15.md b/ucepsa/edge-oee-demo/docs/runbooks/114_governance_review_ui_two_stage_shadow_v0.3.15.md
new file mode 100644
index 0000000..8f0f479
--- /dev/null
+++ b/ucepsa/edge-oee-demo/docs/runbooks/114_governance_review_ui_two_stage_shadow_v0.3.15.md
@@ -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.
+```
diff --git a/ucepsa/edge-oee-demo/governance-ui/Dockerfile b/ucepsa/edge-oee-demo/governance-ui/Dockerfile
index 2d7b208..828cff0 100644
--- a/ucepsa/edge-oee-demo/governance-ui/Dockerfile
+++ b/ucepsa/edge-oee-demo/governance-ui/Dockerfile
@@ -14,6 +14,7 @@ RUN pip install --no-cache-dir \
COPY app /app/app
COPY tools /app/tools
+COPY tests /app/tests
USER appuser
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/auth.py b/ucepsa/edge-oee-demo/governance-ui/app/auth.py
index c834c47..65799eb 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/auth.py
+++ b/ucepsa/edge-oee-demo/governance-ui/app/auth.py
@@ -10,10 +10,15 @@ from itsdangerous import BadSignature, SignatureExpired
from itsdangerous.url_safe import URLSafeTimedSerializer
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_SALT = "mv-governance-ui-v0314"
+SESSION_SALT = "mv-governance-ui-v0315"
@dataclass(frozen=True)
@@ -134,22 +139,10 @@ 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
+ return can_view_owner_roles(
+ user.roles,
+ review_owner,
+ )
def can_review_owner(
@@ -160,3 +153,19 @@ def can_review_owner(
user,
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
+ )
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/db.py b/ucepsa/edge-oee-demo/governance-ui/app/db.py
index 70f2a1b..238963d 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/db.py
+++ b/ucepsa/edge-oee-demo/governance-ui/app/db.py
@@ -20,7 +20,7 @@ def get_connection() -> Iterator[Connection]:
user=settings.pg_user,
password=settings.pg_password,
connect_timeout=10,
- application_name="governance_review_ui_v0314",
+ application_name="governance_review_ui_v0315",
)
try:
yield connection
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/labels.py b/ucepsa/edge-oee-demo/governance-ui/app/labels.py
new file mode 100644
index 0000000..ab5ffec
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/labels.py
@@ -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(),
+ )
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/main.py b/ucepsa/edge-oee-demo/governance-ui/app/main.py
index 5b668d7..f5c5ba7 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/main.py
+++ b/ucepsa/edge-oee-demo/governance-ui/app/main.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
@@ -23,6 +24,8 @@ from .auth import (
SESSION_COOKIE,
authenticate,
build_session_token,
+ can_approve_context,
+ can_manage_clarification,
can_review_owner,
can_view_owner,
read_current_user,
@@ -30,10 +33,20 @@ from .auth import (
verify_csrf,
)
from .db import get_connection
+from .labels import label
+from .proposal_service import (
+ ProposalActionInput,
+ ProposalError,
+ approve_proposal,
+ change_proposal_status,
+)
from .review_service import (
+ ConsultationInput,
ReviewError,
ReviewInput,
- apply_review,
+ apply_production_review,
+ mark_in_consultation,
+ resume_from_consultation,
)
from .settings import get_settings
@@ -42,7 +55,7 @@ BASE_DIR = Path(__file__).resolve().parent
app = FastAPI(
title="MESAVAULT Governance Review UI",
- version="0.3.14",
+ version="0.3.15",
)
app.mount(
@@ -72,11 +85,19 @@ def format_datetime(value: Any) -> str:
templates.env.filters[
"datetime_es"
] = format_datetime
+templates.env.filters[
+ "label"
+] = label
-def visible_queue(
- user,
-) -> list[dict[str, Any]]:
+def redirect_login() -> RedirectResponse:
+ return RedirectResponse(
+ "/login",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+
+def visible_queue(user) -> list[dict[str, Any]]:
with get_connection() as connection:
with connection.cursor(
cursor_factory=RealDictCursor,
@@ -86,7 +107,7 @@ def visible_queue(
SELECT *
FROM
mv_reports_ucepsa_prod
- .li_context_governance_ui_queue_v1
+ .li_context_governance_ui_queue_v2
ORDER BY agenda_rank
"""
)
@@ -105,8 +126,8 @@ def visible_queue(
]
-def recent_actions(
- limit: int = 50,
+def recent_activity(
+ limit: int = 100,
) -> list[dict[str, Any]]:
with get_connection() as connection:
with connection.cursor(
@@ -117,10 +138,11 @@ def recent_actions(
SELECT *
FROM
mv_reports_ucepsa_prod
- .li_context_governance_ui_recent_actions_v1
+ .li_context_governance_ui_activity_v1
ORDER BY
created_at DESC,
- ui_action_id DESC
+ activity_type,
+ activity_id DESC
LIMIT %s
""",
(limit,),
@@ -136,6 +158,7 @@ def load_decision(
) -> tuple[
dict[str, Any],
list[dict[str, Any]],
+ dict[str, Any] | None,
]:
with get_connection() as connection:
with connection.cursor(
@@ -177,80 +200,128 @@ def load_decision(
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(
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",
- ),
+ codes = [
+ "AUTHORIZED_LEGACY_PRODUCTION",
+ "ODOO_START_OMITTED",
+ "TEST_OR_SETUP",
+ "RESIDUAL_MATERIAL",
+ "MAINTENANCE",
+ "NOT_PRODUCTION",
+ "OTHER",
]
-
- 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",
- ),
+ elif decision_family == "ODOO_ORDER_START_CONFLICT":
+ codes = [
+ "ODOO_DOUBLE_START_ONE_VALID",
+ "ODOO_DOUBLE_START_BOTH_INVALID",
+ "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
+ "DATA_ISSUE",
+ "OTHER",
+ ]
+ else:
+ codes = [
+ "DATA_ISSUE",
+ "OTHER",
]
return [
- (
- "DATA_ISSUE",
- "Incidencia de datos",
- ),
- (
- "OTHER",
- "Otro",
- ),
+ (code, label(code))
+ for code in codes
]
+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(
"/health",
response_class=HTMLResponse,
@@ -267,13 +338,11 @@ def health() -> str:
"/login",
response_class=HTMLResponse,
)
-def login_page(
- request: Request,
-):
+def login_page(request: Request):
if read_current_user(request):
return RedirectResponse(
"/",
- status_code=status.HTTP_303_SEE_OTHER,
+ status_code=303,
)
return templates.TemplateResponse(
@@ -314,7 +383,7 @@ def login_submit(
response = RedirectResponse(
"/",
- status_code=status.HTTP_303_SEE_OTHER,
+ status_code=303,
)
response.set_cookie(
SESSION_COOKIE,
@@ -341,7 +410,7 @@ def logout(
response = RedirectResponse(
"/login",
- status_code=status.HTTP_303_SEE_OTHER,
+ status_code=303,
)
response.delete_cookie(
SESSION_COOKIE,
@@ -360,10 +429,7 @@ def queue_page(
):
user = read_current_user(request)
if user is None:
- return RedirectResponse(
- "/login",
- status_code=status.HTTP_303_SEE_OTHER,
- )
+ return redirect_login()
return templates.TemplateResponse(
request=request,
@@ -373,6 +439,12 @@ def queue_page(
"user": user,
"rows": visible_queue(user),
"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)
if user is None:
- return RedirectResponse(
- "/login",
- status_code=status.HTTP_303_SEE_OTHER,
- )
+ return redirect_login()
- decision, cases = load_decision(
+ decision, cases, consultation = load_decision(
decision_group_key
)
@@ -441,6 +510,7 @@ def decision_page(
"decision": decision,
"cases": cases,
"pending_cases": pending_cases,
+ "consultation": consultation,
"answer_options": answer_options(
str(
decision[
@@ -458,6 +528,12 @@ def decision_page(
),
"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,
},
)
@@ -470,19 +546,16 @@ def review_decision(
request: Request,
decision_group_key: str,
csrf_token: str = Form(...),
+ submission_key: uuid.UUID = 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,
- )
+ verify_csrf(user, csrf_token)
if not get_settings().write_enabled:
raise HTTPException(
@@ -490,7 +563,7 @@ def review_decision(
detail="La interfaz está en modo solo lectura.",
)
- decision, _ = load_decision(
+ decision, _, _ = load_decision(
decision_group_key
)
@@ -538,6 +611,7 @@ def review_decision(
)
review = ReviewInput(
+ submission_key=submission_key,
decision_group_key=
decision_group_key,
selected_case_ids=tuple(
@@ -556,14 +630,11 @@ def review_decision(
selected_workorder_id,
corrected_in_odoo=
corrected_in_odoo,
- create_contexts=
- create_contexts,
- confirmation=
- confirmation,
+ confirmation=confirmation,
)
try:
- result = apply_review(
+ result = apply_production_review(
review=review,
user=user,
source_ip=(
@@ -591,22 +662,18 @@ def review_decision(
)
message = (
- "Revisión registrada. "
+ "Respuesta registrada. "
f"Acción UI {result['ui_action_id']}."
)
- if result[
- "created_context_session_ids"
- ]:
+ proposal_ids = result.get(
+ "context_proposal_ids"
+ ) or []
+ if proposal_ids:
message += (
- " Contextos creados: "
- + ", ".join(
- str(value)
- for value in result[
- "created_context_session_ids"
- ]
- )
- + "."
+ " Se han enviado "
+ f"{len(proposal_ids)} propuestas "
+ "a validación técnica."
)
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(
"/history",
response_class=HTMLResponse,
)
-def history_page(
- request: Request,
-):
+def history_page(request: Request):
user = read_current_user(request)
if user is None:
- return RedirectResponse(
- "/login",
- status_code=303,
- )
+ return redirect_login()
return templates.TemplateResponse(
request=request,
@@ -636,6 +1067,6 @@ def history_page(
context={
"settings": get_settings(),
"user": user,
- "actions": recent_actions(),
+ "activities": recent_activity(),
},
)
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/policy.py b/ucepsa/edge-oee-demo/governance-ui/app/policy.py
new file mode 100644
index 0000000..8a6a1af
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/policy.py
@@ -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",
+ }
+ )
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py b/ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py
new file mode 100644
index 0000000..5e1f618
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py
@@ -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
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/review_service.py b/ucepsa/edge-oee-demo/governance-ui/app/review_service.py
index ff45dfd..c655483 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/review_service.py
+++ b/ucepsa/edge-oee-demo/governance-ui/app/review_service.py
@@ -50,7 +50,7 @@ MAPPED_CLASSIFICATION = {
"OTHER",
}
-CONTEXT_SOURCE_TYPE = {
+PROPOSED_CONTEXT_SOURCE = {
"AUTHORIZED_LEGACY_PRODUCTION":
"LEGACY_ERP",
"TEST_OR_SETUP":
@@ -62,6 +62,7 @@ CONTEXT_SOURCE_TYPE = {
@dataclass(frozen=True)
class ReviewInput:
+ submission_key: uuid.UUID
decision_group_key: str
selected_case_ids: tuple[int, ...]
answer_code: str
@@ -69,10 +70,17 @@ class ReviewInput:
selected_order_ref: str | None
selected_workorder_id: int | None
corrected_in_odoo: bool
- create_contexts: bool
confirmation: bool
+@dataclass(frozen=True)
+class ConsultationInput:
+ submission_key: uuid.UUID
+ decision_group_key: str
+ reason: str
+ follow_up_with: str
+
+
class ReviewError(RuntimeError):
pass
@@ -123,16 +131,14 @@ def fetch_cases_for_update(
) -> list[dict[str, Any]]:
cursor.execute(
"""
- SELECT
- c.*
+ SELECT c.*
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_cases_v1 c
JOIN
mv_loss_intelligence
.context_review_cases raw
- ON raw.case_id =
- c.case_id
+ ON raw.case_id = c.case_id
WHERE c.decision_group_key = %s
ORDER BY c.case_id
FOR UPDATE OF raw
@@ -145,7 +151,39 @@ def fetch_cases_for_update(
]
-def validate_input(
+def existing_review_action(
+ cursor: RealDictCursor,
+ settings,
+ submission_key: uuid.UUID,
+) -> dict[str, Any] | None:
+ cursor.execute(
+ """
+ SELECT
+ ui_action_id,
+ request_id,
+ decision_group_key,
+ selected_case_ids,
+ answer_code,
+ resulting_state,
+ status
+ FROM
+ mv_loss_intelligence
+ .context_governance_ui_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 validate_review(
decision: dict[str, Any],
all_cases: list[dict[str, Any]],
review: ReviewInput,
@@ -282,29 +320,6 @@ def validate_input(
"La respuesta no corresponde a actividad sin contexto."
)
- if review.create_contexts:
- if (
- review.answer_code
- not in CONTEXT_SOURCE_TYPE
- ):
- raise ReviewError(
- "Esta respuesta no permite crear contextos retrospectivos."
- )
-
- for case in selected_cases:
- if (
- case["case_status"] != "CLOSED"
- or case["ended_at"] is None
- ):
- raise ReviewError(
- "Solo pueden crearse contextos para intervalos terminados."
- )
-
- if not case["machine_id"]:
- raise ReviewError(
- "El caso no tiene máquina asociada."
- )
-
mapped = MAPPED_CLASSIFICATION[
review.answer_code
]
@@ -316,190 +331,6 @@ def validate_input(
)
-def insert_contexts(
- cursor: RealDictCursor,
- decision: dict[str, Any],
- selected_cases: list[dict[str, Any]],
- review: ReviewInput,
- user: CurrentUser,
- request_id: uuid.UUID,
-) -> list[int]:
- if not review.create_contexts:
- return []
-
- source_type = CONTEXT_SOURCE_TYPE[
- review.answer_code
- ]
- settings = get_settings()
- created_ids: list[int] = []
-
- for case in selected_cases:
- case_id = int(case["case_id"])
- source_ref = (
- f"GOVERNANCE_UI_CASE:{case_id}:"
- f"{review.answer_code}"
- )
-
- cursor.execute(
- """
- SELECT context_session_id
- FROM
- mv_loss_intelligence
- .production_context_sessions
- WHERE source_ref = %s
- """,
- (source_ref,),
- )
- existing = cursor.fetchone()
-
- if existing:
- created_ids.append(
- int(
- existing[
- "context_session_id"
- ]
- )
- )
- continue
-
- cursor.execute(
- """
- SELECT COUNT(*) AS overlap_count
- FROM (
- SELECT 1
- FROM
- mv_loss_intelligence
- .production_context_sessions s
- WHERE s.machine_id = %s
- AND s.started_at < %s
- AND COALESCE(
- s.ended_at,
- 'infinity'::timestamptz
- ) > %s
-
- UNION ALL
-
- SELECT 1
- FROM
- mv_loss_intelligence
- .odoo_shopfloor_sessions o
- WHERE o.machine_id = %s
- AND o.started_at IS NOT NULL
- AND o.started_at < %s
- AND COALESCE(
- o.ended_at,
- 'infinity'::timestamptz
- ) > %s
- AND o.session_status IN (
- 'OPEN',
- 'CLOSED'
- )
- ) overlaps
- """,
- (
- case["machine_id"],
- case["ended_at"],
- case["first_seen_at"],
- case["machine_id"],
- case["ended_at"],
- case["first_seen_at"],
- ),
- )
-
- overlap_count = int(
- cursor.fetchone()["overlap_count"]
- )
-
- if overlap_count > 0:
- raise ReviewError(
- f"El caso {case_id} se solapa con otro contexto autoritativo. "
- "No se ha creado ningún contexto."
- )
-
- cursor.execute(
- """
- INSERT INTO
- mv_loss_intelligence
- .production_context_sessions (
- tenant,
- site,
- machine_id,
- source_type,
- source_ref,
- started_at,
- ended_at,
- status,
- authorization_status,
- confidence,
- official_eligible,
- authorized_by,
- authorized_at,
- closed_by,
- notes,
- evidence_json
- )
- VALUES (
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- 'CLOSED',
- 'AUTHORIZED',
- 'MEDIUM',
- false,
- %s,
- now(),
- %s,
- %s,
- %s
- )
- RETURNING context_session_id
- """,
- (
- settings.tenant,
- settings.site,
- case["machine_id"],
- source_type,
- source_ref,
- case["first_seen_at"],
- case["ended_at"],
- user.display_name,
- user.display_name,
- review.notes,
- Json({
- "created_with":
- "governance_review_ui_v0314",
- "request_id":
- str(request_id),
- "decision_group_key":
- decision[
- "decision_group_key"
- ],
- "case_id":
- case_id,
- "answer_code":
- review.answer_code,
- "retrospective":
- True,
- "deployment_mode":
- "SHADOW",
- }),
- ),
- )
- created_ids.append(
- int(
- cursor.fetchone()[
- "context_session_id"
- ]
- )
- )
-
- return created_ids
-
-
def insert_case_and_episode_reviews(
cursor: RealDictCursor,
decision: dict[str, Any],
@@ -507,7 +338,6 @@ def insert_case_and_episode_reviews(
mapped_classification: str,
review: ReviewInput,
user: CurrentUser,
- request_id: uuid.UUID,
) -> tuple[list[int], list[int]]:
case_review_ids: list[int] = []
episode_review_ids: list[int] = []
@@ -538,15 +368,8 @@ def insert_case_and_episode_reviews(
evidence_json
)
VALUES (
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s
+ %s, %s, %s, %s, %s,
+ %s, %s, %s, %s
)
RETURNING review_id
""",
@@ -584,15 +407,17 @@ def insert_case_and_episode_reviews(
review.notes,
Json({
"source":
- "governance_review_ui_v0314",
+ "governance_review_ui_v0315",
"request_id":
- str(request_id),
+ str(review.submission_key),
"decision_group_key":
decision[
"decision_group_key"
],
"answer_code":
review.answer_code,
+ "two_stage_context":
+ True,
}),
),
)
@@ -613,8 +438,7 @@ def insert_case_and_episode_reviews(
JOIN
mv_reports_ucepsa_prod
.li_shopfloor_context_incident_history_v1 h
- ON h.episode_id =
- e.episode_id
+ ON h.episode_id = e.episode_id
WHERE e.case_id = %s
ORDER BY h.episode_id
""",
@@ -656,13 +480,8 @@ def insert_case_and_episode_reviews(
evidence_json
)
VALUES (
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s
+ %s, %s, %s, %s,
+ %s, %s, %s
)
RETURNING review_id
""",
@@ -698,9 +517,9 @@ def insert_case_and_episode_reviews(
review.notes,
Json({
"source":
- "governance_review_ui_v0314",
+ "governance_review_ui_v0315",
"request_id":
- str(request_id),
+ str(review.submission_key),
"decision_group_key":
decision[
"decision_group_key"
@@ -726,13 +545,175 @@ def insert_case_and_episode_reviews(
)
-def apply_review(
+def create_context_proposals(
+ cursor: RealDictCursor,
+ settings,
+ decision: dict[str, Any],
+ selected_cases: list[dict[str, Any]],
+ mapped_classification: str,
+ review: ReviewInput,
+ user: CurrentUser,
+) -> list[int]:
+ source_type = PROPOSED_CONTEXT_SOURCE.get(
+ review.answer_code
+ )
+
+ if source_type is None:
+ return []
+
+ proposal_ids: list[int] = []
+
+ for case in selected_cases:
+ case_id = int(case["case_id"])
+ proposal_key = (
+ f"CONTEXT_PROPOSAL:CASE:{case_id}:"
+ f"{review.answer_code}"
+ )
+
+ cursor.execute(
+ """
+ INSERT INTO
+ mv_loss_intelligence
+ .context_governance_context_proposals (
+ tenant,
+ site,
+ proposal_key,
+ request_id,
+ decision_group_key,
+ case_id,
+ answer_code,
+ mapped_classification,
+ proposed_source_type,
+ proposed_by_username,
+ proposed_by_display_name,
+ source_evidence,
+ official_eligible
+ )
+ VALUES (
+ %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s,
+ %s, %s, false
+ )
+ ON CONFLICT (
+ tenant,
+ site,
+ proposal_key
+ )
+ DO NOTHING
+ RETURNING proposal_id
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ proposal_key,
+ str(review.submission_key),
+ review.decision_group_key,
+ case_id,
+ review.answer_code,
+ mapped_classification,
+ source_type,
+ user.username,
+ user.display_name,
+ Json({
+ "machine_id":
+ case["machine_id"],
+ "first_seen_at":
+ json_safe(
+ case["first_seen_at"]
+ ),
+ "ended_at":
+ json_safe(
+ case["ended_at"]
+ ),
+ "case_status":
+ case["case_status"],
+ "production_orders":
+ case[
+ "production_orders"
+ ],
+ "odoo_workorder_ids":
+ case[
+ "odoo_workorder_ids"
+ ],
+ "production_notes":
+ review.notes,
+ "deployment_mode":
+ "SHADOW",
+ }),
+ ),
+ )
+ row = cursor.fetchone()
+
+ if row:
+ proposal_ids.append(
+ int(row["proposal_id"])
+ )
+ continue
+
+ cursor.execute(
+ """
+ SELECT proposal_id
+ FROM
+ mv_loss_intelligence
+ .context_governance_context_proposals
+ WHERE tenant = %s
+ AND site = %s
+ AND proposal_key = %s
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ proposal_key,
+ ),
+ )
+ existing = cursor.fetchone()
+ if existing:
+ proposal_ids.append(
+ int(existing["proposal_id"])
+ )
+
+ return proposal_ids
+
+
+def close_open_consultation(
+ cursor: RealDictCursor,
+ settings,
+ decision_group_key: str,
+ user: CurrentUser,
+) -> None:
+ cursor.execute(
+ """
+ UPDATE
+ mv_loss_intelligence
+ .context_governance_ui_consultations
+ SET
+ status = 'CLOSED',
+ closed_by_username = %s,
+ closed_by_display_name = %s,
+ closed_at = now(),
+ close_reason =
+ 'La decisión fue respondida desde la interfaz.'
+ WHERE tenant = %s
+ AND site = %s
+ AND decision_group_key = %s
+ AND status = 'OPEN'
+ """,
+ (
+ user.username,
+ user.display_name,
+ settings.tenant,
+ settings.site,
+ decision_group_key,
+ ),
+ )
+
+
+def apply_production_review(
review: ReviewInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
- request_id = uuid.uuid4()
settings = get_settings()
with get_connection() as connection:
@@ -742,6 +723,40 @@ def apply_review(
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
+ cursor.execute(
+ """
+ SELECT pg_advisory_xact_lock(
+ hashtext(%s)
+ )
+ """,
+ (
+ (
+ f"{settings.tenant}:"
+ f"{settings.site}:"
+ f"{review.submission_key}"
+ ),
+ ),
+ )
+
+ replay = existing_review_action(
+ cursor,
+ settings,
+ review.submission_key,
+ )
+ if replay:
+ connection.rollback()
+ return {
+ "idempotent_replay": True,
+ "ui_action_id":
+ replay["ui_action_id"],
+ "request_id":
+ str(
+ replay["request_id"]
+ ),
+ "resulting_state":
+ replay["resulting_state"],
+ }
+
cursor.execute(
"""
SELECT pg_advisory_xact_lock(
@@ -770,7 +785,7 @@ def apply_review(
pending_cases,
selected_cases,
mapped_classification,
- ) = validate_input(
+ ) = validate_review(
decision,
all_cases,
review,
@@ -781,15 +796,6 @@ def apply_review(
"cases": all_cases,
}
- created_context_ids = insert_contexts(
- cursor,
- decision,
- selected_cases,
- review,
- user,
- request_id,
- )
-
pending_ids = {
int(case["case_id"])
for case in pending_cases
@@ -830,16 +836,8 @@ def apply_review(
evidence_json
)
VALUES (
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
+ %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s,
%s
)
RETURNING review_id
@@ -859,15 +857,19 @@ def apply_review(
review.notes,
Json({
"source":
- "governance_review_ui_v0314",
+ "governance_review_ui_v0315",
"request_id":
- str(request_id),
+ str(
+ review.submission_key
+ ),
"answer_code":
review.answer_code,
"selected_case_ids":
list(
review.selected_case_ids
),
+ "two_stage_context":
+ True,
}),
),
)
@@ -887,25 +889,38 @@ def apply_review(
mapped_classification,
review,
user,
- request_id,
)
- resulting_decision = fetch_decision(
+ proposal_ids = create_context_proposals(
cursor,
+ settings,
+ decision,
+ selected_cases,
+ mapped_classification,
+ review,
+ user,
+ )
+
+ close_open_consultation(
+ cursor,
+ settings,
review.decision_group_key,
+ user,
)
resulting_state = {
- "decision":
- resulting_decision,
"decision_review_id":
decision_review_id,
"case_review_ids":
case_review_ids,
"episode_review_ids":
episode_review_ids,
+ "context_proposal_ids":
+ proposal_ids,
"created_context_session_ids":
- created_context_ids,
+ [],
+ "all_pending_cases_selected":
+ all_pending_selected,
}
cursor.execute(
@@ -938,36 +953,18 @@ def apply_review(
official_eligible
)
VALUES (
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- %s,
- 'APPLIED',
- %s,
- %s,
- false
+ %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s,
+ %s, false, ARRAY[]::bigint[],
+ %s, %s, %s, %s, %s,
+ %s, 'APPLIED', %s, %s, false
)
RETURNING ui_action_id
""",
(
settings.tenant,
settings.site,
- str(request_id),
+ str(review.submission_key),
review.decision_group_key,
list(review.selected_case_ids),
(
@@ -980,8 +977,6 @@ def apply_review(
review.selected_order_ref,
review.selected_workorder_id,
review.corrected_in_odoo,
- review.create_contexts,
- created_context_ids,
user.username,
user.display_name,
sorted(user.roles),
@@ -1009,20 +1004,253 @@ def apply_review(
connection.commit()
return {
- "ui_action_id":
- ui_action_id,
+ "idempotent_replay": False,
+ "ui_action_id": ui_action_id,
"request_id":
- str(request_id),
- "decision_review_id":
- decision_review_id,
- "case_review_ids":
- case_review_ids,
- "episode_review_ids":
- episode_review_ids,
- "created_context_session_ids":
- created_context_ids,
- "all_pending_cases_selected":
- all_pending_selected,
+ str(review.submission_key),
+ **resulting_state,
+ }
+
+ except Exception:
+ connection.rollback()
+ raise
+
+
+def mark_in_consultation(
+ consultation: ConsultationInput,
+ user: CurrentUser,
+) -> dict[str, Any]:
+ settings = get_settings()
+
+ if not consultation.reason.strip():
+ raise ReviewError(
+ "Debe indicar qué información falta o a quién hay que preguntar."
+ )
+
+ with get_connection() as connection:
+ connection.autocommit = False
+
+ try:
+ with connection.cursor(
+ cursor_factory=RealDictCursor,
+ ) as cursor:
+ cursor.execute(
+ """
+ SELECT consultation_id
+ FROM
+ mv_loss_intelligence
+ .context_governance_ui_consultations
+ WHERE tenant = %s
+ AND site = %s
+ AND request_id = %s
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ str(
+ consultation.submission_key
+ ),
+ ),
+ )
+ replay = cursor.fetchone()
+ if replay:
+ connection.rollback()
+ return {
+ "consultation_id":
+ int(
+ replay[
+ "consultation_id"
+ ]
+ ),
+ "idempotent_replay":
+ True,
+ }
+
+ decision = fetch_decision(
+ cursor,
+ consultation.decision_group_key,
+ )
+
+ if decision["review_status"] != "PENDING":
+ raise ReviewError(
+ "La decisión ya no está pendiente."
+ )
+
+ cursor.execute(
+ """
+ INSERT INTO
+ mv_loss_intelligence
+ .context_governance_ui_consultations (
+ tenant,
+ site,
+ request_id,
+ decision_group_key,
+ status,
+ reason,
+ follow_up_with,
+ created_by_username,
+ created_by_display_name,
+ created_by_roles,
+ official_eligible
+ )
+ VALUES (
+ %s, %s, %s, %s, 'OPEN',
+ %s, %s, %s, %s, %s, false
+ )
+ ON CONFLICT (
+ tenant,
+ site,
+ decision_group_key
+ )
+ WHERE status = 'OPEN'
+ DO UPDATE SET
+ request_id = EXCLUDED.request_id,
+ reason = EXCLUDED.reason,
+ follow_up_with =
+ EXCLUDED.follow_up_with,
+ created_by_username =
+ EXCLUDED.created_by_username,
+ created_by_display_name =
+ EXCLUDED.created_by_display_name,
+ created_by_roles =
+ EXCLUDED.created_by_roles,
+ created_at = now()
+ RETURNING consultation_id
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ str(
+ consultation.submission_key
+ ),
+ consultation.decision_group_key,
+ consultation.reason.strip(),
+ consultation.follow_up_with.strip()
+ or None,
+ user.username,
+ user.display_name,
+ sorted(user.roles),
+ ),
+ )
+ consultation_id = int(
+ cursor.fetchone()[
+ "consultation_id"
+ ]
+ )
+
+ connection.commit()
+ return {
+ "consultation_id":
+ consultation_id,
+ "idempotent_replay":
+ False,
+ }
+
+ except Exception:
+ connection.rollback()
+ raise
+
+
+def resume_from_consultation(
+ submission_key: uuid.UUID,
+ decision_group_key: str,
+ user: CurrentUser,
+) -> 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 consultation_id
+ FROM
+ mv_loss_intelligence
+ .context_governance_ui_consultations
+ WHERE tenant = %s
+ AND site = %s
+ AND request_id = %s
+ """,
+ (
+ settings.tenant,
+ settings.site,
+ str(submission_key),
+ ),
+ )
+ replay = cursor.fetchone()
+ if replay:
+ connection.rollback()
+ return {
+ "consultation_id":
+ int(
+ replay[
+ "consultation_id"
+ ]
+ ),
+ "idempotent_replay":
+ True,
+ }
+
+ cursor.execute(
+ """
+ UPDATE
+ mv_loss_intelligence
+ .context_governance_ui_consultations
+ SET
+ status = 'CLOSED',
+ closed_by_username = %s,
+ closed_by_display_name = %s,
+ closed_at = now(),
+ close_reason =
+ 'La información ya está disponible; vuelve a Pendiente.'
+ WHERE tenant = %s
+ AND site = %s
+ AND decision_group_key = %s
+ AND status = 'OPEN'
+ RETURNING consultation_id
+ """,
+ (
+ user.username,
+ user.display_name,
+ settings.tenant,
+ settings.site,
+ decision_group_key,
+ ),
+ )
+ row = cursor.fetchone()
+ if not row:
+ raise ReviewError(
+ "La decisión no estaba marcada En consulta."
+ )
+
+ consultation_id = int(
+ row["consultation_id"]
+ )
+
+ # The request id is stored on the closed row to make retries idempotent.
+ cursor.execute(
+ """
+ UPDATE
+ mv_loss_intelligence
+ .context_governance_ui_consultations
+ SET request_id = %s
+ WHERE consultation_id = %s
+ """,
+ (
+ str(submission_key),
+ consultation_id,
+ ),
+ )
+
+ connection.commit()
+ return {
+ "consultation_id":
+ consultation_id,
+ "idempotent_replay":
+ False,
}
except Exception:
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/static/style.css b/ucepsa/edge-oee-demo/governance-ui/app/static/style.css
index 5c8844d..bc4bd7c 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/static/style.css
+++ b/ucepsa/edge-oee-demo/governance-ui/app/static/style.css
@@ -337,3 +337,41 @@ th { color: var(--muted); }
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;
+}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/approval_detail.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/approval_detail.html
new file mode 100644
index 0000000..f223217
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/approval_detail.html
@@ -0,0 +1,82 @@
+{% extends "base.html" %}
+{% block content %}
+← Volver a aprobación técnica
+
+{% if error %}
+
{{ error }}
+{% endif %}
+
+
+
+
+ {{ proposal.machine_id }}
+ {{ proposal.proposal_status|label }}
+
Propuesta de contexto: {{ proposal.proposed_source_type|label }}
+
+
+
+
+
Respuesta aportada por Producción
+
{{ proposal.answer_code|label }}
+
Respondido por {{ proposal.proposed_by_display_name }} el {{ proposal.proposed_at|datetime_es }}
+
+
+
+
Inicio exacto {{ proposal.first_seen_at|datetime_es }}
+
Fin exacto {{ proposal.ended_at|datetime_es }}
+
Estado del intervalo {{ proposal.case_status|label }}
+
Estado de revisión {{ proposal.case_review_status|label }}
+
Órdenes {{ proposal.production_orders|join(", ") if proposal.production_orders else "—" }}
+
Workorders {{ proposal.odoo_workorder_ids|join(", ") if proposal.odoo_workorder_ids else "—" }}
+
+
+ {% if proposal.last_action_notes %}
+
+
Último intercambio
+
{{ proposal.last_action_notes }}
+
{{ proposal.last_action_by }} · {{ proposal.last_action_at|datetime_es }}
+
+ {% endif %}
+
+
+
Validaciones al aprobar
+
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.
+
+
+ {% if not write_enabled %}
+ La interfaz está en modo solo lectura.
+ {% elif proposal.proposal_status == 'PENDING' %}
+
+
+
+
+
+
+ Aclaración necesaria
+
+
+ Solicitar aclaración
+
+
+
+
+
+
+ Motivo del rechazo
+
+
+ Rechazar propuesta
+
+ {% else %}
+ La propuesta ya no está pendiente: {{ proposal.proposal_status|label }}.
+ {% endif %}
+
+{% endblock %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/approvals.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/approvals.html
new file mode 100644
index 0000000..6abfa17
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/approvals.html
@@ -0,0 +1,49 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+
Aprobación técnica de contextos
+
Producción ya explicó qué ocurrió. Esta cola valida si la respuesta puede convertirse en contexto canónico sin crear solapamientos.
+
+
{{ proposals|length }} propuestas
+
+
+{% if message %}
+{{ message }}
+{% endif %}
+
+{% if not proposals %}
+
+
No hay propuestas pendientes
+
Las respuestas de Producción que no necesitan contexto no aparecen aquí.
+
+{% endif %}
+
+
+{% for proposal in proposals %}
+
+
+
+ {{ proposal.machine_id }}
+ {{ proposal.proposal_status|label }}
+
+
+ {{ proposal.proposed_source_type|label }}
+ Respuesta de Producción: {{ proposal.answer_code|label }}
+
+
Inicio {{ proposal.first_seen_at|datetime_es }}
+
Fin {{ proposal.ended_at|datetime_es }}
+
Propuesto por {{ proposal.proposed_by_display_name }}
+
Caso {{ proposal.case_id }}
+
+ {% if proposal.last_action_notes %}
+
+
Último intercambio
+
{{ proposal.last_action_notes }}
+
+ {% endif %}
+ Revisar propuesta
+
+{% endfor %}
+
+{% endblock %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
index 1930876..f5db143 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
@@ -15,9 +15,17 @@
Pendientes
+ {% if 'TECHNICAL' in user.roles or 'ADMIN' in user.roles %}
+ Aprobación técnica
+ {% endif %}
+ {% if 'PRODUCTION' in user.roles or 'ADMIN' in user.roles %}
+ Aclaraciones
+ {% endif %}
Historial
{{ user.display_name }}
- {% if not settings.write_enabled %}Solo lectura {% endif %}
+ {% if not settings.write_enabled %}
+ Solo lectura
+ {% endif %}
Salir
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/clarifications.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/clarifications.html
new file mode 100644
index 0000000..9338ad5
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/clarifications.html
@@ -0,0 +1,46 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+
Aclaraciones solicitadas
+
El revisor técnico necesita un dato adicional antes de aprobar o rechazar el contexto propuesto.
+
+
{{ proposals|length }} pendientes
+
+
+{% if message %}
+{{ message }}
+{% endif %}
+
+{% if not proposals %}
+
+
No hay aclaraciones pendientes
+
+{% endif %}
+
+{% for proposal in proposals %}
+
+
+
+ {{ proposal.machine_id }}
+
{{ proposal.proposed_source_type|label }}
+
+
{{ proposal.proposal_status|label }}
+
+ Pregunta técnica: {{ proposal.last_action_notes }}
+ Intervalo: {{ proposal.first_seen_at|datetime_es }} → {{ proposal.ended_at|datetime_es }}
+
+ {% if write_enabled %}
+
+
+
+
+ Respuesta o aclaración
+
+
+ Enviar aclaración
+
+ {% endif %}
+
+{% endfor %}
+{% endblock %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
index ffc9277..62c46e4 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
@@ -10,15 +10,12 @@
{{ decision.machine_id or "GLOBAL" }}
- {{ decision.max_severity }}
+ {{ decision.max_severity|label }}
{{ decision.decision_title }}
-
- Incidencia:
- {% if decision.decision_status == "OPEN" %}Activa{% else %}Terminada{% endif %}
-
- Revisión: {{ decision.review_status }}
+ Incidencia: {{ decision.decision_status|label }}
+ Revisión: {{ decision.review_status|label }}
@@ -27,6 +24,25 @@
{{ decision.question_text }}
+ {% if consultation %}
+
+
En consulta
+
{{ consultation.reason }}
+ {% if consultation.follow_up_with %}
+
Consultar con: {{ consultation.follow_up_with }}
+ {% endif %}
+
Marcado por {{ consultation.created_by_display_name }} el {{ consultation.created_at|datetime_es }}
+
+ {% if write_enabled %}
+
+
+
+ Ya tengo la información
+
+ {% endif %}
+
+ {% endif %}
+
Intervalos y casos
@@ -39,12 +55,11 @@
Caso {{ case.case_id }} · {{ case.case_title }}
- Incidencia:
- {% if case.case_status == "OPEN" %}activa{% else %}terminada{% endif %}
- · Revisión: {{ case.case_review_status }}
+ Incidencia: {{ case.case_status|label }}
+ · Revisión: {{ case.case_review_status|label }}
{% if case.case_resolution_classification %}
-
Resolución previa: {{ case.case_resolution_classification }}
+
Periodo ya aclarado: {{ case.case_resolution_classification|label }}
{% endif %}
{% if case.production_orders %}
Órdenes: {{ case.production_orders|join(", ") }}
@@ -79,8 +94,28 @@
{% endif %}
{% if can_review and pending_cases %}
+ {% if not consultation %}
+
+ No conozco todavía la respuesta
+
+
+
+
+ Qué información falta
+
+
+
+ A quién hay que consultar
+
+
+ Marcar En consulta
+
+
+ {% endif %}
+
+
1. Intervalos a los que se aplica la respuesta
@@ -99,10 +134,10 @@
2. Qué ocurrió
- {% for code, label in answer_options %}
+ {% for code, text in answer_options %}
- {{ label }}
+ {{ text }}
{% endfor %}
@@ -123,19 +158,10 @@
{% endif %}
- {% if decision.decision_family == "UNCONFIRMED_ACTIVITY" %}
-
- 3. Contexto retrospectivo
-
-
- Crear un contexto exacto por cada intervalo seleccionado cuando la respuesta lo permita
-
-
- Solo se admite para sistema antiguo, prueba/ajuste o mantenimiento.
- No cubre los huecos entre intervalos y no se permite sobre casos todavía abiertos.
-
-
- {% endif %}
+
+
Qué ocurrirá después
+
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.
+
Observaciones
@@ -151,7 +177,7 @@
{% elif not pending_cases %}
Esta decisión ya no tiene casos pendientes.
- {% else %}
+ {% elif write_enabled %}
Su rol puede consultar esta decisión, pero no resolverla.
{% endif %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/history.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/history.html
index b79d2ee..5601a98 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/templates/history.html
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/history.html
@@ -2,8 +2,8 @@
{% block content %}
-
Historial de acciones
-
Acciones realizadas desde la interfaz durante los últimos 30 días.
+
Historial de actividad
+
Revisiones de Producción, consultas y decisiones técnicas durante los últimos 30 días.
@@ -12,24 +12,24 @@
Fecha
+ Tipo
Usuario
Decisión
- Respuesta
- Casos
- Contextos
+ Acción
Estado
+ Notas
- {% for action in actions %}
+ {% for activity in activities %}
- {{ action.created_at|datetime_es }}
- {{ action.actor_display_name }}
- {{ action.decision_group_key }}
- {{ action.answer_code }}
- {{ action.selected_case_ids|join(", ") }}
- {{ action.created_context_session_ids|join(", ") if action.created_context_session_ids else "—" }}
- {{ action.status }}
+ {{ activity.created_at|datetime_es }}
+ {{ activity.activity_type|label }}
+ {{ activity.actor_display_name }}
+ {{ activity.decision_group_key }}
+ {{ activity.action_code|label }}
+ {{ activity.status|label }}
+ {{ activity.notes or "—" }}
{% endfor %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
index 5661df6..7868536 100644
--- a/ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
+++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
@@ -3,7 +3,7 @@
Decisiones pendientes
-
Una tarjeta equivale a una pregunta humana. Los datos técnicos se conservan como evidencia.
+
Una tarjeta equivale a una pregunta humana. Producción explica lo ocurrido; la creación de contexto se valida después por separado.
{{ rows|length }} pendientes
@@ -15,7 +15,7 @@
{% if not rows %}
No hay decisiones pendientes para su rol
-
La cola se actualizará automáticamente cuando aparezca una excepción que requiera intervención humana.
+
La cola se actualizará automáticamente cuando aparezca una excepción que necesite una explicación humana.
{% endif %}
@@ -25,27 +25,34 @@
{{ row.machine_id or "GLOBAL" }}
- {{ row.max_severity }}
+ {{ row.max_severity|label }}
-
- Incidencia:
- {% if row.incident_status == "ACTIVE" %}Activa{% else %}Terminada{% endif %}
-
- Revisión: Pendiente
+ Incidencia: {{ row.incident_status|label }}
+ Revisión: {{ row.workflow_status|label }}
{{ row.decision_title }}
{{ row.question_text }}
+ {% if row.workflow_status == 'IN_CONSULTATION' %}
+
+
En consulta
+
{{ row.consultation_reason }}
+ {% if row.follow_up_with %}
+
Consultar con: {{ row.follow_up_with }}
+ {% endif %}
+
+ {% endif %}
+
Inicio {{ row.first_seen_at|datetime_es }}
Fin {{ row.ended_at|datetime_es }}
-
Responsable {{ row.review_owner }}
+
Responsable {{ row.review_owner|label }}
Casos pendientes {{ row.pending_root_case_count }}
Evidencias {{ row.linked_episode_count }}
-
Atención {{ row.attention_status }}
+
Atención {{ row.attention_status|label }}
{% if row.production_orders %}
diff --git a/ucepsa/edge-oee-demo/governance-ui/requirements.txt b/ucepsa/edge-oee-demo/governance-ui/requirements.txt
index 657dcb4..1f1fe88 100644
--- a/ucepsa/edge-oee-demo/governance-ui/requirements.txt
+++ b/ucepsa/edge-oee-demo/governance-ui/requirements.txt
@@ -5,3 +5,4 @@ psycopg2-binary>=2.9,<3
python-multipart>=0.0.20,<1
itsdangerous>=2.2,<3
bcrypt>=4.1,<5
+pytest>=8,<9
diff --git a/ucepsa/edge-oee-demo/governance-ui/tests/test_roles_and_labels.py b/ucepsa/edge-oee-demo/governance-ui/tests/test_roles_and_labels.py
new file mode 100644
index 0000000..82e5b15
--- /dev/null
+++ b/ucepsa/edge-oee-demo/governance-ui/tests/test_roles_and_labels.py
@@ -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"
diff --git a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json
index 854035d..95d5d6e 100644
--- a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json
+++ b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json
@@ -15,7 +15,7 @@
}
]
},
- "description": "Supervisión SHADOW 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,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
@@ -2743,7 +2743,7 @@
"editorMode": "code",
"format": "table",
"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"
}
],
@@ -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 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"
}
],
@@ -3640,6 +3640,201 @@
],
"title": "Histórico de regresión · 30 días",
"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",
@@ -3676,7 +3871,10 @@
"v03131",
"governance-ui",
"human-review",
- "v0314"
+ "v0314",
+ "two-stage-approval",
+ "consultation",
+ "v0315"
],
"templating": {
"list": []
diff --git a/ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh b/ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh
new file mode 100755
index 0000000..2ac284f
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh
@@ -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
diff --git a/ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh b/ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
new file mode 100755
index 0000000..311fa9d
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
@@ -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"
diff --git a/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh b/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh
new file mode 100755
index 0000000..cc0d05a
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ACTION="${1:-status}"
+RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
+APP_ROOT="${GOVERNANCE_UI_APP_ROOT:-$RUNTIME_ROOT/governance-ui}"
+CONTAINER="${GOVERNANCE_UI_CONTAINER:-mv_ucepsa_governance_ui}"
+IMAGE="${GOVERNANCE_UI_IMAGE:-mesavault/ucepsa-governance-ui:0.3.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
diff --git a/ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0315.sh b/ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0315.sh
new file mode 100755
index 0000000..679929b
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0315.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${GOVERNANCE_UI_HOST_PORT:-8091}"
+BASE_URL="${GOVERNANCE_UI_BASE_URL:-http://127.0.0.1:${PORT}}"
+
+echo "=== health ==="
+curl -fsS "${BASE_URL}/health"
+echo
+
+echo "=== login page ==="
+curl -fsS "${BASE_URL}/login" \
+ | grep -F "Revisión de gobernanza" \
+ >/dev/null
+
+echo "=== protected queue redirects to login ==="
+STATUS="$(
+ curl -sS -o /dev/null -w '%{http_code}' \
+ "${BASE_URL}/"
+)"
+
+if [[ "$STATUS" != "307" && "$STATUS" != "303" ]]; then
+ echo "ERROR: la cola sin sesión devolvió HTTP $STATUS" >&2
+ exit 1
+fi
+
+echo "Smoke test OK"
diff --git a/ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh b/ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh
new file mode 100755
index 0000000..8bce3d0
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh
@@ -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
diff --git a/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql b/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql
new file mode 100644
index 0000000..b58e2b2
--- /dev/null
+++ b/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql
@@ -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;
diff --git a/ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql b/ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql
new file mode 100644
index 0000000..f18d3da
--- /dev/null
+++ b/ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql
@@ -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;