import json import re import uuid from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import text from app.db import engine OPPORTUNITY_STAGE_LABELS: Dict[str, str] = { "NEW_LEAD": "Novo pedido", "INFO_REQUESTED": "Informação solicitada", "INFO_SENT": "Informação enviada", "QUOTE_REQUESTED": "Orçamento solicitado", "QUOTE_SENT": "Orçamento enviado", "PROFORMA_REQUESTED": "Pró-forma solicitada", "PROFORMA_SENT": "Pró-forma enviada", "INVOICE_REQUESTED": "Fatura solicitada", "INVOICE_SENT": "Fatura enviada", "WAITING_PAYMENT": "A aguardar pagamento", "PAYMENT_CONFIRMED": "Pagamento confirmado", "ODOO_ORDER_CREATED": "Venda Odoo criada", "IN_PRODUCTION": "Em produção", "READY_TO_SHIP": "Pronto para envio", "INVOICED": "Faturado", "SHIPMENT_CREATED": "Envio criado", "TRACKING_SENT": "Tracking enviado", "DELIVERED": "Entregue", "ORDER_PREPARATION": "Em preparação", "SHIPPED": "Enviado", "WON": "Concluído", "LOST": "Perdido", "NO_INTEREST": "Sem interesse", "REVIEW": "Rever", } OPPORTUNITY_STAGE_RANK: Dict[str, int] = { "NEW_LEAD": 10, "INFO_REQUESTED": 20, "INFO_SENT": 30, "QUOTE_REQUESTED": 40, "QUOTE_SENT": 50, "PROFORMA_REQUESTED": 55, "PROFORMA_SENT": 60, "INVOICE_REQUESTED": 62, "INVOICE_SENT": 65, "WAITING_PAYMENT": 70, "PAYMENT_CONFIRMED": 80, "ODOO_ORDER_CREATED": 85, "IN_PRODUCTION": 88, "ORDER_PREPARATION": 90, "READY_TO_SHIP": 92, "INVOICED": 94, "SHIPMENT_CREATED": 98, "SHIPPED": 100, "TRACKING_SENT": 102, "DELIVERED": 108, "WON": 110, "LOST": 120, "NO_INTEREST": 118, "REVIEW": 5, } OPPORTUNITY_BOARD_COLUMNS: List[Tuple[str, str, List[str]]] = [ ("requests", "Pedidos", ["NEW_LEAD", "INFO_REQUESTED", "QUOTE_REQUESTED", "PROFORMA_REQUESTED", "INVOICE_REQUESTED", "REVIEW"]), ("sent", "Info / orçamento", ["INFO_SENT", "QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT"]), ("payment", "Pagamento", ["WAITING_PAYMENT", "PAYMENT_CONFIRMED"]), ("operations", "Operação / envio", ["PAYMENT_CONFIRMED", "ODOO_ORDER_CREATED", "IN_PRODUCTION", "ORDER_PREPARATION", "READY_TO_SHIP", "INVOICED", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED"]), ("closed", "Fechadas", ["WON", "LOST", "NO_INTEREST"]), ] # Actions that may update an existing commercial opportunity. Creation is more # restrictive and is controlled by can_create_new_opportunity_for_task(). OPPORTUNITY_RELEVANT_ACTION_CODES = { "SEND_INFO", "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "CREATE_SHIPMENT", "MARK_NO_INTEREST", } # Actions that are allowed to create a brand-new opportunity when there is no # strong/unique existing match. Keep this list conservative: Operations can still # hold a task without creating a commercial process. OPPORTUNITY_CREATE_ACTION_CODES = { "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "CREATE_SHIPMENT", } NEVER_CREATE_OPPORTUNITY_ACTION_CODES = { "REVIEW_MANUALLY", "REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION", "IGNORE_BOUNCE", "SUPPORT", "MARK_NO_INTEREST", } SYSTEM_SENDER_PATTERNS = ( "mail delivery subsystem", "mailer-daemon", "postmaster", "delivery status notification", "microsoft exchange", "office 365", ) SYSTEM_SUBJECT_PATTERNS = ( "returned mail", "undelivered mail", "delivery status notification", "failure notice", "mail delivery failed", "undeliverable", "delivery has failed", "non-delivery report", "non delivery report", "your message couldn't be delivered", "your message couldn’t be delivered", "recipient wasn't found", "recipient was not found", "unknown to address", "remote server returned", "550 5.1.1", "5.1.10", "wasn't found at", "was not found at", "office 365", "microsoft exchange", "não entregue", ) COMMERCIAL_INTENT_TERMS = ( "orçamento", "orcamento", "cotação", "cotacao", "proposta", "preço", "preco", "comprar", "encomendar", "encomenda", "fatura", "factura", "pró-forma", "proforma", "pagamento", "comprovativo", "disponibilidade", "quero avançar", "pretendo avançar", "adjudicar", "pedido de cotação", "pedido de orçamento", ) STAGE_ON_TASK_CREATED = { "SEND_INFO": "INFO_REQUESTED", "SEND_QUOTE": "QUOTE_REQUESTED", "SEND_PROFORMA": "PROFORMA_REQUESTED", "SEND_INVOICE": "INVOICE_REQUESTED", "CONFIRM_PAYMENT": "WAITING_PAYMENT", "PREPARE_ORDER": "ORDER_PREPARATION", "CREATE_SHIPMENT": "READY_TO_SHIP", "MARK_NO_INTEREST": "REVIEW", } STAGE_ON_TASK_DONE = { "SEND_INFO": "INFO_SENT", "SEND_QUOTE": "QUOTE_SENT", "SEND_PROFORMA": "WAITING_PAYMENT", "SEND_INVOICE": "WAITING_PAYMENT", "CONFIRM_PAYMENT": "PAYMENT_CONFIRMED", "PREPARE_ORDER": "ORDER_PREPARATION", "CREATE_SHIPMENT": "SHIPMENT_CREATED", "MARK_NO_INTEREST": "NO_INTEREST", } _SCHEMA_READY = False def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def _uuid(value: Optional[str]) -> Optional[str]: value = str(value or "").strip() return value or None def ensure_opportunity_schema() -> None: """Cria a primeira versão local do pipeline ClientFlow. É intencionalmente aditiva: não altera nem remove dados antigos. """ global _SCHEMA_READY if _SCHEMA_READY: return with engine.begin() as conn: conn.execute(text(""" CREATE TABLE IF NOT EXISTS opportunities ( id UUID PRIMARY KEY, title TEXT NOT NULL, stage TEXT NOT NULL DEFAULT 'NEW_LEAD', status TEXT NOT NULL DEFAULT 'open', contact_id TEXT, customer_id TEXT, conversation_id TEXT, customer_name TEXT, customer_email TEXT, customer_phone TEXT, product_interest TEXT, value_amount NUMERIC(12,2), currency TEXT NOT NULL DEFAULT 'EUR', source_system TEXT NOT NULL DEFAULT 'clientflow', source_event_id TEXT, last_action_code TEXT, last_task_id UUID, last_message_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), closed_at TIMESTAMPTZ ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS opportunity_events ( id UUID PRIMARY KEY, opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE, event_type TEXT NOT NULL, task_id UUID, action_code TEXT, from_stage TEXT, to_stage TEXT, note TEXT, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_by TEXT NOT NULL DEFAULT 'system', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) conn.execute(text("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS opportunity_id UUID")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_stage ON opportunities(stage)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_contact ON opportunities(contact_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_conversation ON opportunities(conversation_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunity_events_opportunity ON opportunity_events(opportunity_id, created_at DESC)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_opportunity_id ON tasks(opportunity_id)")) _SCHEMA_READY = True def stage_label(stage: Optional[str]) -> str: return OPPORTUNITY_STAGE_LABELS.get(str(stage or ""), str(stage or "—")) def _stage_rank(stage: Optional[str]) -> int: return OPPORTUNITY_STAGE_RANK.get(str(stage or ""), 0) def _advance_stage(current: Optional[str], suggested: Optional[str]) -> str: current = str(current or "NEW_LEAD") suggested = str(suggested or current) if current in {"WON", "LOST", "NO_INTEREST"}: return current if _stage_rank(suggested) >= _stage_rank(current): return suggested return current def _detect_product_interest(text_value: str) -> str: text_value = str(text_value or "").lower() patterns = [ ("Carregador EV", ["carregador", "wallbox", "wall box", "ev charger", "carregamento"]), ("Cabo", ["cabo", "type 2", "tipo 2"]), ("Instalação", ["instalação", "instalacao", "instalar"]), ] for label, terms in patterns: if any(term in text_value for term in terms): return label return "" def _normalized_text(*values: Any) -> str: return " ".join(str(value or "") for value in values).casefold() def is_system_or_bounce_task(task: Dict[str, Any]) -> bool: """Detect automatic e-mail system messages that should not become sales opportunities.""" sender = _normalized_text(task.get("customer_name"), task.get("customer_email")) subject = _normalized_text(task.get("message_subject"), task.get("request_text"), task.get("note")) if any(pattern in sender for pattern in SYSTEM_SENDER_PATTERNS): return True return any(pattern in subject for pattern in SYSTEM_SUBJECT_PATTERNS) def has_commercial_intent(task: Dict[str, Any]) -> bool: text_value = _normalized_text(task.get("message_subject"), task.get("request_text"), task.get("note"), task.get("action")) return any(term in text_value for term in COMMERCIAL_INTENT_TERMS) def _mark_task_no_opportunity(task_id: str, *, reason: str, created_by: str = "system") -> None: sql = text(""" UPDATE tasks SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:task_id AS UUID) """) with engine.begin() as conn: conn.execute(sql, { "task_id": task_id, "metadata": _json({ "no_opportunity_reason": reason, "opportunity_creation_status": "skipped", "opportunity_creation_checked_by": created_by, }), }) def can_create_new_opportunity_for_task(task: Dict[str, Any], action_code: str) -> tuple[bool, str]: """Return whether this task is allowed to create a new opportunity. v4.8.3 guardrail: not every inbound Chatwoot message is a commercial opportunity. Bounces, spam/system messages, unsubscribe requests and failed classifications should stay as tasks/review items without polluting the opportunity board. """ action_code = str(action_code or "").upper() if is_system_or_bounce_task(task): return False, "system_or_bounce_message" if action_code in NEVER_CREATE_OPPORTUNITY_ACTION_CODES: return False, "action_code_never_creates_opportunity" if action_code == "SEND_INFO" and not has_commercial_intent(task): return False, "send_info_without_clear_commercial_intent" if action_code == "SEND_INFO": return True, "send_info_with_commercial_intent" if action_code in OPPORTUNITY_CREATE_ACTION_CODES: return True, "commercial_action_code" return False, "action_code_not_commercial" def _compact(value: Any, limit: int = 180) -> str: value = re.sub(r"\s+", " ", str(value or "")).strip() if len(value) > limit: return value[: limit - 1].rstrip() + "…" return value def get_task_context(task_id: str) -> Optional[Dict[str, Any]]: ensure_opportunity_schema() sql = text(""" SELECT t.id::text, t.action_run_id::text, t.message_id::text, t.raw_event_id::text, t.opportunity_id::text, t.conversation_id, t.contact_id, t.customer_id, t.action_code, t.route, t.action, t.note, t.status, t.source_system, t.source_event_id, t.created_at, t.updated_at, COALESCE( NULLIF(re.payload->'sender'->>'name', ''), NULLIF(re.payload->'conversation'->'meta'->'sender'->>'name', ''), NULLIF(t.customer_id, ''), NULLIF(t.contact_id, '') ) AS customer_name, COALESCE( NULLIF(re.payload->'sender'->>'email', ''), NULLIF(re.payload->'conversation'->'meta'->'sender'->>'email', ''), NULLIF(re.payload->'conversation'->'contact_inbox'->>'source_id', '') ) AS customer_email, COALESCE( NULLIF(re.payload->'sender'->>'phone_number', ''), NULLIF(re.payload->'conversation'->'meta'->'sender'->>'phone_number', '') ) AS customer_phone, COALESCE( NULLIF(re.payload->'conversation'->'additional_attributes'->>'mail_subject', ''), NULLIF(re.payload->'content_attributes'->'email'->>'subject', ''), NULLIF(re.payload->'conversation'->'messages'->0->'content_attributes'->'email'->>'subject', '') ) AS message_subject, COALESCE( NULLIF(m.clean_body, ''), NULLIF(m.raw_body, ''), NULLIF(re.payload->>'content', '') ) AS request_text FROM tasks t LEFT JOIN messages m ON m.id = t.message_id LEFT JOIN raw_events re ON re.id = t.raw_event_id WHERE t.id = CAST(:task_id AS UUID) LIMIT 1 """) with engine.begin() as conn: row = conn.execute(sql, {"task_id": task_id}).mappings().first() return dict(row) if row else None CONTACT_MATCH_RECENT_DAYS = 45 def _find_open_opportunity_by_conversation(conversation_id: object) -> Optional[Dict[str, Any]]: conversation_id = str(conversation_id or "").strip() if not conversation_id: return None sql = text(""" SELECT * FROM opportunities WHERE status = 'open' AND conversation_id = :conversation_id ORDER BY updated_at DESC LIMIT 1 """) with engine.begin() as conn: row = conn.execute(sql, {"conversation_id": conversation_id}).mappings().first() if not row: return None result = dict(row) result["_link_match_reason"] = "conversation_id" return result def _open_opportunities_for_contact(contact_id: object, *, limit: int = 3) -> List[Dict[str, Any]]: contact_id = str(contact_id or "").strip() if not contact_id: return [] sql = text(""" SELECT * FROM opportunities WHERE status = 'open' AND contact_id = :contact_id AND updated_at >= now() - make_interval(days => :recent_days) ORDER BY updated_at DESC LIMIT :limit """) with engine.begin() as conn: rows = conn.execute(sql, { "contact_id": contact_id, "recent_days": CONTACT_MATCH_RECENT_DAYS, "limit": int(limit), }).mappings().all() return [dict(row) for row in rows] def has_ambiguous_opportunity_match(task: Dict[str, Any]) -> bool: """Return True when a Chatwoot contact maps to more than one recent open opportunity. conversation_id is the only strong automatic match. contact_id is weak because it represents a Chatwoot person/contact, while fiscal customer can be a company. """ if _find_open_opportunity_by_conversation(task.get("conversation_id")): return False return len(_open_opportunities_for_contact(task.get("contact_id"), limit=2)) > 1 def mark_task_opportunity_link_ambiguous(task_id: str, *, reason: str, created_by: str = "system") -> None: sql = text(""" UPDATE tasks SET route = CASE WHEN status = 'pending' THEN 'rever' ELSE route END, metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:task_id AS UUID) """) with engine.begin() as conn: conn.execute(sql, { "task_id": task_id, "metadata": _json({ "opportunity_linking_status": "ambiguous", "opportunity_linking_reason": reason, "opportunity_linking_checked_by": created_by, }), }) def find_open_opportunity_for_task(task: Dict[str, Any]) -> Optional[Dict[str, Any]]: ensure_opportunity_schema() # Strong match: the exact Chatwoot conversation already belongs to an open opportunity. by_conversation = _find_open_opportunity_by_conversation(task.get("conversation_id")) if by_conversation: return by_conversation # Weak match: contact_id may be a person whose fiscal customer is a company. # Only auto-link when there is exactly one recent open opportunity for that contact. contact_matches = _open_opportunities_for_contact(task.get("contact_id"), limit=2) if len(contact_matches) == 1: result = dict(contact_matches[0]) result["_link_match_reason"] = "contact_id_unique_recent" return result return None def _build_title(task: Dict[str, Any]) -> str: subject = _compact(task.get("message_subject"), 90) customer = _compact(task.get("customer_name") or task.get("customer_email") or task.get("contact_id"), 70) product = _detect_product_interest(" ".join([ str(task.get("message_subject") or ""), str(task.get("request_text") or ""), str(task.get("note") or ""), ])) if subject: return subject if customer and product: return f"{customer} · {product}" if customer: return f"Oportunidade · {customer}" return "Nova oportunidade" def upsert_opportunity_for_task( task_id: str, *, trigger: str = "task_created", created_by: str = "system", ) -> Optional[str]: ensure_opportunity_schema() task = get_task_context(task_id) if not task: return None action_code = str(task.get("action_code") or "").upper() if action_code not in OPPORTUNITY_RELEVANT_ACTION_CODES: _mark_task_no_opportunity(task_id, reason="action_code_not_opportunity_relevant", created_by=created_by) return None if is_system_or_bounce_task(task): _mark_task_no_opportunity(task_id, reason="system_or_bounce_message", created_by=created_by) return None suggested_stage = STAGE_ON_TASK_CREATED.get(action_code, "NEW_LEAD") existing = find_open_opportunity_for_task(task) if not existing and has_ambiguous_opportunity_match(task): mark_task_opportunity_link_ambiguous( task_id, reason="multiple_recent_open_opportunities_for_chatwoot_contact", created_by=created_by, ) return None if action_code == "MARK_NO_INTEREST" and not existing: _mark_task_no_opportunity(task_id, reason="mark_no_interest_without_existing_opportunity", created_by=created_by) return None if not existing: can_create, skip_reason = can_create_new_opportunity_for_task(task, action_code) if not can_create: _mark_task_no_opportunity(task_id, reason=skip_reason, created_by=created_by) return None product_interest = _detect_product_interest(" ".join([ str(task.get("message_subject") or ""), str(task.get("request_text") or ""), str(task.get("note") or ""), ])) with engine.begin() as conn: if existing: opportunity_id = str(existing["id"]) old_stage = str(existing.get("stage") or "NEW_LEAD") new_stage = _advance_stage(old_stage, suggested_stage) conn.execute(text(""" UPDATE opportunities SET title = COALESCE(NULLIF(title, ''), :title), stage = :stage, contact_id = COALESCE(NULLIF(contact_id, ''), :contact_id), customer_id = COALESCE(NULLIF(customer_id, ''), :customer_id), conversation_id = COALESCE(NULLIF(conversation_id, ''), :conversation_id), customer_name = COALESCE(NULLIF(:customer_name, ''), customer_name), customer_email = COALESCE(NULLIF(:customer_email, ''), customer_email), customer_phone = COALESCE(NULLIF(:customer_phone, ''), customer_phone), product_interest = COALESCE(NULLIF(:product_interest, ''), product_interest), last_action_code = :action_code, last_task_id = CAST(:task_id AS UUID), last_message_at = COALESCE(:last_message_at, now()), updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB) WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "title": _build_title(task), "stage": new_stage, "contact_id": task.get("contact_id"), "customer_id": task.get("customer_id"), "conversation_id": task.get("conversation_id"), "customer_name": task.get("customer_name") or "", "customer_email": task.get("customer_email") or "", "customer_phone": task.get("customer_phone") or "", "product_interest": product_interest or "", "action_code": action_code, "task_id": task_id, "last_message_at": task.get("created_at"), "metadata": _json({"last_trigger": trigger}), }) event_id = str(uuid.uuid4()) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, task_id, action_code, from_stage, to_stage, note, payload, created_by ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), :event_type, CAST(:task_id AS UUID), :action_code, :from_stage, :to_stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "id": event_id, "opportunity_id": opportunity_id, "event_type": trigger, "task_id": task_id, "action_code": action_code, "from_stage": old_stage, "to_stage": new_stage, "note": task.get("note") or task.get("action") or "", "payload": _json({"task_status": task.get("status")}), "created_by": created_by, }) else: opportunity_id = str(uuid.uuid4()) new_stage = suggested_stage conn.execute(text(""" INSERT INTO opportunities ( id, title, stage, status, contact_id, customer_id, conversation_id, customer_name, customer_email, customer_phone, product_interest, source_system, source_event_id, last_action_code, last_task_id, last_message_at, metadata ) VALUES ( CAST(:id AS UUID), :title, :stage, 'open', :contact_id, :customer_id, :conversation_id, :customer_name, :customer_email, :customer_phone, :product_interest, :source_system, :source_event_id, :action_code, CAST(:task_id AS UUID), COALESCE(:last_message_at, now()), CAST(:metadata AS JSONB) ) """), { "id": opportunity_id, "title": _build_title(task), "stage": new_stage, "contact_id": task.get("contact_id"), "customer_id": task.get("customer_id"), "conversation_id": task.get("conversation_id"), "customer_name": task.get("customer_name"), "customer_email": task.get("customer_email"), "customer_phone": task.get("customer_phone"), "product_interest": product_interest, "source_system": task.get("source_system") or "clientflow", "source_event_id": task.get("source_event_id"), "action_code": action_code, "task_id": task_id, "last_message_at": task.get("created_at"), "metadata": _json({"created_from_task_id": task_id, "last_trigger": trigger}), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, task_id, action_code, from_stage, to_stage, note, payload, created_by ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), :event_type, CAST(:task_id AS UUID), :action_code, NULL, :to_stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "event_type": trigger, "task_id": task_id, "action_code": action_code, "to_stage": new_stage, "note": task.get("note") or task.get("action") or "", "payload": _json({"task_status": task.get("status")}), "created_by": created_by, }) conn.execute(text(""" UPDATE tasks SET opportunity_id = CAST(:opportunity_id AS UUID), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('opportunity_id', :opportunity_id), updated_at = now() WHERE id = CAST(:task_id AS UUID) """), {"opportunity_id": opportunity_id, "task_id": task_id}) # v4.9.25: newly created/updated opportunities should immediately try to # get a fiscal customer suggestion. This is best-effort and never blocks # task processing or opportunity creation. try: from app.fiscal_enrichment_service import enrich_opportunity enrich_opportunity(opportunity_id, apply_safe=True) except Exception: pass return opportunity_id def advance_opportunity_after_task_done( task_id: str, *, event_type: Optional[str] = None, payload: Optional[Dict[str, Any]] = None, created_by: str = "operator", ) -> Optional[str]: ensure_opportunity_schema() task = get_task_context(task_id) if not task: return None opportunity_id = task.get("opportunity_id") or upsert_opportunity_for_task( task_id, trigger="task_completed_link_created", created_by=created_by, ) if not opportunity_id: return None action_code = str(task.get("action_code") or "").upper() suggested_stage = STAGE_ON_TASK_DONE.get(action_code) if not suggested_stage: return str(opportunity_id) with engine.begin() as conn: current = conn.execute(text(""" SELECT stage, status FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}).mappings().first() if not current: return str(opportunity_id) old_stage = str(current.get("stage") or "NEW_LEAD") new_stage = _advance_stage(old_stage, suggested_stage) status = "closed" if new_stage in {"WON", "LOST", "NO_INTEREST"} else "open" conn.execute(text(""" UPDATE opportunities SET stage = :stage, status = :status, closed_at = CASE WHEN CAST(:status AS TEXT) = 'closed' THEN COALESCE(closed_at, now()) ELSE closed_at END, last_action_code = :action_code, last_task_id = CAST(:task_id AS UUID), updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB) WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "stage": new_stage, "status": status, "action_code": action_code, "task_id": task_id, "metadata": _json({"last_done_event_type": event_type or "task_done"}), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, task_id, action_code, from_stage, to_stage, note, payload, created_by ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), CAST(:event_type AS TEXT), CAST(:task_id AS UUID), CAST(:action_code AS TEXT), CAST(:from_stage AS TEXT), CAST(:to_stage AS TEXT), CAST(:note AS TEXT), CAST(:payload AS JSONB), CAST(:created_by AS TEXT) ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "event_type": event_type or "task_done", "task_id": task_id, "action_code": action_code, "from_stage": old_stage, "to_stage": new_stage, "note": f"Tarefa concluída: {task.get('action') or action_code}", "payload": _json(payload or {}), "created_by": created_by, }) return str(opportunity_id) MANUAL_REQUEST_TYPES = { "quote": ("SEND_QUOTE", "QUOTE_REQUESTED", "Orçamento"), "info": ("SEND_INFO", "INFO_REQUESTED", "Informação"), "proforma": ("SEND_PROFORMA", "PROFORMA_REQUESTED", "Pró-forma"), "invoice": ("SEND_INVOICE", "INVOICE_REQUESTED", "Fatura"), "order": ("SEND_QUOTE", "QUOTE_REQUESTED", "Encomenda"), "support": ("SUPPORT", "REVIEW", "Assistência"), "manual": ("SEND_QUOTE", "NEW_LEAD", "Pedido manual"), } MANUAL_ORIGIN_LABELS = { "phone": "telefone", "manual": "manual", "email": "email", "whatsapp": "WhatsApp", "presential": "presencial", "reconciliation": "reconciliação", } def create_manual_opportunity_from_customer( customer_id: str, *, origin: str = "phone", request_type: str = "quote", contact_name: str = "", contact_email: str = "", contact_phone: str = "", product_interest: str = "", notes: str = "", create_task: bool = True, created_by: str = "operator", ) -> Dict[str, Any]: """Create a fiscal-customer-first opportunity from the customer page. This covers manual/phone/WhatsApp/presential requests where no Chatwoot conversation exists yet. The opportunity is created already linked to the fiscal customer, and can optionally create the initial human task. """ from app.action_catalog import get_action_config from app.commercial_service import get_customer, ensure_commercial_schema from app.db import ensure_core_schema ensure_core_schema() ensure_opportunity_schema() ensure_commercial_schema() customer = get_customer(customer_id) if not customer: raise ValueError("Cliente fiscal não encontrado") origin_key = str(origin or "phone").strip().lower() or "phone" request_key = str(request_type or "quote").strip().lower() or "quote" action_code, stage, request_label = MANUAL_REQUEST_TYPES.get(request_key, MANUAL_REQUEST_TYPES["quote"]) origin_label = MANUAL_ORIGIN_LABELS.get(origin_key, origin_key) contact_name = str(contact_name or "").strip() contact_email = str(contact_email or "").strip() contact_phone = str(contact_phone or "").strip() product_interest = str(product_interest or "").strip() notes = str(notes or "").strip() fiscal_name = str(customer.get("name") or "Cliente").strip() title_bits = [request_label, fiscal_name] if product_interest: title_bits.append(product_interest[:80]) title = " — ".join(bit for bit in title_bits if bit) config = get_action_config(action_code) metadata = { "created_manually": True, "created_from_customer_page": True, "origin": origin_key, "origin_label": origin_label, "request_type": request_key, "notes": notes, "customer_snapshot": { "id": str(customer.get("id") or customer_id), "name": customer.get("name"), "tax_id": customer.get("tax_id"), "email": customer.get("email"), "phone": customer.get("phone"), "street_name": customer.get("street_name"), "postal_zone": customer.get("postal_zone"), "city_name": customer.get("city_name"), "country": customer.get("country"), }, } opportunity_id = str(uuid.uuid4()) task_id: Optional[str] = None with engine.begin() as conn: conn.execute(text(""" INSERT INTO opportunities ( id, title, stage, status, local_customer_id, customer_name, customer_email, customer_phone, product_interest, source_system, source_event_id, last_action_code, metadata ) VALUES ( CAST(:id AS UUID), :title, :stage, 'open', CAST(:customer_id AS UUID), :customer_name, :customer_email, :customer_phone, :product_interest, 'clientflow_manual', :source_event_id, :action_code, CAST(:metadata AS JSONB) ) """), { "id": opportunity_id, "title": title, "stage": stage, "customer_id": customer_id, "customer_name": contact_name or fiscal_name, "customer_email": contact_email or customer.get("email"), "customer_phone": contact_phone or customer.get("phone"), "product_interest": product_interest, "source_event_id": f"manual:{opportunity_id}", "action_code": action_code, "metadata": _json(metadata), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, from_stage, to_stage, note, payload, created_by ) VALUES ( gen_random_uuid(), CAST(:opportunity_id AS UUID), 'manual_opportunity_created', :action_code, NULL, :stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "opportunity_id": opportunity_id, "action_code": action_code, "stage": stage, "note": notes or f"Oportunidade criada manualmente a partir do cliente fiscal por {origin_label}.", "payload": _json(metadata), "created_by": created_by, }) if create_task and action_code: task_row = conn.execute(text(""" INSERT INTO tasks ( opportunity_id, customer_id, action_code, route, action, note, action_required, safe_to_post, status, source_system, source_event_id, idempotency_key, metadata ) VALUES ( CAST(:opportunity_id AS UUID), :customer_id, :action_code, :route, :action, :note, :action_required, :safe_to_post, 'pending', 'clientflow_manual', :source_event_id, :idempotency_key, CAST(:metadata AS JSONB) ) RETURNING id::text """), { "opportunity_id": opportunity_id, "customer_id": str(customer_id), "action_code": action_code, "route": config.get("route") or "vendas", "action": config.get("action") or action_code, "note": notes or f"Pedido criado manualmente por {origin_label}. Preparar próxima ação.", "action_required": bool(config.get("action_required", True)), "safe_to_post": bool(config.get("safe_to_post", False)), "source_event_id": f"manual:{opportunity_id}", "idempotency_key": f"manual-opportunity:{opportunity_id}:initial-task", "metadata": _json({ "created_from_manual_opportunity": True, "opportunity_id": opportunity_id, "origin": origin_key, "request_type": request_key, "customer_snapshot": metadata["customer_snapshot"], }), }).mappings().first() task_id = str(task_row["id"]) if task_row else None if task_id: conn.execute(text(""" UPDATE opportunities SET last_task_id = CAST(:task_id AS UUID), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id, "task_id": task_id}) conn.execute(text(""" INSERT INTO task_events (task_id, event_type, payload, created_by) VALUES (CAST(:task_id AS UUID), 'task_created', CAST(:payload AS JSONB), :created_by) """), { "task_id": task_id, "payload": _json({"source": "manual_opportunity", "opportunity_id": opportunity_id}), "created_by": created_by, }) return { "ok": True, "opportunity_id": opportunity_id, "task_id": task_id, "customer_id": str(customer_id), "stage": stage, "action_code": action_code, "next_url": f"/opportunities/{opportunity_id}", } def list_opportunities( *, stage: Optional[str] = None, q: Optional[str] = None, status: Optional[str] = None, limit: int = 300, ) -> List[Dict[str, Any]]: ensure_opportunity_schema() # A oportunidade mantém dois conceitos diferentes: # - o.customer_*: contacto/origem captado da conversa/tarefa; # - o.local_customer_id -> customers: ficha fiscal usada em Jasmin/documentos. # A board deve mostrar ambos quando divergem, para evitar abrir uma oportunidade # que parece ser de um contacto mas emite documentos para outro cliente fiscal. filters = [] params: Dict[str, Any] = {"limit": int(limit)} if stage and stage != "all": filters.append("o.stage = :stage") params["stage"] = stage if status and status != "all": filters.append("o.status = :status") params["status"] = status if q: filters.append(""" ( o.title ILIKE :q OR o.customer_name ILIKE :q OR o.customer_email ILIKE :q OR o.contact_id ILIKE :q OR o.conversation_id ILIKE :q OR o.product_interest ILIKE :q OR c.name ILIKE :q OR c.email ILIKE :q OR c.tax_id ILIKE :q ) """) params["q"] = f"%{str(q).strip()}%" where_sql = "WHERE " + " AND ".join(filters) if filters else "" sql = text(f""" SELECT o.*, c.id::text AS linked_customer_id, c.name AS linked_customer_name, c.email AS linked_customer_email, c.tax_id AS linked_customer_tax_id, c.street_name AS linked_customer_street_name, c.postal_zone AS linked_customer_postal_zone, c.city_name AS linked_customer_city_name, c.phone AS linked_customer_phone, (SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id) AS task_count, (SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id AND t.status = 'pending') AS pending_task_count FROM opportunities o LEFT JOIN customers c ON c.id = o.local_customer_id {where_sql} ORDER BY CASE o.status WHEN 'open' THEN 0 ELSE 1 END, o.updated_at DESC LIMIT :limit """) with engine.begin() as conn: rows = conn.execute(sql, params).mappings().all() return [dict(row) for row in rows] def get_opportunity(opportunity_id: str) -> Optional[Dict[str, Any]]: ensure_opportunity_schema() sql = text(""" SELECT o.*, c.id::text AS linked_customer_id, c.name AS linked_customer_name, c.email AS linked_customer_email, c.tax_id AS linked_customer_tax_id, c.street_name AS linked_customer_street_name, c.postal_zone AS linked_customer_postal_zone, c.city_name AS linked_customer_city_name, c.phone AS linked_customer_phone, (SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id) AS task_count, (SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id AND t.status = 'pending') AS pending_task_count FROM opportunities o LEFT JOIN customers c ON c.id = o.local_customer_id WHERE o.id = CAST(:opportunity_id AS UUID) LIMIT 1 """) with engine.begin() as conn: row = conn.execute(sql, {"opportunity_id": opportunity_id}).mappings().first() return dict(row) if row else None def list_opportunity_tasks(opportunity_id: str, *, limit: int = 100) -> List[Dict[str, Any]]: ensure_opportunity_schema() sql = text(""" SELECT id::text, created_at, updated_at, action_code, route, action, note, status, conversation_id, contact_id, done_at, done_by FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) ORDER BY created_at DESC LIMIT :limit """) with engine.begin() as conn: rows = conn.execute(sql, {"opportunity_id": opportunity_id, "limit": int(limit)}).mappings().all() return [dict(row) for row in rows] def list_opportunity_events(opportunity_id: str, *, limit: int = 100) -> List[Dict[str, Any]]: ensure_opportunity_schema() sql = text(""" SELECT id::text, opportunity_id::text, event_type, task_id::text, action_code, from_stage, to_stage, note, payload, created_by, created_at FROM opportunity_events WHERE opportunity_id = CAST(:opportunity_id AS UUID) ORDER BY created_at DESC LIMIT :limit """) with engine.begin() as conn: rows = conn.execute(sql, {"opportunity_id": opportunity_id, "limit": int(limit)}).mappings().all() return [dict(row) for row in rows] def set_opportunity_stage( opportunity_id: str, stage: str, *, note: str = "", created_by: str = "operator", ) -> bool: ensure_opportunity_schema() stage = str(stage or "").strip().upper() if stage not in OPPORTUNITY_STAGE_LABELS: raise ValueError(f"Unsupported opportunity stage: {stage}") with engine.begin() as conn: current = conn.execute(text(""" SELECT stage FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}).mappings().first() if not current: return False old_stage = current.get("stage") status = "closed" if stage in {"WON", "LOST", "NO_INTEREST"} else "open" conn.execute(text(""" UPDATE opportunities SET stage = CAST(:stage AS TEXT), status = CAST(:status AS TEXT), closed_at = CASE WHEN CAST(:status AS TEXT) = 'closed' THEN COALESCE(closed_at, now()) ELSE NULL END, updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('manual_stage_changed_at', now(), 'manual_stage_changed_by', CAST(:created_by AS TEXT)) WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id, "stage": stage, "status": status, "created_by": created_by}) 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), 'manual_stage_changed', CAST(:from_stage AS TEXT), CAST(:to_stage AS TEXT), CAST(:note AS TEXT), '{}'::jsonb, CAST(:created_by AS TEXT) ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "from_stage": old_stage, "to_stage": stage, "note": note or f"Estado alterado manualmente para {stage_label(stage)}.", "created_by": created_by, }) return True