120 lines
3.8 KiB
Python
Executable File
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())
|