2026-07-21 16:45:13 +02:00

1259 lines
37 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",
}
PROPOSED_CONTEXT_SOURCE = {
"AUTHORIZED_LEGACY_PRODUCTION":
"LEGACY_ERP",
"TEST_OR_SETUP":
"TEST_SETUP",
"MAINTENANCE":
"MAINTENANCE",
}
@dataclass(frozen=True)
class ReviewInput:
submission_key: uuid.UUID
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
confirmation: bool
@dataclass(frozen=True)
class ConsultationInput:
submission_key: uuid.UUID
decision_group_key: str
reason: str
follow_up_with: str
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 existing_review_action(
cursor: RealDictCursor,
settings,
submission_key: uuid.UUID,
) -> dict[str, Any] | None:
cursor.execute(
"""
SELECT
ui_action_id,
request_id,
decision_group_key,
selected_case_ids,
answer_code,
resulting_state,
status
FROM
mv_loss_intelligence
.context_governance_ui_actions
WHERE tenant = %s
AND site = %s
AND request_id = %s
""",
(
settings.tenant,
settings.site,
str(submission_key),
),
)
row = cursor.fetchone()
return dict(row) if row else None
def validate_review(
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."
)
mapped = MAPPED_CLASSIFICATION[
review.answer_code
]
return (
pending_cases,
selected_cases,
mapped,
)
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,
) -> 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_v0315",
"request_id":
str(review.submission_key),
"decision_group_key":
decision[
"decision_group_key"
],
"answer_code":
review.answer_code,
"two_stage_context":
True,
}),
),
)
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_v0315",
"request_id":
str(review.submission_key),
"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 create_context_proposals(
cursor: RealDictCursor,
settings,
decision: dict[str, Any],
selected_cases: list[dict[str, Any]],
mapped_classification: str,
review: ReviewInput,
user: CurrentUser,
) -> list[int]:
source_type = PROPOSED_CONTEXT_SOURCE.get(
review.answer_code
)
if source_type is None:
return []
proposal_ids: list[int] = []
for case in selected_cases:
case_id = int(case["case_id"])
proposal_key = (
f"CONTEXT_PROPOSAL:CASE:{case_id}:"
f"{review.answer_code}"
)
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.context_governance_context_proposals (
tenant,
site,
proposal_key,
request_id,
decision_group_key,
case_id,
answer_code,
mapped_classification,
proposed_source_type,
proposed_by_username,
proposed_by_display_name,
source_evidence,
official_eligible
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, false
)
ON CONFLICT (
tenant,
site,
proposal_key
)
DO NOTHING
RETURNING proposal_id
""",
(
settings.tenant,
settings.site,
proposal_key,
str(review.submission_key),
review.decision_group_key,
case_id,
review.answer_code,
mapped_classification,
source_type,
user.username,
user.display_name,
Json({
"machine_id":
case["machine_id"],
"first_seen_at":
json_safe(
case["first_seen_at"]
),
"ended_at":
json_safe(
case["ended_at"]
),
"case_status":
case["case_status"],
"production_orders":
case[
"production_orders"
],
"odoo_workorder_ids":
case[
"odoo_workorder_ids"
],
"production_notes":
review.notes,
"deployment_mode":
"SHADOW",
}),
),
)
row = cursor.fetchone()
if row:
proposal_ids.append(
int(row["proposal_id"])
)
continue
cursor.execute(
"""
SELECT proposal_id
FROM
mv_loss_intelligence
.context_governance_context_proposals
WHERE tenant = %s
AND site = %s
AND proposal_key = %s
""",
(
settings.tenant,
settings.site,
proposal_key,
),
)
existing = cursor.fetchone()
if existing:
proposal_ids.append(
int(existing["proposal_id"])
)
return proposal_ids
def close_open_consultation(
cursor: RealDictCursor,
settings,
decision_group_key: str,
user: CurrentUser,
) -> None:
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_ui_consultations
SET
status = 'CLOSED',
closed_by_username = %s,
closed_by_display_name = %s,
closed_at = now(),
close_reason =
'La decisión fue respondida desde la interfaz.'
WHERE tenant = %s
AND site = %s
AND decision_group_key = %s
AND status = 'OPEN'
""",
(
user.username,
user.display_name,
settings.tenant,
settings.site,
decision_group_key,
),
)
def apply_production_review(
review: ReviewInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
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.submission_key}"
),
),
)
replay = existing_review_action(
cursor,
settings,
review.submission_key,
)
if replay:
connection.rollback()
return {
"idempotent_replay": True,
"ui_action_id":
replay["ui_action_id"],
"request_id":
str(
replay["request_id"]
),
"resulting_state":
replay["resulting_state"],
}
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_review(
decision,
all_cases,
review,
)
previous_state = {
"decision": decision,
"cases": all_cases,
}
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_v0315",
"request_id":
str(
review.submission_key
),
"answer_code":
review.answer_code,
"selected_case_ids":
list(
review.selected_case_ids
),
"two_stage_context":
True,
}),
),
)
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,
)
proposal_ids = create_context_proposals(
cursor,
settings,
decision,
selected_cases,
mapped_classification,
review,
user,
)
close_open_consultation(
cursor,
settings,
review.decision_group_key,
user,
)
resulting_state = {
"decision_review_id":
decision_review_id,
"case_review_ids":
case_review_ids,
"episode_review_ids":
episode_review_ids,
"context_proposal_ids":
proposal_ids,
"created_context_session_ids":
[],
"all_pending_cases_selected":
all_pending_selected,
}
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, false, ARRAY[]::bigint[],
%s, %s, %s, %s, %s,
%s, 'APPLIED', %s, %s, false
)
RETURNING ui_action_id
""",
(
settings.tenant,
settings.site,
str(review.submission_key),
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,
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 {
"idempotent_replay": False,
"ui_action_id": ui_action_id,
"request_id":
str(review.submission_key),
**resulting_state,
}
except Exception:
connection.rollback()
raise
def mark_in_consultation(
consultation: ConsultationInput,
user: CurrentUser,
) -> dict[str, Any]:
settings = get_settings()
if not consultation.reason.strip():
raise ReviewError(
"Debe indicar qué información falta o a quién hay que preguntar."
)
with get_connection() as connection:
connection.autocommit = False
try:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT consultation_id
FROM
mv_loss_intelligence
.context_governance_ui_consultations
WHERE tenant = %s
AND site = %s
AND request_id = %s
""",
(
settings.tenant,
settings.site,
str(
consultation.submission_key
),
),
)
replay = cursor.fetchone()
if replay:
connection.rollback()
return {
"consultation_id":
int(
replay[
"consultation_id"
]
),
"idempotent_replay":
True,
}
decision = fetch_decision(
cursor,
consultation.decision_group_key,
)
if decision["review_status"] != "PENDING":
raise ReviewError(
"La decisión ya no está pendiente."
)
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.context_governance_ui_consultations (
tenant,
site,
request_id,
decision_group_key,
status,
reason,
follow_up_with,
created_by_username,
created_by_display_name,
created_by_roles,
official_eligible
)
VALUES (
%s, %s, %s, %s, 'OPEN',
%s, %s, %s, %s, %s, false
)
ON CONFLICT (
tenant,
site,
decision_group_key
)
WHERE status = 'OPEN'
DO UPDATE SET
request_id = EXCLUDED.request_id,
reason = EXCLUDED.reason,
follow_up_with =
EXCLUDED.follow_up_with,
created_by_username =
EXCLUDED.created_by_username,
created_by_display_name =
EXCLUDED.created_by_display_name,
created_by_roles =
EXCLUDED.created_by_roles,
created_at = now()
RETURNING consultation_id
""",
(
settings.tenant,
settings.site,
str(
consultation.submission_key
),
consultation.decision_group_key,
consultation.reason.strip(),
consultation.follow_up_with.strip()
or None,
user.username,
user.display_name,
sorted(user.roles),
),
)
consultation_id = int(
cursor.fetchone()[
"consultation_id"
]
)
connection.commit()
return {
"consultation_id":
consultation_id,
"idempotent_replay":
False,
}
except Exception:
connection.rollback()
raise
def resume_from_consultation(
submission_key: uuid.UUID,
decision_group_key: str,
user: CurrentUser,
) -> dict[str, Any]:
settings = get_settings()
with get_connection() as connection:
connection.autocommit = False
try:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT consultation_id
FROM
mv_loss_intelligence
.context_governance_ui_consultations
WHERE tenant = %s
AND site = %s
AND request_id = %s
""",
(
settings.tenant,
settings.site,
str(submission_key),
),
)
replay = cursor.fetchone()
if replay:
connection.rollback()
return {
"consultation_id":
int(
replay[
"consultation_id"
]
),
"idempotent_replay":
True,
}
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_ui_consultations
SET
status = 'CLOSED',
closed_by_username = %s,
closed_by_display_name = %s,
closed_at = now(),
close_reason =
'La información ya está disponible; vuelve a Pendiente.'
WHERE tenant = %s
AND site = %s
AND decision_group_key = %s
AND status = 'OPEN'
RETURNING consultation_id
""",
(
user.username,
user.display_name,
settings.tenant,
settings.site,
decision_group_key,
),
)
row = cursor.fetchone()
if not row:
raise ReviewError(
"La decisión no estaba marcada En consulta."
)
consultation_id = int(
row["consultation_id"]
)
# The request id is stored on the closed row to make retries idempotent.
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_ui_consultations
SET request_id = %s
WHERE consultation_id = %s
""",
(
str(submission_key),
consultation_id,
),
)
connection.commit()
return {
"consultation_id":
consultation_id,
"idempotent_replay":
False,
}
except Exception:
connection.rollback()
raise