Files
clientflow_backend/scripts/cleanup_non_commercial_opportunities.py
2026-06-09 22:55:58 +01:00

106 lines
4.1 KiB
Python
Executable File

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