1259 lines
37 KiB
Python
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
|