From c3eb98a91d59253f8c264f55dd8bf82f4909f7ba Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Tue, 21 Jul 2026 16:45:13 +0200 Subject: [PATCH] feat(ucepsa): add two-stage governance context approval --- ...ance_review_ui_two_stage_shadow_v0.3.15.md | 230 +++++ ucepsa/edge-oee-demo/governance-ui/Dockerfile | 1 + .../edge-oee-demo/governance-ui/app/auth.py | 43 +- ucepsa/edge-oee-demo/governance-ui/app/db.py | 2 +- .../edge-oee-demo/governance-ui/app/labels.py | 68 ++ .../edge-oee-demo/governance-ui/app/main.py | 665 +++++++++++--- .../edge-oee-demo/governance-ui/app/policy.py | 49 + .../governance-ui/app/proposal_service.py | 597 +++++++++++++ .../governance-ui/app/review_service.py | 838 +++++++++++------- .../governance-ui/app/static/style.css | 38 + .../app/templates/approval_detail.html | 82 ++ .../app/templates/approvals.html | 49 + .../governance-ui/app/templates/base.html | 10 +- .../app/templates/clarifications.html | 46 + .../governance-ui/app/templates/decision.html | 78 +- .../governance-ui/app/templates/history.html | 26 +- .../governance-ui/app/templates/queue.html | 27 +- .../governance-ui/requirements.txt | 1 + .../tests/test_roles_and_labels.py | 81 ++ ...sa-shopfloor-context-health.dashboard.json | 206 ++++- ...oy_governance_review_ui_dashboard_v0315.sh | 323 +++++++ .../ops/install_governance_review_ui_v0315.sh | 36 + .../ops/manage_governance_review_ui_v0315.sh | 93 ++ .../ops/smoke_governance_review_ui_v0315.sh | 27 + .../ops/test_governance_review_ui_v0315.sh | 9 + .../validate_governance_review_ui_v0315.sql | 225 +++++ ...nance_review_ui_two_stage_shadow_v0315.sql | 411 +++++++++ 27 files changed, 3767 insertions(+), 494 deletions(-) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/114_governance_review_ui_two_stage_shadow_v0.3.15.md create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/labels.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/policy.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/approval_detail.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/approvals.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/app/templates/clarifications.html create mode 100644 ucepsa/edge-oee-demo/governance-ui/tests/test_roles_and_labels.py create mode 100755 ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh create mode 100755 ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh create mode 100755 ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh create mode 100755 ucepsa/edge-oee-demo/ops/smoke_governance_review_ui_v0315.sh create mode 100755 ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh create mode 100644 ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql diff --git a/ucepsa/edge-oee-demo/docs/runbooks/114_governance_review_ui_two_stage_shadow_v0.3.15.md b/ucepsa/edge-oee-demo/docs/runbooks/114_governance_review_ui_two_stage_shadow_v0.3.15.md new file mode 100644 index 0000000..8f0f479 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/114_governance_review_ui_two_stage_shadow_v0.3.15.md @@ -0,0 +1,230 @@ +# UCEPSA — Governance Review UI en dos etapas SHADOW v0.3.15 + +## Objetivo + +Separar claramente las dos responsabilidades: + +```text +Producción +→ explica qué ocurrió realmente en planta. + +MESAVAULT / responsable técnico +→ decide si esa explicación puede convertirse en contexto canónico. +``` + +Juan Pablo deja de ver y utilizar la casilla técnica de creación de contexto. +Cuando una respuesta permite proponer un contexto, la aplicación crea una +propuesta pendiente de aprobación técnica, no el contexto definitivo. + +## Novedades + +```text +respuesta de Producción +→ revisión de decisión, casos y episodios +→ propuesta técnica cuando procede +→ aprobación / rechazo / solicitud de aclaración +→ creación de contexto solo al aprobar +``` + +Incluye además: + +- estados visibles en lenguaje normal; +- `En consulta` para los casos cuya respuesta todavía no se conoce; +- aclaraciones entre Producción y el revisor técnico; +- idempotencia mediante una clave única por formulario; +- auditoría unificada; +- pruebas de roles y traducciones; +- tres paneles nuevos de Grafana. + +## Estados + +### Decisión de Producción + +```text +Pendiente +En consulta +Resuelta +``` + +### Propuesta de contexto + +```text +Pendiente +Requiere aclaración +Aprobada +Rechazada +``` + +## Contextos que pueden proponerse + +```text +Producción con sistema antiguo +→ LEGACY_ERP + +Prueba, ajuste o preparación +→ TEST_SETUP + +Mantenimiento +→ MAINTENANCE +``` + +La propuesta no significa que el contexto esté creado. + +## Validaciones antes de aprobar + +La aplicación comprueba: + +```text +intervalo terminado; +inicio y fin conocidos; +máquina identificada; +sin solapamiento con una sesión Odoo; +sin solapamiento con otro contexto explícito; +un contexto exacto por caso; +official_eligible = false. +``` + +## Despliegue + +No reiniciar la aplicación mientras Juan Pablo tenga un formulario abierto. +Esperar a que termine la acción o indicarle que recargue después del despliegue. + +### Migración 118 + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$FILE" +``` + +Debe finalizar con: + +```text +DO +COMMIT +``` + +### Instalar la aplicación conservando `.env` + +```bash +INSTALLER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh + +"$INSTALLER" +``` + +### Construir y reiniciar + +```bash +MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh + +"$MANAGER" build + +TESTS=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh +"$TESTS" + +"$MANAGER" restart +sleep 15 +"$MANAGER" status +"$MANAGER" health +``` + +El cambio de `SESSION_SALT` cerrará las sesiones anteriores. Los usuarios +deben volver a iniciar sesión después del despliegue. + +## Validación SQL + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$VALIDATION" +``` + +Valores obligatorios: + +```text +ui_v2_rows = agenda_rows +invalid_workflow_status_rows = 0 +duplicate_open_consultations = 0 +duplicate_active_proposals = 0 +approved_without_context = 0 +invalid_approved_contexts = 0 +direct_context_creation_actions = 0 +duplicate_review_requests = 0 +duplicate_proposal_action_requests = 0 +official_consultation_rows = 0 +official_proposal_rows = 0 +official_proposal_action_rows = 0 +official_operator_queue_rows = 0 +``` + +## Prueba funcional sin crear contexto + +1. Entrar como Juan Pablo. +2. Abrir una decisión pendiente. +3. Comprobar que no aparece ninguna casilla para crear contexto. +4. Marcar una decisión real como `En consulta` y comprobar la cola. +5. Volver a Pendiente o resolverla cuando exista respuesta. +6. Confirmar que una respuesta `Sistema antiguo`, `Prueba` o `Mantenimiento` + crea una propuesta técnica, no un contexto. +7. Entrar como Víctor. +8. Abrir `Aprobación técnica`. +9. Validar o solicitar aclaración. + +## Aprobación técnica + +Al aprobar: + +```text +propuesta PENDING +→ comprobación de solapamientos +→ producción_context_sessions +→ propuesta APPROVED +→ auditoría +``` + +Al solicitar aclaración: + +```text +propuesta NEEDS_CLARIFICATION +→ aparece en Aclaraciones para Producción +→ Producción responde +→ propuesta vuelve a PENDING +``` + +## Idempotencia + +Cada formulario lleva un UUID único. Un doble clic, reenvío o recarga no debe +crear dos revisiones ni dos contextos. + +Las tablas aplican restricciones únicas sobre: + +```text +review request_id +consultation request_id +proposal action request_id +proposal activa por case_id +``` + +## Grafana + +Se mantienen los 31 paneles previos y se añaden: + +```text +32 · Contextos pendientes de aprobación +33 · Decisiones en consulta +34 · Actividad de la interfaz · 30 días +``` + +Los paneles de decisiones utilizan `li_context_governance_ui_queue_v2`. + +## Regla operativa + +```text +Juan Pablo responde la realidad de planta. +Víctor valida la coherencia técnica. +Ninguna respuesta de Producción crea por sí sola un contexto autoritativo. +``` diff --git a/ucepsa/edge-oee-demo/governance-ui/Dockerfile b/ucepsa/edge-oee-demo/governance-ui/Dockerfile index 2d7b208..828cff0 100644 --- a/ucepsa/edge-oee-demo/governance-ui/Dockerfile +++ b/ucepsa/edge-oee-demo/governance-ui/Dockerfile @@ -14,6 +14,7 @@ RUN pip install --no-cache-dir \ COPY app /app/app COPY tools /app/tools +COPY tests /app/tests USER appuser diff --git a/ucepsa/edge-oee-demo/governance-ui/app/auth.py b/ucepsa/edge-oee-demo/governance-ui/app/auth.py index c834c47..65799eb 100644 --- a/ucepsa/edge-oee-demo/governance-ui/app/auth.py +++ b/ucepsa/edge-oee-demo/governance-ui/app/auth.py @@ -10,10 +10,15 @@ from itsdangerous import BadSignature, SignatureExpired from itsdangerous.url_safe import URLSafeTimedSerializer from .settings import UserConfig, get_settings +from .policy import ( + can_approve_context_roles, + can_manage_clarification_roles, + can_view_owner_roles, +) SESSION_COOKIE = "mv_governance_session" -SESSION_SALT = "mv-governance-ui-v0314" +SESSION_SALT = "mv-governance-ui-v0315" @dataclass(frozen=True) @@ -134,22 +139,10 @@ def can_view_owner( user: CurrentUser, review_owner: str, ) -> bool: - if "ADMIN" in user.roles: - return True - if review_owner == "PRODUCTION": - return "PRODUCTION" in user.roles - if review_owner == "TECHNICAL": - return "TECHNICAL" in user.roles - if review_owner == "JOINT": - return bool( - user.roles - & { - "PRODUCTION", - "TECHNICAL", - "JOINT", - } - ) - return False + return can_view_owner_roles( + user.roles, + review_owner, + ) def can_review_owner( @@ -160,3 +153,19 @@ def can_review_owner( user, review_owner, ) + + +def can_approve_context( + user: CurrentUser, +) -> bool: + return can_approve_context_roles( + user.roles + ) + + +def can_manage_clarification( + user: CurrentUser, +) -> bool: + return can_manage_clarification_roles( + user.roles + ) diff --git a/ucepsa/edge-oee-demo/governance-ui/app/db.py b/ucepsa/edge-oee-demo/governance-ui/app/db.py index 70f2a1b..238963d 100644 --- a/ucepsa/edge-oee-demo/governance-ui/app/db.py +++ b/ucepsa/edge-oee-demo/governance-ui/app/db.py @@ -20,7 +20,7 @@ def get_connection() -> Iterator[Connection]: user=settings.pg_user, password=settings.pg_password, connect_timeout=10, - application_name="governance_review_ui_v0314", + application_name="governance_review_ui_v0315", ) try: yield connection diff --git a/ucepsa/edge-oee-demo/governance-ui/app/labels.py b/ucepsa/edge-oee-demo/governance-ui/app/labels.py new file mode 100644 index 0000000..ab5ffec --- /dev/null +++ b/ucepsa/edge-oee-demo/governance-ui/app/labels.py @@ -0,0 +1,68 @@ +from __future__ import annotations + + +LABELS = { + "RECENT": "Reciente", + "ATTENTION": "Requiere atención", + "OVERDUE": "Vencida", + "APPLIED": "Aplicada", + "ERROR": "Error", + "PRODUCTION_REVIEW": "Revisión de Producción", + "CONSULTATION": "Consulta", + "CONTEXT_PROPOSAL": "Propuesta de contexto", + "MARK_IN_CONSULTATION": "Marcada En consulta", + "CLOSE_CONSULTATION": "Consulta cerrada", + "WARNING": "Aviso", + "CRITICAL": "Crítica", + "PENDING": "Pendiente", + "REVIEWED": "Resuelta", + "DISMISSED": "Descartada", + "NOT_REQUIRED": "No requiere revisión", + "OPEN": "Activa", + "CLOSED": "Terminada", + "ACTIVE": "Activa", + "ENDED": "Terminada", + "IN_CONSULTATION": "En consulta", + "PRODUCTION": "Producción", + "TECHNICAL": "Técnico", + "JOINT": "Conjunto", + "ADMIN": "Administrador", + "AUTHORIZED_LEGACY_PRODUCTION": + "Producción con el sistema antiguo", + "ODOO_START_OMITTED": + "Producción real, pero se olvidó iniciar Odoo", + "TEST_OR_SETUP": "Prueba, ajuste o preparación", + "RESIDUAL_MATERIAL": + "Material residual o vaciado de máquina", + "MAINTENANCE": "Mantenimiento", + "NOT_PRODUCTION": "No era producción", + "NOT_RELEVANT": "No era producción", + "ODOO_DOUBLE_START_ONE_VALID": + "Una orden era válida y la otra quedó abierta por error", + "ODOO_DOUBLE_START_BOTH_INVALID": + "Ninguna de las órdenes era válida", + "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED": + "Eran órdenes consecutivas y la anterior no se cerró", + "DATA_ISSUE": "Incidencia de datos o configuración", + "OTHER": "Otro", + "LEGACY_ERP": "Sistema antiguo", + "TEST_SETUP": "Prueba o ajuste", + "APPROVED": "Aprobado", + "REJECTED": "Rechazado", + "NEEDS_CLARIFICATION": "Requiere aclaración", + "CANCELLED": "Cancelado", + "APPROVE": "Aprobar contexto", + "REJECT": "Rechazar propuesta", + "REQUEST_CLARIFICATION": "Solicitar aclaración", + "CLARIFICATION_RESPONSE": "Aclaración respondida", +} + + +def label(value: object) -> str: + if value is None: + return "—" + raw = str(value) + return LABELS.get( + raw, + raw.replace("_", " ").capitalize(), + ) diff --git a/ucepsa/edge-oee-demo/governance-ui/app/main.py b/ucepsa/edge-oee-demo/governance-ui/app/main.py index 5b668d7..f5c5ba7 100644 --- a/ucepsa/edge-oee-demo/governance-ui/app/main.py +++ b/ucepsa/edge-oee-demo/governance-ui/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import uuid from pathlib import Path from typing import Any from urllib.parse import quote @@ -23,6 +24,8 @@ from .auth import ( SESSION_COOKIE, authenticate, build_session_token, + can_approve_context, + can_manage_clarification, can_review_owner, can_view_owner, read_current_user, @@ -30,10 +33,20 @@ from .auth import ( verify_csrf, ) from .db import get_connection +from .labels import label +from .proposal_service import ( + ProposalActionInput, + ProposalError, + approve_proposal, + change_proposal_status, +) from .review_service import ( + ConsultationInput, ReviewError, ReviewInput, - apply_review, + apply_production_review, + mark_in_consultation, + resume_from_consultation, ) from .settings import get_settings @@ -42,7 +55,7 @@ BASE_DIR = Path(__file__).resolve().parent app = FastAPI( title="MESAVAULT Governance Review UI", - version="0.3.14", + version="0.3.15", ) app.mount( @@ -72,11 +85,19 @@ def format_datetime(value: Any) -> str: templates.env.filters[ "datetime_es" ] = format_datetime +templates.env.filters[ + "label" +] = label -def visible_queue( - user, -) -> list[dict[str, Any]]: +def redirect_login() -> RedirectResponse: + return RedirectResponse( + "/login", + status_code=status.HTTP_303_SEE_OTHER, + ) + + +def visible_queue(user) -> list[dict[str, Any]]: with get_connection() as connection: with connection.cursor( cursor_factory=RealDictCursor, @@ -86,7 +107,7 @@ def visible_queue( SELECT * FROM mv_reports_ucepsa_prod - .li_context_governance_ui_queue_v1 + .li_context_governance_ui_queue_v2 ORDER BY agenda_rank """ ) @@ -105,8 +126,8 @@ def visible_queue( ] -def recent_actions( - limit: int = 50, +def recent_activity( + limit: int = 100, ) -> list[dict[str, Any]]: with get_connection() as connection: with connection.cursor( @@ -117,10 +138,11 @@ def recent_actions( SELECT * FROM mv_reports_ucepsa_prod - .li_context_governance_ui_recent_actions_v1 + .li_context_governance_ui_activity_v1 ORDER BY created_at DESC, - ui_action_id DESC + activity_type, + activity_id DESC LIMIT %s """, (limit,), @@ -136,6 +158,7 @@ def load_decision( ) -> tuple[ dict[str, Any], list[dict[str, Any]], + dict[str, Any] | None, ]: with get_connection() as connection: with connection.cursor( @@ -177,80 +200,128 @@ def load_decision( for row in cursor.fetchall() ] - return dict(decision), cases + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_governance_ui_consultation_current_v1 + WHERE decision_group_key = %s + """, + (decision_group_key,), + ) + consultation = cursor.fetchone() + + return ( + dict(decision), + cases, + dict(consultation) + if consultation + else None, + ) def answer_options( decision_family: str, ) -> list[tuple[str, str]]: if decision_family == "UNCONFIRMED_ACTIVITY": - return [ - ( - "AUTHORIZED_LEGACY_PRODUCTION", - "Producción con el sistema antiguo", - ), - ( - "ODOO_START_OMITTED", - "Producción real, pero se olvidó iniciar Odoo", - ), - ( - "TEST_OR_SETUP", - "Prueba, ajuste o preparación", - ), - ( - "RESIDUAL_MATERIAL", - "Material residual o vaciado de máquina", - ), - ( - "MAINTENANCE", - "Mantenimiento", - ), - ( - "NOT_PRODUCTION", - "No era producción", - ), - ( - "OTHER", - "Otro", - ), + codes = [ + "AUTHORIZED_LEGACY_PRODUCTION", + "ODOO_START_OMITTED", + "TEST_OR_SETUP", + "RESIDUAL_MATERIAL", + "MAINTENANCE", + "NOT_PRODUCTION", + "OTHER", ] - - if decision_family == "ODOO_ORDER_START_CONFLICT": - return [ - ( - "ODOO_DOUBLE_START_ONE_VALID", - "Una orden era válida y la otra quedó abierta por error", - ), - ( - "ODOO_DOUBLE_START_BOTH_INVALID", - "Ninguna de las órdenes era válida", - ), - ( - "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED", - "Eran órdenes consecutivas y la anterior no se cerró", - ), - ( - "DATA_ISSUE", - "Incidencia de datos o configuración", - ), - ( - "OTHER", - "Otro", - ), + elif decision_family == "ODOO_ORDER_START_CONFLICT": + codes = [ + "ODOO_DOUBLE_START_ONE_VALID", + "ODOO_DOUBLE_START_BOTH_INVALID", + "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED", + "DATA_ISSUE", + "OTHER", + ] + else: + codes = [ + "DATA_ISSUE", + "OTHER", ] return [ - ( - "DATA_ISSUE", - "Incidencia de datos", - ), - ( - "OTHER", - "Otro", - ), + (code, label(code)) + for code in codes ] +def load_approval_queue() -> list[dict[str, Any]]: + with get_connection() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_governance_context_approval_queue_v1 + ORDER BY approval_rank + """ + ) + return [ + dict(row) + for row in cursor.fetchall() + ] + + +def load_proposal( + proposal_id: int, +) -> dict[str, Any]: + with get_connection() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_governance_context_proposals_v1 + WHERE proposal_id = %s + """, + (proposal_id,), + ) + row = cursor.fetchone() + if not row: + raise HTTPException( + status_code=404, + detail="Propuesta no encontrada.", + ) + return dict(row) + + +def load_clarifications() -> list[dict[str, Any]]: + with get_connection() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_governance_context_clarification_queue_v1 + ORDER BY + last_action_at, + proposal_id + """ + ) + return [ + dict(row) + for row in cursor.fetchall() + ] + + @app.get( "/health", response_class=HTMLResponse, @@ -267,13 +338,11 @@ def health() -> str: "/login", response_class=HTMLResponse, ) -def login_page( - request: Request, -): +def login_page(request: Request): if read_current_user(request): return RedirectResponse( "/", - status_code=status.HTTP_303_SEE_OTHER, + status_code=303, ) return templates.TemplateResponse( @@ -314,7 +383,7 @@ def login_submit( response = RedirectResponse( "/", - status_code=status.HTTP_303_SEE_OTHER, + status_code=303, ) response.set_cookie( SESSION_COOKIE, @@ -341,7 +410,7 @@ def logout( response = RedirectResponse( "/login", - status_code=status.HTTP_303_SEE_OTHER, + status_code=303, ) response.delete_cookie( SESSION_COOKIE, @@ -360,10 +429,7 @@ def queue_page( ): user = read_current_user(request) if user is None: - return RedirectResponse( - "/login", - status_code=status.HTTP_303_SEE_OTHER, - ) + return redirect_login() return templates.TemplateResponse( request=request, @@ -373,6 +439,12 @@ def queue_page( "user": user, "rows": visible_queue(user), "message": message, + "can_approve": + can_approve_context(user), + "clarification_count": + len(load_clarifications()) + if can_manage_clarification(user) + else 0, }, ) @@ -388,12 +460,9 @@ def decision_page( ): user = read_current_user(request) if user is None: - return RedirectResponse( - "/login", - status_code=status.HTTP_303_SEE_OTHER, - ) + return redirect_login() - decision, cases = load_decision( + decision, cases, consultation = load_decision( decision_group_key ) @@ -441,6 +510,7 @@ def decision_page( "decision": decision, "cases": cases, "pending_cases": pending_cases, + "consultation": consultation, "answer_options": answer_options( str( decision[ @@ -458,6 +528,12 @@ def decision_page( ), "write_enabled": get_settings().write_enabled, + "review_submission_key": + str(uuid.uuid4()), + "consultation_submission_key": + str(uuid.uuid4()), + "resume_submission_key": + str(uuid.uuid4()), "error": error, }, ) @@ -470,19 +546,16 @@ def review_decision( request: Request, decision_group_key: str, csrf_token: str = Form(...), + submission_key: uuid.UUID = Form(...), case_ids: list[int] = Form(default=[]), answer_code: str = Form(...), notes: str = Form(""), selected_order: str = Form(""), corrected_in_odoo: bool = Form(False), - create_contexts: bool = Form(False), confirmation: bool = Form(False), ): user = require_user(request) - verify_csrf( - user, - csrf_token, - ) + verify_csrf(user, csrf_token) if not get_settings().write_enabled: raise HTTPException( @@ -490,7 +563,7 @@ def review_decision( detail="La interfaz está en modo solo lectura.", ) - decision, _ = load_decision( + decision, _, _ = load_decision( decision_group_key ) @@ -538,6 +611,7 @@ def review_decision( ) review = ReviewInput( + submission_key=submission_key, decision_group_key= decision_group_key, selected_case_ids=tuple( @@ -556,14 +630,11 @@ def review_decision( selected_workorder_id, corrected_in_odoo= corrected_in_odoo, - create_contexts= - create_contexts, - confirmation= - confirmation, + confirmation=confirmation, ) try: - result = apply_review( + result = apply_production_review( review=review, user=user, source_ip=( @@ -591,22 +662,18 @@ def review_decision( ) message = ( - "Revisión registrada. " + "Respuesta registrada. " f"Acción UI {result['ui_action_id']}." ) - if result[ - "created_context_session_ids" - ]: + proposal_ids = result.get( + "context_proposal_ids" + ) or [] + if proposal_ids: message += ( - " Contextos creados: " - + ", ".join( - str(value) - for value in result[ - "created_context_session_ids" - ] - ) - + "." + " Se han enviado " + f"{len(proposal_ids)} propuestas " + "a validación técnica." ) return RedirectResponse( @@ -616,19 +683,383 @@ def review_decision( ) +@app.post( + "/decisions/{decision_group_key:path}/consultation" +) +def consultation_submit( + request: Request, + decision_group_key: str, + csrf_token: str = Form(...), + submission_key: uuid.UUID = Form(...), + reason: str = Form(...), + follow_up_with: str = Form(""), +): + user = require_user(request) + verify_csrf(user, csrf_token) + + if not get_settings().write_enabled: + raise HTTPException( + status_code=503, + detail="La interfaz está en modo solo lectura.", + ) + + decision, _, _ = load_decision( + decision_group_key + ) + if not can_review_owner( + user, + str(decision["review_owner"]), + ): + raise HTTPException( + status_code=403, + detail="No puede gestionar esta decisión.", + ) + + try: + mark_in_consultation( + ConsultationInput( + submission_key= + submission_key, + decision_group_key= + decision_group_key, + reason=reason, + follow_up_with= + follow_up_with, + ), + user, + ) + except ReviewError as exc: + return RedirectResponse( + "/decisions/" + + quote( + decision_group_key, + safe="", + ) + + "?error=" + + quote(str(exc)), + status_code=303, + ) + + return RedirectResponse( + "/?message=" + + quote( + "La decisión queda marcada En consulta." + ), + status_code=303, + ) + + +@app.post( + "/decisions/{decision_group_key:path}/resume" +) +def consultation_resume( + request: Request, + decision_group_key: str, + csrf_token: str = Form(...), + submission_key: uuid.UUID = Form(...), +): + user = require_user(request) + verify_csrf(user, csrf_token) + + if not get_settings().write_enabled: + raise HTTPException( + status_code=503, + detail="La interfaz está en modo solo lectura.", + ) + + try: + resume_from_consultation( + submission_key, + decision_group_key, + user, + ) + except ReviewError as exc: + return RedirectResponse( + "/decisions/" + + quote( + decision_group_key, + safe="", + ) + + "?error=" + + quote(str(exc)), + status_code=303, + ) + + return RedirectResponse( + "/decisions/" + + quote( + decision_group_key, + safe="", + ), + status_code=303, + ) + + +@app.get( + "/approvals", + response_class=HTMLResponse, +) +def approvals_page( + request: Request, + message: str | None = None, +): + user = read_current_user(request) + if user is None: + return redirect_login() + if not can_approve_context(user): + raise HTTPException( + status_code=403, + detail="No tiene permisos de aprobación técnica.", + ) + + return templates.TemplateResponse( + request=request, + name="approvals.html", + context={ + "settings": get_settings(), + "user": user, + "proposals": load_approval_queue(), + "message": message, + }, + ) + + +@app.get( + "/approvals/{proposal_id}", + response_class=HTMLResponse, +) +def approval_detail( + request: Request, + proposal_id: int, + error: str | None = None, +): + user = read_current_user(request) + if user is None: + return redirect_login() + if not can_approve_context(user): + raise HTTPException( + status_code=403, + detail="No tiene permisos de aprobación técnica.", + ) + + return templates.TemplateResponse( + request=request, + name="approval_detail.html", + context={ + "settings": get_settings(), + "user": user, + "proposal": load_proposal( + proposal_id + ), + "write_enabled": + get_settings().write_enabled, + "submission_key": + str(uuid.uuid4()), + "error": error, + }, + ) + + +@app.post( + "/approvals/{proposal_id}/{action_code}" +) +def approval_action( + request: Request, + proposal_id: int, + action_code: str, + csrf_token: str = Form(...), + submission_key: uuid.UUID = Form(...), + notes: str = Form(""), +): + user = require_user(request) + verify_csrf(user, csrf_token) + + if not get_settings().write_enabled: + raise HTTPException( + status_code=503, + detail="La interfaz está en modo solo lectura.", + ) + if not can_approve_context(user): + raise HTTPException( + status_code=403, + detail="No tiene permisos de aprobación técnica.", + ) + + normalized = action_code.upper() + if normalized not in { + "APPROVE", + "REJECT", + "REQUEST_CLARIFICATION", + }: + raise HTTPException( + status_code=404, + detail="Acción no permitida.", + ) + + action = ProposalActionInput( + submission_key=submission_key, + proposal_id=proposal_id, + action_code=normalized, + notes=notes, + ) + + try: + if normalized == "APPROVE": + result = approve_proposal( + action, + user, + ( + request.client.host + if request.client + else None + ), + request.headers.get( + "user-agent" + ), + ) + message = ( + "Contexto aprobado y creado: " + f"{result['context_session_id']}." + ) + else: + result = change_proposal_status( + action, + user, + ( + request.client.host + if request.client + else None + ), + request.headers.get( + "user-agent" + ), + ) + message = ( + "Propuesta actualizada: " + + label( + result[ + "resulting_status" + ] + ) + + "." + ) + except ProposalError as exc: + return RedirectResponse( + f"/approvals/{proposal_id}?error=" + + quote(str(exc)), + status_code=303, + ) + + return RedirectResponse( + "/approvals?message=" + + quote(message), + status_code=303, + ) + + +@app.get( + "/clarifications", + response_class=HTMLResponse, +) +def clarifications_page( + request: Request, + message: str | None = None, +): + user = read_current_user(request) + if user is None: + return redirect_login() + if not can_manage_clarification(user): + raise HTTPException( + status_code=403, + detail="No tiene acceso a aclaraciones de Producción.", + ) + + return templates.TemplateResponse( + request=request, + name="clarifications.html", + context={ + "settings": get_settings(), + "user": user, + "proposals": load_clarifications(), + "submission_keys": { + int(row["proposal_id"]): + str(uuid.uuid4()) + for row in load_clarifications() + }, + "message": message, + "write_enabled": + get_settings().write_enabled, + }, + ) + + +@app.post( + "/clarifications/{proposal_id}/respond" +) +def clarification_respond( + request: Request, + proposal_id: int, + csrf_token: str = Form(...), + submission_key: uuid.UUID = Form(...), + notes: str = Form(...), +): + user = require_user(request) + verify_csrf(user, csrf_token) + + if not get_settings().write_enabled: + raise HTTPException( + status_code=503, + detail="La interfaz está en modo solo lectura.", + ) + if not can_manage_clarification(user): + raise HTTPException( + status_code=403, + detail="No puede responder esta aclaración.", + ) + + try: + change_proposal_status( + ProposalActionInput( + submission_key=submission_key, + proposal_id=proposal_id, + action_code= + "CLARIFICATION_RESPONSE", + notes=notes, + ), + user, + ( + request.client.host + if request.client + else None + ), + request.headers.get( + "user-agent" + ), + ) + except ProposalError as exc: + return RedirectResponse( + "/clarifications?message=" + + quote(str(exc)), + status_code=303, + ) + + return RedirectResponse( + "/clarifications?message=" + + quote( + "Aclaración enviada a revisión técnica." + ), + status_code=303, + ) + + @app.get( "/history", response_class=HTMLResponse, ) -def history_page( - request: Request, -): +def history_page(request: Request): user = read_current_user(request) if user is None: - return RedirectResponse( - "/login", - status_code=303, - ) + return redirect_login() return templates.TemplateResponse( request=request, @@ -636,6 +1067,6 @@ def history_page( context={ "settings": get_settings(), "user": user, - "actions": recent_actions(), + "activities": recent_activity(), }, ) diff --git a/ucepsa/edge-oee-demo/governance-ui/app/policy.py b/ucepsa/edge-oee-demo/governance-ui/app/policy.py new file mode 100644 index 0000000..8a6a1af --- /dev/null +++ b/ucepsa/edge-oee-demo/governance-ui/app/policy.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import AbstractSet + + +def can_view_owner_roles( + roles: AbstractSet[str], + review_owner: str, +) -> bool: + if "ADMIN" in roles: + return True + if review_owner == "PRODUCTION": + return "PRODUCTION" in roles + if review_owner == "TECHNICAL": + return "TECHNICAL" in roles + if review_owner == "JOINT": + return bool( + roles + & { + "PRODUCTION", + "TECHNICAL", + "JOINT", + } + ) + return False + + +def can_approve_context_roles( + roles: AbstractSet[str], +) -> bool: + return bool( + roles + & { + "TECHNICAL", + "ADMIN", + } + ) + + +def can_manage_clarification_roles( + roles: AbstractSet[str], +) -> bool: + return bool( + roles + & { + "PRODUCTION", + "ADMIN", + } + ) diff --git a/ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py b/ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py new file mode 100644 index 0000000..5e1f618 --- /dev/null +++ b/ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py @@ -0,0 +1,597 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Any + +from psycopg2.extras import Json, RealDictCursor + +from .auth import CurrentUser +from .db import get_connection +from .settings import get_settings + + +@dataclass(frozen=True) +class ProposalActionInput: + submission_key: uuid.UUID + proposal_id: int + action_code: str + notes: str + + +class ProposalError(RuntimeError): + pass + + +def json_safe(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): json_safe(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple, set)): + return [ + json_safe(item) + for item in value + ] + if hasattr(value, "isoformat"): + return value.isoformat() + return value + + +def fetch_proposal_for_update( + cursor: RealDictCursor, + proposal_id: int, +) -> dict[str, Any]: + cursor.execute( + """ + SELECT p.* + FROM + mv_loss_intelligence + .context_governance_context_proposals raw + JOIN + mv_reports_ucepsa_prod + .li_context_governance_context_proposals_v1 p + ON p.proposal_id = raw.proposal_id + WHERE raw.proposal_id = %s + FOR UPDATE OF raw + """, + (proposal_id,), + ) + row = cursor.fetchone() + if not row: + raise ProposalError( + "La propuesta no existe." + ) + return dict(row) + + +def existing_action( + cursor: RealDictCursor, + settings, + submission_key: uuid.UUID, +) -> dict[str, Any] | None: + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_governance_context_proposal_actions + WHERE tenant = %s + AND site = %s + AND request_id = %s + """, + ( + settings.tenant, + settings.site, + str(submission_key), + ), + ) + row = cursor.fetchone() + return dict(row) if row else None + + +def count_authoritative_overlaps( + cursor: RealDictCursor, + proposal: dict[str, Any], +) -> int: + cursor.execute( + """ + SELECT COUNT(*) AS overlap_count + FROM ( + SELECT 1 + FROM + mv_loss_intelligence + .production_context_sessions s + WHERE s.machine_id = %s + AND s.started_at < %s + AND COALESCE( + s.ended_at, + 'infinity'::timestamptz + ) > %s + + UNION ALL + + SELECT 1 + FROM + mv_loss_intelligence + .odoo_shopfloor_sessions o + WHERE o.machine_id = %s + AND o.started_at IS NOT NULL + AND o.started_at < %s + AND COALESCE( + o.ended_at, + 'infinity'::timestamptz + ) > %s + AND o.session_status IN ( + 'OPEN', + 'CLOSED' + ) + ) overlaps + """, + ( + proposal["machine_id"], + proposal["ended_at"], + proposal["first_seen_at"], + proposal["machine_id"], + proposal["ended_at"], + proposal["first_seen_at"], + ), + ) + return int( + cursor.fetchone()["overlap_count"] + ) + + +def insert_proposal_action( + cursor: RealDictCursor, + settings, + action: ProposalActionInput, + proposal: dict[str, Any], + user: CurrentUser, + resulting_status: str, + context_session_id: int | None, + source_ip: str | None, + user_agent: str | None, +) -> int: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_governance_context_proposal_actions ( + tenant, + site, + request_id, + proposal_id, + action_code, + previous_status, + resulting_status, + actor_username, + actor_display_name, + actor_roles, + notes, + context_session_id, + source_ip, + user_agent, + official_eligible + ) + VALUES ( + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, %s, %s, false + ) + RETURNING proposal_action_id + """, + ( + settings.tenant, + settings.site, + str(action.submission_key), + action.proposal_id, + action.action_code, + proposal["proposal_status"], + resulting_status, + user.username, + user.display_name, + sorted(user.roles), + action.notes.strip() or None, + context_session_id, + source_ip, + user_agent, + ), + ) + return int( + cursor.fetchone()[ + "proposal_action_id" + ] + ) + + +def approve_proposal( + action: ProposalActionInput, + user: CurrentUser, + source_ip: str | None, + user_agent: str | None, +) -> dict[str, Any]: + settings = get_settings() + + with get_connection() as connection: + connection.autocommit = False + try: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT pg_advisory_xact_lock( + hashtext(%s) + ) + """, + ( + ( + f"{settings.tenant}:" + f"{settings.site}:" + f"{action.submission_key}" + ), + ), + ) + + replay = existing_action( + cursor, + settings, + action.submission_key, + ) + if replay: + connection.rollback() + return { + "idempotent_replay": True, + "proposal_action_id": + replay[ + "proposal_action_id" + ], + "context_session_id": + replay[ + "context_session_id" + ], + } + + proposal = fetch_proposal_for_update( + cursor, + action.proposal_id, + ) + + if proposal["proposal_status"] != "PENDING": + raise ProposalError( + "Solo pueden aprobarse propuestas pendientes." + ) + + if ( + proposal["case_status"] != + "CLOSED" + or proposal["ended_at"] is None + ): + raise ProposalError( + "El intervalo todavía no ha terminado." + ) + + overlap_count = count_authoritative_overlaps( + cursor, + proposal, + ) + + if overlap_count > 0: + raise ProposalError( + "El intervalo se solapa con otro contexto autoritativo. " + "La propuesta no puede aprobarse." + ) + + source_ref = ( + "GOVERNANCE_PROPOSAL:" + f"{proposal['proposal_id']}" + ) + + cursor.execute( + """ + SELECT context_session_id + FROM + mv_loss_intelligence + .production_context_sessions + WHERE source_ref = %s + """, + (source_ref,), + ) + existing_context = cursor.fetchone() + + if existing_context: + context_session_id = int( + existing_context[ + "context_session_id" + ] + ) + else: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .production_context_sessions ( + tenant, + site, + machine_id, + source_type, + source_ref, + started_at, + ended_at, + status, + authorization_status, + confidence, + official_eligible, + authorized_by, + authorized_at, + closed_by, + notes, + evidence_json + ) + VALUES ( + %s, %s, %s, %s, %s, + %s, %s, 'CLOSED', + 'AUTHORIZED', 'MEDIUM', false, + %s, now(), %s, %s, %s + ) + RETURNING context_session_id + """, + ( + settings.tenant, + settings.site, + proposal["machine_id"], + proposal[ + "proposed_source_type" + ], + source_ref, + proposal["first_seen_at"], + proposal["ended_at"], + user.display_name, + user.display_name, + action.notes.strip() + or proposal.get( + "technical_review_notes" + ) + or "Aprobación técnica desde Governance Review UI.", + Json({ + "created_with": + "governance_review_ui_v0315", + "proposal_id": + proposal["proposal_id"], + "proposal_key": + proposal["proposal_key"], + "decision_group_key": + proposal[ + "decision_group_key" + ], + "case_id": + proposal["case_id"], + "answer_code": + proposal["answer_code"], + "proposed_by": + proposal[ + "proposed_by_display_name" + ], + "approved_by": + user.display_name, + "deployment_mode": + "SHADOW", + }), + ), + ) + context_session_id = int( + cursor.fetchone()[ + "context_session_id" + ] + ) + + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_governance_context_proposals + SET + proposal_status = 'APPROVED', + technical_review_by_username = %s, + technical_review_by_display_name = %s, + technical_review_at = now(), + technical_review_notes = %s, + context_session_id = %s, + updated_at = now() + WHERE proposal_id = %s + """, + ( + user.username, + user.display_name, + action.notes.strip() or None, + context_session_id, + action.proposal_id, + ), + ) + + proposal_action_id = insert_proposal_action( + cursor, + settings, + action, + proposal, + user, + "APPROVED", + context_session_id, + source_ip, + user_agent, + ) + + connection.commit() + return { + "idempotent_replay": False, + "proposal_action_id": + proposal_action_id, + "context_session_id": + context_session_id, + } + + except Exception: + connection.rollback() + raise + + +def change_proposal_status( + action: ProposalActionInput, + user: CurrentUser, + source_ip: str | None, + user_agent: str | None, +) -> dict[str, Any]: + settings = get_settings() + + target_status = { + "REJECT": "REJECTED", + "REQUEST_CLARIFICATION": + "NEEDS_CLARIFICATION", + "CLARIFICATION_RESPONSE": + "PENDING", + }.get(action.action_code) + + if target_status is None: + raise ProposalError( + "Acción de propuesta no permitida." + ) + + if not action.notes.strip(): + raise ProposalError( + "Debe escribir una explicación." + ) + + with get_connection() as connection: + connection.autocommit = False + try: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT pg_advisory_xact_lock( + hashtext(%s) + ) + """, + ( + ( + f"{settings.tenant}:" + f"{settings.site}:" + f"{action.submission_key}" + ), + ), + ) + + replay = existing_action( + cursor, + settings, + action.submission_key, + ) + if replay: + connection.rollback() + return { + "idempotent_replay": True, + "proposal_action_id": + replay[ + "proposal_action_id" + ], + "resulting_status": + replay[ + "resulting_status" + ], + } + + proposal = fetch_proposal_for_update( + cursor, + action.proposal_id, + ) + + expected_status = ( + "NEEDS_CLARIFICATION" + if action.action_code == + "CLARIFICATION_RESPONSE" + else "PENDING" + ) + + if proposal["proposal_status"] != expected_status: + raise ProposalError( + "La propuesta ha cambiado de estado. Recargue la página." + ) + + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_governance_context_proposals + SET + proposal_status = %s, + technical_review_by_username = + CASE + WHEN %s = + 'CLARIFICATION_RESPONSE' + THEN technical_review_by_username + ELSE %s + END, + technical_review_by_display_name = + CASE + WHEN %s = + 'CLARIFICATION_RESPONSE' + THEN technical_review_by_display_name + ELSE %s + END, + technical_review_at = + CASE + WHEN %s = + 'CLARIFICATION_RESPONSE' + THEN technical_review_at + ELSE now() + END, + technical_review_notes = + CASE + WHEN %s = + 'CLARIFICATION_RESPONSE' + THEN technical_review_notes + ELSE %s + END, + updated_at = now() + WHERE proposal_id = %s + """, + ( + target_status, + action.action_code, + user.username, + action.action_code, + user.display_name, + action.action_code, + action.action_code, + action.notes.strip(), + action.proposal_id, + ), + ) + + proposal_action_id = insert_proposal_action( + cursor, + settings, + action, + proposal, + user, + target_status, + None, + source_ip, + user_agent, + ) + + connection.commit() + return { + "idempotent_replay": False, + "proposal_action_id": + proposal_action_id, + "resulting_status": + target_status, + } + + except Exception: + connection.rollback() + raise diff --git a/ucepsa/edge-oee-demo/governance-ui/app/review_service.py b/ucepsa/edge-oee-demo/governance-ui/app/review_service.py index ff45dfd..c655483 100644 --- a/ucepsa/edge-oee-demo/governance-ui/app/review_service.py +++ b/ucepsa/edge-oee-demo/governance-ui/app/review_service.py @@ -50,7 +50,7 @@ MAPPED_CLASSIFICATION = { "OTHER", } -CONTEXT_SOURCE_TYPE = { +PROPOSED_CONTEXT_SOURCE = { "AUTHORIZED_LEGACY_PRODUCTION": "LEGACY_ERP", "TEST_OR_SETUP": @@ -62,6 +62,7 @@ CONTEXT_SOURCE_TYPE = { @dataclass(frozen=True) class ReviewInput: + submission_key: uuid.UUID decision_group_key: str selected_case_ids: tuple[int, ...] answer_code: str @@ -69,10 +70,17 @@ class ReviewInput: selected_order_ref: str | None selected_workorder_id: int | None corrected_in_odoo: bool - create_contexts: bool confirmation: bool +@dataclass(frozen=True) +class ConsultationInput: + submission_key: uuid.UUID + decision_group_key: str + reason: str + follow_up_with: str + + class ReviewError(RuntimeError): pass @@ -123,16 +131,14 @@ def fetch_cases_for_update( ) -> list[dict[str, Any]]: cursor.execute( """ - SELECT - c.* + SELECT c.* FROM mv_reports_ucepsa_prod .li_context_decision_group_cases_v1 c JOIN mv_loss_intelligence .context_review_cases raw - ON raw.case_id = - c.case_id + ON raw.case_id = c.case_id WHERE c.decision_group_key = %s ORDER BY c.case_id FOR UPDATE OF raw @@ -145,7 +151,39 @@ def fetch_cases_for_update( ] -def validate_input( +def existing_review_action( + cursor: RealDictCursor, + settings, + submission_key: uuid.UUID, +) -> dict[str, Any] | None: + cursor.execute( + """ + SELECT + ui_action_id, + request_id, + decision_group_key, + selected_case_ids, + answer_code, + resulting_state, + status + FROM + mv_loss_intelligence + .context_governance_ui_actions + WHERE tenant = %s + AND site = %s + AND request_id = %s + """, + ( + settings.tenant, + settings.site, + str(submission_key), + ), + ) + row = cursor.fetchone() + return dict(row) if row else None + + +def validate_review( decision: dict[str, Any], all_cases: list[dict[str, Any]], review: ReviewInput, @@ -282,29 +320,6 @@ def validate_input( "La respuesta no corresponde a actividad sin contexto." ) - if review.create_contexts: - if ( - review.answer_code - not in CONTEXT_SOURCE_TYPE - ): - raise ReviewError( - "Esta respuesta no permite crear contextos retrospectivos." - ) - - for case in selected_cases: - if ( - case["case_status"] != "CLOSED" - or case["ended_at"] is None - ): - raise ReviewError( - "Solo pueden crearse contextos para intervalos terminados." - ) - - if not case["machine_id"]: - raise ReviewError( - "El caso no tiene máquina asociada." - ) - mapped = MAPPED_CLASSIFICATION[ review.answer_code ] @@ -316,190 +331,6 @@ def validate_input( ) -def insert_contexts( - cursor: RealDictCursor, - decision: dict[str, Any], - selected_cases: list[dict[str, Any]], - review: ReviewInput, - user: CurrentUser, - request_id: uuid.UUID, -) -> list[int]: - if not review.create_contexts: - return [] - - source_type = CONTEXT_SOURCE_TYPE[ - review.answer_code - ] - settings = get_settings() - created_ids: list[int] = [] - - for case in selected_cases: - case_id = int(case["case_id"]) - source_ref = ( - f"GOVERNANCE_UI_CASE:{case_id}:" - f"{review.answer_code}" - ) - - cursor.execute( - """ - SELECT context_session_id - FROM - mv_loss_intelligence - .production_context_sessions - WHERE source_ref = %s - """, - (source_ref,), - ) - existing = cursor.fetchone() - - if existing: - created_ids.append( - int( - existing[ - "context_session_id" - ] - ) - ) - continue - - cursor.execute( - """ - SELECT COUNT(*) AS overlap_count - FROM ( - SELECT 1 - FROM - mv_loss_intelligence - .production_context_sessions s - WHERE s.machine_id = %s - AND s.started_at < %s - AND COALESCE( - s.ended_at, - 'infinity'::timestamptz - ) > %s - - UNION ALL - - SELECT 1 - FROM - mv_loss_intelligence - .odoo_shopfloor_sessions o - WHERE o.machine_id = %s - AND o.started_at IS NOT NULL - AND o.started_at < %s - AND COALESCE( - o.ended_at, - 'infinity'::timestamptz - ) > %s - AND o.session_status IN ( - 'OPEN', - 'CLOSED' - ) - ) overlaps - """, - ( - case["machine_id"], - case["ended_at"], - case["first_seen_at"], - case["machine_id"], - case["ended_at"], - case["first_seen_at"], - ), - ) - - overlap_count = int( - cursor.fetchone()["overlap_count"] - ) - - if overlap_count > 0: - raise ReviewError( - f"El caso {case_id} se solapa con otro contexto autoritativo. " - "No se ha creado ningún contexto." - ) - - cursor.execute( - """ - INSERT INTO - mv_loss_intelligence - .production_context_sessions ( - tenant, - site, - machine_id, - source_type, - source_ref, - started_at, - ended_at, - status, - authorization_status, - confidence, - official_eligible, - authorized_by, - authorized_at, - closed_by, - notes, - evidence_json - ) - VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - 'CLOSED', - 'AUTHORIZED', - 'MEDIUM', - false, - %s, - now(), - %s, - %s, - %s - ) - RETURNING context_session_id - """, - ( - settings.tenant, - settings.site, - case["machine_id"], - source_type, - source_ref, - case["first_seen_at"], - case["ended_at"], - user.display_name, - user.display_name, - review.notes, - Json({ - "created_with": - "governance_review_ui_v0314", - "request_id": - str(request_id), - "decision_group_key": - decision[ - "decision_group_key" - ], - "case_id": - case_id, - "answer_code": - review.answer_code, - "retrospective": - True, - "deployment_mode": - "SHADOW", - }), - ), - ) - created_ids.append( - int( - cursor.fetchone()[ - "context_session_id" - ] - ) - ) - - return created_ids - - def insert_case_and_episode_reviews( cursor: RealDictCursor, decision: dict[str, Any], @@ -507,7 +338,6 @@ def insert_case_and_episode_reviews( mapped_classification: str, review: ReviewInput, user: CurrentUser, - request_id: uuid.UUID, ) -> tuple[list[int], list[int]]: case_review_ids: list[int] = [] episode_review_ids: list[int] = [] @@ -538,15 +368,8 @@ def insert_case_and_episode_reviews( evidence_json ) VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s + %s, %s, %s, %s, %s, + %s, %s, %s, %s ) RETURNING review_id """, @@ -584,15 +407,17 @@ def insert_case_and_episode_reviews( review.notes, Json({ "source": - "governance_review_ui_v0314", + "governance_review_ui_v0315", "request_id": - str(request_id), + str(review.submission_key), "decision_group_key": decision[ "decision_group_key" ], "answer_code": review.answer_code, + "two_stage_context": + True, }), ), ) @@ -613,8 +438,7 @@ def insert_case_and_episode_reviews( JOIN mv_reports_ucepsa_prod .li_shopfloor_context_incident_history_v1 h - ON h.episode_id = - e.episode_id + ON h.episode_id = e.episode_id WHERE e.case_id = %s ORDER BY h.episode_id """, @@ -656,13 +480,8 @@ def insert_case_and_episode_reviews( evidence_json ) VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s + %s, %s, %s, %s, + %s, %s, %s ) RETURNING review_id """, @@ -698,9 +517,9 @@ def insert_case_and_episode_reviews( review.notes, Json({ "source": - "governance_review_ui_v0314", + "governance_review_ui_v0315", "request_id": - str(request_id), + str(review.submission_key), "decision_group_key": decision[ "decision_group_key" @@ -726,13 +545,175 @@ def insert_case_and_episode_reviews( ) -def apply_review( +def create_context_proposals( + cursor: RealDictCursor, + settings, + decision: dict[str, Any], + selected_cases: list[dict[str, Any]], + mapped_classification: str, + review: ReviewInput, + user: CurrentUser, +) -> list[int]: + source_type = PROPOSED_CONTEXT_SOURCE.get( + review.answer_code + ) + + if source_type is None: + return [] + + proposal_ids: list[int] = [] + + for case in selected_cases: + case_id = int(case["case_id"]) + proposal_key = ( + f"CONTEXT_PROPOSAL:CASE:{case_id}:" + f"{review.answer_code}" + ) + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_governance_context_proposals ( + tenant, + site, + proposal_key, + request_id, + decision_group_key, + case_id, + answer_code, + mapped_classification, + proposed_source_type, + proposed_by_username, + proposed_by_display_name, + source_evidence, + official_eligible + ) + VALUES ( + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, false + ) + ON CONFLICT ( + tenant, + site, + proposal_key + ) + DO NOTHING + RETURNING proposal_id + """, + ( + settings.tenant, + settings.site, + proposal_key, + str(review.submission_key), + review.decision_group_key, + case_id, + review.answer_code, + mapped_classification, + source_type, + user.username, + user.display_name, + Json({ + "machine_id": + case["machine_id"], + "first_seen_at": + json_safe( + case["first_seen_at"] + ), + "ended_at": + json_safe( + case["ended_at"] + ), + "case_status": + case["case_status"], + "production_orders": + case[ + "production_orders" + ], + "odoo_workorder_ids": + case[ + "odoo_workorder_ids" + ], + "production_notes": + review.notes, + "deployment_mode": + "SHADOW", + }), + ), + ) + row = cursor.fetchone() + + if row: + proposal_ids.append( + int(row["proposal_id"]) + ) + continue + + cursor.execute( + """ + SELECT proposal_id + FROM + mv_loss_intelligence + .context_governance_context_proposals + WHERE tenant = %s + AND site = %s + AND proposal_key = %s + """, + ( + settings.tenant, + settings.site, + proposal_key, + ), + ) + existing = cursor.fetchone() + if existing: + proposal_ids.append( + int(existing["proposal_id"]) + ) + + return proposal_ids + + +def close_open_consultation( + cursor: RealDictCursor, + settings, + decision_group_key: str, + user: CurrentUser, +) -> None: + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_governance_ui_consultations + SET + status = 'CLOSED', + closed_by_username = %s, + closed_by_display_name = %s, + closed_at = now(), + close_reason = + 'La decisión fue respondida desde la interfaz.' + WHERE tenant = %s + AND site = %s + AND decision_group_key = %s + AND status = 'OPEN' + """, + ( + user.username, + user.display_name, + settings.tenant, + settings.site, + decision_group_key, + ), + ) + + +def apply_production_review( review: ReviewInput, user: CurrentUser, source_ip: str | None, user_agent: str | None, ) -> dict[str, Any]: - request_id = uuid.uuid4() settings = get_settings() with get_connection() as connection: @@ -742,6 +723,40 @@ def apply_review( with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: + cursor.execute( + """ + SELECT pg_advisory_xact_lock( + hashtext(%s) + ) + """, + ( + ( + f"{settings.tenant}:" + f"{settings.site}:" + f"{review.submission_key}" + ), + ), + ) + + replay = existing_review_action( + cursor, + settings, + review.submission_key, + ) + if replay: + connection.rollback() + return { + "idempotent_replay": True, + "ui_action_id": + replay["ui_action_id"], + "request_id": + str( + replay["request_id"] + ), + "resulting_state": + replay["resulting_state"], + } + cursor.execute( """ SELECT pg_advisory_xact_lock( @@ -770,7 +785,7 @@ def apply_review( pending_cases, selected_cases, mapped_classification, - ) = validate_input( + ) = validate_review( decision, all_cases, review, @@ -781,15 +796,6 @@ def apply_review( "cases": all_cases, } - created_context_ids = insert_contexts( - cursor, - decision, - selected_cases, - review, - user, - request_id, - ) - pending_ids = { int(case["case_id"]) for case in pending_cases @@ -830,16 +836,8 @@ def apply_review( evidence_json ) VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s ) RETURNING review_id @@ -859,15 +857,19 @@ def apply_review( review.notes, Json({ "source": - "governance_review_ui_v0314", + "governance_review_ui_v0315", "request_id": - str(request_id), + str( + review.submission_key + ), "answer_code": review.answer_code, "selected_case_ids": list( review.selected_case_ids ), + "two_stage_context": + True, }), ), ) @@ -887,25 +889,38 @@ def apply_review( mapped_classification, review, user, - request_id, ) - resulting_decision = fetch_decision( + proposal_ids = create_context_proposals( cursor, + settings, + decision, + selected_cases, + mapped_classification, + review, + user, + ) + + close_open_consultation( + cursor, + settings, review.decision_group_key, + user, ) resulting_state = { - "decision": - resulting_decision, "decision_review_id": decision_review_id, "case_review_ids": case_review_ids, "episode_review_ids": episode_review_ids, + "context_proposal_ids": + proposal_ids, "created_context_session_ids": - created_context_ids, + [], + "all_pending_cases_selected": + all_pending_selected, } cursor.execute( @@ -938,36 +953,18 @@ def apply_review( official_eligible ) VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - 'APPLIED', - %s, - %s, - false + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, false, ARRAY[]::bigint[], + %s, %s, %s, %s, %s, + %s, 'APPLIED', %s, %s, false ) RETURNING ui_action_id """, ( settings.tenant, settings.site, - str(request_id), + str(review.submission_key), review.decision_group_key, list(review.selected_case_ids), ( @@ -980,8 +977,6 @@ def apply_review( review.selected_order_ref, review.selected_workorder_id, review.corrected_in_odoo, - review.create_contexts, - created_context_ids, user.username, user.display_name, sorted(user.roles), @@ -1009,20 +1004,253 @@ def apply_review( connection.commit() return { - "ui_action_id": - ui_action_id, + "idempotent_replay": False, + "ui_action_id": ui_action_id, "request_id": - str(request_id), - "decision_review_id": - decision_review_id, - "case_review_ids": - case_review_ids, - "episode_review_ids": - episode_review_ids, - "created_context_session_ids": - created_context_ids, - "all_pending_cases_selected": - all_pending_selected, + str(review.submission_key), + **resulting_state, + } + + except Exception: + connection.rollback() + raise + + +def mark_in_consultation( + consultation: ConsultationInput, + user: CurrentUser, +) -> dict[str, Any]: + settings = get_settings() + + if not consultation.reason.strip(): + raise ReviewError( + "Debe indicar qué información falta o a quién hay que preguntar." + ) + + with get_connection() as connection: + connection.autocommit = False + + try: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT consultation_id + FROM + mv_loss_intelligence + .context_governance_ui_consultations + WHERE tenant = %s + AND site = %s + AND request_id = %s + """, + ( + settings.tenant, + settings.site, + str( + consultation.submission_key + ), + ), + ) + replay = cursor.fetchone() + if replay: + connection.rollback() + return { + "consultation_id": + int( + replay[ + "consultation_id" + ] + ), + "idempotent_replay": + True, + } + + decision = fetch_decision( + cursor, + consultation.decision_group_key, + ) + + if decision["review_status"] != "PENDING": + raise ReviewError( + "La decisión ya no está pendiente." + ) + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_governance_ui_consultations ( + tenant, + site, + request_id, + decision_group_key, + status, + reason, + follow_up_with, + created_by_username, + created_by_display_name, + created_by_roles, + official_eligible + ) + VALUES ( + %s, %s, %s, %s, 'OPEN', + %s, %s, %s, %s, %s, false + ) + ON CONFLICT ( + tenant, + site, + decision_group_key + ) + WHERE status = 'OPEN' + DO UPDATE SET + request_id = EXCLUDED.request_id, + reason = EXCLUDED.reason, + follow_up_with = + EXCLUDED.follow_up_with, + created_by_username = + EXCLUDED.created_by_username, + created_by_display_name = + EXCLUDED.created_by_display_name, + created_by_roles = + EXCLUDED.created_by_roles, + created_at = now() + RETURNING consultation_id + """, + ( + settings.tenant, + settings.site, + str( + consultation.submission_key + ), + consultation.decision_group_key, + consultation.reason.strip(), + consultation.follow_up_with.strip() + or None, + user.username, + user.display_name, + sorted(user.roles), + ), + ) + consultation_id = int( + cursor.fetchone()[ + "consultation_id" + ] + ) + + connection.commit() + return { + "consultation_id": + consultation_id, + "idempotent_replay": + False, + } + + except Exception: + connection.rollback() + raise + + +def resume_from_consultation( + submission_key: uuid.UUID, + decision_group_key: str, + user: CurrentUser, +) -> dict[str, Any]: + settings = get_settings() + + with get_connection() as connection: + connection.autocommit = False + try: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT consultation_id + FROM + mv_loss_intelligence + .context_governance_ui_consultations + WHERE tenant = %s + AND site = %s + AND request_id = %s + """, + ( + settings.tenant, + settings.site, + str(submission_key), + ), + ) + replay = cursor.fetchone() + if replay: + connection.rollback() + return { + "consultation_id": + int( + replay[ + "consultation_id" + ] + ), + "idempotent_replay": + True, + } + + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_governance_ui_consultations + SET + status = 'CLOSED', + closed_by_username = %s, + closed_by_display_name = %s, + closed_at = now(), + close_reason = + 'La información ya está disponible; vuelve a Pendiente.' + WHERE tenant = %s + AND site = %s + AND decision_group_key = %s + AND status = 'OPEN' + RETURNING consultation_id + """, + ( + user.username, + user.display_name, + settings.tenant, + settings.site, + decision_group_key, + ), + ) + row = cursor.fetchone() + if not row: + raise ReviewError( + "La decisión no estaba marcada En consulta." + ) + + consultation_id = int( + row["consultation_id"] + ) + + # The request id is stored on the closed row to make retries idempotent. + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_governance_ui_consultations + SET request_id = %s + WHERE consultation_id = %s + """, + ( + str(submission_key), + consultation_id, + ), + ) + + connection.commit() + return { + "consultation_id": + consultation_id, + "idempotent_replay": + False, } except Exception: diff --git a/ucepsa/edge-oee-demo/governance-ui/app/static/style.css b/ucepsa/edge-oee-demo/governance-ui/app/static/style.css index 5c8844d..bc4bd7c 100644 --- a/ucepsa/edge-oee-demo/governance-ui/app/static/style.css +++ b/ucepsa/edge-oee-demo/governance-ui/app/static/style.css @@ -337,3 +337,41 @@ th { color: var(--muted); } grid-template-columns: 1fr; } } + +.secondary { + background: #52616f; + color: var(--text); + font-weight: 700; +} + +.danger-button { + background: var(--danger); + color: #160604; + font-weight: 800; +} + +.info-box, +.consultation-box { + background: rgba(91, 168, 255, .10); + border: 1px solid rgba(91, 168, 255, .35); + border-radius: 10px; + padding: 1rem; + margin: 1rem 0; +} + +.consultation-box { + background: rgba(240, 165, 58, .10); + border-color: rgba(240, 165, 58, .35); +} + +.consultation-form { + border-color: rgba(240, 165, 58, .45); +} + +.compact-section { + margin-bottom: 1rem; +} + +.topbar nav { + flex-wrap: wrap; +} diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/approval_detail.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/approval_detail.html new file mode 100644 index 0000000..f223217 --- /dev/null +++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/approval_detail.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block content %} +← Volver a aprobación técnica + +{% if error %} +
{{ error }}
+{% endif %} + +
+
+
+ {{ proposal.machine_id }} + {{ proposal.proposal_status|label }} +

