40-clients/ucepsa/edge-oee-demo/tools/li_context_admin.py
2026-07-17 11:47:18 +02:00

440 lines
15 KiB
Python

#!/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["mode"])
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] {type(exc).__name__}: {exc!r}", file=sys.stderr)
raise SystemExit(1)