from pathlib import Path
import os
from datetime import datetime, timezone
import html
import json
from uuid import UUID
from hmac import compare_digest
from typing import Optional
from sqlalchemy import text
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, Response
from starlette.concurrency import run_in_threadpool
from app.admin_queries import list_action_runs, list_business_events
from app.integration_outbox_service import get_outbox_item, list_outbox, set_outbox_status
from app.config import is_production_like_env, settings
from app.preparation_service import prepare_task as run_task_preparation
from app.preparation_view_model import build_preparation_view_model
from app.workflow_guard import OperationActionBlocked, get_workflow_action_plan
from app.odoo_service import (
test_odoo_connection,
sync_odoo_products,
get_odoo_product_snapshot,
sync_opportunity_odoo_status,
)
from app.operation_service import get_operation_snapshot, operation_next_steps, register_operation_action
from app.operations_service import get_operations_summary, get_system_health_summary, list_unified_opportunity_timeline
from app.communication_service import (
classification_action,
create_timeline_event,
get_communication,
get_communications_summary,
link_communication_to_customer,
link_communication_to_opportunity,
list_communications,
list_communications_for_opportunity,
set_communication_status,
)
from app.task_service import complete_task, complete_task_with_note, get_admin_dashboard_metrics, get_customer_profile, get_system_health_metrics, get_task_detail, get_latest_task_preparation, list_admin_recent_raw_events, list_admin_recent_tasks, list_customer_messages, list_customer_opportunity_mappings, list_customer_task_history, list_customer_tasks, list_tasks, skip_task, reclassify_task
from app.opportunity_service import (
OPPORTUNITY_BOARD_COLUMNS,
OPPORTUNITY_STAGE_LABELS,
get_opportunity,
list_opportunities,
list_opportunity_events,
list_opportunity_tasks,
set_opportunity_stage,
stage_label,
)
from app.product_service import (
add_opportunity_item,
create_product,
delete_opportunity_item,
get_product,
list_opportunity_items,
list_product_categories,
list_products,
set_product_active,
update_opportunity_item,
update_product,
)
from app.admin_ui.components import kpi_card
from app.admin_ui.layout import layout
from app.admin_ui.styles import ADMIN_UI_V451_CSS
# Route handlers moved to app.admin_ui.pages.* in v4.7.2. ADMIN_UI_CSS moved to app.admin_ui.styles. Já existe documento atual. A associação direta fica bloqueada
def require_admin_access(request: Request) -> None:
"""Proteção opcional da UI admin.
Se CLIENTFLOW_ADMIN_TOKEN estiver vazio, mantém compatibilidade local.
Em produção deve ser definido e enviado em X-ClientFlow-Admin-Token,
cookie clientflow_admin_token, ou query param admin_token atrás de HTTPS/proxy.
"""
expected = (settings.clientflow_admin_token or "").strip()
if not expected:
if is_production_like_env():
raise HTTPException(status_code=503, detail="admin auth not configured")
return
received = (
request.headers.get("X-ClientFlow-Admin-Token")
or request.cookies.get("clientflow_admin_token")
or (request.query_params.get("admin_token") if not is_production_like_env() else None)
or ""
).strip()
if not received or not compare_digest(received, expected):
raise HTTPException(status_code=401, detail="admin auth required")
router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)])
def esc(value) -> str:
return html.escape(str(value or ""))
def chatwoot_conversation_url(conversation_id: object) -> str:
conversation_id = str(conversation_id or "").strip()
if not conversation_id:
return ""
public_url = (
getattr(settings, "chatwoot_public_url", "")
or getattr(settings, "chatwoot_base_url", "")
or ""
).rstrip("/")
account_id = str(getattr(settings, "chatwoot_account_id", "") or "").strip()
if not public_url or not account_id:
return ""
return f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}"
def chatwoot_button(conversation_id: object, label: str = "Abrir Chatwoot") -> str:
href = chatwoot_conversation_url(conversation_id)
if not href:
return ""
return f' {esc(label)}'
def shell_output(cmd: list[str], *, timeout: int = 8) -> str:
try:
import subprocess
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
output = (result.stdout or "") + (result.stderr or "")
return output.strip()
except Exception as e:
return f"erro ao executar {' '.join(cmd)}: {e!r}"
def status_pill(ok: bool, label: str) -> str:
cls = "pill-ok" if ok else "pill-bad"
return f'{esc(label)}'
def latest_backup_info() -> dict:
backup_dir = Path(os.getenv("CLIENTFLOW_BACKUP_DIR", "./backups/clientflow"))
files = sorted(
backup_dir.glob("clientflow-*.sql.gz"),
key=lambda x: x.stat().st_mtime if x.exists() else 0,
reverse=True,
)
if not files:
return {
"exists": False,
"file": "",
"size": "",
"mtime": "",
}
f = files[0]
stat = f.stat()
return {
"exists": True,
"file": str(f),
"size": f"{stat.st_size / 1024:.1f} KB",
"mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
}
def done_note_options_html_for(action_code: str) -> str:
done_note_templates = {
"SEND_INFO": [
"Informação enviada ao cliente no Chatwoot.",
"Cliente informado por email.",
"Informação comercial enviada; aguardar resposta.",
],
"SEND_QUOTE": [
"Proposta/cotação enviada ao cliente.",
"Cotação enviada no Chatwoot.",
"Proposta enviada; aguardar confirmação do cliente.",
],
"SEND_PROFORMA": [
"Orçamento para pagamento enviado ao cliente.",
"Orçamento enviado; aguardar pagamento/confirmação.",
],
"SEND_INVOICE": [
"Fatura enviada ao cliente.",
"Cliente informado do envio da fatura.",
],
"CONFIRM_PAYMENT": [
"Pagamento confirmado.",
"Comprovativo validado; processo segue para operações se aplicável.",
],
"VALIDATE_PHYSICAL_ORDER": ["Encomenda física validada e pronta para expedição.", "Picking e produtos conferidos; pode avançar para envio."], "SUPPORT": [
"Pedido de suporte respondido ou encaminhado.",
"Cliente informado; suporte vai acompanhar o caso.",
"Pedido encaminhado para análise.",
],
"REMOVE_FROM_LIST": [
"Contacto removido da lista.",
"Pedido de remoção tratado.",
],
"REVIEW_MANUALLY": [
"Caso revisto manualmente.",
"Sem ação automática; tratado manualmente.",
],
"MARK_NO_INTEREST": [
"Marcado sem interesse atual.",
"Cliente informou que não tem necessidade atual.",
"Oportunidade encerrada/sem seguimento comercial por agora.",
],
"FOLLOW_UP_QUOTE": [
"Follow-up do orçamento feito; aguardar resposta.",
"Cliente contactado sobre a proposta.",
"Follow-up feito e sem resposta imediata.",
],
"FOLLOW_UP_PROFORMA": [
"Follow-up do orçamento para pagamento feito; aguardar pagamento/confirmação.",
"Cliente contactado sobre o orçamento para pagamento.",
],
"FOLLOW_UP_PAYMENT": [
"Follow-up de pagamento feito; aguardar comprovativo/confirmação.",
"Cliente contactado sobre pagamento pendente.",
],
"FOLLOW_UP_CUSTOMER_REVIEW": [
"Follow-up da informação enviada feito; aguardar decisão do cliente.",
"Cliente contactado para perceber se pretende orçamento.",
],
"FOLLOW_UP_GENERIC": [
"Follow-up manual feito; aguardar resposta.",
"Cliente contactado manualmente.",
],
"NO_ACTION": [
"Sem ação necessária.",
],
"IGNORE_SPAM": [
"Mensagem ignorada como spam.",
],
}
default_done_notes = [
"Tarefa concluída.",
"Cliente informado no Chatwoot.",
"Pedido tratado manualmente.",
]
templates = done_note_templates.get(action_code or "", default_done_notes)
return "".join(
f''
for option in templates
)
def suggested_reply_for_task(task: dict) -> str:
action_code = task.get("action_code") or ""
metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {}
if not metadata and isinstance(task.get("metadata"), str):
try:
metadata = json.loads(task.get("metadata") or "{}")
except Exception:
metadata = {}
if metadata.get("suggested_message"):
return str(metadata.get("suggested_message") or "")
customer_name = task.get("customer_name") or ""
first_name = str(customer_name).strip().split(" ")[0] if customer_name else ""
greeting = f"Olá {first_name}," if first_name and first_name.lower() not in ["cliente", "desconhecido"] else "Olá,"
closing = "Obrigado,\nEquipa BLIF"
templates = {
"SEND_INFO": f"""{greeting}
Obrigado pelo seu contacto.
Segue informação sobre os nossos carregadores para veículos elétricos. Podemos ajudar com a escolha do modelo mais adequado, disponibilidade, condições de entrega e instalação.
Caso pretenda, envie-nos por favor:
- tipo de viatura;
- local de instalação;
- potência disponível;
- se pretende carregador monofásico ou trifásico.
{closing}""",
"SEND_QUOTE": f"""{greeting}
Obrigado pelo seu pedido.
Vamos preparar/enviar a proposta para o carregador solicitado, incluindo preço, disponibilidade e condições de entrega.
Se ainda não tiver indicado, confirme por favor:
- modelo pretendido;
- quantidade;
- morada/localidade para entrega;
- dados para faturação, se desejar avançar.
{closing}""",
"SEND_PROFORMA": f"""{greeting}
Podemos enviar o orçamento para pagamento.
Para isso, envie por favor os dados de faturação:
- nome/empresa;
- NIF;
- morada;
- email para envio;
- produto/quantidade pretendida.
{closing}""",
"SEND_INVOICE": f"""{greeting}
Obrigado pela confirmação.
Vamos enviar a fatura conforme solicitado. Caso ainda não tenha enviado os dados de faturação, envie por favor:
- nome/empresa;
- NIF;
- morada;
- email.
{closing}""",
"CONFIRM_PAYMENT": f"""{greeting}
Obrigado pelo envio da informação/comprovativo.
Vamos confirmar o pagamento e dar seguimento ao processo. Se for aplicável, encaminhamos também a encomenda para preparação/envio.
Assim que tivermos atualização, informamos.
{closing}""",
"SUPPORT": f"""{greeting}
Obrigado pelo contacto.
Vamos encaminhar o seu pedido para suporte. Para ajudar na análise, envie por favor, se aplicável:
- modelo do carregador/equipamento;
- descrição do pedido/problema;
- fotos/vídeos, se possível;
- morada/local de instalação, entrega ou recolha;
- contacto telefónico.
{closing}""",
"REMOVE_FROM_LIST": f"""{greeting}
Confirmamos que vamos tratar o pedido de remoção da lista de contactos.
{closing}""",
"MARK_NO_INTEREST": f"""{greeting}
Obrigado pela informação.
Ficamos ao dispor caso no futuro venham a integrar veículos elétricos na frota ou necessitem de soluções de carregamento.
{closing}""",
"REVIEW_MANUALLY": f"""{greeting}
Obrigado pela sua mensagem.
Vamos analisar o pedido internamente e responder assim que possível.
{closing}""",
}
return templates.get(action_code, f"""{greeting}
Obrigado pela sua mensagem.
Vamos analisar o pedido e responder assim que possível.
{closing}""")
ACTION_UI_LABELS = {
"SEND_INFO": "Enviar informação",
"SEND_QUOTE": "Enviar orçamento",
"SEND_PROFORMA": "Enviar orçamento para pagamento",
"SEND_INVOICE": "Enviar fatura",
"CONFIRM_PAYMENT": "Confirmar pagamento",
"SUPPORT": "Tratar suporte",
"REMOVE_FROM_LIST": "Remover da lista",
"MARK_NO_INTEREST": "Marcar sem interesse",
"IGNORE_SPAM": "Ignorar spam",
"REVIEW_MANUALLY": "Rever manualmente",
"NO_ACTION": "Sem ação",
"FOLLOW_UP_QUOTE": "Follow-up orçamento",
"FOLLOW_UP_PROFORMA": "Follow-up pagamento",
"FOLLOW_UP_PAYMENT": "Follow-up pagamento",
"FOLLOW_UP_CUSTOMER_REVIEW": "Follow-up informação",
"FOLLOW_UP_GENERIC": "Follow-up manual",
}
ACTION_HINTS = {
"SEND_INFO": "Enviar informação geral e pedir os dados mínimos para recomendar o carregador certo.",
"SEND_QUOTE": "Preparar/enviar orçamento com preço, disponibilidade, condições de entrega e dados necessários para avançar.",
"SEND_PROFORMA": "Confirmar dados e enviar orçamento para pagamento.",
"SEND_INVOICE": "Confirmar dados de faturação e enviar a fatura solicitada.",
"CONFIRM_PAYMENT": "Validar pagamento/comprovativo e encaminhar para preparação/envio se aplicável.",
"SUPPORT": "Responder ao pedido e recolher informação técnica mínima para análise.",
"REMOVE_FROM_LIST": "Confirmar remoção do contacto de comunicações futuras.",
"MARK_NO_INTEREST": "Cliente indicou ausência de interesse atual/necessidade após divulgação; não é o mesmo que oportunidade perdida por preço ou funcionalidades.",
"IGNORE_SPAM": "Ignorar a mensagem e não criar seguimento comercial.",
"REVIEW_MANUALLY": "Analisar manualmente porque a intenção não ficou suficientemente clara.",
"NO_ACTION": "Não é necessária ação operacional.",
"FOLLOW_UP_QUOTE": "Confirmar manualmente se o cliente recebeu a proposta e se tem dúvidas.",
"FOLLOW_UP_PROFORMA": "Confirmar manualmente receção do orçamento/dados de pagamento.",
"FOLLOW_UP_PAYMENT": "Confirmar manualmente pagamento/comprovativo pendente.",
"FOLLOW_UP_CUSTOMER_REVIEW": "Confirmar manualmente se a informação enviada foi suficiente e se quer orçamento.",
"FOLLOW_UP_GENERIC": "Contactar o cliente conforme contexto da oportunidade.",
}
ACTION_MISSING_HINTS = {
"SEND_INFO": ["potência pretendida", "tipo de instalação", "localidade", "contacto telefónico"],
"SEND_QUOTE": ["modelo/produto", "quantidade", "morada/localidade", "dados de faturação se avançar"],
"SEND_PROFORMA": ["nome/empresa", "NIF", "morada fiscal", "email de faturação", "produto/quantidade"],
"SEND_INVOICE": ["nome/empresa", "NIF", "morada fiscal", "email de faturação"],
"CONFIRM_PAYMENT": ["valor recebido", "referência/comprovativo", "morada de entrega", "contacto para entrega"],
"SUPPORT": ["modelo", "descrição do problema", "fotos/vídeos", "local de instalação", "telefone"],
"MARK_NO_INTEREST": ["motivo", "se é apenas falta de interesse atual", "se deve manter contacto para futuro"],
"FOLLOW_UP_QUOTE": ["confirmar receção", "dúvidas técnicas", "se pretende avançar"],
"FOLLOW_UP_PROFORMA": ["confirmar receção", "pagamento", "dados pendentes"],
"FOLLOW_UP_PAYMENT": ["comprovativo", "previsão de pagamento", "pendências"],
"FOLLOW_UP_CUSTOMER_REVIEW": ["interesse atual", "necessidade de proposta", "dúvidas"],
"FOLLOW_UP_GENERIC": ["contexto", "próxima decisão", "prazo de resposta"],
}
PIPELINE_STEPS = [
("NEW_LEAD", "Novo pedido"),
("INFO_SENT", "Info enviada"),
("QUOTE_SENT", "Proposta"),
("PAYMENT_CONFIRMED", "Pagamento"),
("ODOO_ORDER_CREATED", "Odoo"),
("IN_PRODUCTION", "Produção"),
("READY_TO_SHIP", "Pronto"),
("SHIPMENT_CREATED", "Envio"),
("WON", "Concluído"),
("NO_INTEREST", "Sem interesse"),
]
def action_label(code: str) -> str:
code = str(code or "").strip().upper()
return ACTION_UI_LABELS.get(code, code or "Tarefa")
def compact_text(value, limit: int = 120) -> str:
text = " ".join(str(value or "").split())
if len(text) > limit:
return text[: max(0, limit - 1)].rstrip() + "…"
return text
def fmt_dt(value) -> str:
"""Formata datas/timestamps para leitura rápida na UI."""
if not value:
return "—"
try:
if isinstance(value, str):
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
else:
dt = value
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return compact_text(value, 32)
def humanize_task_detail(value) -> str:
detail = str(value or "").strip()
lower = detail.casefold()
if "resposta llm inválida" in lower or ("invalid" in lower and "action_code" in lower):
return "Classificação da mensagem falhou. Rever no Chatwoot e escolher a ação correta."
if "limpo manualmente" in lower or "resolvido manualmente" in lower:
return "Item já limpo manualmente. Deve ficar no histórico, não na fila diária."
return detail
def task_next_action_text(task: dict) -> str:
note = compact_text(humanize_task_detail(task.get("note") or task.get("action") or ""), 120)
if note:
return note
code = str(task.get("action_code") or "")
return action_label(code)
def opportunity_contact_name(opportunity: dict) -> str:
return str(
opportunity.get("customer_name")
or opportunity.get("customer_email")
or opportunity.get("contact_id")
or "Cliente"
).strip()
def opportunity_customer_name(opportunity: dict) -> str:
# Preferir a ficha fiscal ligada, porque é ela que será usada para Jasmin,
# faturas e envios. O contacto original continua visível como origem.
return str(
opportunity.get("linked_customer_name")
or opportunity.get("customer_name")
or opportunity.get("customer_email")
or opportunity.get("contact_id")
or "Cliente"
).strip()
def _norm_customer_text(value: object) -> str:
return " ".join(str(value or "").strip().casefold().split())
def _customer_name_tokens(value: object) -> set[str]:
text = _norm_customer_text(value)
for ch in "-_,.;:/()[]{}+&|\n\t":
text = text.replace(ch, " ")
legal_suffixes = {
"lda", "ltd", "sa", "s", "a", "unipessoal", "sociedade", "limitada",
"empresa", "companhia", "pt", "portugal", "the", "and", "e", "de", "da", "do",
"das", "dos", "para", "com", "ao", "aos", "as", "os",
}
tokens: set[str] = set()
for token in text.split():
token = token.strip()
if len(token) < 3 or token in legal_suffixes or "@" in token:
continue
tokens.add(token)
# Prefixes reduce false positives with short/truncated labels such as
# "Riotec elec" vs "Riotec - Electricidade, ...".
if len(token) >= 4:
tokens.add(token[:4])
return tokens
def opportunity_customer_mismatch(opportunity: dict) -> bool:
"""Nome de contacto ≠ cliente fiscal não é um erro fiável.
Ex.: contacto "Bruno Oliveira" pode representar a empresa fiscal
"Nortuflex"; "Riotec elec" pode ser abreviação da entidade fiscal.
A validação crítica deve focar NIF/morada/documentos, não semelhança de nomes.
"""
return False
def opportunity_customer_context_html(opportunity: dict) -> str:
linked = opportunity.get("linked_customer_name")
original = opportunity.get("customer_name") or opportunity.get("customer_email")
tax_id = opportunity.get("linked_customer_tax_id")
linked_email = opportunity.get("linked_customer_email")
if linked:
html = f'
Cliente fiscal: {esc(linked)}'
if tax_id:
html += f' · NIF {esc(tax_id)}'
html += '
'
if opportunity_customer_mismatch(opportunity):
html += f'
Contacto original: {esc(original or "—")}
'
elif linked_email:
html += f'
{esc(linked_email)}
'
return html
return f'
{esc(original or "Sem cliente fiscal associado")}
'
def opportunity_next_action_text(opportunity: dict) -> str:
pending_count = int(opportunity.get("pending_task_count") or 0)
stage = str(opportunity.get("stage") or "NEW_LEAD")
last_action = str(opportunity.get("last_action_code") or "")
if pending_count:
return "Concluir tarefa pendente"
by_stage = {
"NEW_LEAD": "Qualificar pedido",
"INFO_REQUESTED": "Aguardar dados do cliente",
"INFO_SENT": "Confirmar interesse",
"QUOTE_REQUESTED": "Preparar proposta",
"QUOTE_SENT": "Acompanhar decisão",
"PROFORMA_REQUESTED": "Preparar orçamento para pagamento",
"PROFORMA_SENT": "Aguardar pagamento",
"INVOICE_REQUESTED": "Emitir fatura",
"INVOICE_SENT": "Aguardar pagamento",
"WAITING_PAYMENT": "Confirmar pagamento",
"PAYMENT_CONFIRMED": "Preparar encomenda",
"ORDER_PREPARATION": "Preparar material/envio",
"ODOO_ORDER_CREATED": "Validar estado Odoo",
"IN_PRODUCTION": "Aguardar WH/OUT",
"READY_TO_SHIP": "Enviar encomenda",
"INVOICED": "Enviar encomenda",
"SHIPMENT_CREATED": "Concluir oportunidade",
"SHIPPED": "Concluir oportunidade",
"TRACKING_SENT": "Concluir oportunidade",
"DELIVERED": "Fechar oportunidade",
"WON": "Concluída",
"LOST": "Perdida",
"NO_INTEREST": "Sem interesse",
"REVIEW": "Rever manualmente",
"ARCHIVED": "Arquivada",
}
if last_action:
return by_stage.get(stage, action_label(last_action))
return by_stage.get(stage, "Acompanhar oportunidade")
def opportunity_priority_chip(opportunity: dict) -> str:
pending = int(opportunity.get("pending_task_count") or 0)
stage = str(opportunity.get("stage") or "")
if pending:
return 'Requer ação'
if stage in {"WAITING_PAYMENT", "PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP"}:
return 'Prioritária'
if stage in {"WON", "LOST", "NO_INTEREST", "ARCHIVED"}:
return 'Fechada'
return 'Normal'
def is_uuid_text(value: object) -> bool:
try:
UUID(str(value or ""))
return True
except Exception:
return False
def metadata_dict(value) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def opportunity_id_from_task(task: dict) -> str:
meta = metadata_dict(task.get("metadata"))
return str(task.get("opportunity_id") or meta.get("opportunity_id") or "").strip()
def customer_display(task: dict) -> str:
return str(task.get("customer_name") or task.get("customer_email") or task.get("contact_id") or task.get("customer_id") or "Cliente").strip()
def action_recommendation_html(action_code: str) -> str:
action_code = str(action_code or "").upper()
hint = ACTION_HINTS.get(action_code, "Executar a ação indicada e atualizar o estado da tarefa.")
missing = ACTION_MISSING_HINTS.get(action_code, [])
missing_html = ""
if missing:
missing_html = "
Pedir se faltar: " + ", ".join(esc(x) for x in missing) + ".
"
return f"""
{esc(hint)}
{missing_html}
"""
def stage_progress_html(current_stage: str) -> str:
rank = OPPORTUNITY_STAGE_LABELS
current = str(current_stage or "NEW_LEAD")
stage_order = [x[0] for x in PIPELINE_STEPS]
current_index = 0
for idx, stage in enumerate(stage_order):
if stage == current:
current_index = idx
break
if stage in {"NEW_LEAD", "INFO_SENT", "QUOTE_SENT", "WAITING_PAYMENT", "ORDER_PREPARATION", "SHIPPED", "WON"}:
pass
# aproxima estados intermédios para o passo visual mais próximo
stage_to_step = {
"NEW_LEAD": 0,
"INFO_REQUESTED": 0,
"INFO_SENT": 1,
"QUOTE_REQUESTED": 1,
"QUOTE_SENT": 2,
"PROFORMA_REQUESTED": 2,
"PROFORMA_SENT": 3,
"INVOICE_REQUESTED": 2,
"INVOICE_SENT": 3,
"WAITING_PAYMENT": 3,
"PAYMENT_CONFIRMED": 4,
"ODOO_ORDER_CREATED": 5,
"ORDER_PREPARATION": 5,
"IN_PRODUCTION": 6,
"READY_TO_SHIP": 7,
"INVOICED": 7,
"SHIPMENT_CREATED": 8,
"SHIPPED": 8,
"TRACKING_SENT": 8,
"DELIVERED": 9,
"WON": 9,
"LOST": 9,
"NO_INTEREST": 9,
"REVIEW": 0,
}
current_index = stage_to_step.get(current, 0)
items = ""
for idx, (stage, label) in enumerate(PIPELINE_STEPS):
css = "done" if idx < current_index else ("active" if idx == current_index else "")
items += f"
{esc(label)}
"
return f"
{items}
"
def opportunity_quick_actions_html(opportunity_id: str) -> str:
return ""
def operation_status_badge(status: str) -> str:
value = str(status or "not_created").strip()
normalized = {
"0": "draft",
"1": "open",
"2": "completed",
"3": "closed",
"open": "open",
"completed": "completed",
"complete": "completed",
"closed": "closed",
"converted": "converted",
}.get(value.casefold(), value.casefold())
cls = {
"not_created": "cf-chip-gray", "pending": "cf-chip-orange", "processing": "cf-chip-purple", "created": "cf-chip-blue",
"open": "cf-chip-blue", "draft": "cf-chip-gray", "completed": "cf-chip-green", "closed": "cf-chip-gray",
"converted": "cf-chip-green", "issued": "cf-chip-green", "confirmed": "cf-chip-green", "validated": "cf-chip-green",
"in_progress": "cf-chip-orange", "sent": "cf-chip-green", "delivered": "cf-chip-green", "failed": "cf-chip-red",
"blocked": "cf-chip-red", "dry_run": "cf-chip-gray", "ignored": "cf-chip-gray", "cancelled": "cf-chip-gray",
}.get(normalized, "cf-chip-gray")
label = {
"not_created": "Não criado", "pending": "Pendente", "processing": "A processar", "created": "Criado", "issued": "Emitida",
"open": "Aberto", "draft": "Rascunho", "completed": "Concluído", "closed": "Fechado", "converted": "Convertido",
"confirmed": "Confirmado", "validated": "Validado", "in_progress": "Em curso", "sent": "Processado",
"delivered": "Entregue", "failed": "Falhou", "blocked": "Bloqueado", "dry_run": "Dry-run", "ignored": "Ignorado", "cancelled": "Cancelado",
}.get(normalized, value)
return f'{esc(label)}'
def commercial_document_display_number(doc: dict | None, *, fallback: str = "sem número") -> str:
"""Return a human commercial document number without exposing UUIDs.
Imported Jasmin documents can temporarily have only a UUID/internal id. That
is useful for diagnostics, but confusing and unsafe as a commercial number.
"""
doc = doc or {}
parts = " ".join([
str(doc.get("document_type") or "").strip(),
str(doc.get("serie") or "").strip(),
str(doc.get("series_number") or "").strip(),
]).strip()
for value in (doc.get("document_number"), parts, doc.get("external_name"), doc.get("external_ref")):
text_value = str(value or "").strip()
if text_value and not is_uuid_text(text_value):
return text_value
return fallback
def should_hide_regressive_quotation_hint(opportunity: dict | None, snapshot: dict | None, next_action: dict | None) -> bool:
"""Avoid suggesting quote creation in later commercial/fulfilment phases."""
opportunity = opportunity or {}
snapshot = snapshot or {}
next_action = next_action or {}
action_key = str(next_action.get("action_key") or "").strip()
label = str(next_action.get("label") or "").strip().casefold()
if action_key != "jasmin_quotation" and "criar orçamento" not in label:
return False
stage = str(opportunity.get("stage") or "").strip().upper()
late_stages = {
"QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED",
"ORDER_CONFIRMED", "IN_PRODUCTION", "READY_TO_SHIP", "SHIPMENT_CREATED", "SHIPPED",
"TRACKING_SENT", "DELIVERED", "WON", "LOST", "NO_INTEREST",
}
if stage in late_stages:
return True
document_keys = {"jasmin_quotation", "jasmin_proforma", "jasmin_invoice"}
for card in snapshot.get("cards") or []:
key = str(card.get("key") or "").strip()
status = str(card.get("status") or "").strip().lower()
if key in document_keys and status not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"}:
return True
return False
def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dict) -> str:
plan = get_workflow_action_plan(opportunity_id)
pending_tasks = int(opportunity.get("pending_task_count") or 0)
stage_upper = str(opportunity.get("stage") or "").upper()
status_lower = str(opportunity.get("status") or "").lower()
terminal_stage = status_lower == "closed" or stage_upper in {"WON", "LOST", "NO_INTEREST", "DELIVERED"}
if terminal_stage:
# Terminal opportunities should not look actionable just because an old
# follow-up task counter was denormalized before cleanup.
pending_tasks = int(opportunity.get("pending_task_count") or 0)
invoice_document = None
quotation_document = None
try:
from app.commercial_service import list_commercial_documents
commercial_documents = list_commercial_documents(opportunity_id=opportunity_id, limit=30)
invoice_candidates = [
doc for doc in commercial_documents
if str(doc.get("document_kind") or "").lower() == "invoice"
and str(doc.get("status") or "").lower() not in {"cancelled", "failed", "rejected"}
and str(doc.get("role") or "current") not in {"historical", "superseded"}
]
quotation_candidates = [
doc for doc in commercial_documents
if str(doc.get("document_kind") or "").lower() in {"quotation", "quote", "proforma"}
and str(doc.get("status") or "").lower() not in {"cancelled", "failed", "rejected"}
and str(doc.get("role") or "current") not in {"superseded"}
]
invoice_document = next((doc for doc in invoice_candidates if bool(doc.get("is_primary", False))), invoice_candidates[0] if invoice_candidates else None)
quotation_document = next((doc for doc in quotation_candidates if bool(doc.get("is_primary", False))), quotation_candidates[0] if quotation_candidates else None)
except Exception:
invoice_document = None
quotation_document = None
cards = [dict(card) for card in (snapshot.get("cards") or [])]
def ensure_card(key: str, *, label: str, status: str, status_label: str, external_name: str = "", external_url: str = "") -> None:
non_empty_bad = {"", "not_created", "failed", "blocked", "cancelled", "ignored", "not_found", "unknown"}
for card in cards:
if str(card.get("key") or "") == key:
if str(card.get("status") or "").lower() in non_empty_bad:
card["status"] = status
card["status_label"] = status_label
if external_name:
card["external_name"] = external_name
if external_url:
card["external_url"] = external_url
return
cards.append({
"key": key,
"label": label,
"status": status,
"status_label": status_label,
"external_name": external_name,
"external_url": external_url,
})
def card_by_key(key: str) -> dict:
for card in cards:
if str(card.get("key") or "") == key:
return card
return {}
def payload_dict(value) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def physical_whout_done() -> bool:
card = card_by_key("odoo_physical_status")
payload = payload_dict(card.get("payload"))
status = str(card.get("status") or payload.get("physical_status") or payload.get("status") or "").strip().lower()
pickings = payload.get("outgoing_pickings") or payload.get("pickings") or []
states = {
str(p.get("state") or "").strip().lower()
for p in pickings
if isinstance(p, dict) and str(p.get("state") or "").strip()
}
return (
bool(payload.get("delivery_done"))
or status in {"done", "shipped", "delivered", "validated"}
or (bool(states) and states <= {"done", "cancel"} and "done" in states)
)
def physical_whout_ready() -> bool:
card = card_by_key("odoo_physical_status")
payload = payload_dict(card.get("payload"))
status = str(card.get("status") or payload.get("physical_status") or payload.get("status") or "").strip().lower()
pickings = payload.get("outgoing_pickings") or payload.get("pickings") or []
states = {
str(p.get("state") or "").strip().lower()
for p in pickings
if isinstance(p, dict) and str(p.get("state") or "").strip()
}
if "assigned" in states and "done" not in states:
return False
return (
bool(payload.get("ready_to_ship") or payload.get("delivery_ready"))
or status in {"ready_to_ship", "ready", "validated"}
)
if quotation_document:
ensure_card(
"jasmin_quotation",
label="Orçamento",
status="created",
status_label="Associado",
external_name=str(quotation_document.get("document_number") or quotation_document.get("external_id") or ""),
external_url=str(quotation_document.get("external_url") or ""),
)
if invoice_document:
ensure_card(
"jasmin_invoice",
label="Fatura",
status="issued",
status_label="Emitida",
external_name=str(invoice_document.get("document_number") or invoice_document.get("external_id") or ""),
external_url=str(invoice_document.get("external_url") or ""),
)
whout_done = physical_whout_done()
whout_ready = physical_whout_ready()
# Do not infer a green Odoo sale from invoice/payment/stage alone.
# Sale evidence must come from the linked Odoo snapshot itself.
# WH/MO/production is technical Odoo detail only.
physical_validation_card = card_by_key("physical_validation")
physical_validation_status = str(
physical_validation_card.get("status") or ""
).strip().lower()
physical_validated = physical_validation_status in {
"validated",
"done",
"completed",
}
# A fase comercial, uma fatura emitida ou um picking apenas atribuído/pronto
# não constituem validação física. A validação exige evidência explícita ou
# WH-OUT concluído.
if physical_validated or whout_done:
ensure_card(
"physical_validation",
label="Validado",
status="validated",
status_label="Validado",
)
if whout_done:
ensure_card("odoo_physical_status", label="Estado físico Odoo", status="shipped", status_label="Expedida")
ensure_card("packlink_shipment", label="Envio", status="done", status_label="Concluído no Odoo")
elif whout_ready:
ensure_card("odoo_physical_status", label="Estado físico Odoo", status="ready_to_ship", status_label="Pronta para despacho")
has_invoice_card = any(
str(card.get("key") or "") == "jasmin_invoice"
and str(card.get("status") or "").lower() not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"}
for card in cards
)
next_action = plan.get("next_action") or {}
next_kind = str(next_action.get("kind") or "")
workflow_label = next_action.get("label") or "Sem ação"
workflow_reason = next_action.get("reason") or ""
physical_reason = plan.get("physical_reason") or ""
physical_next = plan.get("physical_next_action") or ""
central_next_action = opportunity.get("clientflow_next_action") if isinstance(opportunity, dict) else None
if not isinstance(central_next_action, dict):
central_next_action = {}
central_action_code = str(central_next_action.get("action_code") or "").upper()
def _central_action_button_html(action_code: str, label: str, target_url: str | None) -> str:
action_code = str(action_code or "").upper()
label = str(label or "Continuar")
target_url = str(target_url or "").strip()
if action_code == "CLOSE_OPPORTUNITY":
return (
f''
)
if action_code == "PREPARE_ORDER":
return (
f''
)
if action_code in {"WAIT_PRODUCTION", "NO_ACTION"}:
return 'Aguardar'
if not target_url:
target_url = f"/opportunities/{opportunity_id}#operacao"
return f'{esc(label)}'
if pending_tasks > 0:
main_label = "Concluir tarefa pendente"
main_reason = "Existe uma tarefa ativa nesta oportunidade."
if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action):
main_extra = "Depois: continuar a partir do documento/fase atual."
else:
main_extra = f"Depois: {workflow_label}"
action_html = 'Ver tarefas'
else:
main_label = workflow_label
main_reason = physical_reason or workflow_reason
main_extra = physical_next if physical_next and physical_next != main_reason else ""
if central_action_code:
# v1.5.107: the opportunity detail top card is driven by the
# central next-action engine. Mirror it here so the operational
# cockpit does not regress to a stale legacy plan such as
# "Enviar fatura" after the central engine already decided
# CLOSE_OPPORTUNITY.
main_label = central_next_action.get("label") or main_label
main_reason = central_next_action.get("description") or central_next_action.get("reason") or main_reason
main_extra = ""
action_html = _central_action_button_html(
central_action_code,
str(main_label or "Continuar"),
central_next_action.get("target_url"),
)
else:
if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action):
main_label = "Rever fluxo atual"
main_reason = "A oportunidade já tem documento/fase posterior; não criar novo orçamento neste processo."
main_extra = "Continua pela fatura, pagamento, envio ou histórico conforme o caso."
next_kind = "review"
if next_kind == "operation" and next_action.get("action_key"):
action_key = str(next_action.get("action_key") or "")
action_html = (
f''
)
elif next_kind == "sync_odoo":
action_html = (
f''
)
elif next_kind == "wait":
action_html = 'Aguardar'
elif next_kind == "manual" and str(next_action.get("action_code") or "").upper() == "SEND_INVOICE":
action_html = f'Enviar fatura'
else:
action_html = 'Sem ação'
def step_visual(status):
s = str(status or "").lower()
if s in {"confirmed", "issued", "created", "validated", "sent", "delivered", "done", "ready_to_ship", "shipped"}:
return "bg-success text-white", "✓"
if s in {"in_progress", "pending", "running", "open", "in_production"}:
return "bg-warning text-dark", "…"
if s in {"failed", "blocked", "cancelled", "not_found"}:
return "bg-danger text-white", "!"
return "bg-light text-secondary border", "○"
short_labels = {
"payment": "Pagamento",
"proforma": "Orçamento legado",
"odoo_sale_order": "Venda",
"odoo_production": "Produção",
"odoo_physical_status": "Estado físico Odoo",
"physical_status": "Odoo",
"physical_validation": "Validado",
"jasmin_quotation": "Orçamento",
"jasmin_invoice": "Fatura",
"packlink_shipment": "Envio",
"tracking": "Tracking",
"delivery": "Entregue",
}
steps_html = ""
for card in cards:
key = str(card.get("key") or "")
if key == "odoo_production":
continue
badge_class, mark = step_visual(card.get("status"))
label = short_labels.get(key, card.get("label") or "")
url = str(card.get("external_url") or "").strip()
title = card.get("external_name") or card.get("status_label") or label
link_open = ""
if url:
link_open = (
''
)
steps_html += (
'
'
'
'
'
'
f'{mark}'
f'
{esc(label)}
'
f'{link_open}'
'
'
'
'
'
'
)
if not steps_html:
steps_html = '
Sem integrações registadas.
'
if has_invoice_card and str(main_label).strip().casefold() == "criar orçamento jasmin":
main_label = "Acompanhar fatura"
main_reason = "Já existe fatura Jasmin associada; não criar novo orçamento neste processo."
main_extra = "Confirma pagamento, envio ou marca como histórico/concluído."
action_html = 'Rever processo'
reason_html = ""
if main_reason:
reason_html += f'
{esc(main_reason)}
'
if main_extra:
reason_html += f'
{esc(main_extra)}
'
return (
''
'
'
'
'
'
'
'
Fluxo operacional
'
f'
{esc(main_label)}
'
f'{reason_html}'
'
'
f'
{action_html}
'
'
'
'
'
f'{steps_html}'
'
'
'
'
''
)
def task_priority_chip(task: dict) -> str:
priority = str(task.get("priority") or "").strip().lower()
if is_task_overdue(task) or priority == "alta":
return 'Alta'
if priority == "normal":
return 'Normal'
if priority == "baixa":
return 'Baixa'
route = str(task.get("route") or "")
if route in {"financeiro", "operacoes"}:
return 'Normal'
return 'Baixa'
def pretty_json(value) -> str:
return json.dumps(value or {}, ensure_ascii=False, indent=2, default=str)
def status_badge(status: str) -> str:
value = str(status or "").strip() or "unknown"
label = {
"pending": "Pendente",
"done": "Concluída",
"skipped": "Ignorada",
"failed": "Falha",
"sent": "Enviada",
"ignored": "Ignorada",
}.get(value, value)
cls = {
"pending": "status-pending",
"done": "status-done",
"skipped": "status-skipped",
"failed": "status-failed",
"sent": "status-done",
"ignored": "status-skipped",
}.get(value, "status-skipped")
return f'{esc(label)}'
def route_badge(route: str) -> str:
value = str(route or "").strip() or "rever"
label = {
"vendas": "COMERCIAL",
"financeiro": "FINANCEIRO",
"suporte": "SUPORTE",
"operacoes": "LOGÍSTICA",
"spam": "SPAM",
"rever": "REVER",
}.get(value, value.upper())
cls = {
"vendas": "route-vendas",
"financeiro": "route-financeiro",
"suporte": "route-suporte",
"operacoes": "route-operacoes",
"spam": "route-rever",
"rever": "route-rever",
}.get(value, "route-rever")
return f'{esc(label)}'
def task_sla_minutes(route: str) -> int:
return {
"suporte": 120,
"vendas": 240,
"financeiro": 480,
"operacoes": 1440,
"rever": 1440,
}.get(route or "", 1440)
def _coerce_datetime_utc(value):
if not value:
return None
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
except Exception:
return None
if getattr(value, "tzinfo", None) is None:
value = value.replace(tzinfo=timezone.utc)
return value
def task_age_minutes(task: dict) -> int:
created_at = _coerce_datetime_utc(task.get("created_at"))
if not created_at:
return 0
return max(0, int((datetime.now(timezone.utc) - created_at).total_seconds() // 60))
def task_due_minutes(task: dict) -> Optional[int]:
due_at = _coerce_datetime_utc(task.get("due_at"))
if not due_at:
return None
return int((due_at - datetime.now(timezone.utc)).total_seconds() // 60)
def is_task_overdue(task: dict) -> bool:
if task.get("status") != "pending":
return False
due_minutes = task_due_minutes(task)
if due_minutes is not None:
return due_minutes < 0
return task_age_minutes(task) > task_sla_minutes(task.get("route"))
def is_task_today(task: dict) -> bool:
due_at = _coerce_datetime_utc(task.get("due_at"))
created_at = _coerce_datetime_utc(task.get("created_at"))
dt = due_at or created_at
if not dt:
return False
now = datetime.now(timezone.utc)
return dt.date() == now.date()
def sla_badge_html(task: dict) -> str:
if task.get("status") != "pending":
return ""
due_minutes = task_due_minutes(task)
if due_minutes is not None:
if due_minutes < 0:
overdue = abs(due_minutes)
label = f"Follow-up atrasado {overdue // 60}h" if overdue >= 60 else f"Follow-up atrasado {overdue}m"
return f'{esc(label)}'
if due_minutes <= 24 * 60:
label = f"Vence hoje" if due_minutes >= 0 else "Vencido"
return f'{esc(label)}'
days = max(1, due_minutes // (24 * 60))
return f'{esc(f"Follow-up D+{days}")}'
age = task_age_minutes(task)
sla = task_sla_minutes(task.get("route"))
if age > sla:
overdue = age - sla
label = f"Atrasada {overdue // 60}h" if overdue >= 60 else f"Atrasada {overdue}m"
return f'{esc(label)}'
remaining = sla - age
label = f"SLA {remaining // 60}h" if remaining >= 60 else f"SLA {remaining}m"
return f'{esc(label)}'
# ADMIN_UI_V451_CSS moved to app.admin_ui.styles in v4.7.
# Layout, navigation and KPI card helpers moved to app.admin_ui in v4.7.
def is_htmx(request: Request) -> bool:
return str(request.headers.get("HX-Request") or "").lower() == "true"
@router.get("/ui.css")
async def admin_ui_css():
return Response(ADMIN_UI_V451_CSS, media_type="text/css")
def opportunity_stage_badge(stage: str) -> str:
classes = {
"NEW_LEAD": "cf-chip-blue",
"INFO_REQUESTED": "cf-chip-blue",
"INFO_SENT": "cf-chip-green",
"QUOTE_REQUESTED": "cf-chip-orange",
"QUOTE_SENT": "cf-chip-green",
"PROFORMA_REQUESTED": "cf-chip-orange",
"PROFORMA_SENT": "cf-chip-green",
"INVOICE_REQUESTED": "cf-chip-orange",
"INVOICE_SENT": "cf-chip-green",
"WAITING_PAYMENT": "cf-chip-orange",
"PAYMENT_CONFIRMED": "cf-chip-green",
"ORDER_PREPARATION": "cf-chip-purple",
"SHIPPED": "cf-chip-purple",
"WON": "cf-chip-green",
"LOST": "cf-chip-red",
"NO_INTEREST": "cf-chip-gray",
"REVIEW": "cf-chip-gray",
"ARCHIVED": "cf-chip-gray",
}
return f'{esc(stage_label(stage))}'
def _opportunity_board_column_for_stage(stage: str) -> str:
stage = str(stage or "")
for key, _label, stages in OPPORTUNITY_BOARD_COLUMNS:
if stage in stages:
return key
return "requests"
def money_html(value, currency: str = "€") -> str:
try:
number = float(value or 0)
except Exception:
number = 0.0
formatted = f"{number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"{formatted} {esc(currency)}"
def product_status_badge(active) -> str:
if active:
return 'Ativo'
return 'Inativo'
def item_status_label(status: str) -> str:
labels = {
"INTERESTED": "Em análise",
"QUOTED": "Orçamentado",
"ACCEPTED": "Aceite",
"REJECTED": "Rejeitado",
"CANCELLED": "Cancelado",
"UNAVAILABLE": "Indisponível",
}
return labels.get(str(status or "").upper(), str(status or "—"))
def item_status_badge(status: str) -> str:
status = str(status or "").upper()
cls = {
"INTERESTED": "cf-chip-orange",
"QUOTED": "cf-chip-blue",
"ACCEPTED": "cf-chip-green",
"REJECTED": "cf-chip-red",
"CANCELLED": "cf-chip-gray",
"UNAVAILABLE": "cf-chip-gray",
}.get(status, "cf-chip-gray")
return f'{esc(item_status_label(status))}'
def product_form_html(product: Optional[dict] = None, *, action: str = "/products", submit_label: str = "Guardar produto") -> str:
product = product or {}
checked = "checked" if product.get("active", True) else ""
return f'''
'''
def _outbox_items_for_opportunity(opportunity_id: str, *, target_system: str | None = "jasmin", limit: int = 30) -> list[dict]:
try:
items = list_outbox(target_system=target_system, limit=300)
except Exception:
return []
filtered = []
for item in items:
payload = item.get("payload") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
if str(payload.get("opportunity_id") or "") == str(opportunity_id):
item = dict(item)
item["payload"] = payload
filtered.append(item)
if len(filtered) >= limit:
break
return filtered
def opportunity_integrations_panel_html(opportunity_id: str) -> str:
items = _outbox_items_for_opportunity(opportunity_id, target_system=None, limit=16)
counts = {"pending": 0, "failed": 0, "blocked": 0, "dry_run": 0}
for item in items:
status = str(item.get("status") or "pending")
if status in counts:
counts[status] += 1
rows = ""
for item in items[:8]:
status = str(item.get("status") or "pending")
row_cls = f"cf-outbox-row-{status}"
err = compact_text(item.get("last_error") or "", 160)
actions = ""
if status in {"failed", "blocked", "dry_run", "ignored", "cancelled"}:
actions = f'''
'''
elif status == "pending":
actions = 'A aguardar timer'
rows += f'''
{esc(item.get('target_system'))}
{esc(item.get('action_type'))}
{operation_status_badge(status)}
{esc(fmt_dt(item.get('updated_at') or item.get('created_at')))}
{esc(err)}
{actions}
'''
if not rows:
rows = '
Sem ações de integração para esta oportunidade.
'
return f'''
Integrações da oportunidade
Estado operacional das ações Jasmin, Packlink, Chatwoot e Mautic ligadas a esta oportunidade.
'
role_label = {
"current": "Atual",
"accepted": "Aceite",
"historical": "Histórico",
"cancelled": "Cancelado",
"related": "Relacionado",
}.get(str(doc.get("role") or "current"), str(doc.get("role") or "current"))
role_class = "text-bg-primary" if str(doc.get("role") or "current") in {"current", "accepted"} and doc.get("is_primary") else "text-bg-light"
external_id_html = ""
if not doc.get("document_number") and doc.get("external_id") and not is_uuid_text(doc.get("external_id")):
external_id_html = f"
ref. externa {esc(doc.get('external_id') or '')}
"
elif not doc.get("document_number") and doc.get("external_id"):
external_id_html = "
ID técnico oculto; usar Atualizar nº.
"
rows += (
"
"
f"
{esc(kind)}
v{esc(doc.get('version_number') or '—')}
{esc(role_label)}
"
f"
{esc(number)}{external_id_html}
"
f"
{operation_status_badge(str(doc.get('status') or 'created'))}
"
f"
{money_html(amount or 0)}
"
f"
{esc(fmt_dt(doc.get('created_at')))}
"
f"
{actions}
"
"
"
)
if not rows:
rows = '
Ainda sem documentos Jasmin nesta oportunidade.
'
current_jasmin_docs_exist = bool(docs)
unlink_jasmin_button_html = ""
if current_jasmin_docs_exist:
unlink_jasmin_button_html = f"""
"""
invoice_source_exists = any(
str(doc.get("document_kind") or "") in {"quotation", "proforma"}
and str(doc.get("status") or "").lower() not in {"cancelled", "failed"}
and str(doc.get("role") or "current") in {"current", "accepted", "related"}
for doc in docs
)
candidate_rows = ""
ignored_rows = ""
valid_candidate_count = 0
ignored_candidate_count = 0
hidden_other_customer_count = 0
for item in jasmin_candidates:
totals = item.get("totals") if isinstance(item.get("totals"), dict) else {}
total_amount = totals.get("total_amount") or item.get("amount") or 0
doc_number = item.get("document_number") or (item.get("external_id") if not is_uuid_text(item.get("external_id")) else None) or "número por atualizar"
match_reason = item.get("match_reason") or "match"
match_score = item.get("match_score") or ""
item_id = str(item.get("id") or "")
is_valid = bool(item.get("is_valid_candidate"))
candidate_tax = ""
try:
from app.commercial_service import normalize_tax_id
candidate_tax = normalize_tax_id(item.get("customer_tax_id"))
except Exception:
candidate_tax = str(item.get("customer_tax_id") or "").strip()
other_customer = bool(linked_tax_id and candidate_tax and candidate_tax != linked_tax_id)
tax_conflict = bool(other_customer)
invalid_reason = item.get("invalid_reason") or ""
if tax_conflict:
# Segurança operacional: um documento Jasmin de NIF diferente nunca deve
# aparecer como candidato acionável. Fica apenas em auditoria/revisão.
is_valid = False
invalid_reason = invalid_reason or "NIF divergente do cliente fiscal validado; rever manualmente."
if is_valid:
valid_candidate_count += 1
else:
ignored_candidate_count += 1
status_label = item.get("jasmin_status_label") or "—"
status_badge_html = (
'NIF divergente'
if tax_conflict
else (
'Aberto/válido'
if is_valid
else 'Ignorado'
)
)
if is_valid:
is_invoice_candidate = str(item.get('external_type') or '') == 'jasmin_invoice'
if current_jasmin_docs_exist and is_invoice_candidate:
action_html = f'''
Fatura do mesmo cliente/processo. Deve ser associada como documento seguinte, não substituir o orçamento.
Já existe documento atual. Usa Substituir para trocar o principal ou Associar adicional quando pertence à mesma compra.
'''
else:
action_html = f'''
'''
else:
action_html = (
''
if tax_conflict
else f''
)
candidate_customer_meta = (
f'
NIF {esc(item.get("customer_tax_id") or "—")}
'
if is_valid
else (
'
NIF divergente oculto em auditoria
'
if tax_conflict
else '
NIF oculto em auditoria
'
)
)
row_html = f'''
{esc(doc_number)}
{esc(item.get('external_type') or 'jasmin')}
{esc(fmt_dt(item.get('document_date') or item.get('updated_at')))}
{money_html(total_amount or 0)}
{esc(item.get('currency') or 'EUR')}
{esc(item.get('customer_name') or '—')}{candidate_customer_meta}
{status_badge_html}
Jasmin: {esc(status_label)} {esc(item.get('jasmin_status_code') or '')}
{esc(invalid_reason)}
{esc(match_reason)} {esc(match_score)}
{esc(item.get('line_count') or 0)} linha(s)
{action_html}
'''
if is_valid:
candidate_rows += row_html
else:
ignored_rows += row_html
candidates_html = ""
if candidate_rows or ignored_rows or hidden_other_customer_count:
if candidate_rows:
candidates_html += f'''
Candidatos Jasmin acionáveis encontrados.
Valida antes de substituir ou associar, especialmente em processos antigos/reconstruídos.
Documento encontrado
Valor
Cliente Jasmin
Validação
Match
Ação
{candidate_rows}
'''
else:
candidates_html += '''
Candidatos adicionais
Nenhum documento Jasmin candidato seguro encontrado. Documentos antigos, cancelados ou de outro cliente ficam apenas na auditoria.
'''
if ignored_rows:
candidates_html += f'''
Documentos ignorados / auditoria
Documento
Valor
Cliente Jasmin
Validação
Match
Ação
{ignored_rows}
'''
if hidden_other_customer_count:
candidates_html += f'
{hidden_other_customer_count} documento(s) ignorado(s) de outro NIF ocultados da lista principal.
'
notice_html = f'
{esc(notice)}
' if notice else ''
error_notice_html = f'
Não foi possível pedir a ação. {esc(error_notice).replace(chr(10), " ")}
' if error_notice else ''
error_html = f'
{esc(error)}
' if error else ''
outbox_html = opportunity_outbox_panel_html(opportunity_id, target_system="jasmin")
refreshed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
if invoice_source_exists:
convert_invoice_button_html = (
f''
)
else:
# Legacy static-test anchor: É necessário um orçamento ou pró-forma atual para converter
convert_invoice_button_html = ''
if current_jasmin_docs_exist:
create_quotation_button_html = f'''
'''
else:
create_quotation_button_html = f'''
'''
return f'''
Documentos Jasmin
Escolhe quais documentos Jasmin pertencem a esta oportunidade. Em clientes com várias compras próximas, define o documento principal ou desassocia apenas o documento errado.
Última atualização: {esc(refreshed_at)}. Atualização manual para evitar reconstrução automática da janela.
Documentos já ligados a esta oportunidade. Usa candidatos adicionais apenas quando forem da mesma compra/processo.
Tipo
Número/ID
Estado
Valor
Criado
Ações
{rows}
Candidatos adicionais
Documentos Jasmin encontrados por reconciliação que ainda não estão associados como documento principal.
{candidates_html}
{outbox_html}
'''
def _opportunity_item_metadata(item: dict) -> dict:
raw = item.get("metadata") if isinstance(item, dict) else {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str) and raw.strip():
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
DEFAULT_NON_BILLABLE_ODOO_LINE_PATTERNS = (
"delivery_007",
"standard delivery",
"shipping",
"transportadora",
)
def _is_non_billable_odoo_line(item: dict) -> bool:
"""True for Odoo logistics helper lines that should not block Jasmin docs."""
meta = _opportunity_item_metadata(item)
status = str(item.get("status") or "").upper()
source_system = str(meta.get("source_system") or "").lower()
if status != "ODOO_IMPORTED" and source_system != "odoo":
return False
haystack = " ".join(
str(value or "")
for value in (
item.get("product_name"),
item.get("sku"),
item.get("description"),
meta.get("product_code"),
meta.get("product_name"),
meta.get("source_external_id"),
)
).casefold()
return any(pattern in haystack for pattern in DEFAULT_NON_BILLABLE_ODOO_LINE_PATTERNS)
def _opportunity_item_origin_label(item: dict) -> str:
meta = _opportunity_item_metadata(item)
status = str(item.get("status") or "").upper()
source_system = str(meta.get("source_system") or "").lower()
source_document = str(meta.get("source_document") or "").strip()
source_external_type = str(meta.get("source_external_type") or "").lower()
if source_system == "odoo" or status == "ODOO_IMPORTED":
sale_name = str(meta.get("source_document") or meta.get("sale_name") or meta.get("source_external_id") or "").strip()
return f"Linhas Odoo {sale_name}" if sale_name else "Linhas Odoo"
if source_document:
if "invoice" in source_external_type or source_document.upper().startswith(("FA", "FT")):
return f"Linhas da fatura {source_document}"
if "quotation" in source_external_type or source_document.upper().startswith("ORC"):
return f"Linhas do orçamento {source_document}"
return f"Linhas Jasmin {source_document}"
if source_system == "jasmin" or status == "JASMIN_IMPORTED":
return "Linhas Jasmin importadas"
return "Linhas importadas"
def opportunity_items_table_html(opportunity_id: str, items: list[dict]) -> str:
rows = ""
imported_groups: dict[str, str] = {}
historical_rows = ""
for item in items:
status_upper = str(item.get('status') or '').upper()
row_html = f"""
{esc(item.get('product_name') or 'Produto')}
SKU/Odoo {esc(item.get('sku') or '—')}
Jasmin {esc(item.get('jasmin_sales_item') or '—')}
{esc(item.get('quantity') or '1')}
{money_html(item.get('unit_price'))}
{money_html(item.get('discount_amount'))}
{money_html(item.get('total_price'))}
{item_status_badge(item.get('status'))}
"""
if status_upper in {"DELIVERED", "HISTORICAL"}:
historical_rows += row_html
elif status_upper.endswith("_IMPORTED") or status_upper in {"JASMIN_IMPORTED", "ODOO_IMPORTED"}:
label = _opportunity_item_origin_label(item)
imported_groups[label] = imported_groups.get(label, "") + row_html
else:
rows += row_html
if not rows:
rows = '
Sem produtos atuais manuais nesta oportunidade.
'
imported_html = ""
if imported_groups:
groups_html = ""
for label, group_rows in imported_groups.items():
groups_html += f"""
{esc(label)}
Produto
Qtd.
Preço
Desc.
Total
Estado
{group_rows}
"""
imported_html = f"""
Linhas importadas agrupadas por origem
Estas linhas são contexto documental/histórico e não entram no total manual atual. O agrupamento evita parecerem duplicados quando vêm do orçamento, fatura e Odoo.
{groups_html}
"""
historical_html = ""
if historical_rows:
historical_html = f"""
Ver linhas históricas / entregues
Produto
Qtd.
Preço
Desc.
Total
Estado
{historical_rows}
"""
return f"""
Produto
Qtd.
Preço
Desc.
Total
Estado
{rows}
{imported_html}
{historical_html}
"""
def opportunity_add_item_form_html(opportunity_id: str, products: list[dict]) -> str:
options = ''
for product in products:
product_id = esc(product.get("id"))
sku = esc(product.get("sku") or "")
jasmin = esc(product.get("jasmin_sales_item") or "")
name = esc(product.get("name") or "")
options += f''
return f'''
'''
def opportunity_products_panel_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str:
try:
items = list_opportunity_items(opportunity_id)
active_products = list_products(active="true", limit=300)
except Exception as exc:
return f'
Erro ao carregar produtos: {esc(exc)}
'
total = sum(
float(item.get("total_price") or 0)
for item in items
if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL", "JASMIN_IMPORTED", "ODOO_IMPORTED"}
and not str(item.get("status") or "").upper().endswith("_IMPORTED")
)
notice_html = f'
{esc(notice)}
' if notice else ''
error_html = f'
{esc(error_notice)}
' if error_notice else ''
missing = [
item
for item in items
if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"}
and not item.get("jasmin_sales_item")
and not _is_non_billable_odoo_line(item)
]
non_billable_missing = [
item
for item in items
if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"}
and not item.get("jasmin_sales_item")
and _is_non_billable_odoo_line(item)
]
validation_html = ""
if missing:
lis = "".join(f"
{esc(i.get('product_name') or i.get('sku') or 'Produto')} sem Artigo Jasmin.
" for i in missing)
validation_html = f'
Atenção: estes produtos bloqueiam o orçamento Jasmin:
{lis}
'
elif non_billable_missing:
lis = "".join(f"
{esc(i.get('product_name') or i.get('sku') or 'Linha logística')} configurada como logística/não faturável.
" for i in non_billable_missing)
validation_html = f'
Linhas logísticas: não bloqueiam o orçamento/fatura Jasmin.