Propuesta de contexto: {{ proposal.proposed_source_type|label }}

+
+
+ +
+

Respuesta aportada por Producción

+

{{ proposal.answer_code|label }}

+

Respondido por {{ proposal.proposed_by_display_name }} el {{ proposal.proposed_at|datetime_es }}

+
+ +
+
Inicio exacto
{{ proposal.first_seen_at|datetime_es }}
+
Fin exacto
{{ proposal.ended_at|datetime_es }}
+
Estado del intervalo
{{ proposal.case_status|label }}
+
Estado de revisión
{{ proposal.case_review_status|label }}
+
Órdenes
{{ proposal.production_orders|join(", ") if proposal.production_orders else "—" }}
+
Workorders
{{ proposal.odoo_workorder_ids|join(", ") if proposal.odoo_workorder_ids else "—" }}
+
+ + {% if proposal.last_action_notes %} +
+ Último intercambio +

{{ proposal.last_action_notes }}

+ {{ proposal.last_action_by }} · {{ proposal.last_action_at|datetime_es }} +
+ {% endif %} + +
+ Validaciones al aprobar +

La aplicación comprobará que el intervalo ha terminado y que no se solapa con Odoo ni con otro contexto autoritativo. El contexto se creará en SHADOW y con elegibilidad oficial desactivada.

