"""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'
{evidence_html}
' 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
{items}
'''
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"""
"""
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(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'''
'''
# "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')}