"""Commercial opportunity routes and actions. Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter, Request import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 from app.admin_ui.labels import primary_action_label from app.operation_noise import is_noise_operation_item from app.opportunity_next_action_service import get_opportunity_next_action from app.admin_ui.guidance import ( blocker_alert_html, fiscal_contact_inline_html, fiscal_contact_panel_html, fiscal_customer_missing_fields, opportunity_blockers, opportunity_context_customer, readiness_checklist_html, shipment_missing_fields, stage_requires_fiscal_customer, ) _opportunity_board_column_for_stage = legacy._opportunity_board_column_for_stage router = APIRouter() def _render_email_identity_review(opportunity_id: str, linked_customer: dict | None) -> str: try: from app.fiscal_enrichment_service import email_identity_review_for_opportunity review = email_identity_review_for_opportunity(opportunity_id, refresh=False) except Exception as exc: return f"""
Identidade do email
Erro ao ler identidade extraída: {esc(exc)}
""" if not review.get("ok") or not review.get("identity"): return f"""
Identidade do email
Ainda não existe identidade extraída para esta oportunidade.
""" identity = review.get("identity") or {} companies = review.get("valid_company_mentions") or identity.get("company_mentions") or [] phones = identity.get("phones") or [] evidence = identity.get("evidence") or [] conflict = bool(review.get("conflict")) suggested = review.get("suggested_internal_customer") or {} model = (identity.get("raw_payload") or {}).get("llm_model") if isinstance(identity.get("raw_payload"), dict) else identity.get("llm_model") model = model or identity.get("llm_model") or "—" confidence = identity.get("confidence") try: confidence_value = float(confidence or 0) confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%" except Exception: confidence_text = "—" company_html = "".join(f'{esc(c)}' for c in companies) or 'Sem empresa explícita válida' phone_html = ", ".join(esc(p) for p in phones) if phones else "—" evidence_html = "".join(f'
  • {esc(compact_text(e, 90))}
  • ' for e in evidence[:3]) conflict_html = "" if conflict: conflict_html = f"""
    Possível conflito fiscal.
    O email menciona {esc(', '.join(companies) or 'outra empresa')}, mas a oportunidade está ligada a {esc(review.get('linked_customer_name') or 'outro cliente')}.
    """ suggested_html = "" if suggested and companies: suggested_html = f"""
    Cliente interno compatível
    {esc(suggested.get('nome') or suggested.get('name') or 'Cliente')}
    NIF {esc(suggested.get('nif') or suggested.get('tax_id') or '—')}
    """ return f"""
    Identidade extraída do email
    {esc(identity.get('extraction_method') or identity.get('method') or '—')} · {esc(model)} · confiança {esc(confidence_text)}
    {status_badge('conflito') if conflict and 'status_badge' in globals() else ''}
    {conflict_html}
    Pessoa
    {esc(identity.get('person_name') or '—')}
    Empresa mencionada
    {company_html}
    Email / domínio
    {esc(identity.get('email') or '—')} · {esc(identity.get('domain') or '—')}
    Morada
    {esc(identity.get('address') or '—')}
    Telefones
    {phone_html}
    {suggested_html} {f'' if evidence_html else ''}
    """ def _local_normalize_fiscal_name(value: object) -> str: text = " ".join(str(value or "").strip().casefold().replace(",", " ").replace(".", " ").split()) legal = {"lda", "ltd", "sa", "s", "a", "unipessoal", "limitada", "sociedade", "portugal"} return " ".join(token for token in text.split() if token not in legal) def _render_fiscal_suggestions(opportunity_id: str, linked_customer: dict | None) -> str: try: from app.fiscal_enrichment_service import list_fiscal_suggestions_for_opportunity suggestions = list_fiscal_suggestions_for_opportunity(opportunity_id, limit=3) except Exception: suggestions = [] if linked_customer and not suggestions: return "" if not suggestions: return f"""
    Sem sugestão fiscal externa registada.
    """ rows = "" linked_name_norm = _local_normalize_fiscal_name(linked_customer.get("name") if linked_customer else "") linked_tax_id = str((linked_customer or {}).get("tax_id") or "").strip() linked_customer_id = str((linked_customer or {}).get("id") or "").strip() visible_suggestions = [] for suggestion in suggestions: status = str(suggestion.get("status") or "pending") lookup_value = str(suggestion.get("lookup_value") or "").strip().lower() suggested_nif = str(suggestion.get("suggested_nif") or "").strip() suggested_name_norm = _local_normalize_fiscal_name(suggestion.get("suggested_name")) suggested_customer_id = str(suggestion.get("suggested_customer_id") or "").strip() if lookup_value in {"pt", "com", "net", "org", "www", "http", "https", "mail", "email"}: continue # Do not show old accepted suggestions that merely confirm the current fiscal customer. # The fiscal card already shows the truth; repeating an accepted suggestion with stale # suggested_nif=NULL is confusing. same_current_customer = bool( linked_customer and status == "accepted" and ( (suggested_customer_id and linked_customer_id and suggested_customer_id == linked_customer_id) or (linked_name_norm and suggested_name_norm and linked_name_norm == suggested_name_norm) or (linked_tax_id and suggested_nif and linked_tax_id == suggested_nif) ) ) if same_current_customer: continue visible_suggestions.append(suggestion) for suggestion in visible_suggestions: sid = str(suggestion.get("id") or "") status = str(suggestion.get("status") or "pending") badge = status_badge(status) if "status_badge" in globals() else f"{esc(status)}" confidence = suggestion.get("confidence") if confidence is not None: try: confidence_value = float(confidence) confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%" except Exception: confidence_text = "—" else: confidence_text = "—" actions = "" if status == "pending" and sid: actions = f"""
    """ rows += f"""
    {esc(suggestion.get('suggested_name') or 'Empresa sugerida')}{badge}
    Sugestão fiscal · NIF {esc(suggestion.get('suggested_nif') or '—')} · confiança {esc(confidence_text)}
    {esc(suggestion.get('match_type') or suggestion.get('lookup_type') or 'match')}
    {actions}
    """ if not rows.strip(): return "" return f"""
    Sugestões fiscais por validar
    Não é cliente fiscal confirmado. Associar apenas depois de validar nome/NIF.
    {rows}
    """ def _jasmin_candidate_tax_conflict_message(opportunity_id: str, item_id: str) -> str: """Return a blocking message when a Jasmin candidate belongs to another NIF.""" try: from app.commercial_service import get_customer_for_opportunity, normalize_tax_id from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity linked_customer = get_customer_for_opportunity(opportunity_id) linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id")) if not linked_tax_id: return "" for item in find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=50): if str(item.get("id") or "") != str(item_id): continue candidate_tax = normalize_tax_id(item.get("customer_tax_id")) if candidate_tax and candidate_tax != linked_tax_id: return ( "NIF divergente: o documento Jasmin pertence a outro cliente fiscal. " "Rever manualmente na reconciliação antes de associar/substituir." ) return "" except Exception: # Não bloquear quando não conseguimos confirmar conflito; o serviço de importação # continua responsável por validar a operação. return "" return "" def _opportunity_jasmin_state(opportunity_id: str) -> dict: # Small UI helper: summarize current Jasmin evidence imported in ClientFlow. try: from sqlalchemy import text from app.db import engine with engine.begin() as conn: row = conn.execute(text(""" SELECT COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_documents, COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'quotation')::int AS quotations, COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'proforma')::int AS proformas, COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'invoice')::int AS invoices, (ARRAY_AGG(document_number ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_number, (ARRAY_AGG(document_kind ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_kind, (ARRAY_AGG(total_amount ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_total_amount FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": str(opportunity_id)}).mappings().first() item_count = conn.execute(text(""" SELECT COUNT(*)::int FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": str(opportunity_id)}).scalar() or 0 data = dict(row or {}) data["item_count"] = int(item_count or 0) return data except Exception: return {"jasmin_documents": 0, "item_count": 0} def _opportunity_consistency_alert_html(opportunity: dict, tasks: list[dict], opportunity_items: list[dict], opportunity_id: str) -> str: # Surface soft inconsistencies without blocking the operator. state = _opportunity_jasmin_state(opportunity_id) stage = str(opportunity.get("stage") or "") pending_action_codes = {str(t.get("action_code") or "") for t in tasks if str(t.get("status") or "") == "pending"} has_payment_task = bool({"CONFIRM_PAYMENT", "CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT"} & pending_action_codes) has_quote = int(state.get("quotations") or 0) > 0 has_proforma = int(state.get("proformas") or 0) > 0 has_invoice = int(state.get("invoices") or 0) > 0 has_items = bool(opportunity_items) or int(state.get("item_count") or 0) > 0 alerts = [] if has_payment_task and has_quote and not (has_proforma or has_invoice): alerts.append( "Existe tarefa de confirmar pagamento, mas o documento Jasmin atual ainda é orçamento. " "Antes de concluir a tarefa, confirma que o cliente recebeu pedido de pagamento/pró-forma ou que o pagamento foi efetivamente indicado." ) if stage == "WAITING_PAYMENT" and has_quote and not (has_proforma or has_invoice): alerts.append( "A fase está em pagamento com apenas orçamento Jasmin importado. Isto pode estar correto se o cliente já aceitou/pagou, " "mas a fase documental ainda não mostra pró-forma/fatura." ) if has_items and int(state.get("jasmin_documents") or 0) <= 0: alerts.append( "A oportunidade tem produtos, mas ainda não tem documento Jasmin importado. Usa Reimportar detalhes ou Criar orçamento." ) if not alerts: return "" items = "".join(f"
  • {esc(a)}
  • " for a in alerts[:3]) return f'''
    Verificação de consistência operacional
    ''' def _derived_timeline_html(opportunity_id: str) -> str: # Fallback timeline based on current documents/items/tasks when no audit events exist. try: from sqlalchemy import text from app.db import engine with engine.begin() as conn: docs = conn.execute(text(""" SELECT document_kind, document_number, total_amount, status, created_at FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) ORDER BY created_at DESC LIMIT 3 """), {"opportunity_id": str(opportunity_id)}).mappings().all() item_count = conn.execute(text(""" SELECT COUNT(*)::int FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": str(opportunity_id)}).scalar() or 0 except Exception: docs, item_count = [], 0 items = "" for doc in docs: title = "Documento Jasmin importado" detail = f"{doc.get('document_number') or 'documento'} · {money_html(doc.get('total_amount') or 0)}" items += f'''
    {esc(fmt_dt(doc.get('created_at')))}
    derivado
    {esc(title)}{operation_status_badge(str(doc.get('status') or 'created'))}
    {esc(detail)}
    ''' if item_count and not docs: items += f'''
    derivado
    Produtos na oportunidade
    {esc(item_count)} linha(s) comerciais associadas.
    ''' return items def _opportunity_query_string(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str: parts = [] if q: parts.append(f"q={esc(q)}") if status and status != "open": parts.append(f"status={esc(status)}") if scope and scope != "all": parts.append(f"scope={esc(scope)}") if limit and int(limit) != 300: parts.append(f"limit={int(limit)}") return ("?" + "&".join(parts)) if parts else "" def _opportunity_visible_set(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> tuple[list[dict], dict, list[tuple[str, str, object]]]: if (status or "open") == "closed": status = "open" opportunities = list_opportunities(q=q, status=status or "open", limit=limit) visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] != "closed"] grouped = {key: [] for key, _label, _stages in visible_board_columns} visible = [] for opportunity in opportunities: if _is_noise_opportunity(opportunity): continue key = _opportunity_board_column_for_opportunity(opportunity) if key == "closed": continue if scope and scope not in {"all", "open"}: if scope == "blocked": pending = int(opportunity.get("pending_task_count") or 0) if pending <= 0 and not opportunity_customer_mismatch(opportunity): continue elif key != scope: continue visible.append(opportunity) grouped.setdefault(key, []).append(opportunity) return visible, grouped, visible_board_columns def _compact_identity(value: object) -> str: value = compact_text(str(value or "").strip(), 42) if value.casefold() in {"", "geral", "cliente", "contacto"} or value.isdigit(): return "" return value def _opportunity_card_identity(opp: dict) -> tuple[str, str]: fiscal = _compact_identity(opp.get("linked_customer_name")) contact_name = _compact_identity(opp.get("customer_name")) contact_email = _compact_identity(opp.get("customer_email")) if fiscal: subtitle = contact_email or contact_name return fiscal, (f"Contacto: {subtitle}" if subtitle and subtitle != fiscal else "") if contact_name: return contact_name, contact_email if contact_email and contact_email != contact_name else "" if contact_email: return contact_email, "" conversation = str(opp.get("conversation_id") or "").strip() return "Contacto sem identificação", (f"Conversa Chatwoot #{conversation}" if conversation else "") # Legacy regression context: cta_label = "Concluir tarefa pendente" if pending else "Ver oportunidade". # v4.8.5 replaces that generic CTA with a specific action label. def _opportunity_card_next_action(opp: dict) -> str: if int(opp.get("pending_task_count") or 0) > 0: action_code = str(opp.get("last_action_code") or "").strip() return primary_action_label(action_code, fallback="Ver tarefa pendente") return opportunity_next_action_text(opp) def _is_noise_opportunity(opp: dict) -> bool: """Hide old bounce/NDR opportunities from the commercial board. Operations already hides technical mailbox noise; the opportunity board must use the same guard so legacy Mail Delivery/postmaster opportunities do not keep appearing as commercial work. """ return is_noise_operation_item({ "customer_name": opp.get("customer_name"), "contact_display_name": opp.get("customer_name"), "fiscal_customer_name": opp.get("linked_customer_name"), "message_subject": opp.get("product_interest"), "title": opp.get("title"), "detail": opp.get("product_interest"), "request_text": (opp.get("metadata") or {}).get("request_text") if isinstance(opp.get("metadata"), dict) else "", "source_system": opp.get("source_system"), "action_code": opp.get("last_action_code"), "no_opportunity_reason": (opp.get("metadata") or {}).get("no_opportunity_reason") if isinstance(opp.get("metadata"), dict) else "", "status": opp.get("status"), }) def _opportunity_board_column_for_opportunity(opp: dict) -> str: """Choose a visual board column from stage plus next pending action. The stored stage remains unchanged. This only avoids showing opportunities with a financial/logistics next step under the initial "Pedidos" column. """ action_code = str(opp.get("last_action_code") or "").upper().strip() if int(opp.get("pending_task_count") or 0) > 0: if action_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT"}: return "payment" if action_code in {"PREPARE_ORDER", "CREATE_SHIPMENT"}: return "operations" return _opportunity_board_column_for_stage(opp.get("stage")) def _render_opportunity_card(opp: dict) -> str: oid = str(opp.get("id") or "") title, subtitle = _opportunity_card_identity(opp) subject = compact_text(opp.get("product_interest") or opp.get("title") or "Pedido comercial", 64) next_action = compact_text(_opportunity_card_next_action(opp), 72) pending = int(opp.get("pending_task_count") or 0) blockers = opportunity_blockers(opp) cta_label = next_action if pending else "Ver oportunidade" cta_class = "btn-primary" if pending else "btn-outline-primary" blocker_html = blocker_alert_html(blockers, empty_text="") if blockers else "" subtitle_html = f'
    {esc(subtitle)}
    ' if subtitle else "" blocker_class = " has-blocker" if blockers else "" return f"""
    {esc(title)}
    {subtitle_html}
    {esc(subject)}
    {blocker_html}
    Próxima ação {esc(next_action)}
    {esc(cta_label)}
    """ def render_opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str: visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit) board_html = "" for key, label, _stages in visible_board_columns: cards = "".join(_render_opportunity_card(opp) for opp in grouped.get(key, [])) if not cards: cards = '
    Sem oportunidades nesta etapa.
    ' board_html += f"""
    {esc(label)} {len(grouped.get(key, []))}
    {cards}
    """ return f"""
    {len(visible_opportunities)} resultado(s) A atualizar…
    {board_html}
    """ @router.get("/opportunities/partials/board", response_class=HTMLResponse) async def opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300): return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)) @router.get("/opportunities", response_class=HTMLResponse) @router.get("/oportunidades", response_class=HTMLResponse) async def opportunities_page( request: Request, q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300, ): # Quadro operacional em Bootstrap 5. v4.7.4 adds an HTMX board partial # while preserving the same opportunity query and card semantics. if (status or "open") == "closed": status = "open" visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit) total_open = sum(1 for opp in visible_opportunities if str(opp.get("status") or "") == "open") total_pending = sum(int(opp.get("pending_task_count") or 0) for opp in visible_opportunities) total_value = sum(float(opp.get("value_amount") or 0) for opp in visible_opportunities) attention = [opp for opp in visible_opportunities if int(opp.get("pending_task_count") or 0) > 0] if is_htmx(request): return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)) status_options = "" for value, label in [("open", "Abertas"), ("all", "Todas")]: selected = "selected" if (status or "open") == value else "" status_options += f'' stage_tabs = "" filters = [("all", "Todas"), ("new", "Novas"), ("quote", "Orçamento enviado"), ("proforma", "Pró-forma enviada"), ("payment", "Pagamento pendente"), ("shipment", "Enviadas"), ("blocked", "Bloqueadas")] for key, label in filters: href = "/opportunities" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit) partial_href = "/opportunities/partials/board" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit) active = "btn-primary" if (scope or "all") == key else "btn-outline-secondary" stage_tabs += f'{esc(label)}' body = f"""
    Abertas
    {total_open}
    em acompanhamento
    Com tarefa
    {len(attention)}
    requerem ação
    Tarefas pendentes
    {total_pending}
    ligadas a vendas
    Valor estimado
    {money_html(total_value)}
    lista atual
    Limpar
    Filtros:{stage_tabs}

    Quadro de oportunidades

    Cards por etapa, com identificação clara, assunto, próxima ação e bloqueios relevantes. Filtros atualizam por HTMX.
    {len(visible_opportunities)} resultado(s)
    {render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)}
    """ return layout("Oportunidades", "Pipeline comercial com foco na próxima ação", body, active="opportunities") @router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse) async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = None): opportunity = get_opportunity(opportunity_id) if not opportunity: return layout("Oportunidade não encontrada", "Pipeline comercial", '
    Oportunidade não encontrada.
    ', "opportunities") tasks = list_opportunity_tasks(opportunity_id, limit=100) events = list_opportunity_events(opportunity_id, limit=100) stage = str(opportunity.get("stage") or "NEW_LEAD") pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"] next_task = pending_tasks[0] if pending_tasks else None opportunity_items = list_opportunity_items(opportunity_id) active_products = list_products(active="true", limit=200) try: from app.commercial_service import list_commercial_documents linked_documents = list_commercial_documents(opportunity_id=opportunity_id, limit=8) except Exception: linked_documents = [] primary_document = next( ( doc for doc in linked_documents if str(doc.get("document_kind") or "") == "invoice" and str(doc.get("role") or "current") in {"current", "accepted"} and bool(doc.get("is_primary", True)) ), next( ( doc for doc in linked_documents if str(doc.get("role") or "current") in {"current", "accepted"} and bool(doc.get("is_primary", True)) ), linked_documents[0] if linked_documents else None, ), ) opportunity_items_total = sum( float(item.get("total_price") or 0) for item in opportunity_items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL"} ) document_value = float(primary_document.get("total_amount") or primary_document.get("amount") or 0) if primary_document else 0 estimated_value = document_value or opportunity_items_total or float(opportunity.get("value_amount") or 0) value_source = "documento principal" if document_value else ("linhas atuais" if opportunity_items_total else "oportunidade") operation_snapshot = get_operation_snapshot(opportunity_id) try: opportunity_communications = list_communications_for_opportunity(opportunity_id, limit=12) except Exception: opportunity_communications = [] notice_html = f'
    {esc(notice)}
    ' if notice else '' metadata = opportunity.get("metadata") if isinstance(opportunity.get("metadata"), dict) else {} record_mode = str(metadata.get("clientflow_record_mode") or "") legacy_mode = record_mode in {"reconstructed_invoice_review", "historical_reconstructed", "legacy_review"} legacy_notice_html = "" if legacy_mode: legacy_notice_html = ( '
    ' 'Registo antigo/reconstruído.
    ' 'A oportunidade foi normalizada a partir de documentos já existentes. ' 'Valida pagamento, valor e linhas antes de executar novas ações.' '
    ' ) try: next_action = get_opportunity_next_action(opportunity_id) except Exception: next_action = {} if next_action: primary_action = next_action.get("label") or action_label(next_action.get("action_code")) primary_note = next_action.get("description") or "Continuar a próxima ação recomendada." target_url = next_action.get("target_url") or (f"/tasks/{next_task.get('id')}" if next_task else "/tasks?status=pending") button_label = "Abrir tarefa" if str(target_url).startswith("/tasks/") else "Continuar" if next_action.get("action_code") == "VALIDATE_FISCAL_CUSTOMER": primary_button = f'
    ' else: primary_button = f'{esc(button_label)}' elif next_task: primary_action = action_label(next_task.get("action_code")) primary_note = next_task.get("note") or next_task.get("action") or "Abrir tarefa pendente para continuar." primary_button = f'Abrir tarefa' else: primary_action = opportunity_next_action_text(opportunity) primary_note = "Não existe tarefa pendente ligada. Atualiza o estado ou acompanha a oportunidade." primary_button = 'Ver tarefas' task_rows = "" for task in tasks[:8]: task_rows += f''' {esc(action_label(task.get('action_code')))}
    {esc(compact_text(task.get('note') or task.get('action') or '', 70))}
    {route_badge(task.get('route'))} {status_badge(task.get('status'))} {esc(fmt_dt(task.get('created_at')))} ''' if not task_rows: task_rows = 'Sem tarefas associadas.' communication_rows = "" for communication in opportunity_communications: action = classification_action(communication.get("classification")) communication_rows += f''' {esc(communication.get('subject') or 'Sem assunto')}
    {esc(communication.get('sender_name') or communication.get('sender_email') or '—')}
    {esc(communication.get('classification') or 'por classificar')} {status_badge(communication.get('status'))} {esc(fmt_dt(communication.get('created_at')))} ''' if not communication_rows: conv = str(opportunity.get("conversation_id") or "").strip() if conv: communication_rows = f''' Conversa Chatwoot #{esc(conv)}
    Ainda não há mensagens indexadas/ligadas nesta oportunidade.
    por sincronizar sem ligação local — ''' else: communication_rows = 'Sem comunicações associadas à oportunidade.' timeline_items = "" try: unified_timeline = list_unified_opportunity_timeline(opportunity_id, limit=14) except Exception: unified_timeline = [] for event in unified_timeline: status = event.get("status") status_html = status_badge(status) if status else "" source = event.get("source") or "event" detail = compact_text(event.get("detail") or "", 140) timeline_items += f'''
    {esc(fmt_dt(event.get('created_at')))}
    {esc(source)}
    {esc(event.get('title') or 'Evento')}{status_html}
    {esc(detail or '—')}
    ''' if not timeline_items: timeline_items = _derived_timeline_html(opportunity_id) if not timeline_items: timeline_items = '
    Sem eventos registados.
    ' stage_options = "" for value, label in OPPORTUNITY_STAGE_LABELS.items(): selected = "selected" if value == opportunity.get("stage") else "" stage_options += f'' customer_name = opportunity_customer_name(opportunity) contact_name = opportunity_contact_name(opportunity) customer_email = opportunity.get("customer_email") or "" customer_phone = opportunity.get("customer_phone") or "" conversation = opportunity.get("conversation_id") or "—" # v4.6.2: não mostrar aviso por divergência de nome. Contacto pessoal e # cliente fiscal/empresa podem ser diferentes e ainda assim estar corretos. customer_mismatch_alert = "" linked_customer = None customer_options = '' try: from app.commercial_service import get_customer_for_opportunity, list_customers linked_customer = get_customer_for_opportunity(opportunity_id) for c in list_customers(limit=150): selected = "selected" if linked_customer and str(c.get("id")) == str(linked_customer.get("id")) else "" label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or 'sem NIF'}" customer_options += f'' except Exception: linked_customer = None fiscal_suggestions_html = _render_fiscal_suggestions(opportunity_id, linked_customer) email_identity_html = _render_email_identity_review(opportunity_id, linked_customer) fiscal_customer = opportunity_context_customer(opportunity, linked_customer) fiscal_customer_href = f"/customers/{esc(fiscal_customer.get('id'))}" if fiscal_customer and fiscal_customer.get("id") else "" fiscal_contact_html = fiscal_contact_panel_html( fiscal_customer=fiscal_customer, contact_name=contact_name, contact_email=customer_email, contact_phone=customer_phone, conversation_id=opportunity.get("conversation_id"), contact_id=opportunity.get("contact_id"), customer_href=fiscal_customer_href, ) next_action_code = (next_action.get("action_code") if isinstance(next_action, dict) else None) or opportunity.get("last_action_code") current_blockers = opportunity_blockers(opportunity, linked_customer, action_code=next_action_code) document_already_issued = bool( primary_document or linked_documents or stage in {"QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", "WON"} ) blockers_html = ( '
    Avisos para revisão' + '
    Existe documento emitido/ligado; estes dados devem ser revistos para próximos documentos ou correção administrativa.
    ' if current_blockers and document_already_issued else blocker_alert_html(current_blockers) ) fiscal_readiness_html = readiness_checklist_html( title="Prontidão para documentos", missing=fiscal_customer_missing_fields(fiscal_customer), ok_text="Cliente fiscal pronto para orçamento, pró-forma ou fatura.", blocked_text=("Dados fiscais incompletos no ClientFlow; rever para próximos documentos." if document_already_issued else "Dados fiscais incompletos no ClientFlow; rever antes de emitir novo documento."), ) shipment_readiness_html = readiness_checklist_html( title="Prontidão para envio", missing=shipment_missing_fields(fiscal_customer, opportunity), ok_text="Dados mínimos de envio completos.", blocked_text="Envio deve aguardar correção destes dados.", ) consistency_alert_html = _opportunity_consistency_alert_html(opportunity, tasks, opportunity_items, opportunity_id) if primary_document: document_label = commercial_document_display_number(primary_document, fallback="número por atualizar") document_kind = { "quotation": "Orçamento", "proforma": "Pró-forma", "invoice": "Fatura", }.get(str(primary_document.get("document_kind") or ""), "Documento") document_state = f"{document_kind} · {document_label}" document_chip = 'ligado' else: document_state = "Sem documento principal" document_chip = 'pendente' fiscal_state = (linked_customer.get("name") if linked_customer else "Por associar") fiscal_chip = 'validado' if linked_customer else 'bloqueia documentos' task_state = f"{len(pending_tasks)} pendente(s)" if pending_tasks else "Sem tarefas pendentes" task_chip = 'requer ação' if pending_tasks else 'limpo' operator_summary_html = f'''

    Mapa operacional

    Leitura rápida do processo: cliente fiscal, documento principal, task e próxima ação.
    Ver reconciliação
    Cliente fiscal{esc(fiscal_state)}{fiscal_chip}
    Documento principal{esc(document_state)}{document_chip}
    Tasks{esc(task_state)}{task_chip}
    Decisão seguinte{esc(primary_action)}{esc(next_action.get('action_code') or opportunity.get('last_action_code') or 'FOLLOW_UP')}
    Ações avançadas
    ''' technical_html = f'''
    ID
    {esc(opportunity_id)}
    Conversa
    {esc(conversation)}
    Última action
    {esc(opportunity.get('last_action_code') or '—')}
    Atualizada
    {esc(fmt_dt(opportunity.get('updated_at')))}
    ''' # "Bloqueios atuais" permanece como conceito de UI/teste, mas o título duplicado foi removido. body = f''' ← Voltar a oportunidades {notice_html} {legacy_notice_html} {customer_mismatch_alert} {consistency_alert_html}
    Oportunidade

    {esc(opportunity.get('title') or 'Oportunidade')}

    {esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}
    {opportunity_stage_badge(stage)}{opportunity_priority_chip(opportunity)}
    Próxima ação

    {esc(primary_action)}

    {esc(primary_note)}
    {primary_button}
    {operator_summary_html} {f'
    {blockers_html}
    ' if current_blockers else ''} {fiscal_contact_html}
    {fiscal_readiness_html}
    {shipment_readiness_html}

    Resumo essencial

    {'Valor principal' if document_value else ('Valor reconstruído' if legacy_mode else 'Valor estimado')}{money_html(estimated_value)}
    {esc(value_source)}
    Tarefas pendentes{len(pending_tasks)}
    Atualizada{esc(fmt_dt(opportunity.get('updated_at')))}

    Pipeline

    {stage_progress_html(stage)}
    {operation_cockpit_html(opportunity_id, opportunity, operation_snapshot)}
    {opportunity_integrations_panel_html(opportunity_id)}
    {jasmin_documents_html(opportunity_id)}
    {opportunity_products_panel_html(opportunity_id)}

    Tasks relacionadas

    Ações humanas já criadas para esta oportunidade.
    {task_rows}
    AçãoFilaEstadoCriada

    Mensagens Chatwoot

    Mensagens relevantes ligadas a esta oportunidade. A resposta continua no Chatwoot.
    {communication_rows}
    MensagemClassificaçãoEstadoRecebida

    Timeline recente

    {timeline_items}
    Ver detalhes técnicos e edição avançada
    {technical_html}
    ''' return layout(str(opportunity.get("title") or "Oportunidade"), "Detalhe comercial com informação essencial", body, "opportunities") @router.get("/opportunities/{opportunity_id}/partials/jasmin-documents", response_class=HTMLResponse) async def opportunity_jasmin_documents_partial(opportunity_id: str): return HTMLResponse(jasmin_documents_html(opportunity_id)) @router.get("/opportunities/{opportunity_id}/partials/products", response_class=HTMLResponse) async def opportunity_products_partial(opportunity_id: str): return HTMLResponse(opportunity_products_panel_html(opportunity_id)) @router.post("/commercial-documents/{document_id}/refresh") async def commercial_document_refresh(document_id: str, request: Request): form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() try: from app.jasmin_service import refresh_commercial_document_from_jasmin await refresh_commercial_document_from_jasmin(document_id) except Exception as exc: if opportunity_id and is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar documento: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao atualizar documento: {exc}", status_code=500) if opportunity_id and is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Documento atualizado a partir do Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}" if opportunity_id else "/outbox", status_code=303) @router.get("/commercial-documents/{document_id}/pdf") async def commercial_document_pdf(document_id: str): try: from app.jasmin_service import get_commercial_document_pdf doc, data, content_type = await get_commercial_document_pdf(document_id) except Exception as exc: return PlainTextResponse(f"Erro ao obter PDF Jasmin: {exc}", status_code=500) name = doc.get("document_number") or doc.get("external_id") or document_id safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(name))[:80] or "documento" headers = {"Content-Disposition": f'inline; filename="{safe_name}.pdf"'} return Response(content=data, media_type=content_type or "application/pdf", headers=headers) @router.post("/opportunities/{opportunity_id}/stage") async def update_opportunity_stage_action(opportunity_id: str, request: Request): form = await request.form() stage = str(form.get("stage") or "").strip() note = str(form.get("note") or "").strip() if stage: set_opportunity_stage(opportunity_id, stage, note=note, created_by="operator") return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) @router.post("/opportunities/{opportunity_id}/customer") async def opportunity_link_customer_action(opportunity_id: str, request: Request): form = await request.form() customer_id = str(form.get("customer_id") or "").strip() try: from app.commercial_service import link_customer_to_opportunity, unlink_customer_from_opportunity if customer_id: link_customer_to_opportunity(customer_id, opportunity_id) else: unlink_customer_from_opportunity(opportunity_id) except Exception as exc: return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) @router.post("/opportunities/{opportunity_id}/email-identity/extract") async def opportunity_email_identity_extract_action(opportunity_id: str, request: Request): try: from app.email_identity_extraction_service import extract_identity_for_opportunity result = extract_identity_for_opportunity(opportunity_id, refresh=True, use_llm=True) except Exception as exc: return PlainTextResponse(f"Erro ao extrair identidade do email: {exc}", status_code=500) if not result: notice = "Sem mensagem associada para extrair identidade." else: companies = result.get("company_mentions") or [] notice = "Identidade extraída" + (f": {', '.join(companies[:2])}" if companies else ".") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/email-identity/assist") async def opportunity_email_identity_assist_action(opportunity_id: str, request: Request): try: from app.fiscal_enrichment_service import assist_email_identity_enrichment result = assist_email_identity_enrichment(opportunity_id, refresh=True, apply_safe=False) except Exception as exc: return PlainTextResponse(f"Erro ao procurar cliente fiscal por identidade: {exc}", status_code=500) if result.get("conflict"): notice = "Possível conflito fiscal detetado pela identidade extraída." elif result.get("status") == "email_identity_matches_current_fiscal_customer": notice = "Identidade extraída confirma o cliente fiscal atual." elif result.get("suggested"): notice = "Sugestão fiscal criada a partir da identidade extraída." else: notice = "Identidade extraída, mas sem cliente fiscal compatível encontrado." return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/email-identity/cleanup-invalid") async def opportunity_email_identity_cleanup_invalid_action(opportunity_id: str, request: Request): try: from app.email_identity_cleanup_service import cleanup_invalid_email_identity_state result = cleanup_invalid_email_identity_state( opportunity_id=opportunity_id, include_accepted=True, fix_extractions=True, apply=True, ) except Exception as exc: return PlainTextResponse(f"Erro ao limpar identidade inválida: {exc}", status_code=500) notice = ( f"Limpeza de identidade: {result.get('rejected', 0)} sugestão(ões) rejeitada(s), " f"{result.get('fixed_extractions', 0)} extração(ões) corrigida(s)." ) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/fiscal-enrich") async def opportunity_fiscal_enrich_action(opportunity_id: str, request: Request): try: from app.fiscal_enrichment_service import enrich_opportunity result = enrich_opportunity(opportunity_id, apply_safe=True) except Exception as exc: return PlainTextResponse(f"Erro ao enriquecer cliente fiscal: {exc}", status_code=500) if result.get("auto_applied"): notice = "Cliente fiscal auto-associado por enriquecimento." elif result.get("suggested"): notice = "Sugestão fiscal criada para revisão." else: notice = f"Sem sugestão fiscal: {result.get('reason') or 'sem correspondência'}" return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/fiscal-suggestions/{suggestion_id}/accept") async def fiscal_suggestion_accept_action(suggestion_id: str, request: Request): try: from app.fiscal_enrichment_service import apply_fiscal_suggestion result = apply_fiscal_suggestion(suggestion_id, actor="operator_ui") except Exception as exc: return PlainTextResponse(f"Erro ao aplicar sugestão fiscal: {exc}", status_code=500) opportunity_id = result.get("opportunity_id") or "" if not result.get("applied"): return PlainTextResponse(f"Sugestão não aplicada: {result.get('reason')}", status_code=409) return RedirectResponse(f"/opportunities/{esc(opportunity_id)}?notice=Sugest%C3%A3o%20fiscal%20aplicada", status_code=303) @router.post("/fiscal-suggestions/{suggestion_id}/reject") async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request): try: from app.fiscal_enrichment_service import reject_fiscal_suggestion reject_fiscal_suggestion(suggestion_id, actor="operator_ui") except Exception as exc: return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500) referer = request.headers.get("referer") or "/opportunities" return RedirectResponse(referer, status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/sync-candidates") async def opportunity_jasmin_sync_candidates_action(opportunity_id: str, request: Request): try: from app.external_reconciliation_sync import sync_jasmin_reconciliation_candidates result = await sync_jasmin_reconciliation_candidates(limit=100, days=30) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao sincronizar Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao sincronizar Jasmin: {exc}", status_code=500) seen = result.get("seen", 0) created = result.get("created_or_updated", 0) notice = f"Jasmin sincronizado: {seen} documento(s) visto(s), {created} criado(s)/atualizado(s)." if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20sincronizado", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/reimport-details") async def opportunity_jasmin_reimport_details_action(opportunity_id: str, request: Request): try: from app.jasmin_backfill_service import backfill_jasmin_opportunity_details_async result = await backfill_jasmin_opportunity_details_async( opportunity_id=opportunity_id, fetch_detail=True, actor="operator_ui_reimport", dry_run=False, ) except Exception as exc: if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao reimportar detalhes Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao reimportar detalhes Jasmin: {exc}", status_code=500) if not result.get("ok"): msg = result.get("error") or "sem itens Jasmin para reimportar" if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível reimportar: {msg}"), status_code=409) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20reimportar%20Jasmin", status_code=303) import_result = result.get("import_result") or {} docs = int(import_result.get("documents") or 0) lines = int(import_result.get("lines") or 0) notice = f"Detalhes Jasmin reimportados: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar produtos/valor no topo." if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/link-candidate/{item_id}") async def opportunity_jasmin_link_candidate_action(opportunity_id: str, item_id: str, request: Request): conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id) if conflict_msg: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409) return PlainTextResponse(conflict_msg, status_code=409) try: from app.jasmin_backfill_service import link_and_import_jasmin_candidate_async result = await link_and_import_jasmin_candidate_async( opportunity_id=opportunity_id, item_id=item_id, actor="operator_ui_link_existing_jasmin", ) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao associar documento Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao associar documento Jasmin: {exc}", status_code=500) if not result.get("ok"): msg = result.get("error") or "não foi possível associar documento Jasmin" if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível associar: {msg}"), status_code=409) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20associar%20Jasmin", status_code=303) import_result = result.get("import_result") or {} docs = import_result.get("documents", 0) lines = import_result.get("lines", 0) notice = f"Documento Jasmin associado e importado: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar valor/produtos no topo." if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20associado", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/replace-candidate/{item_id}") async def opportunity_jasmin_replace_candidate_action(opportunity_id: str, item_id: str, request: Request): conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id) if conflict_msg: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409) return PlainTextResponse(conflict_msg, status_code=409) try: from app.jasmin_backfill_service import replace_jasmin_document_for_opportunity_async result = await replace_jasmin_document_for_opportunity_async( opportunity_id=opportunity_id, item_id=item_id, actor="operator_ui_replace_existing_jasmin", dry_run=False, ) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao substituir documento Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao substituir documento Jasmin: {exc}", status_code=500) if not result.get("ok"): msg = result.get("error") or "não foi possível substituir documento Jasmin" if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível substituir: {msg}"), status_code=409) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20substituir%20Jasmin", status_code=303) import_result = result.get("import_result") or {} docs = import_result.get("documents", 0) lines = import_result.get("lines", 0) removed_docs = result.get("removed_documents", 0) notice = f"Documento Jasmin substituído: {removed_docs} anterior(es) removido(s), {docs} documento(s), {lines} linha(s) importada(s). Recarregue a página para atualizar valor/produtos no topo." if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20substitu%C3%ADdo", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/create-quotation") async def opportunity_jasmin_create_quotation(opportunity_id: str, request: Request): try: if settings.jasmin_enabled: from app.jasmin_service import enqueue_create_quotation enqueue_create_quotation(opportunity_id, created_by="operator") else: return PlainTextResponse("JASMIN_ENABLED=false", status_code=409) except Exception as exc: print(f"ClientFlow Jasmin create quotation failed: {exc}", flush=True) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409) return PlainTextResponse(f"Erro ao criar pedido de orçamento Jasmin: {exc}", status_code=500) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de orçamento enviado para a outbox Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20or%C3%A7amento%20enviado%20para%20a%20outbox%20Jasmin", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/convert-invoice") async def opportunity_jasmin_convert_invoice(opportunity_id: str, request: Request): try: if settings.jasmin_enabled: from app.jasmin_service import enqueue_convert_latest_to_invoice enqueue_convert_latest_to_invoice(opportunity_id, created_by="operator") else: return PlainTextResponse("JASMIN_ENABLED=false", status_code=409) except Exception as exc: print(f"ClientFlow Jasmin convert invoice failed: {exc}", flush=True) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409) return PlainTextResponse(f"Erro ao criar pedido de fatura Jasmin: {exc}", status_code=500) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de fatura enviado para a outbox Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20fatura%20enviado%20para%20a%20outbox%20Jasmin", status_code=303) @router.post("/opportunities/{opportunity_id}/operations/{action_key}") async def opportunity_operation_action(opportunity_id: str, action_key: str, request: Request): form = await request.form() external_id = str(form.get("external_id") or "").strip() external_name = str(form.get("external_name") or form.get("external_ref") or form.get("title") or "").strip() external_url = str(form.get("external_url") or "").strip() note = str(form.get("note") or "").strip() try: # Jasmin e Packlink, sem referência manual, criam itens de outbox para a API real. # Se o operador preencher external_id/external_name, mantém o modo manual/fallback. if action_key == "jasmin_quotation" and not external_id and not external_name: if settings.jasmin_enabled: from app.jasmin_service import enqueue_create_quotation enqueue_create_quotation(opportunity_id, created_by="operator") else: register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator") elif action_key == "packlink_shipment" and not external_id and not external_name: if settings.packlink_enabled: from app.packlink_service import enqueue_packlink_shipment enqueue_packlink_shipment(opportunity_id, created_by="operator") else: register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator") else: register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator") except OperationActionBlocked as exc: return PlainTextResponse(f"Ação bloqueada: {exc}", status_code=409) except Exception as exc: print(f"ClientFlow operation action failed: {exc}", flush=True) return PlainTextResponse(f"Erro ao registar ação: {exc}", status_code=500) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) @router.post("/opportunities/{opportunity_id}/odoo/sync-status") async def opportunity_odoo_sync_status_action(opportunity_id: str, request: Request): try: sync_opportunity_odoo_status(opportunity_id) except Exception: pass return RedirectResponse(url=f"/opportunities/{opportunity_id}", status_code=303)