+
+ + {% if not write_enabled %} +
La interfaz está en modo solo lectura.
+ {% elif proposal.proposal_status == 'PENDING' %} +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ {% else %} +
La propuesta ya no está pendiente: {{ proposal.proposal_status|label }}.
+ {% endif %} +
+{% endblock %} diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/approvals.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/approvals.html new file mode 100644 index 0000000..6abfa17 --- /dev/null +++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/approvals.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Aprobación técnica de contextos

+

Producción ya explicó qué ocurrió. Esta cola valida si la respuesta puede convertirse en contexto canónico sin crear solapamientos.

+
+
{{ proposals|length }} propuestas
+
+ +{% if message %} +
{{ message }}
+{% endif %} + +{% if not proposals %} +
+

No hay propuestas pendientes

+

Las respuestas de Producción que no necesitan contexto no aparecen aquí.

+
+{% endif %} + +
+{% for proposal in proposals %} +
+
+
+ {{ proposal.machine_id }} + {{ proposal.proposal_status|label }} +
+
+

{{ proposal.proposed_source_type|label }}

+

Respuesta de Producción: {{ proposal.answer_code|label }}

+
+
Inicio
{{ proposal.first_seen_at|datetime_es }}
+
Fin
{{ proposal.ended_at|datetime_es }}
+
Propuesto por
{{ proposal.proposed_by_display_name }}
+
Caso
{{ proposal.case_id }}
+
+ {% if proposal.last_action_notes %} +
+ Último intercambio +

{{ proposal.last_action_notes }}

+
+ {% endif %} + Revisar propuesta +
+{% endfor %} +
+{% endblock %} diff --git a/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html b/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html index 1930876..f5db143 100644 --- a/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html +++ b/ucepsa/edge-oee-demo/governance-ui/app/templates/base.html @@ -15,9 +15,17 @@