Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
157
scripts/repair_cross_conversation_company_links.py
Executable file
157
scripts/repair_cross_conversation_company_links.py
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repair/suggest cross-conversation B2B opportunity links.
|
||||
|
||||
Handles the pattern where compras/geral creates an opportunity and later
|
||||
financeiro/contabilidade sends payment in a new Chatwoot conversation.
|
||||
|
||||
Read-only by default. With --apply, only strong document-reference matches are
|
||||
moved automatically. Same-domain matches with more than one candidate are marked
|
||||
for operator association.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
from app.opportunity_service import get_task_context
|
||||
from app.company_opportunity_linking import (
|
||||
associate_task_to_opportunity,
|
||||
find_document_owner_opportunity_for_task,
|
||||
manual_link_candidates_for_task,
|
||||
mark_task_ambiguous_with_candidates,
|
||||
task_document_references,
|
||||
)
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def task_ids(days: int, limit: int) -> List[str]:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
SELECT t.id::text
|
||||
FROM tasks t
|
||||
LEFT JOIN raw_events re ON re.id = t.raw_event_id
|
||||
LEFT JOIN messages m ON m.id = t.message_id
|
||||
WHERE t.created_at >= now() - make_interval(days => :days)
|
||||
AND COALESCE(t.status, '') IN ('pending','done','skipped')
|
||||
AND COALESCE(t.action_code, '') IN ('CONFIRM_PAYMENT','SEND_PROFORMA','SEND_INVOICE','SEND_QUOTE','PREPARE_ORDER','CREATE_SHIPMENT')
|
||||
AND (
|
||||
COALESCE(re.payload::text, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}'
|
||||
OR COALESCE(m.raw_body, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}'
|
||||
OR COALESCE(m.clean_body, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}'
|
||||
OR COALESCE(t.note, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}'
|
||||
)
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT :limit
|
||||
"""), {"days": int(days), "limit": int(limit)}).scalars().all()
|
||||
return [str(x) for x in rows]
|
||||
|
||||
|
||||
def source_opportunity_document_count(opportunity_id: str) -> int:
|
||||
if not opportunity_id:
|
||||
return 0
|
||||
with engine.begin() as conn:
|
||||
return int(conn.execute(text("""
|
||||
SELECT COUNT(*)::int
|
||||
FROM commercial_documents
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND COALESCE(is_active, TRUE) = TRUE
|
||||
"""), {"opportunity_id": opportunity_id}).scalar() or 0)
|
||||
|
||||
|
||||
def close_empty_duplicate(source_opportunity_id: str, target_opportunity_id: str, refs: List[str], apply: bool) -> int:
|
||||
if not source_opportunity_id or source_opportunity_id == target_opportunity_id:
|
||||
return 0
|
||||
if source_opportunity_document_count(source_opportunity_id) > 0:
|
||||
return 0
|
||||
if not apply:
|
||||
return 0
|
||||
meta = {
|
||||
"closed_as_duplicate_by": "repair_cross_conversation_company_links",
|
||||
"duplicate_of_opportunity_id": target_opportunity_id,
|
||||
"document_references": refs,
|
||||
}
|
||||
with engine.begin() as conn:
|
||||
res = conn.execute(text("""
|
||||
UPDATE opportunities
|
||||
SET status = 'closed',
|
||||
closed_at = COALESCE(closed_at, now()),
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:source AS UUID)
|
||||
AND status = 'open'
|
||||
"""), {"source": source_opportunity_id, "metadata": _json(meta)})
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--days", type=int, default=14)
|
||||
parser.add_argument("--limit", type=int, default=200)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
ids = task_ids(args.days, args.limit)
|
||||
print(f"apply={args.apply} scanned={len(ids)} days={args.days}")
|
||||
|
||||
strong = 0
|
||||
moved = 0
|
||||
closed = 0
|
||||
ambiguous = 0
|
||||
|
||||
for task_id in ids:
|
||||
task = get_task_context(task_id)
|
||||
if not task:
|
||||
continue
|
||||
refs = task_document_references(task)
|
||||
target = find_document_owner_opportunity_for_task(task)
|
||||
source_id = str(task.get("opportunity_id") or "")
|
||||
if target:
|
||||
target_id = str(target.get("id") or target.get("opportunity_id") or "")
|
||||
if target_id and target_id != source_id:
|
||||
strong += 1
|
||||
print()
|
||||
print(f"STRONG_DOCUMENT_MATCH task={task_id}")
|
||||
print(f"refs={refs}")
|
||||
print(f"source_opp={source_id or '—'}")
|
||||
print(f"target_opp={target_id} title={target.get('title')} doc={target.get('document_number')}")
|
||||
if args.apply:
|
||||
result = associate_task_to_opportunity(task_id, target_id, actor="repair_cross_conversation_company_links")
|
||||
moved += result.get("tasks", 0)
|
||||
closed += close_empty_duplicate(source_id, target_id, refs, apply=True)
|
||||
continue
|
||||
|
||||
candidates = manual_link_candidates_for_task(task)
|
||||
if len(candidates) > 1:
|
||||
ambiguous += 1
|
||||
print()
|
||||
print(f"AMBIGUOUS_COMPANY_MATCH task={task_id} candidates={len(candidates)} refs={refs}")
|
||||
for c in candidates[:5]:
|
||||
print(f"- opp={c.get('opportunity_id')} title={c.get('title')} doc={c.get('document_number')} reason={c.get('reason')}")
|
||||
if args.apply:
|
||||
mark_task_ambiguous_with_candidates(
|
||||
task_id,
|
||||
reason="multiple_company_domain_candidates",
|
||||
candidates=candidates,
|
||||
created_by="repair_cross_conversation_company_links",
|
||||
)
|
||||
|
||||
print()
|
||||
print("SUMMARY")
|
||||
print(f"strong_document_matches={strong}")
|
||||
print(f"tasks_moved={moved}")
|
||||
print(f"duplicates_closed={closed}")
|
||||
print(f"ambiguous_marked_or_listed={ambiguous}")
|
||||
if not args.apply:
|
||||
print("dry_run=True")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user