#!/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())