from pathlib import Path
import os
from datetime import datetime, timezone
import html
import json
from uuid import UUID
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.admin_auth import require_admin_auth
from app.config import 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:
"""Apply UI auth (including X-ClientFlow-Admin-Token in token mode)."""
require_admin_auth(request, area="admin_ui")
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.