Files
clientflow_backend/scripts/sync_closed_opportunity_followups.py

120 lines
3.8 KiB
Python
Executable File

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