440 lines
15 KiB
Python
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[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)
|