651 lines
19 KiB
Python
Executable File
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())
|