109 lines
4.5 KiB
Python
Executable File
109 lines
4.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Guarded repair for a validated reconstructed review whose blocked task stayed terminal.
|
|
|
|
Dry-run by default. It only reactivates a task when:
|
|
- the opportunity exists and its explicit reconstructed review is validated;
|
|
- the central decision still matches the blocked action;
|
|
- no pending task for that action exists;
|
|
- a skipped/ignored materialized task for that action exists;
|
|
- for physical validation, no validation link already exists.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action
|
|
from app.opportunity_next_action_service import get_opportunity_next_action
|
|
from app.work_center_action_policy import canonical_action_code, reconstructed_review_status
|
|
|
|
|
|
def inspect(opportunity_id: str) -> dict:
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT id::text, title, stage, metadata,
|
|
metadata #>> '{reconstructed_review,blocked_action_code}' AS blocked_action
|
|
FROM opportunities
|
|
WHERE id = CAST(:id AS UUID)
|
|
"""), {"id": opportunity_id}).mappings().first()
|
|
if not row:
|
|
raise SystemExit("Oportunidade não encontrada.")
|
|
tasks = conn.execute(text("""
|
|
SELECT id::text, action_code, status, idempotency_key, metadata
|
|
FROM tasks
|
|
WHERE opportunity_id = CAST(:id AS UUID)
|
|
AND action_code IN ('VALIDATE_PHYSICAL_ORDER','CREATE_SHIPMENT','SEND_INVOICE','PREPARE_ORDER')
|
|
ORDER BY created_at DESC
|
|
"""), {"id": opportunity_id}).mappings().all()
|
|
physical = conn.execute(text("""
|
|
SELECT id::text FROM operation_links
|
|
WHERE opportunity_id = CAST(:id AS UUID)
|
|
AND system='odoo' AND external_type='physical_validation'
|
|
AND status='validated'
|
|
LIMIT 1
|
|
"""), {"id": opportunity_id}).scalar()
|
|
decision = get_opportunity_next_action(opportunity_id)
|
|
return {"opportunity": dict(row), "tasks": [dict(t) for t in tasks], "physical_validated": bool(physical), "decision": decision}
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--opportunity-id", required=True)
|
|
ap.add_argument("--apply", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
data = inspect(args.opportunity_id)
|
|
opp = data["opportunity"]
|
|
review = reconstructed_review_status(opp.get("metadata"))
|
|
blocked = canonical_action_code(opp.get("blocked_action"))
|
|
decision_code = canonical_action_code(data["decision"].get("action_code"))
|
|
pending = [t for t in data["tasks"] if canonical_action_code(t.get("action_code")) == decision_code and str(t.get("status")) == "pending"]
|
|
terminal = [t for t in data["tasks"] if canonical_action_code(t.get("action_code")) == decision_code and str(t.get("status")) in {"skipped", "ignored"}]
|
|
|
|
print(f"Opportunity: {opp.get('title')} ({opp.get('stage')})")
|
|
print(f"Review: {review}")
|
|
print(f"Blocked action: {blocked or '-'}")
|
|
print(f"Central decision: {decision_code}")
|
|
print(f"Pending same action: {len(pending)}")
|
|
print(f"Terminal same action: {len(terminal)}")
|
|
|
|
if review != "validated":
|
|
raise SystemExit("ABORTADO: revisão não está validated.")
|
|
if not blocked or decision_code != blocked:
|
|
raise SystemExit("ABORTADO: decisão central não coincide com a ação desbloqueada.")
|
|
if pending:
|
|
print("OK: já existe task pendente; nenhuma alteração.")
|
|
return 0
|
|
if not terminal:
|
|
raise SystemExit("ABORTADO: não existe task materializada terminal para reativar.")
|
|
if decision_code == "VALIDATE_PHYSICAL_ORDER" and data["physical_validated"]:
|
|
raise SystemExit("ABORTADO: já existe validação física confirmada.")
|
|
|
|
print(f"SAFE: reativar {terminal[0]['id']} ({terminal[0]['status']})")
|
|
if not args.apply:
|
|
print("Dry-run only. Use --apply.")
|
|
return 0
|
|
|
|
result = ensure_pending_task_for_next_action(
|
|
args.opportunity_id,
|
|
data["decision"],
|
|
source="manual_v132_2_reactivation_repair",
|
|
actor="operator",
|
|
reactivate_skipped=True,
|
|
)
|
|
print("Resultado:", result)
|
|
if not result.get("reactivated") and result.get("reason") != "pending_exists":
|
|
return 2
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|