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