40-clients/ucepsa/edge-oee-demo/tools/context_decision_group_admin.py

651 lines
19 KiB
Python
Executable File

#!/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())