Files
clientflow_backend/scripts/repair_v132_2_post_review_task_reactivation.py

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())