feat(ucepsa): add two-stage governance context approval
This commit is contained in:
parent
33706d397a
commit
c3eb98a91d
@ -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.
|
||||||
|
```
|
||||||
@ -14,6 +14,7 @@ RUN pip install --no-cache-dir \
|
|||||||
|
|
||||||
COPY app /app/app
|
COPY app /app/app
|
||||||
COPY tools /app/tools
|
COPY tools /app/tools
|
||||||
|
COPY tests /app/tests
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,15 @@ from itsdangerous import BadSignature, SignatureExpired
|
|||||||
from itsdangerous.url_safe import URLSafeTimedSerializer
|
from itsdangerous.url_safe import URLSafeTimedSerializer
|
||||||
|
|
||||||
from .settings import UserConfig, get_settings
|
from .settings import UserConfig, get_settings
|
||||||
|
from .policy import (
|
||||||
|
can_approve_context_roles,
|
||||||
|
can_manage_clarification_roles,
|
||||||
|
can_view_owner_roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SESSION_COOKIE = "mv_governance_session"
|
SESSION_COOKIE = "mv_governance_session"
|
||||||
SESSION_SALT = "mv-governance-ui-v0314"
|
SESSION_SALT = "mv-governance-ui-v0315"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -134,22 +139,10 @@ def can_view_owner(
|
|||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
review_owner: str,
|
review_owner: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if "ADMIN" in user.roles:
|
return can_view_owner_roles(
|
||||||
return True
|
user.roles,
|
||||||
if review_owner == "PRODUCTION":
|
review_owner,
|
||||||
return "PRODUCTION" in user.roles
|
)
|
||||||
if review_owner == "TECHNICAL":
|
|
||||||
return "TECHNICAL" in user.roles
|
|
||||||
if review_owner == "JOINT":
|
|
||||||
return bool(
|
|
||||||
user.roles
|
|
||||||
& {
|
|
||||||
"PRODUCTION",
|
|
||||||
"TECHNICAL",
|
|
||||||
"JOINT",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def can_review_owner(
|
def can_review_owner(
|
||||||
@ -160,3 +153,19 @@ def can_review_owner(
|
|||||||
user,
|
user,
|
||||||
review_owner,
|
review_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_approve_context(
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> bool:
|
||||||
|
return can_approve_context_roles(
|
||||||
|
user.roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_clarification(
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> bool:
|
||||||
|
return can_manage_clarification_roles(
|
||||||
|
user.roles
|
||||||
|
)
|
||||||
|
|||||||
@ -20,7 +20,7 @@ def get_connection() -> Iterator[Connection]:
|
|||||||
user=settings.pg_user,
|
user=settings.pg_user,
|
||||||
password=settings.pg_password,
|
password=settings.pg_password,
|
||||||
connect_timeout=10,
|
connect_timeout=10,
|
||||||
application_name="governance_review_ui_v0314",
|
application_name="governance_review_ui_v0315",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
yield connection
|
yield connection
|
||||||
|
|||||||
68
ucepsa/edge-oee-demo/governance-ui/app/labels.py
Normal file
68
ucepsa/edge-oee-demo/governance-ui/app/labels.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
"RECENT": "Reciente",
|
||||||
|
"ATTENTION": "Requiere atención",
|
||||||
|
"OVERDUE": "Vencida",
|
||||||
|
"APPLIED": "Aplicada",
|
||||||
|
"ERROR": "Error",
|
||||||
|
"PRODUCTION_REVIEW": "Revisión de Producción",
|
||||||
|
"CONSULTATION": "Consulta",
|
||||||
|
"CONTEXT_PROPOSAL": "Propuesta de contexto",
|
||||||
|
"MARK_IN_CONSULTATION": "Marcada En consulta",
|
||||||
|
"CLOSE_CONSULTATION": "Consulta cerrada",
|
||||||
|
"WARNING": "Aviso",
|
||||||
|
"CRITICAL": "Crítica",
|
||||||
|
"PENDING": "Pendiente",
|
||||||
|
"REVIEWED": "Resuelta",
|
||||||
|
"DISMISSED": "Descartada",
|
||||||
|
"NOT_REQUIRED": "No requiere revisión",
|
||||||
|
"OPEN": "Activa",
|
||||||
|
"CLOSED": "Terminada",
|
||||||
|
"ACTIVE": "Activa",
|
||||||
|
"ENDED": "Terminada",
|
||||||
|
"IN_CONSULTATION": "En consulta",
|
||||||
|
"PRODUCTION": "Producción",
|
||||||
|
"TECHNICAL": "Técnico",
|
||||||
|
"JOINT": "Conjunto",
|
||||||
|
"ADMIN": "Administrador",
|
||||||
|
"AUTHORIZED_LEGACY_PRODUCTION":
|
||||||
|
"Producción con el sistema antiguo",
|
||||||
|
"ODOO_START_OMITTED":
|
||||||
|
"Producción real, pero se olvidó iniciar Odoo",
|
||||||
|
"TEST_OR_SETUP": "Prueba, ajuste o preparación",
|
||||||
|
"RESIDUAL_MATERIAL":
|
||||||
|
"Material residual o vaciado de máquina",
|
||||||
|
"MAINTENANCE": "Mantenimiento",
|
||||||
|
"NOT_PRODUCTION": "No era producción",
|
||||||
|
"NOT_RELEVANT": "No era producción",
|
||||||
|
"ODOO_DOUBLE_START_ONE_VALID":
|
||||||
|
"Una orden era válida y la otra quedó abierta por error",
|
||||||
|
"ODOO_DOUBLE_START_BOTH_INVALID":
|
||||||
|
"Ninguna de las órdenes era válida",
|
||||||
|
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED":
|
||||||
|
"Eran órdenes consecutivas y la anterior no se cerró",
|
||||||
|
"DATA_ISSUE": "Incidencia de datos o configuración",
|
||||||
|
"OTHER": "Otro",
|
||||||
|
"LEGACY_ERP": "Sistema antiguo",
|
||||||
|
"TEST_SETUP": "Prueba o ajuste",
|
||||||
|
"APPROVED": "Aprobado",
|
||||||
|
"REJECTED": "Rechazado",
|
||||||
|
"NEEDS_CLARIFICATION": "Requiere aclaración",
|
||||||
|
"CANCELLED": "Cancelado",
|
||||||
|
"APPROVE": "Aprobar contexto",
|
||||||
|
"REJECT": "Rechazar propuesta",
|
||||||
|
"REQUEST_CLARIFICATION": "Solicitar aclaración",
|
||||||
|
"CLARIFICATION_RESPONSE": "Aclaración respondida",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def label(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
raw = str(value)
|
||||||
|
return LABELS.get(
|
||||||
|
raw,
|
||||||
|
raw.replace("_", " ").capitalize(),
|
||||||
|
)
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@ -23,6 +24,8 @@ from .auth import (
|
|||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
authenticate,
|
authenticate,
|
||||||
build_session_token,
|
build_session_token,
|
||||||
|
can_approve_context,
|
||||||
|
can_manage_clarification,
|
||||||
can_review_owner,
|
can_review_owner,
|
||||||
can_view_owner,
|
can_view_owner,
|
||||||
read_current_user,
|
read_current_user,
|
||||||
@ -30,10 +33,20 @@ from .auth import (
|
|||||||
verify_csrf,
|
verify_csrf,
|
||||||
)
|
)
|
||||||
from .db import get_connection
|
from .db import get_connection
|
||||||
|
from .labels import label
|
||||||
|
from .proposal_service import (
|
||||||
|
ProposalActionInput,
|
||||||
|
ProposalError,
|
||||||
|
approve_proposal,
|
||||||
|
change_proposal_status,
|
||||||
|
)
|
||||||
from .review_service import (
|
from .review_service import (
|
||||||
|
ConsultationInput,
|
||||||
ReviewError,
|
ReviewError,
|
||||||
ReviewInput,
|
ReviewInput,
|
||||||
apply_review,
|
apply_production_review,
|
||||||
|
mark_in_consultation,
|
||||||
|
resume_from_consultation,
|
||||||
)
|
)
|
||||||
from .settings import get_settings
|
from .settings import get_settings
|
||||||
|
|
||||||
@ -42,7 +55,7 @@ BASE_DIR = Path(__file__).resolve().parent
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="MESAVAULT Governance Review UI",
|
title="MESAVAULT Governance Review UI",
|
||||||
version="0.3.14",
|
version="0.3.15",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.mount(
|
app.mount(
|
||||||
@ -72,11 +85,19 @@ def format_datetime(value: Any) -> str:
|
|||||||
templates.env.filters[
|
templates.env.filters[
|
||||||
"datetime_es"
|
"datetime_es"
|
||||||
] = format_datetime
|
] = format_datetime
|
||||||
|
templates.env.filters[
|
||||||
|
"label"
|
||||||
|
] = label
|
||||||
|
|
||||||
|
|
||||||
def visible_queue(
|
def redirect_login() -> RedirectResponse:
|
||||||
user,
|
return RedirectResponse(
|
||||||
) -> list[dict[str, Any]]:
|
"/login",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def visible_queue(user) -> list[dict[str, Any]]:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor(
|
with connection.cursor(
|
||||||
cursor_factory=RealDictCursor,
|
cursor_factory=RealDictCursor,
|
||||||
@ -86,7 +107,7 @@ def visible_queue(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM
|
FROM
|
||||||
mv_reports_ucepsa_prod
|
mv_reports_ucepsa_prod
|
||||||
.li_context_governance_ui_queue_v1
|
.li_context_governance_ui_queue_v2
|
||||||
ORDER BY agenda_rank
|
ORDER BY agenda_rank
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@ -105,8 +126,8 @@ def visible_queue(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def recent_actions(
|
def recent_activity(
|
||||||
limit: int = 50,
|
limit: int = 100,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor(
|
with connection.cursor(
|
||||||
@ -117,10 +138,11 @@ def recent_actions(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM
|
FROM
|
||||||
mv_reports_ucepsa_prod
|
mv_reports_ucepsa_prod
|
||||||
.li_context_governance_ui_recent_actions_v1
|
.li_context_governance_ui_activity_v1
|
||||||
ORDER BY
|
ORDER BY
|
||||||
created_at DESC,
|
created_at DESC,
|
||||||
ui_action_id DESC
|
activity_type,
|
||||||
|
activity_id DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(limit,),
|
(limit,),
|
||||||
@ -136,6 +158,7 @@ def load_decision(
|
|||||||
) -> tuple[
|
) -> tuple[
|
||||||
dict[str, Any],
|
dict[str, Any],
|
||||||
list[dict[str, Any]],
|
list[dict[str, Any]],
|
||||||
|
dict[str, Any] | None,
|
||||||
]:
|
]:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor(
|
with connection.cursor(
|
||||||
@ -177,80 +200,128 @@ def load_decision(
|
|||||||
for row in cursor.fetchall()
|
for row in cursor.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
return dict(decision), cases
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_ui_consultation_current_v1
|
||||||
|
WHERE decision_group_key = %s
|
||||||
|
""",
|
||||||
|
(decision_group_key,),
|
||||||
|
)
|
||||||
|
consultation = cursor.fetchone()
|
||||||
|
|
||||||
|
return (
|
||||||
|
dict(decision),
|
||||||
|
cases,
|
||||||
|
dict(consultation)
|
||||||
|
if consultation
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def answer_options(
|
def answer_options(
|
||||||
decision_family: str,
|
decision_family: str,
|
||||||
) -> list[tuple[str, str]]:
|
) -> list[tuple[str, str]]:
|
||||||
if decision_family == "UNCONFIRMED_ACTIVITY":
|
if decision_family == "UNCONFIRMED_ACTIVITY":
|
||||||
return [
|
codes = [
|
||||||
(
|
"AUTHORIZED_LEGACY_PRODUCTION",
|
||||||
"AUTHORIZED_LEGACY_PRODUCTION",
|
"ODOO_START_OMITTED",
|
||||||
"Producción con el sistema antiguo",
|
"TEST_OR_SETUP",
|
||||||
),
|
"RESIDUAL_MATERIAL",
|
||||||
(
|
"MAINTENANCE",
|
||||||
"ODOO_START_OMITTED",
|
"NOT_PRODUCTION",
|
||||||
"Producción real, pero se olvidó iniciar Odoo",
|
"OTHER",
|
||||||
),
|
|
||||||
(
|
|
||||||
"TEST_OR_SETUP",
|
|
||||||
"Prueba, ajuste o preparación",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"RESIDUAL_MATERIAL",
|
|
||||||
"Material residual o vaciado de máquina",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"MAINTENANCE",
|
|
||||||
"Mantenimiento",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"NOT_PRODUCTION",
|
|
||||||
"No era producción",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"OTHER",
|
|
||||||
"Otro",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
elif decision_family == "ODOO_ORDER_START_CONFLICT":
|
||||||
if decision_family == "ODOO_ORDER_START_CONFLICT":
|
codes = [
|
||||||
return [
|
"ODOO_DOUBLE_START_ONE_VALID",
|
||||||
(
|
"ODOO_DOUBLE_START_BOTH_INVALID",
|
||||||
"ODOO_DOUBLE_START_ONE_VALID",
|
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
|
||||||
"Una orden era válida y la otra quedó abierta por error",
|
"DATA_ISSUE",
|
||||||
),
|
"OTHER",
|
||||||
(
|
]
|
||||||
"ODOO_DOUBLE_START_BOTH_INVALID",
|
else:
|
||||||
"Ninguna de las órdenes era válida",
|
codes = [
|
||||||
),
|
"DATA_ISSUE",
|
||||||
(
|
"OTHER",
|
||||||
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
|
|
||||||
"Eran órdenes consecutivas y la anterior no se cerró",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"DATA_ISSUE",
|
|
||||||
"Incidencia de datos o configuración",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"OTHER",
|
|
||||||
"Otro",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return [
|
return [
|
||||||
(
|
(code, label(code))
|
||||||
"DATA_ISSUE",
|
for code in codes
|
||||||
"Incidencia de datos",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"OTHER",
|
|
||||||
"Otro",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_approval_queue() -> list[dict[str, Any]]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_context_approval_queue_v1
|
||||||
|
ORDER BY approval_rank
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
dict(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_proposal(
|
||||||
|
proposal_id: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_context_proposals_v1
|
||||||
|
WHERE proposal_id = %s
|
||||||
|
""",
|
||||||
|
(proposal_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Propuesta no encontrada.",
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def load_clarifications() -> list[dict[str, Any]]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_context_clarification_queue_v1
|
||||||
|
ORDER BY
|
||||||
|
last_action_at,
|
||||||
|
proposal_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
dict(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/health",
|
"/health",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
@ -267,13 +338,11 @@ def health() -> str:
|
|||||||
"/login",
|
"/login",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
)
|
)
|
||||||
def login_page(
|
def login_page(request: Request):
|
||||||
request: Request,
|
|
||||||
):
|
|
||||||
if read_current_user(request):
|
if read_current_user(request):
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
"/",
|
"/",
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@ -314,7 +383,7 @@ def login_submit(
|
|||||||
|
|
||||||
response = RedirectResponse(
|
response = RedirectResponse(
|
||||||
"/",
|
"/",
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=303,
|
||||||
)
|
)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@ -341,7 +410,7 @@ def logout(
|
|||||||
|
|
||||||
response = RedirectResponse(
|
response = RedirectResponse(
|
||||||
"/login",
|
"/login",
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=303,
|
||||||
)
|
)
|
||||||
response.delete_cookie(
|
response.delete_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@ -360,10 +429,7 @@ def queue_page(
|
|||||||
):
|
):
|
||||||
user = read_current_user(request)
|
user = read_current_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return RedirectResponse(
|
return redirect_login()
|
||||||
"/login",
|
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
|
||||||
)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
@ -373,6 +439,12 @@ def queue_page(
|
|||||||
"user": user,
|
"user": user,
|
||||||
"rows": visible_queue(user),
|
"rows": visible_queue(user),
|
||||||
"message": message,
|
"message": message,
|
||||||
|
"can_approve":
|
||||||
|
can_approve_context(user),
|
||||||
|
"clarification_count":
|
||||||
|
len(load_clarifications())
|
||||||
|
if can_manage_clarification(user)
|
||||||
|
else 0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -388,12 +460,9 @@ def decision_page(
|
|||||||
):
|
):
|
||||||
user = read_current_user(request)
|
user = read_current_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return RedirectResponse(
|
return redirect_login()
|
||||||
"/login",
|
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
|
||||||
)
|
|
||||||
|
|
||||||
decision, cases = load_decision(
|
decision, cases, consultation = load_decision(
|
||||||
decision_group_key
|
decision_group_key
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -441,6 +510,7 @@ def decision_page(
|
|||||||
"decision": decision,
|
"decision": decision,
|
||||||
"cases": cases,
|
"cases": cases,
|
||||||
"pending_cases": pending_cases,
|
"pending_cases": pending_cases,
|
||||||
|
"consultation": consultation,
|
||||||
"answer_options": answer_options(
|
"answer_options": answer_options(
|
||||||
str(
|
str(
|
||||||
decision[
|
decision[
|
||||||
@ -458,6 +528,12 @@ def decision_page(
|
|||||||
),
|
),
|
||||||
"write_enabled":
|
"write_enabled":
|
||||||
get_settings().write_enabled,
|
get_settings().write_enabled,
|
||||||
|
"review_submission_key":
|
||||||
|
str(uuid.uuid4()),
|
||||||
|
"consultation_submission_key":
|
||||||
|
str(uuid.uuid4()),
|
||||||
|
"resume_submission_key":
|
||||||
|
str(uuid.uuid4()),
|
||||||
"error": error,
|
"error": error,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -470,19 +546,16 @@ def review_decision(
|
|||||||
request: Request,
|
request: Request,
|
||||||
decision_group_key: str,
|
decision_group_key: str,
|
||||||
csrf_token: str = Form(...),
|
csrf_token: str = Form(...),
|
||||||
|
submission_key: uuid.UUID = Form(...),
|
||||||
case_ids: list[int] = Form(default=[]),
|
case_ids: list[int] = Form(default=[]),
|
||||||
answer_code: str = Form(...),
|
answer_code: str = Form(...),
|
||||||
notes: str = Form(""),
|
notes: str = Form(""),
|
||||||
selected_order: str = Form(""),
|
selected_order: str = Form(""),
|
||||||
corrected_in_odoo: bool = Form(False),
|
corrected_in_odoo: bool = Form(False),
|
||||||
create_contexts: bool = Form(False),
|
|
||||||
confirmation: bool = Form(False),
|
confirmation: bool = Form(False),
|
||||||
):
|
):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
verify_csrf(
|
verify_csrf(user, csrf_token)
|
||||||
user,
|
|
||||||
csrf_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not get_settings().write_enabled:
|
if not get_settings().write_enabled:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -490,7 +563,7 @@ def review_decision(
|
|||||||
detail="La interfaz está en modo solo lectura.",
|
detail="La interfaz está en modo solo lectura.",
|
||||||
)
|
)
|
||||||
|
|
||||||
decision, _ = load_decision(
|
decision, _, _ = load_decision(
|
||||||
decision_group_key
|
decision_group_key
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -538,6 +611,7 @@ def review_decision(
|
|||||||
)
|
)
|
||||||
|
|
||||||
review = ReviewInput(
|
review = ReviewInput(
|
||||||
|
submission_key=submission_key,
|
||||||
decision_group_key=
|
decision_group_key=
|
||||||
decision_group_key,
|
decision_group_key,
|
||||||
selected_case_ids=tuple(
|
selected_case_ids=tuple(
|
||||||
@ -556,14 +630,11 @@ def review_decision(
|
|||||||
selected_workorder_id,
|
selected_workorder_id,
|
||||||
corrected_in_odoo=
|
corrected_in_odoo=
|
||||||
corrected_in_odoo,
|
corrected_in_odoo,
|
||||||
create_contexts=
|
confirmation=confirmation,
|
||||||
create_contexts,
|
|
||||||
confirmation=
|
|
||||||
confirmation,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = apply_review(
|
result = apply_production_review(
|
||||||
review=review,
|
review=review,
|
||||||
user=user,
|
user=user,
|
||||||
source_ip=(
|
source_ip=(
|
||||||
@ -591,22 +662,18 @@ def review_decision(
|
|||||||
)
|
)
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
"Revisión registrada. "
|
"Respuesta registrada. "
|
||||||
f"Acción UI {result['ui_action_id']}."
|
f"Acción UI {result['ui_action_id']}."
|
||||||
)
|
)
|
||||||
|
|
||||||
if result[
|
proposal_ids = result.get(
|
||||||
"created_context_session_ids"
|
"context_proposal_ids"
|
||||||
]:
|
) or []
|
||||||
|
if proposal_ids:
|
||||||
message += (
|
message += (
|
||||||
" Contextos creados: "
|
" Se han enviado "
|
||||||
+ ", ".join(
|
f"{len(proposal_ids)} propuestas "
|
||||||
str(value)
|
"a validación técnica."
|
||||||
for value in result[
|
|
||||||
"created_context_session_ids"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+ "."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
@ -616,19 +683,383 @@ def review_decision(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/decisions/{decision_group_key:path}/consultation"
|
||||||
|
)
|
||||||
|
def consultation_submit(
|
||||||
|
request: Request,
|
||||||
|
decision_group_key: str,
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
submission_key: uuid.UUID = Form(...),
|
||||||
|
reason: str = Form(...),
|
||||||
|
follow_up_with: str = Form(""),
|
||||||
|
):
|
||||||
|
user = require_user(request)
|
||||||
|
verify_csrf(user, csrf_token)
|
||||||
|
|
||||||
|
if not get_settings().write_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="La interfaz está en modo solo lectura.",
|
||||||
|
)
|
||||||
|
|
||||||
|
decision, _, _ = load_decision(
|
||||||
|
decision_group_key
|
||||||
|
)
|
||||||
|
if not can_review_owner(
|
||||||
|
user,
|
||||||
|
str(decision["review_owner"]),
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="No puede gestionar esta decisión.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mark_in_consultation(
|
||||||
|
ConsultationInput(
|
||||||
|
submission_key=
|
||||||
|
submission_key,
|
||||||
|
decision_group_key=
|
||||||
|
decision_group_key,
|
||||||
|
reason=reason,
|
||||||
|
follow_up_with=
|
||||||
|
follow_up_with,
|
||||||
|
),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
except ReviewError as exc:
|
||||||
|
return RedirectResponse(
|
||||||
|
"/decisions/"
|
||||||
|
+ quote(
|
||||||
|
decision_group_key,
|
||||||
|
safe="",
|
||||||
|
)
|
||||||
|
+ "?error="
|
||||||
|
+ quote(str(exc)),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
"/?message="
|
||||||
|
+ quote(
|
||||||
|
"La decisión queda marcada En consulta."
|
||||||
|
),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/decisions/{decision_group_key:path}/resume"
|
||||||
|
)
|
||||||
|
def consultation_resume(
|
||||||
|
request: Request,
|
||||||
|
decision_group_key: str,
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
submission_key: uuid.UUID = Form(...),
|
||||||
|
):
|
||||||
|
user = require_user(request)
|
||||||
|
verify_csrf(user, csrf_token)
|
||||||
|
|
||||||
|
if not get_settings().write_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="La interfaz está en modo solo lectura.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resume_from_consultation(
|
||||||
|
submission_key,
|
||||||
|
decision_group_key,
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
except ReviewError as exc:
|
||||||
|
return RedirectResponse(
|
||||||
|
"/decisions/"
|
||||||
|
+ quote(
|
||||||
|
decision_group_key,
|
||||||
|
safe="",
|
||||||
|
)
|
||||||
|
+ "?error="
|
||||||
|
+ quote(str(exc)),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
"/decisions/"
|
||||||
|
+ quote(
|
||||||
|
decision_group_key,
|
||||||
|
safe="",
|
||||||
|
),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/approvals",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
def approvals_page(
|
||||||
|
request: Request,
|
||||||
|
message: str | None = None,
|
||||||
|
):
|
||||||
|
user = read_current_user(request)
|
||||||
|
if user is None:
|
||||||
|
return redirect_login()
|
||||||
|
if not can_approve_context(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="No tiene permisos de aprobación técnica.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="approvals.html",
|
||||||
|
context={
|
||||||
|
"settings": get_settings(),
|
||||||
|
"user": user,
|
||||||
|
"proposals": load_approval_queue(),
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/approvals/{proposal_id}",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
def approval_detail(
|
||||||
|
request: Request,
|
||||||
|
proposal_id: int,
|
||||||
|
error: str | None = None,
|
||||||
|
):
|
||||||
|
user = read_current_user(request)
|
||||||
|
if user is None:
|
||||||
|
return redirect_login()
|
||||||
|
if not can_approve_context(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="No tiene permisos de aprobación técnica.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="approval_detail.html",
|
||||||
|
context={
|
||||||
|
"settings": get_settings(),
|
||||||
|
"user": user,
|
||||||
|
"proposal": load_proposal(
|
||||||
|
proposal_id
|
||||||
|
),
|
||||||
|
"write_enabled":
|
||||||
|
get_settings().write_enabled,
|
||||||
|
"submission_key":
|
||||||
|
str(uuid.uuid4()),
|
||||||
|
"error": error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/approvals/{proposal_id}/{action_code}"
|
||||||
|
)
|
||||||
|
def approval_action(
|
||||||
|
request: Request,
|
||||||
|
proposal_id: int,
|
||||||
|
action_code: str,
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
submission_key: uuid.UUID = Form(...),
|
||||||
|
notes: str = Form(""),
|
||||||
|
):
|
||||||
|
user = require_user(request)
|
||||||
|
verify_csrf(user, csrf_token)
|
||||||
|
|
||||||
|
if not get_settings().write_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="La interfaz está en modo solo lectura.",
|
||||||
|
)
|
||||||
|
if not can_approve_context(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="No tiene permisos de aprobación técnica.",
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized = action_code.upper()
|
||||||
|
if normalized not in {
|
||||||
|
"APPROVE",
|
||||||
|
"REJECT",
|
||||||
|
"REQUEST_CLARIFICATION",
|
||||||
|
}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Acción no permitida.",
|
||||||
|
)
|
||||||
|
|
||||||
|
action = ProposalActionInput(
|
||||||
|
submission_key=submission_key,
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
action_code=normalized,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if normalized == "APPROVE":
|
||||||
|
result = approve_proposal(
|
||||||
|
action,
|
||||||
|
user,
|
||||||
|
(
|
||||||
|
request.client.host
|
||||||
|
if request.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
request.headers.get(
|
||||||
|
"user-agent"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
"Contexto aprobado y creado: "
|
||||||
|
f"{result['context_session_id']}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = change_proposal_status(
|
||||||
|
action,
|
||||||
|
user,
|
||||||
|
(
|
||||||
|
request.client.host
|
||||||
|
if request.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
request.headers.get(
|
||||||
|
"user-agent"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
"Propuesta actualizada: "
|
||||||
|
+ label(
|
||||||
|
result[
|
||||||
|
"resulting_status"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
except ProposalError as exc:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/approvals/{proposal_id}?error="
|
||||||
|
+ quote(str(exc)),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
"/approvals?message="
|
||||||
|
+ quote(message),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/clarifications",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
def clarifications_page(
|
||||||
|
request: Request,
|
||||||
|
message: str | None = None,
|
||||||
|
):
|
||||||
|
user = read_current_user(request)
|
||||||
|
if user is None:
|
||||||
|
return redirect_login()
|
||||||
|
if not can_manage_clarification(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="No tiene acceso a aclaraciones de Producción.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="clarifications.html",
|
||||||
|
context={
|
||||||
|
"settings": get_settings(),
|
||||||
|
"user": user,
|
||||||
|
"proposals": load_clarifications(),
|
||||||
|
"submission_keys": {
|
||||||
|
int(row["proposal_id"]):
|
||||||
|
str(uuid.uuid4())
|
||||||
|
for row in load_clarifications()
|
||||||
|
},
|
||||||
|
"message": message,
|
||||||
|
"write_enabled":
|
||||||
|
get_settings().write_enabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/clarifications/{proposal_id}/respond"
|
||||||
|
)
|
||||||
|
def clarification_respond(
|
||||||
|
request: Request,
|
||||||
|
proposal_id: int,
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
submission_key: uuid.UUID = Form(...),
|
||||||
|
notes: str = Form(...),
|
||||||
|
):
|
||||||
|
user = require_user(request)
|
||||||
|
verify_csrf(user, csrf_token)
|
||||||
|
|
||||||
|
if not get_settings().write_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="La interfaz está en modo solo lectura.",
|
||||||
|
)
|
||||||
|
if not can_manage_clarification(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="No puede responder esta aclaración.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
change_proposal_status(
|
||||||
|
ProposalActionInput(
|
||||||
|
submission_key=submission_key,
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
action_code=
|
||||||
|
"CLARIFICATION_RESPONSE",
|
||||||
|
notes=notes,
|
||||||
|
),
|
||||||
|
user,
|
||||||
|
(
|
||||||
|
request.client.host
|
||||||
|
if request.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
request.headers.get(
|
||||||
|
"user-agent"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ProposalError as exc:
|
||||||
|
return RedirectResponse(
|
||||||
|
"/clarifications?message="
|
||||||
|
+ quote(str(exc)),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
"/clarifications?message="
|
||||||
|
+ quote(
|
||||||
|
"Aclaración enviada a revisión técnica."
|
||||||
|
),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/history",
|
"/history",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
)
|
)
|
||||||
def history_page(
|
def history_page(request: Request):
|
||||||
request: Request,
|
|
||||||
):
|
|
||||||
user = read_current_user(request)
|
user = read_current_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return RedirectResponse(
|
return redirect_login()
|
||||||
"/login",
|
|
||||||
status_code=303,
|
|
||||||
)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
@ -636,6 +1067,6 @@ def history_page(
|
|||||||
context={
|
context={
|
||||||
"settings": get_settings(),
|
"settings": get_settings(),
|
||||||
"user": user,
|
"user": user,
|
||||||
"actions": recent_actions(),
|
"activities": recent_activity(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
49
ucepsa/edge-oee-demo/governance-ui/app/policy.py
Normal file
49
ucepsa/edge-oee-demo/governance-ui/app/policy.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import AbstractSet
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_owner_roles(
|
||||||
|
roles: AbstractSet[str],
|
||||||
|
review_owner: str,
|
||||||
|
) -> bool:
|
||||||
|
if "ADMIN" in roles:
|
||||||
|
return True
|
||||||
|
if review_owner == "PRODUCTION":
|
||||||
|
return "PRODUCTION" in roles
|
||||||
|
if review_owner == "TECHNICAL":
|
||||||
|
return "TECHNICAL" in roles
|
||||||
|
if review_owner == "JOINT":
|
||||||
|
return bool(
|
||||||
|
roles
|
||||||
|
& {
|
||||||
|
"PRODUCTION",
|
||||||
|
"TECHNICAL",
|
||||||
|
"JOINT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_approve_context_roles(
|
||||||
|
roles: AbstractSet[str],
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
roles
|
||||||
|
& {
|
||||||
|
"TECHNICAL",
|
||||||
|
"ADMIN",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_clarification_roles(
|
||||||
|
roles: AbstractSet[str],
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
roles
|
||||||
|
& {
|
||||||
|
"PRODUCTION",
|
||||||
|
"ADMIN",
|
||||||
|
}
|
||||||
|
)
|
||||||
597
ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py
Normal file
597
ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
|
||||||
|
from .auth import CurrentUser
|
||||||
|
from .db import get_connection
|
||||||
|
from .settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProposalActionInput:
|
||||||
|
submission_key: uuid.UUID
|
||||||
|
proposal_id: int
|
||||||
|
action_code: str
|
||||||
|
notes: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def json_safe(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(key): json_safe(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [
|
||||||
|
json_safe(item)
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_proposal_for_update(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
proposal_id: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.*
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals raw
|
||||||
|
JOIN
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_context_proposals_v1 p
|
||||||
|
ON p.proposal_id = raw.proposal_id
|
||||||
|
WHERE raw.proposal_id = %s
|
||||||
|
FOR UPDATE OF raw
|
||||||
|
""",
|
||||||
|
(proposal_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ProposalError(
|
||||||
|
"La propuesta no existe."
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_action(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
settings,
|
||||||
|
submission_key: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposal_actions
|
||||||
|
WHERE tenant = %s
|
||||||
|
AND site = %s
|
||||||
|
AND request_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
settings.tenant,
|
||||||
|
settings.site,
|
||||||
|
str(submission_key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def count_authoritative_overlaps(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
proposal: dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS overlap_count
|
||||||
|
FROM (
|
||||||
|
SELECT 1
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.production_context_sessions s
|
||||||
|
WHERE s.machine_id = %s
|
||||||
|
AND s.started_at < %s
|
||||||
|
AND COALESCE(
|
||||||
|
s.ended_at,
|
||||||
|
'infinity'::timestamptz
|
||||||
|
) > %s
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 1
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.odoo_shopfloor_sessions o
|
||||||
|
WHERE o.machine_id = %s
|
||||||
|
AND o.started_at IS NOT NULL
|
||||||
|
AND o.started_at < %s
|
||||||
|
AND COALESCE(
|
||||||
|
o.ended_at,
|
||||||
|
'infinity'::timestamptz
|
||||||
|
) > %s
|
||||||
|
AND o.session_status IN (
|
||||||
|
'OPEN',
|
||||||
|
'CLOSED'
|
||||||
|
)
|
||||||
|
) overlaps
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
proposal["machine_id"],
|
||||||
|
proposal["ended_at"],
|
||||||
|
proposal["first_seen_at"],
|
||||||
|
proposal["machine_id"],
|
||||||
|
proposal["ended_at"],
|
||||||
|
proposal["first_seen_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
cursor.fetchone()["overlap_count"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_proposal_action(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
settings,
|
||||||
|
action: ProposalActionInput,
|
||||||
|
proposal: dict[str, Any],
|
||||||
|
user: CurrentUser,
|
||||||
|
resulting_status: str,
|
||||||
|
context_session_id: int | None,
|
||||||
|
source_ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> int:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposal_actions (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
request_id,
|
||||||
|
proposal_id,
|
||||||
|
action_code,
|
||||||
|
previous_status,
|
||||||
|
resulting_status,
|
||||||
|
actor_username,
|
||||||
|
actor_display_name,
|
||||||
|
actor_roles,
|
||||||
|
notes,
|
||||||
|
context_session_id,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
official_eligible
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, false
|
||||||
|
)
|
||||||
|
RETURNING proposal_action_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
settings.tenant,
|
||||||
|
settings.site,
|
||||||
|
str(action.submission_key),
|
||||||
|
action.proposal_id,
|
||||||
|
action.action_code,
|
||||||
|
proposal["proposal_status"],
|
||||||
|
resulting_status,
|
||||||
|
user.username,
|
||||||
|
user.display_name,
|
||||||
|
sorted(user.roles),
|
||||||
|
action.notes.strip() or None,
|
||||||
|
context_session_id,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
cursor.fetchone()[
|
||||||
|
"proposal_action_id"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_proposal(
|
||||||
|
action: ProposalActionInput,
|
||||||
|
user: CurrentUser,
|
||||||
|
source_ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.autocommit = False
|
||||||
|
try:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtext(%s)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
(
|
||||||
|
f"{settings.tenant}:"
|
||||||
|
f"{settings.site}:"
|
||||||
|
f"{action.submission_key}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
replay = existing_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action.submission_key,
|
||||||
|
)
|
||||||
|
if replay:
|
||||||
|
connection.rollback()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": True,
|
||||||
|
"proposal_action_id":
|
||||||
|
replay[
|
||||||
|
"proposal_action_id"
|
||||||
|
],
|
||||||
|
"context_session_id":
|
||||||
|
replay[
|
||||||
|
"context_session_id"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal = fetch_proposal_for_update(
|
||||||
|
cursor,
|
||||||
|
action.proposal_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if proposal["proposal_status"] != "PENDING":
|
||||||
|
raise ProposalError(
|
||||||
|
"Solo pueden aprobarse propuestas pendientes."
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
proposal["case_status"] !=
|
||||||
|
"CLOSED"
|
||||||
|
or proposal["ended_at"] is None
|
||||||
|
):
|
||||||
|
raise ProposalError(
|
||||||
|
"El intervalo todavía no ha terminado."
|
||||||
|
)
|
||||||
|
|
||||||
|
overlap_count = count_authoritative_overlaps(
|
||||||
|
cursor,
|
||||||
|
proposal,
|
||||||
|
)
|
||||||
|
|
||||||
|
if overlap_count > 0:
|
||||||
|
raise ProposalError(
|
||||||
|
"El intervalo se solapa con otro contexto autoritativo. "
|
||||||
|
"La propuesta no puede aprobarse."
|
||||||
|
)
|
||||||
|
|
||||||
|
source_ref = (
|
||||||
|
"GOVERNANCE_PROPOSAL:"
|
||||||
|
f"{proposal['proposal_id']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT context_session_id
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.production_context_sessions
|
||||||
|
WHERE source_ref = %s
|
||||||
|
""",
|
||||||
|
(source_ref,),
|
||||||
|
)
|
||||||
|
existing_context = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing_context:
|
||||||
|
context_session_id = int(
|
||||||
|
existing_context[
|
||||||
|
"context_session_id"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.production_context_sessions (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
machine_id,
|
||||||
|
source_type,
|
||||||
|
source_ref,
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
status,
|
||||||
|
authorization_status,
|
||||||
|
confidence,
|
||||||
|
official_eligible,
|
||||||
|
authorized_by,
|
||||||
|
authorized_at,
|
||||||
|
closed_by,
|
||||||
|
notes,
|
||||||
|
evidence_json
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, 'CLOSED',
|
||||||
|
'AUTHORIZED', 'MEDIUM', false,
|
||||||
|
%s, now(), %s, %s, %s
|
||||||
|
)
|
||||||
|
RETURNING context_session_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
settings.tenant,
|
||||||
|
settings.site,
|
||||||
|
proposal["machine_id"],
|
||||||
|
proposal[
|
||||||
|
"proposed_source_type"
|
||||||
|
],
|
||||||
|
source_ref,
|
||||||
|
proposal["first_seen_at"],
|
||||||
|
proposal["ended_at"],
|
||||||
|
user.display_name,
|
||||||
|
user.display_name,
|
||||||
|
action.notes.strip()
|
||||||
|
or proposal.get(
|
||||||
|
"technical_review_notes"
|
||||||
|
)
|
||||||
|
or "Aprobación técnica desde Governance Review UI.",
|
||||||
|
Json({
|
||||||
|
"created_with":
|
||||||
|
"governance_review_ui_v0315",
|
||||||
|
"proposal_id":
|
||||||
|
proposal["proposal_id"],
|
||||||
|
"proposal_key":
|
||||||
|
proposal["proposal_key"],
|
||||||
|
"decision_group_key":
|
||||||
|
proposal[
|
||||||
|
"decision_group_key"
|
||||||
|
],
|
||||||
|
"case_id":
|
||||||
|
proposal["case_id"],
|
||||||
|
"answer_code":
|
||||||
|
proposal["answer_code"],
|
||||||
|
"proposed_by":
|
||||||
|
proposal[
|
||||||
|
"proposed_by_display_name"
|
||||||
|
],
|
||||||
|
"approved_by":
|
||||||
|
user.display_name,
|
||||||
|
"deployment_mode":
|
||||||
|
"SHADOW",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context_session_id = int(
|
||||||
|
cursor.fetchone()[
|
||||||
|
"context_session_id"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals
|
||||||
|
SET
|
||||||
|
proposal_status = 'APPROVED',
|
||||||
|
technical_review_by_username = %s,
|
||||||
|
technical_review_by_display_name = %s,
|
||||||
|
technical_review_at = now(),
|
||||||
|
technical_review_notes = %s,
|
||||||
|
context_session_id = %s,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE proposal_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user.username,
|
||||||
|
user.display_name,
|
||||||
|
action.notes.strip() or None,
|
||||||
|
context_session_id,
|
||||||
|
action.proposal_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal_action_id = insert_proposal_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action,
|
||||||
|
proposal,
|
||||||
|
user,
|
||||||
|
"APPROVED",
|
||||||
|
context_session_id,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": False,
|
||||||
|
"proposal_action_id":
|
||||||
|
proposal_action_id,
|
||||||
|
"context_session_id":
|
||||||
|
context_session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def change_proposal_status(
|
||||||
|
action: ProposalActionInput,
|
||||||
|
user: CurrentUser,
|
||||||
|
source_ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
target_status = {
|
||||||
|
"REJECT": "REJECTED",
|
||||||
|
"REQUEST_CLARIFICATION":
|
||||||
|
"NEEDS_CLARIFICATION",
|
||||||
|
"CLARIFICATION_RESPONSE":
|
||||||
|
"PENDING",
|
||||||
|
}.get(action.action_code)
|
||||||
|
|
||||||
|
if target_status is None:
|
||||||
|
raise ProposalError(
|
||||||
|
"Acción de propuesta no permitida."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not action.notes.strip():
|
||||||
|
raise ProposalError(
|
||||||
|
"Debe escribir una explicación."
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.autocommit = False
|
||||||
|
try:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtext(%s)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
(
|
||||||
|
f"{settings.tenant}:"
|
||||||
|
f"{settings.site}:"
|
||||||
|
f"{action.submission_key}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
replay = existing_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action.submission_key,
|
||||||
|
)
|
||||||
|
if replay:
|
||||||
|
connection.rollback()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": True,
|
||||||
|
"proposal_action_id":
|
||||||
|
replay[
|
||||||
|
"proposal_action_id"
|
||||||
|
],
|
||||||
|
"resulting_status":
|
||||||
|
replay[
|
||||||
|
"resulting_status"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal = fetch_proposal_for_update(
|
||||||
|
cursor,
|
||||||
|
action.proposal_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_status = (
|
||||||
|
"NEEDS_CLARIFICATION"
|
||||||
|
if action.action_code ==
|
||||||
|
"CLARIFICATION_RESPONSE"
|
||||||
|
else "PENDING"
|
||||||
|
)
|
||||||
|
|
||||||
|
if proposal["proposal_status"] != expected_status:
|
||||||
|
raise ProposalError(
|
||||||
|
"La propuesta ha cambiado de estado. Recargue la página."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals
|
||||||
|
SET
|
||||||
|
proposal_status = %s,
|
||||||
|
technical_review_by_username =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_by_username
|
||||||
|
ELSE %s
|
||||||
|
END,
|
||||||
|
technical_review_by_display_name =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_by_display_name
|
||||||
|
ELSE %s
|
||||||
|
END,
|
||||||
|
technical_review_at =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_at
|
||||||
|
ELSE now()
|
||||||
|
END,
|
||||||
|
technical_review_notes =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_notes
|
||||||
|
ELSE %s
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE proposal_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
target_status,
|
||||||
|
action.action_code,
|
||||||
|
user.username,
|
||||||
|
action.action_code,
|
||||||
|
user.display_name,
|
||||||
|
action.action_code,
|
||||||
|
action.action_code,
|
||||||
|
action.notes.strip(),
|
||||||
|
action.proposal_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal_action_id = insert_proposal_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action,
|
||||||
|
proposal,
|
||||||
|
user,
|
||||||
|
target_status,
|
||||||
|
None,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": False,
|
||||||
|
"proposal_action_id":
|
||||||
|
proposal_action_id,
|
||||||
|
"resulting_status":
|
||||||
|
target_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
File diff suppressed because it is too large
Load Diff
@ -337,3 +337,41 @@ th { color: var(--muted); }
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: #52616f;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #160604;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box,
|
||||||
|
.consultation-box {
|
||||||
|
background: rgba(91, 168, 255, .10);
|
||||||
|
border: 1px solid rgba(91, 168, 255, .35);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consultation-box {
|
||||||
|
background: rgba(240, 165, 58, .10);
|
||||||
|
border-color: rgba(240, 165, 58, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consultation-form {
|
||||||
|
border-color: rgba(240, 165, 58, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,82 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/approvals">← Volver a aprobación técnica</a>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="decision-detail">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ proposal.machine_id }}</span>
|
||||||
|
<span class="status">{{ proposal.proposal_status|label }}</span>
|
||||||
|
<h1>Propuesta de contexto: {{ proposal.proposed_source_type|label }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-box">
|
||||||
|
<h2>Respuesta aportada por Producción</h2>
|
||||||
|
<p><strong>{{ proposal.answer_code|label }}</strong></p>
|
||||||
|
<p>Respondido por {{ proposal.proposed_by_display_name }} el {{ proposal.proposed_at|datetime_es }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Inicio exacto</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Fin exacto</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Estado del intervalo</dt><dd>{{ proposal.case_status|label }}</dd></div>
|
||||||
|
<div><dt>Estado de revisión</dt><dd>{{ proposal.case_review_status|label }}</dd></div>
|
||||||
|
<div><dt>Órdenes</dt><dd>{{ proposal.production_orders|join(", ") if proposal.production_orders else "—" }}</dd></div>
|
||||||
|
<div><dt>Workorders</dt><dd>{{ proposal.odoo_workorder_ids|join(", ") if proposal.odoo_workorder_ids else "—" }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if proposal.last_action_notes %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<strong>Último intercambio</strong>
|
||||||
|
<p>{{ proposal.last_action_notes }}</p>
|
||||||
|
<small>{{ proposal.last_action_by }} · {{ proposal.last_action_at|datetime_es }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Validaciones al aprobar</strong>
|
||||||
|
<p>La aplicación comprobará que el intervalo ha terminado y que no se solapa con Odoo ni con otro contexto autoritativo. El contexto se creará en SHADOW y con elegibilidad oficial desactivada.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not write_enabled %}
|
||||||
|
<div class="alert error">La interfaz está en modo solo lectura.</div>
|
||||||
|
{% elif proposal.proposal_status == 'PENDING' %}
|
||||||
|
<form method="post" action="/approvals/{{ proposal.proposal_id }}/approve" class="review-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_key }}">
|
||||||
|
<label>
|
||||||
|
Observaciones técnicas
|
||||||
|
<textarea name="notes" rows="4" placeholder="Ejemplo: intervalo cerrado, sin solapamientos y coherente con la respuesta de Producción."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Aprobar y crear contexto</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/approvals/{{ proposal.proposal_id }}/request_clarification" class="review-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_key }}">
|
||||||
|
<label>
|
||||||
|
Aclaración necesaria
|
||||||
|
<textarea name="notes" rows="4" required placeholder="Indique qué dato debe aclarar Producción."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="secondary" type="submit">Solicitar aclaración</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/approvals/{{ proposal.proposal_id }}/reject" class="review-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_key }}">
|
||||||
|
<label>
|
||||||
|
Motivo del rechazo
|
||||||
|
<textarea name="notes" rows="4" required placeholder="Explique por qué no debe crearse el contexto."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="danger-button" type="submit">Rechazar propuesta</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert success">La propuesta ya no está pendiente: {{ proposal.proposal_status|label }}.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Aprobación técnica de contextos</h1>
|
||||||
|
<p>Producción ya explicó qué ocurrió. Esta cola valida si la respuesta puede convertirse en contexto canónico sin crear solapamientos.</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-badge">{{ proposals|length }} propuestas</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not proposals %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No hay propuestas pendientes</h2>
|
||||||
|
<p>Las respuestas de Producción que no necesitan contexto no aparecen aquí.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for proposal in proposals %}
|
||||||
|
<article class="decision-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ proposal.machine_id }}</span>
|
||||||
|
<span class="status">{{ proposal.proposal_status|label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>{{ proposal.proposed_source_type|label }}</h2>
|
||||||
|
<p><strong>Respuesta de Producción:</strong> {{ proposal.answer_code|label }}</p>
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Inicio</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Fin</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Propuesto por</dt><dd>{{ proposal.proposed_by_display_name }}</dd></div>
|
||||||
|
<div><dt>Caso</dt><dd>{{ proposal.case_id }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{% if proposal.last_action_notes %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<strong>Último intercambio</strong>
|
||||||
|
<p>{{ proposal.last_action_notes }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a class="primary button" href="/approvals/{{ proposal.proposal_id }}">Revisar propuesta</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -15,9 +15,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Pendientes</a>
|
<a href="/">Pendientes</a>
|
||||||
|
{% if 'TECHNICAL' in user.roles or 'ADMIN' in user.roles %}
|
||||||
|
<a href="/approvals">Aprobación técnica</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'PRODUCTION' in user.roles or 'ADMIN' in user.roles %}
|
||||||
|
<a href="/clarifications">Aclaraciones</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/history">Historial</a>
|
<a href="/history">Historial</a>
|
||||||
<span class="user">{{ user.display_name }}</span>
|
<span class="user">{{ user.display_name }}</span>
|
||||||
{% if not settings.write_enabled %}<span class="status">Solo lectura</span>{% endif %}
|
{% if not settings.write_enabled %}
|
||||||
|
<span class="status">Solo lectura</span>
|
||||||
|
{% endif %}
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
<button class="link-button" type="submit">Salir</button>
|
<button class="link-button" type="submit">Salir</button>
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Aclaraciones solicitadas</h1>
|
||||||
|
<p>El revisor técnico necesita un dato adicional antes de aprobar o rechazar el contexto propuesto.</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-badge">{{ proposals|length }} pendientes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not proposals %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No hay aclaraciones pendientes</h2>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for proposal in proposals %}
|
||||||
|
<section class="decision-detail compact-section">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ proposal.machine_id }}</span>
|
||||||
|
<h2>{{ proposal.proposed_source_type|label }}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="status">{{ proposal.proposal_status|label }}</span>
|
||||||
|
</div>
|
||||||
|
<p><strong>Pregunta técnica:</strong> {{ proposal.last_action_notes }}</p>
|
||||||
|
<p><strong>Intervalo:</strong> {{ proposal.first_seen_at|datetime_es }} → {{ proposal.ended_at|datetime_es }}</p>
|
||||||
|
|
||||||
|
{% if write_enabled %}
|
||||||
|
<form method="post" action="/clarifications/{{ proposal.proposal_id }}/respond">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_keys[proposal.proposal_id] }}">
|
||||||
|
<label>
|
||||||
|
Respuesta o aclaración
|
||||||
|
<textarea name="notes" rows="4" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Enviar aclaración</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
@ -10,15 +10,12 @@
|
|||||||
<div class="detail-heading">
|
<div class="detail-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="machine">{{ decision.machine_id or "GLOBAL" }}</span>
|
<span class="machine">{{ decision.machine_id or "GLOBAL" }}</span>
|
||||||
<span class="severity">{{ decision.max_severity }}</span>
|
<span class="severity">{{ decision.max_severity|label }}</span>
|
||||||
<h1>{{ decision.decision_title }}</h1>
|
<h1>{{ decision.decision_title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-stack">
|
<div class="status-stack">
|
||||||
<span class="status">
|
<span class="status">Incidencia: {{ decision.decision_status|label }}</span>
|
||||||
Incidencia:
|
<span class="status">Revisión: {{ decision.review_status|label }}</span>
|
||||||
{% if decision.decision_status == "OPEN" %}Activa{% else %}Terminada{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="status">Revisión: {{ decision.review_status }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -27,6 +24,25 @@
|
|||||||
<p>{{ decision.question_text }}</p>
|
<p>{{ decision.question_text }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if consultation %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<h2>En consulta</h2>
|
||||||
|
<p>{{ consultation.reason }}</p>
|
||||||
|
{% if consultation.follow_up_with %}
|
||||||
|
<p><strong>Consultar con:</strong> {{ consultation.follow_up_with }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<small>Marcado por {{ consultation.created_by_display_name }} el {{ consultation.created_at|datetime_es }}</small>
|
||||||
|
|
||||||
|
{% if write_enabled %}
|
||||||
|
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/resume">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ resume_submission_key }}">
|
||||||
|
<button class="secondary" type="submit">Ya tengo la información</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Intervalos y casos</h2>
|
<h2>Intervalos y casos</h2>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
@ -39,12 +55,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3>Caso {{ case.case_id }} · {{ case.case_title }}</h3>
|
<h3>Caso {{ case.case_id }} · {{ case.case_title }}</h3>
|
||||||
<p>
|
<p>
|
||||||
Incidencia:
|
Incidencia: {{ case.case_status|label }}
|
||||||
{% if case.case_status == "OPEN" %}activa{% else %}terminada{% endif %}
|
· Revisión: {{ case.case_review_status|label }}
|
||||||
· Revisión: {{ case.case_review_status }}
|
|
||||||
</p>
|
</p>
|
||||||
{% if case.case_resolution_classification %}
|
{% if case.case_resolution_classification %}
|
||||||
<p><strong>Resolución previa:</strong> {{ case.case_resolution_classification }}</p>
|
<p><strong>Periodo ya aclarado:</strong> {{ case.case_resolution_classification|label }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if case.production_orders %}
|
{% if case.production_orders %}
|
||||||
<p><strong>Órdenes:</strong> {{ case.production_orders|join(", ") }}</p>
|
<p><strong>Órdenes:</strong> {{ case.production_orders|join(", ") }}</p>
|
||||||
@ -79,8 +94,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_review and pending_cases %}
|
{% if can_review and pending_cases %}
|
||||||
|
{% if not consultation %}
|
||||||
|
<details class="consultation-form">
|
||||||
|
<summary>No conozco todavía la respuesta</summary>
|
||||||
|
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/consultation">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ consultation_submission_key }}">
|
||||||
|
<label>
|
||||||
|
Qué información falta
|
||||||
|
<textarea name="reason" rows="3" required placeholder="Ejemplo: preguntar a la operaria del turno de tarde qué estaba haciendo la máquina."></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
A quién hay que consultar
|
||||||
|
<input name="follow_up_with" placeholder="Ejemplo: María / responsable del turno anterior">
|
||||||
|
</label>
|
||||||
|
<button class="secondary" type="submit">Marcar En consulta</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review">
|
<form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review">
|
||||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ review_submission_key }}">
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>1. Intervalos a los que se aplica la respuesta</legend>
|
<legend>1. Intervalos a los que se aplica la respuesta</legend>
|
||||||
@ -99,10 +134,10 @@
|
|||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>2. Qué ocurrió</legend>
|
<legend>2. Qué ocurrió</legend>
|
||||||
{% for code, label in answer_options %}
|
{% for code, text in answer_options %}
|
||||||
<label class="radio-row">
|
<label class="radio-row">
|
||||||
<input type="radio" name="answer_code" value="{{ code }}" required>
|
<input type="radio" name="answer_code" value="{{ code }}" required>
|
||||||
<span>{{ label }}</span>
|
<span>{{ text }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -123,19 +158,10 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if decision.decision_family == "UNCONFIRMED_ACTIVITY" %}
|
<div class="info-box">
|
||||||
<fieldset>
|
<strong>Qué ocurrirá después</strong>
|
||||||
<legend>3. Contexto retrospectivo</legend>
|
<p>Su respuesta cerrará la pregunta de Producción. Cuando la respuesta permita crear un contexto histórico, se enviará una propuesta a validación técnica. Usted no crea ni aprueba contextos.</p>
|
||||||
<label class="check-row">
|
</div>
|
||||||
<input type="checkbox" name="create_contexts" value="true">
|
|
||||||
<span>Crear un contexto exacto por cada intervalo seleccionado cuando la respuesta lo permita</span>
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
Solo se admite para sistema antiguo, prueba/ajuste o mantenimiento.
|
|
||||||
No cubre los huecos entre intervalos y no se permite sobre casos todavía abiertos.
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Observaciones
|
Observaciones
|
||||||
@ -151,7 +177,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% elif not pending_cases %}
|
{% elif not pending_cases %}
|
||||||
<div class="alert success">Esta decisión ya no tiene casos pendientes.</div>
|
<div class="alert success">Esta decisión ya no tiene casos pendientes.</div>
|
||||||
{% else %}
|
{% elif write_enabled %}
|
||||||
<div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div>
|
<div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1>Historial de acciones</h1>
|
<h1>Historial de actividad</h1>
|
||||||
<p>Acciones realizadas desde la interfaz durante los últimos 30 días.</p>
|
<p>Revisiones de Producción, consultas y decisiones técnicas durante los últimos 30 días.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -12,24 +12,24 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Fecha</th>
|
<th>Fecha</th>
|
||||||
|
<th>Tipo</th>
|
||||||
<th>Usuario</th>
|
<th>Usuario</th>
|
||||||
<th>Decisión</th>
|
<th>Decisión</th>
|
||||||
<th>Respuesta</th>
|
<th>Acción</th>
|
||||||
<th>Casos</th>
|
|
||||||
<th>Contextos</th>
|
|
||||||
<th>Estado</th>
|
<th>Estado</th>
|
||||||
|
<th>Notas</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for action in actions %}
|
{% for activity in activities %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ action.created_at|datetime_es }}</td>
|
<td>{{ activity.created_at|datetime_es }}</td>
|
||||||
<td>{{ action.actor_display_name }}</td>
|
<td>{{ activity.activity_type|label }}</td>
|
||||||
<td>{{ action.decision_group_key }}</td>
|
<td>{{ activity.actor_display_name }}</td>
|
||||||
<td>{{ action.answer_code }}</td>
|
<td>{{ activity.decision_group_key }}</td>
|
||||||
<td>{{ action.selected_case_ids|join(", ") }}</td>
|
<td>{{ activity.action_code|label }}</td>
|
||||||
<td>{{ action.created_context_session_ids|join(", ") if action.created_context_session_ids else "—" }}</td>
|
<td>{{ activity.status|label }}</td>
|
||||||
<td>{{ action.status }}</td>
|
<td>{{ activity.notes or "—" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1>Decisiones pendientes</h1>
|
<h1>Decisiones pendientes</h1>
|
||||||
<p>Una tarjeta equivale a una pregunta humana. Los datos técnicos se conservan como evidencia.</p>
|
<p>Una tarjeta equivale a una pregunta humana. Producción explica lo ocurrido; la creación de contexto se valida después por separado.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-badge">{{ rows|length }} pendientes</div>
|
<div class="summary-badge">{{ rows|length }} pendientes</div>
|
||||||
</div>
|
</div>
|
||||||
@ -15,7 +15,7 @@
|
|||||||
{% if not rows %}
|
{% if not rows %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h2>No hay decisiones pendientes para su rol</h2>
|
<h2>No hay decisiones pendientes para su rol</h2>
|
||||||
<p>La cola se actualizará automáticamente cuando aparezca una excepción que requiera intervención humana.</p>
|
<p>La cola se actualizará automáticamente cuando aparezca una excepción que necesite una explicación humana.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -25,27 +25,34 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="machine">{{ row.machine_id or "GLOBAL" }}</span>
|
<span class="machine">{{ row.machine_id or "GLOBAL" }}</span>
|
||||||
<span class="severity">{{ row.max_severity }}</span>
|
<span class="severity">{{ row.max_severity|label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-pair">
|
<div class="status-pair">
|
||||||
<span class="status">
|
<span class="status">Incidencia: {{ row.incident_status|label }}</span>
|
||||||
Incidencia:
|
<span class="status">Revisión: {{ row.workflow_status|label }}</span>
|
||||||
{% if row.incident_status == "ACTIVE" %}Activa{% else %}Terminada{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="status review-pending">Revisión: Pendiente</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>{{ row.decision_title }}</h2>
|
<h2>{{ row.decision_title }}</h2>
|
||||||
<p class="question">{{ row.question_text }}</p>
|
<p class="question">{{ row.question_text }}</p>
|
||||||
|
|
||||||
|
{% if row.workflow_status == 'IN_CONSULTATION' %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<strong>En consulta</strong>
|
||||||
|
<p>{{ row.consultation_reason }}</p>
|
||||||
|
{% if row.follow_up_with %}
|
||||||
|
<small>Consultar con: {{ row.follow_up_with }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<dl class="facts">
|
<dl class="facts">
|
||||||
<div><dt>Inicio</dt><dd>{{ row.first_seen_at|datetime_es }}</dd></div>
|
<div><dt>Inicio</dt><dd>{{ row.first_seen_at|datetime_es }}</dd></div>
|
||||||
<div><dt>Fin</dt><dd>{{ row.ended_at|datetime_es }}</dd></div>
|
<div><dt>Fin</dt><dd>{{ row.ended_at|datetime_es }}</dd></div>
|
||||||
<div><dt>Responsable</dt><dd>{{ row.review_owner }}</dd></div>
|
<div><dt>Responsable</dt><dd>{{ row.review_owner|label }}</dd></div>
|
||||||
<div><dt>Casos pendientes</dt><dd>{{ row.pending_root_case_count }}</dd></div>
|
<div><dt>Casos pendientes</dt><dd>{{ row.pending_root_case_count }}</dd></div>
|
||||||
<div><dt>Evidencias</dt><dd>{{ row.linked_episode_count }}</dd></div>
|
<div><dt>Evidencias</dt><dd>{{ row.linked_episode_count }}</dd></div>
|
||||||
<div><dt>Atención</dt><dd>{{ row.attention_status }}</dd></div>
|
<div><dt>Atención</dt><dd>{{ row.attention_status|label }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{% if row.production_orders %}
|
{% if row.production_orders %}
|
||||||
|
|||||||
@ -5,3 +5,4 @@ psycopg2-binary>=2.9,<3
|
|||||||
python-multipart>=0.0.20,<1
|
python-multipart>=0.0.20,<1
|
||||||
itsdangerous>=2.2,<3
|
itsdangerous>=2.2,<3
|
||||||
bcrypt>=4.1,<5
|
bcrypt>=4.1,<5
|
||||||
|
pytest>=8,<9
|
||||||
|
|||||||
@ -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"
|
||||||
@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Supervisión SHADOW y acceso a la cola de revisión. Los paneles distinguen incidencia activa/terminada de revisión pendiente/resuelta. La escritura se realiza únicamente mediante la Governance Review UI v0.3.14.",
|
"description": "Supervisión SHADOW con revisión humana en dos etapas: Producción explica lo ocurrido y MESAVAULT aprueba por separado cualquier contexto retrospectivo. Incluye En consulta, idempotencia y auditoría.",
|
||||||
"editable": true,
|
"editable": true,
|
||||||
"fiscalYearStartMonth": 0,
|
"fiscalYearStartMonth": 0,
|
||||||
"graphTooltip": 1,
|
"graphTooltip": 1,
|
||||||
@ -2743,7 +2743,7 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"rawQuery": true,
|
"rawQuery": true,
|
||||||
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n CASE review_status\n WHEN 'PENDING' THEN 'Pendiente'\n WHEN 'REVIEWED' THEN 'Resuelta'\n ELSE review_status\n END AS \"Revisión\",\n max_severity AS \"Severidad\",\n decision_title AS \"Pregunta operativa\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v1\nORDER BY agenda_rank\nLIMIT 50",
|
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n CASE workflow_status\n WHEN 'IN_CONSULTATION' THEN 'En consulta'\n ELSE 'Pendiente'\n END AS \"Revisión\",\n CASE max_severity\n WHEN 'CRITICAL' THEN 'Crítica'\n ELSE 'Aviso'\n END AS \"Severidad\",\n decision_title AS \"Pregunta operativa\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n array_to_string(production_orders, ', ') AS \"Órdenes\",\n array_to_string(odoo_workorder_ids, ', ') AS \"Workorders\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n consultation_reason AS \"Consulta pendiente\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v2\nORDER BY agenda_rank\nLIMIT 50",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -3150,7 +3150,7 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"rawQuery": true,
|
"rawQuery": true,
|
||||||
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n max_severity AS \"Severidad\",\n attention_status AS \"Atención\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n 'Pendiente' AS \"Revisión\",\n decision_title AS \"Decisión\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n pending_age_hours AS \"Antigüedad h\",\n review_due_at AS \"Límite\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v1\nORDER BY agenda_rank\nLIMIT 50",
|
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n CASE max_severity\n WHEN 'CRITICAL' THEN 'Crítica'\n ELSE 'Aviso'\n END AS \"Severidad\",\n attention_status AS \"Atención\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n CASE workflow_status\n WHEN 'IN_CONSULTATION' THEN 'En consulta'\n ELSE 'Pendiente'\n END AS \"Revisión\",\n decision_title AS \"Decisión\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n pending_age_hours AS \"Antigüedad h\",\n review_due_at AS \"Límite\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n consultation_reason AS \"Consulta pendiente\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v2\nORDER BY agenda_rank\nLIMIT 50",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -3640,6 +3640,201 @@
|
|||||||
],
|
],
|
||||||
"title": "Histórico de regresión · 30 días",
|
"title": "Histórico de regresión · 30 días",
|
||||||
"type": "table"
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"filterable": true,
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 246
|
||||||
|
},
|
||||||
|
"id": 32,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": []
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.0.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"rawSql": "SELECT\n approval_rank AS \"Prioridad\",\n proposal_id AS \"Propuesta\",\n machine_id AS \"Máquina\",\n CASE proposed_source_type\n WHEN 'LEGACY_ERP' THEN 'Sistema antiguo'\n WHEN 'TEST_SETUP' THEN 'Prueba o ajuste'\n WHEN 'MAINTENANCE' THEN 'Mantenimiento'\n ELSE proposed_source_type\n END AS \"Contexto propuesto\",\n CASE proposal_status\n WHEN 'PENDING' THEN 'Pendiente'\n WHEN 'NEEDS_CLARIFICATION' THEN 'Requiere aclaración'\n ELSE proposal_status\n END AS \"Estado\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n proposed_by_display_name AS \"Propuesto por\",\n case_id AS \"Caso\",\n last_action_notes AS \"Último intercambio\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_context_approval_queue_v1\nORDER BY approval_rank\nLIMIT 100",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Contextos pendientes de aprobación",
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"filterable": true,
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 256
|
||||||
|
},
|
||||||
|
"id": 33,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": []
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.0.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Decisión\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n consultation_reason AS \"Información pendiente\",\n follow_up_with AS \"Consultar con\",\n consultation_created_by AS \"Marcado por\",\n consultation_created_at AS \"Desde\",\n first_seen_at AS \"Inicio del caso\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v2\nWHERE workflow_status = 'IN_CONSULTATION'\nORDER BY agenda_rank\nLIMIT 100",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Decisiones en consulta",
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"filterable": true,
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 266
|
||||||
|
},
|
||||||
|
"id": 34,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": []
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.0.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"rawSql": "SELECT\n created_at AS \"Fecha\",\n activity_type AS \"Tipo\",\n actor_display_name AS \"Usuario\",\n decision_group_key AS \"Decisión\",\n proposal_id AS \"Propuesta\",\n action_code AS \"Acción\",\n status AS \"Estado\",\n notes AS \"Notas\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_activity_v1\nORDER BY created_at DESC\nLIMIT 200",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Actividad de la interfaz · 30 días",
|
||||||
|
"type": "table"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"refresh": "15s",
|
"refresh": "15s",
|
||||||
@ -3676,7 +3871,10 @@
|
|||||||
"v03131",
|
"v03131",
|
||||||
"governance-ui",
|
"governance-ui",
|
||||||
"human-review",
|
"human-review",
|
||||||
"v0314"
|
"v0314",
|
||||||
|
"two-stage-approval",
|
||||||
|
"consultation",
|
||||||
|
"v0315"
|
||||||
],
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": []
|
"list": []
|
||||||
|
|||||||
323
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh
Executable file
323
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh
Executable file
@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
DASHBOARD_UID="ucepsa-shopfloor-context-health"
|
||||||
|
DATASOURCE_UID="bfnbcasbm6hhca"
|
||||||
|
|
||||||
|
resolve_url() {
|
||||||
|
if [[ -n "${GRAFANA_URL:-}" ]]; then
|
||||||
|
printf '%s' "$GRAFANA_URL"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local port_line host_port container_ip
|
||||||
|
port_line="$(
|
||||||
|
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
|
||||||
|
| head -n 1 || true
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$port_line" ]]; then
|
||||||
|
host_port="${port_line##*:}"
|
||||||
|
printf 'http://127.0.0.1:%s' "$host_port"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
container_ip="$(
|
||||||
|
docker inspect "$GRAFANA_CONTAINER" \
|
||||||
|
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "$container_ip" ]]; then
|
||||||
|
echo "ERROR: no se pudo resolver Grafana." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'http://%s:3000' "$container_ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
|
||||||
|
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRAFANA_URL_RESOLVED
|
||||||
|
GRAFANA_URL_RESOLVED="$(resolve_url)"
|
||||||
|
|
||||||
|
python3 - \
|
||||||
|
"$ACTION" \
|
||||||
|
"$DASHBOARD_FILE" \
|
||||||
|
"$FOLDER_UID" \
|
||||||
|
"$DASHBOARD_UID" \
|
||||||
|
"$DATASOURCE_UID" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:]
|
||||||
|
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
|
||||||
|
token = os.environ["GRAFANA_API_TOKEN"]
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, payload=None):
|
||||||
|
body = None
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Grafana API {method} {path}: "
|
||||||
|
f"HTTP {exc.code}: {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
health = request("GET", "/api/health")
|
||||||
|
if not health or health.get("database") != "ok":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Grafana no está saludable: {health!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "deploy":
|
||||||
|
with open(
|
||||||
|
dashboard_file,
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
dashboard = json.load(handle)
|
||||||
|
|
||||||
|
result = request(
|
||||||
|
"POST",
|
||||||
|
"/api/dashboards/db",
|
||||||
|
{
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"folderUid": folder_uid,
|
||||||
|
"overwrite": True,
|
||||||
|
"message": (
|
||||||
|
"MESAVAULT governance review UI "
|
||||||
|
"SHADOW v0.3.15"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"dashboard_uid": dashboard_uid,
|
||||||
|
"folder_uid": folder_uid,
|
||||||
|
"response": result,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
elif action == "validate":
|
||||||
|
result = request(
|
||||||
|
"GET",
|
||||||
|
"/api/dashboards/uid/"
|
||||||
|
+ urllib.parse.quote(dashboard_uid),
|
||||||
|
)
|
||||||
|
dashboard = result["dashboard"]
|
||||||
|
panels = {
|
||||||
|
panel.get("id"): panel
|
||||||
|
for panel in dashboard.get("panels", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource_uids = sorted({
|
||||||
|
panel.get("datasource", {}).get("uid")
|
||||||
|
for panel in dashboard.get("panels", [])
|
||||||
|
if isinstance(
|
||||||
|
panel.get("datasource"),
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
and panel.get(
|
||||||
|
"datasource",
|
||||||
|
{},
|
||||||
|
).get("uid")
|
||||||
|
})
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if len(dashboard.get("panels", [])) != 34:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 34 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
29: "li_context_regression_latest_run_v1",
|
||||||
|
30: "li_context_regression_failures_v1",
|
||||||
|
31: "li_context_regression_run_history_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
two_stage_expected = {
|
||||||
|
32: "li_context_governance_context_approval_queue_v1",
|
||||||
|
33: "li_context_governance_ui_queue_v2",
|
||||||
|
34: "li_context_governance_ui_activity_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
governance_expected = {
|
||||||
|
23: "li_context_governance_ui_queue_v2",
|
||||||
|
26: "li_context_governance_ui_queue_v2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in governance_expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
'"Incidencia"' not in sql
|
||||||
|
or '"Revisión"' not in sql
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no separa Incidencia y Revisión"
|
||||||
|
)
|
||||||
|
|
||||||
|
for panel_id, fragment in two_stage_expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"regression_panels": {
|
||||||
|
"latest_run": 29 in panels,
|
||||||
|
"issues": 30 in panels,
|
||||||
|
"history": 31 in panels,
|
||||||
|
},
|
||||||
|
"governance_ui_panels": {
|
||||||
|
"pending_review": 23 in panels,
|
||||||
|
"daily_agenda": 26 in panels,
|
||||||
|
"semantic_status_split": all(
|
||||||
|
(
|
||||||
|
'"Incidencia"' in (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
and '"Revisión"' in (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for panel_id in (23, 26)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"two_stage_panels": {
|
||||||
|
"approval_queue": 32 in panels,
|
||||||
|
"consultations": 33 in panels,
|
||||||
|
"activity": 34 in panels,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: deploy_governance_review_ui_dashboard_v0315.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
36
ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
Executable file
36
ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
Executable file
@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET_ROOT="${GOVERNANCE_UI_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
SOURCE="$TARGET_ROOT/governance-ui"
|
||||||
|
DEST="$RUNTIME_ROOT/governance-ui"
|
||||||
|
TMP_ENV=""
|
||||||
|
|
||||||
|
if [[ ! -f "$SOURCE/Dockerfile" ]]; then
|
||||||
|
echo "ERROR: no existe $SOURCE/Dockerfile" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$DEST/.env" ]]; then
|
||||||
|
TMP_ENV="$(mktemp)"
|
||||||
|
cp "$DEST/.env" "$TMP_ENV"
|
||||||
|
chmod 600 "$TMP_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$DEST"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
cp -a "$SOURCE/." "$DEST/"
|
||||||
|
|
||||||
|
if [[ -n "$TMP_ENV" ]]; then
|
||||||
|
cp "$TMP_ENV" "$DEST/.env"
|
||||||
|
chmod 600 "$DEST/.env"
|
||||||
|
rm -f "$TMP_ENV"
|
||||||
|
echo "Configuración .env preservada."
|
||||||
|
else
|
||||||
|
echo "AVISO: no existía .env previo; configure $DEST/.env antes de arrancar." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$DEST" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||||||
|
|
||||||
|
echo "Governance Review UI v0.3.15 instalada en $DEST"
|
||||||
93
ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh
Executable file
93
ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh
Executable file
@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-status}"
|
||||||
|
RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
APP_ROOT="${GOVERNANCE_UI_APP_ROOT:-$RUNTIME_ROOT/governance-ui}"
|
||||||
|
CONTAINER="${GOVERNANCE_UI_CONTAINER:-mv_ucepsa_governance_ui}"
|
||||||
|
IMAGE="${GOVERNANCE_UI_IMAGE:-mesavault/ucepsa-governance-ui:0.3.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
|
||||||
27
ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0315.sh
Executable file
27
ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0315.sh
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${GOVERNANCE_UI_HOST_PORT:-8091}"
|
||||||
|
BASE_URL="${GOVERNANCE_UI_BASE_URL:-http://127.0.0.1:${PORT}}"
|
||||||
|
|
||||||
|
echo "=== health ==="
|
||||||
|
curl -fsS "${BASE_URL}/health"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== login page ==="
|
||||||
|
curl -fsS "${BASE_URL}/login" \
|
||||||
|
| grep -F "Revisión de gobernanza" \
|
||||||
|
>/dev/null
|
||||||
|
|
||||||
|
echo "=== protected queue redirects to login ==="
|
||||||
|
STATUS="$(
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
|
"${BASE_URL}/"
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ "$STATUS" != "307" && "$STATUS" != "303" ]]; then
|
||||||
|
echo "ERROR: la cola sin sesión devolvió HTTP $STATUS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Smoke test OK"
|
||||||
9
ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh
Executable file
9
ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="${GOVERNANCE_UI_IMAGE:-mesavault/ucepsa-governance-ui:0.3.15}"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--entrypoint pytest \
|
||||||
|
"$IMAGE" \
|
||||||
|
-q /app/tests
|
||||||
225
ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql
Normal file
225
ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
\pset pager off
|
||||||
|
|
||||||
|
\echo '=== 1. Objetos v0.3.15 ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_governance_ui_consultations'
|
||||||
|
) AS consultations_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_governance_context_proposals'
|
||||||
|
) AS proposals_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_governance_context_proposal_actions'
|
||||||
|
) AS proposal_actions_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_governance_ui_queue_v2'
|
||||||
|
) AS queue_v2_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_governance_context_approval_queue_v1'
|
||||||
|
) AS approval_queue_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_governance_context_clarification_queue_v1'
|
||||||
|
) AS clarification_queue_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_governance_ui_activity_v1'
|
||||||
|
) AS activity_view;
|
||||||
|
|
||||||
|
\echo '=== 2. Cola v2 conserva la agenda ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_ui_queue_v2
|
||||||
|
) AS ui_v2_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_daily_agenda_v1
|
||||||
|
) AS agenda_rows;
|
||||||
|
|
||||||
|
\echo '=== 3. Workflow visible válido ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS invalid_workflow_status_rows
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_ui_queue_v2
|
||||||
|
WHERE workflow_status NOT IN (
|
||||||
|
'PENDING',
|
||||||
|
'IN_CONSULTATION'
|
||||||
|
)
|
||||||
|
OR review_status <> 'PENDING';
|
||||||
|
|
||||||
|
\echo '=== 4. Una consulta abierta por decisión ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS duplicate_open_consultations
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
decision_group_key,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_ui_consultations
|
||||||
|
WHERE status = 'OPEN'
|
||||||
|
GROUP BY
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
decision_group_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) duplicated;
|
||||||
|
|
||||||
|
\echo '=== 5. Una propuesta activa por caso ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS duplicate_active_proposals
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
case_id,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals
|
||||||
|
WHERE proposal_status IN (
|
||||||
|
'PENDING',
|
||||||
|
'NEEDS_CLARIFICATION'
|
||||||
|
)
|
||||||
|
GROUP BY
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
case_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) duplicated;
|
||||||
|
|
||||||
|
\echo '=== 6. Propuestas aprobadas tienen contexto ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS approved_without_context
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals
|
||||||
|
WHERE proposal_status = 'APPROVED'
|
||||||
|
AND context_session_id IS NULL;
|
||||||
|
|
||||||
|
\echo '=== 7. Contextos aprobados conservan SHADOW ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS invalid_approved_contexts
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals p
|
||||||
|
JOIN mv_loss_intelligence
|
||||||
|
.production_context_sessions s
|
||||||
|
ON s.context_session_id = p.context_session_id
|
||||||
|
WHERE p.proposal_status = 'APPROVED'
|
||||||
|
AND (
|
||||||
|
s.source_ref <>
|
||||||
|
'GOVERNANCE_PROPOSAL:'
|
||||||
|
|| p.proposal_id::text
|
||||||
|
OR s.source_type <>
|
||||||
|
p.proposed_source_type
|
||||||
|
OR s.official_eligible
|
||||||
|
OR p.official_eligible
|
||||||
|
);
|
||||||
|
|
||||||
|
\echo '=== 8. Producción no crea contextos directamente ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS direct_context_creation_actions
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_ui_actions
|
||||||
|
WHERE create_contexts
|
||||||
|
OR cardinality(
|
||||||
|
created_context_session_ids
|
||||||
|
) > 0;
|
||||||
|
|
||||||
|
\echo '=== 9. Idempotencia de requests ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
request_id,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_ui_actions
|
||||||
|
GROUP BY tenant, site, request_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) x
|
||||||
|
) AS duplicate_review_requests,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
request_id,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_context_proposal_actions
|
||||||
|
GROUP BY tenant, site, request_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) x
|
||||||
|
) AS duplicate_proposal_action_requests;
|
||||||
|
|
||||||
|
\echo '=== 10. Colas de trabajo ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_context_approval_queue_v1
|
||||||
|
WHERE proposal_status = 'PENDING'
|
||||||
|
) AS technical_pending_count,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_context_clarification_queue_v1
|
||||||
|
) AS clarification_count,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_ui_queue_v2
|
||||||
|
WHERE workflow_status = 'IN_CONSULTATION'
|
||||||
|
) AS in_consultation_count;
|
||||||
|
|
||||||
|
\echo '=== 11. Nada oficial ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_ui_consultations
|
||||||
|
WHERE official_eligible
|
||||||
|
) AS official_consultation_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals
|
||||||
|
WHERE official_eligible
|
||||||
|
) AS official_proposal_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_loss_intelligence
|
||||||
|
.context_governance_context_proposal_actions
|
||||||
|
WHERE official_eligible
|
||||||
|
) AS official_proposal_action_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_operator_stop_queue_v2
|
||||||
|
) AS official_operator_queue_rows;
|
||||||
|
|
||||||
|
\echo '=== 12. Actividad UI reciente ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
activity_type,
|
||||||
|
decision_group_key,
|
||||||
|
proposal_id,
|
||||||
|
actor_display_name,
|
||||||
|
action_code,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
created_at
|
||||||
|
FROM mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_ui_activity_v1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 30;
|
||||||
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user