#!/usr/bin/env python3 """Close pending follow-up tasks on terminal opportunities. Read-only by default. With --apply, marks pending FOLLOW_UP_* tasks as skipped when the linked opportunity is already closed/concluded/lost/no-interest. """ from __future__ import annotations import argparse import json from typing import Any from sqlalchemy import text from app.db import engine FOLLOW_UP_ACTIONS = ( "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", ) TERMINAL_STAGES = ("WON", "LOST", "NO_INTEREST", "DELIVERED") def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def find_candidates(limit: int) -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute(text(""" SELECT t.id::text AS task_id, t.created_at, t.due_at, t.route, t.action_code, t.action, t.note, t.status AS task_status, t.priority, t.opportunity_id::text, o.title AS opportunity_title, o.customer_name, o.stage AS opportunity_stage, o.status AS opportunity_status, o.closed_at FROM tasks t JOIN opportunities o ON o.id = t.opportunity_id WHERE t.status = 'pending' AND t.action_code IN ( 'FOLLOW_UP_QUOTE', 'FOLLOW_UP_PROFORMA', 'FOLLOW_UP_PAYMENT', 'FOLLOW_UP_CUSTOMER_REVIEW', 'FOLLOW_UP_GENERIC' ) AND ( o.status = 'closed' OR o.stage IN ('WON', 'LOST', 'NO_INTEREST', 'DELIVERED') ) ORDER BY t.due_at NULLS LAST, t.created_at DESC LIMIT :limit """), {"limit": int(limit)}).mappings().all() return [dict(row) for row in rows] def apply_candidate(task_id: str, *, actor: str = "sync_closed_opportunity_followups") -> int: patch = { "auto_closed_follow_up": True, "auto_closed_reason": "opportunity_already_terminal", "auto_closed_by": actor, } with engine.begin() as conn: result = conn.execute(text(""" UPDATE tasks SET status = 'skipped', done_at = COALESCE(done_at, now()), done_by = CAST(:actor AS TEXT), updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB) WHERE id = CAST(:task_id AS UUID) AND status = 'pending' AND action_code IN ( 'FOLLOW_UP_QUOTE', 'FOLLOW_UP_PROFORMA', 'FOLLOW_UP_PAYMENT', 'FOLLOW_UP_CUSTOMER_REVIEW', 'FOLLOW_UP_GENERIC' ) """), {"task_id": task_id, "actor": actor, "metadata_patch": _json(patch)}) return int(getattr(result, "rowcount", 0) or 0) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true") parser.add_argument("--limit", type=int, default=100) args = parser.parse_args() rows = find_candidates(args.limit) print(f"Follow-ups pendentes em oportunidades terminais: {len(rows)}") updated = 0 for row in rows: print("-", row["task_id"], row["action_code"], row["opportunity_stage"], row["opportunity_status"], row["opportunity_title"]) if args.apply: updated += apply_candidate(row["task_id"]) if args.apply: print(f"updated: {updated}") else: print("dry-run: usa --apply para fechar estes follow-ups.") return 1 if rows and not args.apply else 0 if __name__ == "__main__": raise SystemExit(main())