106 lines
4.1 KiB
Python
Executable File
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()
|