Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View 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())