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

598 lines
18 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
@dataclass(frozen=True)
class ProposalActionInput:
submission_key: uuid.UUID
proposal_id: int
action_code: str
notes: str
class ProposalError(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_proposal_for_update(
cursor: RealDictCursor,
proposal_id: int,
) -> dict[str, Any]:
cursor.execute(
"""
SELECT p.*
FROM
mv_loss_intelligence
.context_governance_context_proposals raw
JOIN
mv_reports_ucepsa_prod
.li_context_governance_context_proposals_v1 p
ON p.proposal_id = raw.proposal_id
WHERE raw.proposal_id = %s
FOR UPDATE OF raw
""",
(proposal_id,),
)
row = cursor.fetchone()
if not row:
raise ProposalError(
"La propuesta no existe."
)
return dict(row)
def existing_action(
cursor: RealDictCursor,
settings,
submission_key: uuid.UUID,
) -> dict[str, Any] | None:
cursor.execute(
"""
SELECT *
FROM
mv_loss_intelligence
.context_governance_context_proposal_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 count_authoritative_overlaps(
cursor: RealDictCursor,
proposal: dict[str, Any],
) -> int:
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
""",
(
proposal["machine_id"],
proposal["ended_at"],
proposal["first_seen_at"],
proposal["machine_id"],
proposal["ended_at"],
proposal["first_seen_at"],
),
)
return int(
cursor.fetchone()["overlap_count"]
)
def insert_proposal_action(
cursor: RealDictCursor,
settings,
action: ProposalActionInput,
proposal: dict[str, Any],
user: CurrentUser,
resulting_status: str,
context_session_id: int | None,
source_ip: str | None,
user_agent: str | None,
) -> int:
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.context_governance_context_proposal_actions (
tenant,
site,
request_id,
proposal_id,
action_code,
previous_status,
resulting_status,
actor_username,
actor_display_name,
actor_roles,
notes,
context_session_id,
source_ip,
user_agent,
official_eligible
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s, false
)
RETURNING proposal_action_id
""",
(
settings.tenant,
settings.site,
str(action.submission_key),
action.proposal_id,
action.action_code,
proposal["proposal_status"],
resulting_status,
user.username,
user.display_name,
sorted(user.roles),
action.notes.strip() or None,
context_session_id,
source_ip,
user_agent,
),
)
return int(
cursor.fetchone()[
"proposal_action_id"
]
)
def approve_proposal(
action: ProposalActionInput,
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"{action.submission_key}"
),
),
)
replay = existing_action(
cursor,
settings,
action.submission_key,
)
if replay:
connection.rollback()
return {
"idempotent_replay": True,
"proposal_action_id":
replay[
"proposal_action_id"
],
"context_session_id":
replay[
"context_session_id"
],
}
proposal = fetch_proposal_for_update(
cursor,
action.proposal_id,
)
if proposal["proposal_status"] != "PENDING":
raise ProposalError(
"Solo pueden aprobarse propuestas pendientes."
)
if (
proposal["case_status"] !=
"CLOSED"
or proposal["ended_at"] is None
):
raise ProposalError(
"El intervalo todavía no ha terminado."
)
overlap_count = count_authoritative_overlaps(
cursor,
proposal,
)
if overlap_count > 0:
raise ProposalError(
"El intervalo se solapa con otro contexto autoritativo. "
"La propuesta no puede aprobarse."
)
source_ref = (
"GOVERNANCE_PROPOSAL:"
f"{proposal['proposal_id']}"
)
cursor.execute(
"""
SELECT context_session_id
FROM
mv_loss_intelligence
.production_context_sessions
WHERE source_ref = %s
""",
(source_ref,),
)
existing_context = cursor.fetchone()
if existing_context:
context_session_id = int(
existing_context[
"context_session_id"
]
)
else:
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,
proposal["machine_id"],
proposal[
"proposed_source_type"
],
source_ref,
proposal["first_seen_at"],
proposal["ended_at"],
user.display_name,
user.display_name,
action.notes.strip()
or proposal.get(
"technical_review_notes"
)
or "Aprobación técnica desde Governance Review UI.",
Json({
"created_with":
"governance_review_ui_v0315",
"proposal_id":
proposal["proposal_id"],
"proposal_key":
proposal["proposal_key"],
"decision_group_key":
proposal[
"decision_group_key"
],
"case_id":
proposal["case_id"],
"answer_code":
proposal["answer_code"],
"proposed_by":
proposal[
"proposed_by_display_name"
],
"approved_by":
user.display_name,
"deployment_mode":
"SHADOW",
}),
),
)
context_session_id = int(
cursor.fetchone()[
"context_session_id"
]
)
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_context_proposals
SET
proposal_status = 'APPROVED',
technical_review_by_username = %s,
technical_review_by_display_name = %s,
technical_review_at = now(),
technical_review_notes = %s,
context_session_id = %s,
updated_at = now()
WHERE proposal_id = %s
""",
(
user.username,
user.display_name,
action.notes.strip() or None,
context_session_id,
action.proposal_id,
),
)
proposal_action_id = insert_proposal_action(
cursor,
settings,
action,
proposal,
user,
"APPROVED",
context_session_id,
source_ip,
user_agent,
)
connection.commit()
return {
"idempotent_replay": False,
"proposal_action_id":
proposal_action_id,
"context_session_id":
context_session_id,
}
except Exception:
connection.rollback()
raise
def change_proposal_status(
action: ProposalActionInput,
user: CurrentUser,
source_ip: str | None,
user_agent: str | None,
) -> dict[str, Any]:
settings = get_settings()
target_status = {
"REJECT": "REJECTED",
"REQUEST_CLARIFICATION":
"NEEDS_CLARIFICATION",
"CLARIFICATION_RESPONSE":
"PENDING",
}.get(action.action_code)
if target_status is None:
raise ProposalError(
"Acción de propuesta no permitida."
)
if not action.notes.strip():
raise ProposalError(
"Debe escribir una explicación."
)
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"{action.submission_key}"
),
),
)
replay = existing_action(
cursor,
settings,
action.submission_key,
)
if replay:
connection.rollback()
return {
"idempotent_replay": True,
"proposal_action_id":
replay[
"proposal_action_id"
],
"resulting_status":
replay[
"resulting_status"
],
}
proposal = fetch_proposal_for_update(
cursor,
action.proposal_id,
)
expected_status = (
"NEEDS_CLARIFICATION"
if action.action_code ==
"CLARIFICATION_RESPONSE"
else "PENDING"
)
if proposal["proposal_status"] != expected_status:
raise ProposalError(
"La propuesta ha cambiado de estado. Recargue la página."
)
cursor.execute(
"""
UPDATE
mv_loss_intelligence
.context_governance_context_proposals
SET
proposal_status = %s,
technical_review_by_username =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_by_username
ELSE %s
END,
technical_review_by_display_name =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_by_display_name
ELSE %s
END,
technical_review_at =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_at
ELSE now()
END,
technical_review_notes =
CASE
WHEN %s =
'CLARIFICATION_RESPONSE'
THEN technical_review_notes
ELSE %s
END,
updated_at = now()
WHERE proposal_id = %s
""",
(
target_status,
action.action_code,
user.username,
action.action_code,
user.display_name,
action.action_code,
action.action_code,
action.notes.strip(),
action.proposal_id,
),
)
proposal_action_id = insert_proposal_action(
cursor,
settings,
action,
proposal,
user,
target_status,
None,
source_ip,
user_agent,
)
connection.commit()
return {
"idempotent_replay": False,
"proposal_action_id":
proposal_action_id,
"resulting_status":
target_status,
}
except Exception:
connection.rollback()
raise