2026-07-21 10:29:31 +02:00

1031 lines
29 KiB
Python

from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from psycopg2.extras import Json, RealDictCursor
from .auth import CurrentUser
from .db import get_connection
from .settings import get_settings
ALLOWED_ANSWER_CODES = {
"AUTHORIZED_LEGACY_PRODUCTION",
"ODOO_START_OMITTED",
"TEST_OR_SETUP",
"RESIDUAL_MATERIAL",
"MAINTENANCE",
"NOT_PRODUCTION",
"ODOO_DOUBLE_START_ONE_VALID",
"ODOO_DOUBLE_START_BOTH_INVALID",
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
"DATA_ISSUE",
"OTHER",
}
MAPPED_CLASSIFICATION = {
"AUTHORIZED_LEGACY_PRODUCTION":
"AUTHORIZED_LEGACY_PRODUCTION",
"ODOO_START_OMITTED":
"ODOO_START_OMITTED",
"TEST_OR_SETUP":
"TEST_OR_SETUP",
"RESIDUAL_MATERIAL":
"RESIDUAL_MATERIAL",
"MAINTENANCE":
"OTHER",
"NOT_PRODUCTION":
"NOT_RELEVANT",
"ODOO_DOUBLE_START_ONE_VALID":
"ODOO_DOUBLE_START_ONE_VALID",
"ODOO_DOUBLE_START_BOTH_INVALID":
"ODOO_DOUBLE_START_BOTH_INVALID",
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED":
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
"DATA_ISSUE":
"DATA_ISSUE",
"OTHER":
"OTHER",
}
CONTEXT_SOURCE_TYPE = {
"AUTHORIZED_LEGACY_PRODUCTION":
"LEGACY_ERP",
"TEST_OR_SETUP":
"TEST_SETUP",
"MAINTENANCE":
"MAINTENANCE",
}
@dataclass(frozen=True)
class ReviewInput:
decision_group_key: str
selected_case_ids: tuple[int, ...]
answer_code: str
notes: str
selected_order_ref: str | None
selected_workorder_id: int | None
corrected_in_odoo: bool
create_contexts: bool
confirmation: bool
class ReviewError(RuntimeError):
pass
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 hasattr(value, "isoformat"):
return value.isoformat()
return value
def fetch_decision(
cursor: RealDictCursor,
decision_group_key: str,
) -> dict[str, Any]:
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_governance_decision_evidence_pack_v1
WHERE decision_group_key = %s
""",
(decision_group_key,),
)
row = cursor.fetchone()
if not row:
raise ReviewError(
"La decisión ya no existe."
)
return dict(row)
def fetch_cases_for_update(
cursor: RealDictCursor,
decision_group_key: str,
) -> list[dict[str, Any]]:
cursor.execute(
"""
SELECT
c.*
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_cases_v1 c
JOIN
mv_loss_intelligence
.context_review_cases raw
ON raw.case_id =
c.case_id
WHERE c.decision_group_key = %s
ORDER BY c.case_id
FOR UPDATE OF raw
""",
(decision_group_key,),
)
return [
dict(row)
for row in cursor.fetchall()
]
def validate_input(
decision: dict[str, Any],
all_cases: list[dict[str, Any]],
review: ReviewInput,
) -> tuple[
list[dict[str, Any]],
list[dict[str, Any]],
str,
]:
if not review.confirmation:
raise ReviewError(
"Debe confirmar que la respuesta refleja la realidad de planta."
)
if review.answer_code not in ALLOWED_ANSWER_CODES:
raise ReviewError(
"Código de respuesta no permitido."
)
pending_cases = [
case
for case in all_cases
if case["case_review_status"] ==
"PENDING"
]
if not pending_cases:
raise ReviewError(
"La decisión ya no tiene casos pendientes."
)
selected_ids = set(
review.selected_case_ids
)
pending_ids = {
int(case["case_id"])
for case in pending_cases
}
if not selected_ids:
raise ReviewError(
"Debe seleccionar al menos un intervalo o caso."
)
if not selected_ids.issubset(
pending_ids
):
raise ReviewError(
"La selección contiene casos que ya no están pendientes."
)
selected_cases = [
case
for case in pending_cases
if int(case["case_id"])
in selected_ids
]
family = str(
decision["decision_family"]
)
if family == "ODOO_ORDER_START_CONFLICT":
allowed = {
"ODOO_DOUBLE_START_ONE_VALID",
"ODOO_DOUBLE_START_BOTH_INVALID",
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
"DATA_ISSUE",
"OTHER",
}
if review.answer_code not in allowed:
raise ReviewError(
"La respuesta no corresponde a un conflicto de arranque Odoo."
)
if (
review.answer_code ==
"ODOO_DOUBLE_START_ONE_VALID"
):
if (
not review.selected_order_ref
or review.selected_workorder_id
is None
):
raise ReviewError(
"Debe seleccionar la orden y el workorder válidos."
)
valid_pairs = {
(
str(item["production_order"]),
int(item["odoo_workorder_id"]),
)
for item in (
decision.get(
"episode_evidence"
)
or []
)
if item.get(
"production_order"
)
and item.get(
"odoo_workorder_id"
) is not None
}
if (
review.selected_order_ref,
review.selected_workorder_id,
) not in valid_pairs:
raise ReviewError(
"La orden seleccionada no pertenece a la evidencia."
)
if not review.corrected_in_odoo:
raise ReviewError(
"Debe confirmar que la incoherencia ya fue corregida en Odoo."
)
elif family == "UNCONFIRMED_ACTIVITY":
allowed = {
"AUTHORIZED_LEGACY_PRODUCTION",
"ODOO_START_OMITTED",
"TEST_OR_SETUP",
"RESIDUAL_MATERIAL",
"MAINTENANCE",
"NOT_PRODUCTION",
"OTHER",
}
if review.answer_code not in allowed:
raise ReviewError(
"La respuesta no corresponde a actividad sin contexto."
)
if review.create_contexts:
if (
review.answer_code
not in CONTEXT_SOURCE_TYPE
):
raise ReviewError(
"Esta respuesta no permite crear contextos retrospectivos."
)
for case in selected_cases:
if (
case["case_status"] != "CLOSED"
or case["ended_at"] is None
):
raise ReviewError(
"Solo pueden crearse contextos para intervalos terminados."
)
if not case["machine_id"]:
raise ReviewError(
"El caso no tiene máquina asociada."
)
mapped = MAPPED_CLASSIFICATION[
review.answer_code
]
return (
pending_cases,
selected_cases,
mapped,
)
def insert_contexts(
cursor: RealDictCursor,
decision: dict[str, Any],
selected_cases: list[dict[str, Any]],
review: ReviewInput,
user: CurrentUser,
request_id: uuid.UUID,
) -> list[int]:
if not review.create_contexts:
return []
source_type = CONTEXT_SOURCE_TYPE[
review.answer_code
]
settings = get_settings()
created_ids: list[int] = []
for case in selected_cases:
case_id = int(case["case_id"])
source_ref = (
f"GOVERNANCE_UI_CASE:{case_id}:"
f"{review.answer_code}"
)
cursor.execute(
"""
SELECT context_session_id
FROM
mv_loss_intelligence
.production_context_sessions
WHERE source_ref = %s
""",
(source_ref,),
)
existing = cursor.fetchone()
if existing:
created_ids.append(
int(
existing[
"context_session_id"
]
)
)
continue
cursor.execute(
"""
SELECT COUNT(*) AS overlap_count
FROM (
SELECT 1
FROM
mv_loss_intelligence
.production_context_sessions s
WHERE s.machine_id = %s
AND s.started_at < %s
AND COALESCE(
s.ended_at,
'infinity'::timestamptz
) > %s
UNION ALL
SELECT 1
FROM
mv_loss_intelligence
.odoo_shopfloor_sessions o
WHERE o.machine_id = %s
AND o.started_at IS NOT NULL
AND o.started_at < %s
AND COALESCE(
o.ended_at,
'infinity'::timestamptz
) > %s
AND o.session_status IN (
'OPEN',
'CLOSED'
)
) overlaps
""",
(
case["machine_id"],
case["ended_at"],
case["first_seen_at"],
case["machine_id"],
case["ended_at"],
case["first_seen_at"],
),
)
overlap_count = int(
cursor.fetchone()["overlap_count"]
)
if overlap_count > 0:
raise ReviewError(
f"El caso {case_id} se solapa con otro contexto autoritativo. "
"No se ha creado ningún contexto."
)
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.production_context_sessions (
tenant,
site,
machine_id,
source_type,
source_ref,
started_at,
ended_at,
status,
authorization_status,
confidence,
official_eligible,
authorized_by,
authorized_at,
closed_by,
notes,
evidence_json
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s,
'CLOSED',
'AUTHORIZED',
'MEDIUM',
false,
%s,
now(),
%s,
%s,
%s
)
RETURNING context_session_id
""",
(
settings.tenant,
settings.site,
case["machine_id"],
source_type,
source_ref,
case["first_seen_at"],
case["ended_at"],
user.display_name,
user.display_name,
review.notes,
Json({
"created_with":
"governance_review_ui_v0314",
"request_id":
str(request_id),
"decision_group_key":
decision[
"decision_group_key"
],
"case_id":
case_id,
"answer_code":
review.answer_code,
"retrospective":
True,
"deployment_mode":
"SHADOW",
}),
),
)
created_ids.append(
int(
cursor.fetchone()[
"context_session_id"
]
)
)
return created_ids
def insert_case_and_episode_reviews(
cursor: RealDictCursor,
decision: dict[str, Any],
selected_cases: list[dict[str, Any]],
mapped_classification: str,
review: ReviewInput,
user: CurrentUser,
request_id: uuid.UUID,
) -> tuple[list[int], list[int]]:
case_review_ids: list[int] = []
episode_review_ids: list[int] = []
new_status = (
"DISMISSED"
if mapped_classification ==
"NOT_RELEVANT"
else "REVIEWED"
)
for case in selected_cases:
case_id = int(case["case_id"])
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_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,
mapped_classification,
review.selected_order_ref,
review.selected_workorder_id,
user.display_name,
review.notes,
Json({
"source":
"governance_review_ui_v0314",
"request_id":
str(request_id),
"decision_group_key":
decision[
"decision_group_key"
],
"answer_code":
review.answer_code,
}),
),
)
case_review_ids.append(
int(
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_id,),
)
episodes = [
dict(row)
for row in cursor.fetchall()
]
episode_classification = (
"DATA_ISSUE"
if mapped_classification in {
"ODOO_DOUBLE_START_ONE_VALID",
"ODOO_DOUBLE_START_BOTH_INVALID",
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
}
else mapped_classification
)
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"
],
})
),
new_status,
episode_classification,
user.display_name,
review.notes,
Json({
"source":
"governance_review_ui_v0314",
"request_id":
str(request_id),
"decision_group_key":
decision[
"decision_group_key"
],
"case_id":
case_id,
"answer_code":
review.answer_code,
}),
),
)
episode_review_ids.append(
int(
cursor.fetchone()[
"review_id"
]
)
)
return (
case_review_ids,
episode_review_ids,
)
def apply_review(
review: ReviewInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
request_id = uuid.uuid4()
settings = get_settings()
with get_connection() as connection:
connection.autocommit = False
try:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT pg_advisory_xact_lock(
hashtext(%s)
)
""",
(
(
f"{settings.tenant}:"
f"{settings.site}:"
f"{review.decision_group_key}"
),
),
)
decision = fetch_decision(
cursor,
review.decision_group_key,
)
all_cases = fetch_cases_for_update(
cursor,
review.decision_group_key,
)
(
pending_cases,
selected_cases,
mapped_classification,
) = validate_input(
decision,
all_cases,
review,
)
previous_state = {
"decision": decision,
"cases": all_cases,
}
created_context_ids = insert_contexts(
cursor,
decision,
selected_cases,
review,
user,
request_id,
)
pending_ids = {
int(case["case_id"])
for case in pending_cases
}
selected_ids = {
int(case["case_id"])
for case in selected_cases
}
all_pending_selected = (
pending_ids == selected_ids
)
decision_review_id = None
if all_pending_selected:
new_status = (
"DISMISSED"
if mapped_classification ==
"NOT_RELEVANT"
else "REVIEWED"
)
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 review_id
""",
(
settings.tenant,
settings.site,
review.decision_group_key,
Json(
json_safe(decision)
),
new_status,
mapped_classification,
review.selected_order_ref,
review.selected_workorder_id,
user.display_name,
review.notes,
Json({
"source":
"governance_review_ui_v0314",
"request_id":
str(request_id),
"answer_code":
review.answer_code,
"selected_case_ids":
list(
review.selected_case_ids
),
}),
),
)
decision_review_id = int(
cursor.fetchone()[
"review_id"
]
)
(
case_review_ids,
episode_review_ids,
) = insert_case_and_episode_reviews(
cursor,
decision,
selected_cases,
mapped_classification,
review,
user,
request_id,
)
resulting_decision = fetch_decision(
cursor,
review.decision_group_key,
)
resulting_state = {
"decision":
resulting_decision,
"decision_review_id":
decision_review_id,
"case_review_ids":
case_review_ids,
"episode_review_ids":
episode_review_ids,
"created_context_session_ids":
created_context_ids,
}
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.context_governance_ui_actions (
tenant,
site,
request_id,
decision_group_key,
selected_case_ids,
action_scope,
answer_code,
mapped_classification,
selected_order_ref,
selected_workorder_id,
corrected_in_odoo,
create_contexts,
created_context_session_ids,
actor_username,
actor_display_name,
actor_roles,
notes,
previous_state,
resulting_state,
status,
source_ip,
user_agent,
official_eligible
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
'APPLIED',
%s,
%s,
false
)
RETURNING ui_action_id
""",
(
settings.tenant,
settings.site,
str(request_id),
review.decision_group_key,
list(review.selected_case_ids),
(
"ALL_PENDING_CASES"
if all_pending_selected
else "SELECTED_CASES"
),
review.answer_code,
mapped_classification,
review.selected_order_ref,
review.selected_workorder_id,
review.corrected_in_odoo,
review.create_contexts,
created_context_ids,
user.username,
user.display_name,
sorted(user.roles),
review.notes,
Json(
json_safe(
previous_state
)
),
Json(
json_safe(
resulting_state
)
),
source_ip,
user_agent,
),
)
ui_action_id = int(
cursor.fetchone()[
"ui_action_id"
]
)
connection.commit()
return {
"ui_action_id":
ui_action_id,
"request_id":
str(request_id),
"decision_review_id":
decision_review_id,
"case_review_ids":
case_review_ids,
"episode_review_ids":
episode_review_ids,
"created_context_session_ids":
created_context_ids,
"all_pending_cases_selected":
all_pending_selected,
}
except Exception:
connection.rollback()
raise