#!/usr/bin/env python3 """ MESAVAULT LossIntelligence — Production Context Admin v0.3 Herramienta controlada para abrir, cerrar, crear retrospectivamente y cancelar contextos explícitos de producción. Seguridad: - en modo SHADOW fuerza official_eligible=false; - no modifica Odoo, WISE, RevPi ni balizas; - no almacena PINs; - usa la conexión PostgreSQL ya disponible en el entorno UCEPSA. """ from __future__ import annotations import argparse import json import os import sys from datetime import datetime, timezone from typing import Any, Dict, Optional import psycopg2 import psycopg2.extras ALLOWED_SOURCES = ( "LEGACY_ERP", "MANUAL_AUTHORIZED", "MAINTENANCE", "TEST_SETUP", ) MACHINES = ("CORT-00", "CORT-01", "CORT-02") def env(name: str, default: Optional[str] = None) -> str: value = os.getenv(name, default) if value is None or value == "": raise RuntimeError(f"Falta variable de entorno {name}") return value def connect(): return psycopg2.connect( host=env("PGHOST", "mv_ucepsa_postgres_hot"), port=int(env("PGPORT", "5432")), dbname=env("PGDATABASE"), user=env("PGUSER"), password=env("PGPASSWORD"), ) def parse_ts(value: Optional[str]) -> Optional[datetime]: if value is None: return None if value.lower() == "now": return datetime.now(timezone.utc) parsed = datetime.fromisoformat(value) if parsed.tzinfo is None: raise ValueError( "La fecha debe incluir zona horaria, por ejemplo 2026-07-17T10:30:00+02:00" ) return parsed def deployment_mode(cur) -> str: cur.execute( """ SELECT mode FROM mv_loss_intelligence.deployment_config WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND scope_code = 'CUTTERS' ORDER BY id DESC LIMIT 1 """ ) row = cur.fetchone() if not row: raise RuntimeError("No existe deployment_config CUTTERS") return str(row[0]) def print_json(payload: Any) -> None: print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) def open_context(args) -> None: started_at = parse_ts(args.started_at) or datetime.now(timezone.utc) with connect() as conn: with conn.cursor( cursor_factory=psycopg2.extras.RealDictCursor ) as cur: mode = deployment_mode(cur) official = bool(args.official_eligible and mode == "ACTIVE") if args.official_eligible and mode != "ACTIVE": print( "[WARN] El despliegue está en SHADOW: official_eligible se fuerza a false.", file=sys.stderr, ) cur.execute( """ INSERT INTO mv_loss_intelligence.production_context_sessions ( tenant, site, machine_id, source_type, source_ref, external_order_ref, product_id, product_default_code, product_name, operator_employee_id, operator_name, started_at, status, authorization_status, confidence, official_eligible, authorized_by, authorized_at, notes, evidence_json ) VALUES ( 'ucepsa', 'ucepsa_onpremise', %(machine_id)s, %(source_type)s, %(source_ref)s, %(external_order_ref)s, %(product_id)s, %(product_default_code)s, %(product_name)s, %(operator_employee_id)s, %(operator_name)s, %(started_at)s, 'OPEN', 'AUTHORIZED', %(confidence)s, %(official_eligible)s, %(authorized_by)s, now(), %(notes)s, %(evidence_json)s ) RETURNING * """, { "machine_id": args.machine, "source_type": args.source, "source_ref": args.source_ref, "external_order_ref": args.external_order_ref, "product_id": args.product_id, "product_default_code": args.product_code, "product_name": args.product_name, "operator_employee_id": args.operator_employee_id, "operator_name": args.operator_name, "started_at": started_at, "confidence": args.confidence, "official_eligible": official, "authorized_by": args.authorized_by, "notes": args.notes, "evidence_json": psycopg2.extras.Json({ "created_with": "li_context_admin.py", "deployment_mode": mode, }), }, ) print_json(cur.fetchone()) def close_context(args) -> None: ended_at = parse_ts(args.ended_at) or datetime.now(timezone.utc) with connect() as conn: with conn.cursor( cursor_factory=psycopg2.extras.RealDictCursor ) as cur: cur.execute( """ UPDATE mv_loss_intelligence.production_context_sessions SET ended_at = %(ended_at)s, status = 'CLOSED', closed_by = %(closed_by)s, notes = concat_ws( E'\n', notes, %(notes)s ), updated_at = now() WHERE context_session_id = %(context_session_id)s AND status = 'OPEN' AND ended_at IS NULL AND %(ended_at)s > started_at RETURNING * """, { "context_session_id": args.session_id, "ended_at": ended_at, "closed_by": args.closed_by, "notes": args.notes, }, ) row = cur.fetchone() if not row: raise RuntimeError( "No se ha cerrado ninguna sesión. Comprueba ID, estado y fechas." ) print_json(row) def create_retrospective(args) -> None: started_at = parse_ts(args.started_at) ended_at = parse_ts(args.ended_at) if not started_at or not ended_at or ended_at <= started_at: raise ValueError("El intervalo retrospectivo debe tener inicio y fin válidos.") with connect() as conn: with conn.cursor( cursor_factory=psycopg2.extras.RealDictCursor ) as cur: mode = deployment_mode(cur) official = bool(args.official_eligible and mode == "ACTIVE") if args.official_eligible and mode != "ACTIVE": print( "[WARN] El despliegue está en SHADOW: official_eligible se fuerza a false.", file=sys.stderr, ) cur.execute( """ INSERT INTO mv_loss_intelligence.production_context_sessions ( tenant, site, machine_id, source_type, source_ref, external_order_ref, product_id, product_default_code, product_name, operator_employee_id, operator_name, started_at, ended_at, status, authorization_status, confidence, official_eligible, authorized_by, authorized_at, closed_by, notes, evidence_json ) VALUES ( 'ucepsa', 'ucepsa_onpremise', %(machine_id)s, %(source_type)s, %(source_ref)s, %(external_order_ref)s, %(product_id)s, %(product_default_code)s, %(product_name)s, %(operator_employee_id)s, %(operator_name)s, %(started_at)s, %(ended_at)s, 'CLOSED', 'AUTHORIZED', %(confidence)s, %(official_eligible)s, %(authorized_by)s, now(), %(closed_by)s, %(notes)s, %(evidence_json)s ) RETURNING * """, { "machine_id": args.machine, "source_type": args.source, "source_ref": args.source_ref, "external_order_ref": args.external_order_ref, "product_id": args.product_id, "product_default_code": args.product_code, "product_name": args.product_name, "operator_employee_id": args.operator_employee_id, "operator_name": args.operator_name, "started_at": started_at, "ended_at": ended_at, "confidence": args.confidence, "official_eligible": official, "authorized_by": args.authorized_by, "closed_by": args.closed_by, "notes": args.notes, "evidence_json": psycopg2.extras.Json({ "created_with": "li_context_admin.py", "deployment_mode": mode, "retrospective": True, }), }, ) print_json(cur.fetchone()) def cancel_context(args) -> None: with connect() as conn: with conn.cursor( cursor_factory=psycopg2.extras.RealDictCursor ) as cur: cur.execute( """ UPDATE mv_loss_intelligence.production_context_sessions SET status = 'CANCELLED', ended_at = COALESCE(ended_at, now()), closed_by = %(closed_by)s, notes = concat_ws(E'\n', notes, %(notes)s), updated_at = now() WHERE context_session_id = %(context_session_id)s AND status <> 'CANCELLED' RETURNING * """, { "context_session_id": args.session_id, "closed_by": args.closed_by, "notes": args.notes, }, ) row = cur.fetchone() if not row: raise RuntimeError("No se ha cancelado ninguna sesión.") print_json(row) def list_contexts(args) -> None: with connect() as conn: with conn.cursor( cursor_factory=psycopg2.extras.RealDictCursor ) as cur: where = [ "tenant = 'ucepsa'", "site = 'ucepsa_onpremise'", ] params: Dict[str, Any] = {"limit": args.limit} if args.machine: where.append("machine_id = %(machine_id)s") params["machine_id"] = args.machine if args.open_only: where.append("status = 'OPEN'") cur.execute( f""" SELECT * FROM mv_loss_intelligence.production_context_sessions WHERE {' AND '.join(where)} ORDER BY context_session_id DESC LIMIT %(limit)s """, params, ) print_json(cur.fetchall()) def add_common_context_args(parser) -> None: parser.add_argument("--machine", required=True, choices=MACHINES) parser.add_argument("--source", required=True, choices=ALLOWED_SOURCES) parser.add_argument("--source-ref") parser.add_argument("--external-order-ref") parser.add_argument("--product-id", type=int) parser.add_argument("--product-code") parser.add_argument("--product-name") parser.add_argument("--operator-employee-id", type=int) parser.add_argument("--operator-name") parser.add_argument( "--confidence", choices=("HIGH", "MEDIUM", "LOW"), default="MEDIUM", ) parser.add_argument("--authorized-by", required=True) parser.add_argument("--notes") parser.add_argument("--official-eligible", action="store_true") def main() -> int: parser = argparse.ArgumentParser( description="Administración controlada de contextos productivos" ) sub = parser.add_subparsers(dest="command", required=True) p_open = sub.add_parser("open") add_common_context_args(p_open) p_open.add_argument("--started-at", default="now") p_open.set_defaults(func=open_context) p_close = sub.add_parser("close") p_close.add_argument("--session-id", type=int, required=True) p_close.add_argument("--ended-at", default="now") p_close.add_argument("--closed-by", required=True) p_close.add_argument("--notes") p_close.set_defaults(func=close_context) p_create = sub.add_parser("create-retrospective") add_common_context_args(p_create) p_create.add_argument("--started-at", required=True) p_create.add_argument("--ended-at", required=True) p_create.add_argument("--closed-by", required=True) p_create.set_defaults(func=create_retrospective) p_cancel = sub.add_parser("cancel") p_cancel.add_argument("--session-id", type=int, required=True) p_cancel.add_argument("--closed-by", required=True) p_cancel.add_argument("--notes") p_cancel.set_defaults(func=cancel_context) p_list = sub.add_parser("list") p_list.add_argument("--machine", choices=MACHINES) p_list.add_argument("--open-only", action="store_true") p_list.add_argument("--limit", type=int, default=30) p_list.set_defaults(func=list_contexts) args = parser.parse_args() args.func(args) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(f"[ERROR] {exc}", file=sys.stderr) raise SystemExit(1)