#!/usr/bin/env python3 """Identify or close opportunities that were probably created from system messages. Conservative by default: use --dry-run to list candidates. Use --apply to mark safe candidates as LOST with a metadata reason. It only targets opportunities with value 0, no commercial documents and no shipments. """ from __future__ import annotations import argparse import json import uuid from sqlalchemy import text from app.db import engine from app.opportunity_service import ensure_opportunity_schema SYSTEM_TERMS = ( "mail delivery subsystem", "mailer-daemon", "postmaster", "returned mail", "undelivered mail", "delivery status notification", "failure notice", "mail delivery failed", ) def _json(value: object) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def find_candidates(limit: int = 100) -> list[dict]: ensure_opportunity_schema() like_sql = " OR ".join( ["lower(coalesce(o.title,'') || ' ' || coalesce(o.customer_name,'') || ' ' || coalesce(o.customer_email,'') || ' ' || coalesce(o.metadata::text,'')) LIKE :term_{}".format(i) for i, _ in enumerate(SYSTEM_TERMS)] ) params = {f"term_{i}": f"%{term}%" for i, term in enumerate(SYSTEM_TERMS)} params["limit"] = int(limit) sql = text(f""" SELECT o.id::text, o.title, o.customer_name, o.customer_email, o.value_amount, o.stage, o.status, o.created_at FROM opportunities o WHERE o.status = 'open' AND COALESCE(o.value_amount, 0) = 0 AND NOT EXISTS (SELECT 1 FROM commercial_documents cd WHERE cd.opportunity_id = o.id) AND NOT EXISTS (SELECT 1 FROM shipments s WHERE s.opportunity_id = o.id) AND ({like_sql}) ORDER BY o.created_at DESC LIMIT :limit """) with engine.begin() as conn: rows = conn.execute(sql, params).mappings().all() return [dict(row) for row in rows] def close_candidates(candidates: list[dict]) -> int: if not candidates: return 0 ids = [row["id"] for row in candidates] with engine.begin() as conn: for opportunity_id in ids: conn.execute(text(""" UPDATE opportunities SET status = 'closed', stage = 'LOST', closed_at = COALESCE(closed_at, now()), updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB) WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "metadata": _json({"closed_reason": "system_or_bounce_created_by_mistake", "closed_by": "cleanup_non_commercial_opportunities"}), }) conn.execute(text(""" INSERT INTO opportunity_events (id, opportunity_id, event_type, from_stage, to_stage, note, payload, created_by) VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'cleanup_closed_non_commercial', NULL, 'LOST', :note, CAST(:payload AS JSONB), 'system') """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "note": "Oportunidade fechada por parecer mensagem automática/bounce sem atividade comercial.", "payload": _json({"reason": "system_or_bounce_created_by_mistake"}), }) return len(ids) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--limit", type=int, default=100) parser.add_argument("--apply", action="store_true", help="Apply changes. Without this, only prints candidates.") args = parser.parse_args() candidates = find_candidates(limit=args.limit) print(f"Found {len(candidates)} candidate(s).") for row in candidates: print(f"- {row.get('id')} | {row.get('title')} | {row.get('customer_name')} | {row.get('created_at')}") if args.apply: total = close_candidates(candidates) print(f"Closed {total} candidate(s).") else: print("Dry-run only. Re-run with --apply to close candidates.") if __name__ == "__main__": main()