#!/usr/bin/env python3 """Audit/repair pending follow-ups that contradict the current opportunity stage. Dry-run by default. Use --apply to update rows. The repair is idempotent and never sends email or Chatwoot messages. """ from __future__ import annotations import argparse import os from pathlib import Path from dotenv import load_dotenv from sqlalchemy import create_engine, text def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true") args = parser.parse_args() load_dotenv(dotenv_path=Path(".env"), override=False) db_url = os.environ.get("DATABASE_URL") if not db_url: raise SystemExit("DATABASE_URL em falta") engine = create_engine(db_url) select_sql = text(""" SELECT t.id::text AS task_id, t.opportunity_id::text, o.customer_name, o.stage, t.action_code, t.action, t.route, t.status, EXISTS ( SELECT 1 FROM tasks tx WHERE tx.opportunity_id = o.id AND tx.status = 'pending' AND tx.action_code IN ('SEND_INVOICE','CONFIRM_PAYMENT','PREPARE_ORDER','CREATE_SHIPMENT') ) AS has_superseding_task FROM tasks t JOIN opportunities o ON o.id = t.opportunity_id WHERE o.status = 'open' AND t.status = 'pending' AND t.action_code IN ('FOLLOW_UP_QUOTE','FOLLOW_UP_PROFORMA') AND ( upper(o.stage) IN ( 'INVOICE_REQUESTED','INVOICE_SENT','WAITING_PAYMENT','PAYMENT_CONFIRMED', 'ODOO_ORDER_CREATED','IN_PRODUCTION','ORDER_PREPARATION','READY_TO_SHIP', 'INVOICED','SHIPMENT_CREATED','SHIPPED','TRACKING_SENT','DELIVERED','WON' ) OR EXISTS ( SELECT 1 FROM tasks tx WHERE tx.opportunity_id = o.id AND tx.status = 'pending' AND tx.action_code IN ('SEND_INVOICE','CONFIRM_PAYMENT','PREPARE_ORDER','CREATE_SHIPMENT') ) ) ORDER BY o.updated_at DESC """) with engine.begin() as conn: rows = [dict(r) for r in conn.execute(select_sql).mappings().all()] print(f"Candidatos: {len(rows)}") for row in rows: decision = "close" if row["has_superseding_task"] or row["stage"] != "WAITING_PAYMENT" else "convert_to_payment" print(f"{decision:20} {row['task_id']} | {row['customer_name']} | {row['stage']} | {row['action_code']}") if not args.apply: print("Dry-run: use --apply para corrigir.") return 0 from app.followup_service import align_pending_followups_for_opportunity opportunity_ids = sorted({r["opportunity_id"] for r in rows}) updated = closed = 0 for opportunity_id in opportunity_ids: result = align_pending_followups_for_opportunity(opportunity_id=opportunity_id, created_by="repair_stale_followups") updated += int(result.get("updated") or 0) closed += int(result.get("closed") or 0) print(opportunity_id, result) print(f"Aplicado: convertidas={updated}; encerradas={closed}") return 0 if __name__ == "__main__": raise SystemExit(main())