#!/usr/bin/env python3 """Administración de decisiones humanas consolidadas v0.3.11.""" from __future__ import annotations import argparse import json import os from datetime import date, datetime from decimal import Decimal from typing import Any import psycopg2 from psycopg2.extras import Json, RealDictCursor CLASSIFICATIONS = ( "ODOO_DOUBLE_START_ONE_VALID", "ODOO_DOUBLE_START_BOTH_INVALID", "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED", "AUTHORIZED_LEGACY_PRODUCTION", "TEST_OR_SETUP", "RESIDUAL_MATERIAL", "ODOO_START_OMITTED", "DATA_ISSUE", "NOT_RELEVANT", "OTHER", ) def env_first(*names: str, default: str | None = None) -> str | None: for name in names: value = os.getenv(name) if value not in (None, ""): return value return default def connect(): database = env_first("PGDATABASE", "POSTGRES_DB") user = env_first("PGUSER", "POSTGRES_USER") password = env_first("PGPASSWORD", "POSTGRES_PASSWORD") missing = [ name for name, value in ( ("PGDATABASE", database), ("PGUSER", user), ("PGPASSWORD", password), ) if not value ] if missing: raise RuntimeError( "Faltan variables: " + ", ".join(missing) ) return psycopg2.connect( host=env_first( "PGHOST", default="mv_ucepsa_postgres_hot", ), port=int( env_first("PGPORT", default="5432") or "5432" ), dbname=database, user=user, password=password, connect_timeout=10, application_name=( "context_decision_group_admin_v0311" ), ) 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 isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, Decimal): return str(value) return value def print_json(value: Any) -> None: print( json.dumps( json_safe(value), ensure_ascii=False, indent=2, ) ) def list_groups(limit: int) -> None: with connect() as connection: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT backlog_rank, decision_group_key, decision_status, max_severity, first_seen_at, last_seen_at, decision_duration_min, machine_id, decision_title, root_case_ids, production_orders, odoo_workorder_ids, linked_root_case_count, pending_root_case_count, reviewed_root_case_count, known_resolution_classifications, review_owner, review_status, question_text, recommended_action FROM mv_reports_ucepsa_prod .li_context_decision_group_backlog_v1 ORDER BY backlog_rank LIMIT %s """, (limit,), ) print_json( [dict(row) for row in cursor.fetchall()] ) def show_group(decision_group_key: str) -> None: with connect() as connection: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_decision_group_history_v1 WHERE decision_group_key = %s """, (decision_group_key,), ) group = cursor.fetchone() if not group: raise RuntimeError( "No existe la decisión " f"{decision_group_key!r}" ) cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_decision_group_cases_v1 WHERE decision_group_key = %s ORDER BY case_id """, (decision_group_key,), ) cases = [ dict(row) for row in cursor.fetchall() ] cursor.execute( """ SELECT * FROM mv_loss_intelligence .context_decision_group_reviews WHERE tenant = %s AND site = %s AND decision_group_key = %s ORDER BY reviewed_at, review_id """, ( group["tenant"], group["site"], decision_group_key, ), ) reviews = [ dict(row) for row in cursor.fetchall() ] print_json({ "decision_group": dict(group), "root_cases": cases, "reviews": reviews, }) def episode_classification( classification: str, ) -> str: mapping = { "ODOO_DOUBLE_START_ONE_VALID": "DATA_ISSUE", "ODOO_DOUBLE_START_BOTH_INVALID": "DATA_ISSUE", "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED": "DATA_ISSUE", "AUTHORIZED_LEGACY_PRODUCTION": "AUTHORIZED_LEGACY_PRODUCTION", "TEST_OR_SETUP": "TEST_OR_SETUP", "RESIDUAL_MATERIAL": "RESIDUAL_MATERIAL", "ODOO_START_OMITTED": "ODOO_START_OMITTED", "DATA_ISSUE": "DATA_ISSUE", "NOT_RELEVANT": "NOT_RELEVANT", "OTHER": "OTHER", } return mapping[classification] def review_group( decision_group_key: str, classification: str, reviewed_by: str, notes: str | None, selected_order_ref: str | None, selected_workorder_id: int | None, ) -> None: if ( classification == "ODOO_DOUBLE_START_ONE_VALID" and ( not selected_order_ref or selected_workorder_id is None ) ): raise RuntimeError( "ODOO_DOUBLE_START_ONE_VALID exige " "--selected-order-ref y " "--selected-workorder-id." ) new_status = ( "DISMISSED" if classification == "NOT_RELEVANT" else "REVIEWED" ) episode_status = new_status episode_class = episode_classification( classification ) with connect() as connection: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_decision_group_history_v1 WHERE decision_group_key = %s """, (decision_group_key,), ) group = cursor.fetchone() if not group: raise RuntimeError( "No existe la decisión " f"{decision_group_key!r}" ) if group["review_status"] != "PENDING": raise RuntimeError( "La decisión ya no está pendiente: " f"{group['review_status']}" ) cursor.execute( """ INSERT INTO mv_loss_intelligence .context_decision_group_reviews ( tenant, site, decision_group_key, previous_values, new_review_status, resolution_classification, selected_order_ref, selected_workorder_id, reviewed_by, notes, evidence_json ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) RETURNING * """, ( group["tenant"], group["site"], decision_group_key, Json(json_safe(dict(group))), new_status, classification, selected_order_ref, selected_workorder_id, reviewed_by, notes, Json({ "source": "context_decision_group_admin_v0311", "cascade_to_root_cases": True, "cascade_to_episodes": True, }), ), ) group_review = dict( cursor.fetchone() ) cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_decision_group_cases_v1 WHERE decision_group_key = %s ORDER BY case_id """, (decision_group_key,), ) cases = [ dict(row) for row in cursor.fetchall() ] cascaded_cases = [] cascaded_episodes = [] for case in cases: if ( case["case_review_status"] != "PENDING" ): continue case_notes = ( f"Decisión consolidada " f"{decision_group_key}: " f"{notes or classification}. " f"Orden seleccionada: " f"{selected_order_ref or '—'}; " f"workorder seleccionado: " f"{selected_workorder_id or '—'}." ) cursor.execute( """ INSERT INTO mv_loss_intelligence .context_review_case_reviews ( case_id, previous_values, new_review_status, resolution_classification, selected_order_ref, selected_workorder_id, reviewed_by, notes, evidence_json ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s ) RETURNING review_id """, ( case["case_id"], Json(json_safe({ "review_status": case[ "case_review_status" ], "resolution_classification": case[ "case_resolution_classification" ], "reviewed_by": case["case_reviewed_by"], "reviewed_at": case["case_reviewed_at"], "review_notes": case["case_review_notes"], })), new_status, classification, selected_order_ref, selected_workorder_id, reviewed_by, case_notes, Json({ "source_decision_group_key": decision_group_key, }), ), ) cascaded_cases.append({ "case_id": case["case_id"], "review_id": cursor.fetchone()["review_id"], }) cursor.execute( """ SELECT h.* FROM mv_loss_intelligence .context_review_case_evidence e JOIN mv_reports_ucepsa_prod .li_shopfloor_context_incident_history_v1 h ON h.episode_id = e.episode_id WHERE e.case_id = %s ORDER BY h.episode_id """, (case["case_id"],), ) episodes = [ dict(row) for row in cursor.fetchall() ] for episode in episodes: if ( episode["review_status"] != "PENDING" ): continue cursor.execute( """ INSERT INTO mv_loss_intelligence .shopfloor_context_incident_reviews ( episode_id, previous_values, new_review_status, review_classification, reviewed_by, notes, evidence_json ) VALUES ( %s, %s, %s, %s, %s, %s, %s ) RETURNING review_id """, ( episode["episode_id"], Json(json_safe({ "review_status": episode[ "review_status" ], "review_classification": episode[ "review_classification" ], "reviewed_by": episode[ "reviewed_by" ], "reviewed_at": episode[ "reviewed_at" ], "review_notes": episode[ "review_notes" ], })), episode_status, episode_class, reviewed_by, case_notes, Json({ "source_decision_group_key": decision_group_key, "source_case_id": case["case_id"], "root_classification": classification, }), ), ) cascaded_episodes.append({ "episode_id": episode["episode_id"], "review_id": cursor.fetchone()[ "review_id" ], }) cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_decision_group_history_v1 WHERE decision_group_key = %s """, (decision_group_key,), ) current = dict(cursor.fetchone()) print_json({ "decision_group_review": group_review, "decision_group": current, "cascaded_root_case_reviews": cascaded_cases, "cascaded_episode_reviews": cascaded_episodes, "warning": ( "La decisión consolida la revisión. " "No modifica Odoo, no crea contextos " "retrospectivos automáticamente y no " "habilita el Ledger oficial." ), }) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Administración de decisiones " "humanas consolidadas." ) ) sub = parser.add_subparsers( dest="command", required=True, ) list_cmd = sub.add_parser("list") list_cmd.add_argument( "--limit", type=int, default=50, ) show_cmd = sub.add_parser("show") show_cmd.add_argument( "--decision-group-key", required=True, ) review_cmd = sub.add_parser("review") review_cmd.add_argument( "--decision-group-key", required=True, ) review_cmd.add_argument( "--classification", choices=CLASSIFICATIONS, required=True, ) review_cmd.add_argument( "--reviewed-by", required=True, ) review_cmd.add_argument("--notes") review_cmd.add_argument( "--selected-order-ref" ) review_cmd.add_argument( "--selected-workorder-id", type=int, ) return parser.parse_args() def main() -> int: args = parse_args() if args.command == "list": list_groups(args.limit) return 0 if args.command == "show": show_group( args.decision_group_key ) return 0 if args.command == "review": review_group( decision_group_key= args.decision_group_key, classification= args.classification, reviewed_by= args.reviewed_by, notes= args.notes, selected_order_ref= args.selected_order_ref, selected_workorder_id= args.selected_workorder_id, ) return 0 raise RuntimeError("Comando no soportado") if __name__ == "__main__": raise SystemExit(main())