Files
clientflow_backend/app/admin_dashboard.py

1822 lines
95 KiB
Python

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'<a class="btn btn-sm btn-outline-primary" href="{esc(href)}" target="_blank" rel="noopener"><i class="bi bi-chat-dots"></i> {esc(label)}</a>'
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'<span class="system-pill {cls}">{esc(label)}</span>'
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'<option value="{esc(option)}">{esc(option)}</option>'
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'<div class="small text-secondary text-truncate">Cliente fiscal: {esc(linked)}'
if tax_id:
html += f' · NIF {esc(tax_id)}'
html += '</div>'
if opportunity_customer_mismatch(opportunity):
html += f'<div class="small text-warning fw-semibold text-truncate">Contacto original: {esc(original or "")}</div>'
elif linked_email:
html += f'<div class="small text-secondary text-truncate">{esc(linked_email)}</div>'
return html
return f'<div class="small text-secondary text-truncate">{esc(original or "Sem cliente fiscal associado")}</div>'
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 '<span class="cf-chip cf-chip-orange">Requer ação</span>'
if stage in {"WAITING_PAYMENT", "PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP"}:
return '<span class="cf-chip cf-chip-blue">Prioritária</span>'
if stage in {"WON", "LOST", "NO_INTEREST", "ARCHIVED"}:
return '<span class="cf-chip cf-chip-gray">Fechada</span>'
return '<span class="cf-chip cf-chip-gray">Normal</span>'
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 = "<div class='small text-secondary mt-2'>Pedir se faltar: " + ", ".join(esc(x) for x in missing) + ".</div>"
return f"""
<div class="cf-action-focus">
<div class="fw-bold">{esc(hint)}</div>
{missing_html}
</div>
"""
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"<div class='cf-step {css}'><span></span><small>{esc(label)}</small></div>"
return f"<div class='cf-stage-progress'>{items}</div>"
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'<span class="cf-chip {esc(cls)}">{esc(label)}</span>'
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'<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/delivered">'
'<input type="hidden" name="external_name" value="Oportunidade concluída">'
'<input type="hidden" name="note" value="Fatura enviada, pagamento confirmado e Odoo/WH-OUT concluído.">'
f'<button class="btn btn-success btn-sm" type="submit">{esc(label)}</button>'
'</form>'
)
if action_code == "PREPARE_ORDER":
return (
f'<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/link-sale" class="d-flex flex-wrap gap-2 align-items-center">'
'<input type="text" class="form-control form-control-sm" style="max-width:180px" name="sale_ref" placeholder="Venda Odoo ex.: S00308" required>'
f'<button class="btn btn-primary btn-sm" type="submit">{esc(label)}</button>'
'</form>'
)
if action_code in {"WAIT_PRODUCTION", "NO_ACTION"}:
return '<span class="badge bg-warning text-dark">Aguardar</span>'
if not target_url:
target_url = f"/opportunities/{opportunity_id}#operacao"
return f'<a class="btn btn-primary btn-sm" href="{esc(target_url)}">{esc(label)}</a>'
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 = '<a class="btn btn-primary btn-sm" href="/tasks">Ver tarefas</a>'
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'<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/{esc(action_key)}">'
f'<div class="input-group input-group-sm">'
f'<input type="text" class="form-control" name="external_name" placeholder="Referência opcional">'
f'<button class="btn btn-primary" type="submit">{esc(main_label)}</button>'
f'</div>'
f'</form>'
)
elif next_kind == "sync_odoo":
action_html = (
f'<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/sync-status">'
f'<button class="btn btn-primary btn-sm" type="submit">Sincronizar Odoo</button>'
f'</form>'
)
elif next_kind == "wait":
action_html = '<span class="badge bg-warning text-dark">Aguardar</span>'
elif next_kind == "manual" and str(next_action.get("action_code") or "").upper() == "SEND_INVOICE":
action_html = f'<a class="btn btn-primary btn-sm" href="/opportunities/{esc(opportunity_id)}#documentos">Enviar fatura</a>'
else:
action_html = '<span class="badge bg-secondary">Sem ação</span>'
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 = (
'<a class="stretched-link" '
f'href="{esc(url)}" target="_blank" rel="noopener" title="{esc(title)}"></a>'
)
steps_html += (
'<div class="col">'
'<div class="card border-0 bg-light h-100 position-relative">'
'<div class="card-body p-2 text-center">'
f'<span class="badge rounded-pill {badge_class} mb-1">{mark}</span>'
f'<div class="small fw-semibold text-truncate" title="{esc(label)}">{esc(label)}</div>'
f'{link_open}'
'</div>'
'</div>'
'</div>'
)
if not steps_html:
steps_html = '<div class="text-secondary small">Sem integrações registadas.</div>'
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 = '<span class="badge bg-warning text-dark">Rever processo</span>'
reason_html = ""
if main_reason:
reason_html += f'<div class="small text-secondary">{esc(main_reason)}</div>'
if main_extra:
reason_html += f'<div class="small text-secondary">{esc(main_extra)}</div>'
return (
'<section class="card cf-card mb-4">'
'<div class="card-body p-3">'
'<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">'
'<div>'
'<div class="cf-eyebrow">Fluxo operacional</div>'
f'<h2 class="h5 mb-1">{esc(main_label)}</h2>'
f'{reason_html}'
'</div>'
f'<div>{action_html}</div>'
'</div>'
'<div class="row row-cols-3 row-cols-md-5 row-cols-xl-8 g-2">'
f'{steps_html}'
'</div>'
'</div>'
'</section>'
)
def task_priority_chip(task: dict) -> str:
priority = str(task.get("priority") or "").strip().lower()
if is_task_overdue(task) or priority == "alta":
return '<span class="cf-chip cf-chip-red">Alta</span>'
if priority == "normal":
return '<span class="cf-chip cf-chip-orange">Normal</span>'
if priority == "baixa":
return '<span class="cf-chip cf-chip-gray">Baixa</span>'
route = str(task.get("route") or "")
if route in {"financeiro", "operacoes"}:
return '<span class="cf-chip cf-chip-orange">Normal</span>'
return '<span class="cf-chip cf-chip-gray">Baixa</span>'
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'<span class="status-badge {esc(cls)}">{esc(label)}</span>'
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'<span class="route-badge {esc(cls)}">{esc(label)}</span>'
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'<span class="sla-badge sla-overdue">{esc(label)}</span>'
if due_minutes <= 24 * 60:
label = f"Vence hoje" if due_minutes >= 0 else "Vencido"
return f'<span class="sla-badge sla-ok">{esc(label)}</span>'
days = max(1, due_minutes // (24 * 60))
return f'<span class="sla-badge sla-ok">{esc(f"Follow-up D+{days}")}</span>'
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'<span class="sla-badge sla-overdue">{esc(label)}</span>'
remaining = sla - age
label = f"SLA {remaining // 60}h" if remaining >= 60 else f"SLA {remaining}m"
return f'<span class="sla-badge sla-ok">{esc(label)}</span>'
# 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'<span class="cf-chip {esc(classes.get(str(stage or ""), "cf-chip-gray"))}">{esc(stage_label(stage))}</span>'
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 '<span class="cf-chip cf-chip-green">Ativo</span>'
return '<span class="cf-chip cf-chip-gray">Inativo</span>'
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'<span class="cf-chip {cls}">{esc(item_status_label(status))}</span>'
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'''
<form method="post" action="{esc(action)}" class="row g-3">
<div class="col-md-4">
<label class="form-label small fw-bold text-secondary">SKU interno / Odoo</label>
<input class="form-control" name="sku" value="{esc(product.get('sku') or '')}" placeholder="ODOO-1" required>
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-secondary">Artigo Jasmin</label>
<input class="form-control" name="jasmin_sales_item" value="{esc(product.get('jasmin_sales_item') or '')}" placeholder="CARREGADOR_MONO_7KW">
<div class="form-text">Usado para criar orçamentos/faturas Jasmin. Pode ser diferente do SKU Odoo.</div>
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-secondary">Nome</label>
<input class="form-control" name="name" value="{esc(product.get('name') or '')}" placeholder="Carregador EV 7.4kW monofásico" required>
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-secondary">Categoria</label>
<input class="form-control" name="category" value="{esc(product.get('category') or 'Carregadores')}" placeholder="Carregadores">
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-secondary">Preço base sem IVA</label>
<input class="form-control" name="default_unit_price" value="{esc(product.get('default_unit_price') if product.get('default_unit_price') is not None else '')}" placeholder="590.00" inputmode="decimal">
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-secondary">IVA %</label>
<input class="form-control" name="vat_rate" value="{esc(product.get('vat_rate') if product.get('vat_rate') is not None else '23.00')}" placeholder="23.00" inputmode="decimal">
</div>
<div class="col-12">
<label class="form-label small fw-bold text-secondary">Descrição / texto comercial</label>
<textarea class="form-control" name="description" rows="4" placeholder="Descrição curta para propostas e respostas sugeridas">{esc(product.get('description') or '')}</textarea>
</div>
<div class="col-12">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="active" value="true" {checked}>
<span class="form-check-label">Produto ativo</span>
</label>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button class="btn btn-primary" type="submit">{esc(submit_label)}</button>
<a class="btn btn-outline-secondary" href="/products">Cancelar</a>
</div>
</form>
'''
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'''
<form method="post" action="/outbox/{esc(item.get('id'))}/retry" class="d-inline">
<input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}">
<button class="btn btn-sm btn-outline-primary" type="submit">Reprocessar</button>
</form>
'''
elif status == "pending":
actions = '<span class="small text-secondary">A aguardar timer</span>'
rows += f'''
<tr class="{esc(row_cls)}">
<td><strong>{esc(item.get('target_system'))}</strong><div class="small text-secondary"><code>{esc(item.get('action_type'))}</code></div></td>
<td>{operation_status_badge(status)}</td>
<td class="small text-secondary">{esc(fmt_dt(item.get('updated_at') or item.get('created_at')))}</td>
<td class="small text-danger text-break">{esc(err)}</td>
<td class="text-end">{actions}</td>
</tr>
'''
if not rows:
rows = '<tr><td colspan="5" class="text-secondary py-4">Sem ações de integração para esta oportunidade.</td></tr>'
return f'''
<section class="card cf-card">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h2 class="cf-section-title">Integrações da oportunidade</h2>
<div class="small text-secondary">Estado operacional das ações Jasmin, Packlink, Chatwoot e Mautic ligadas a esta oportunidade.</div>
</div>
<a class="btn btn-sm btn-outline-secondary" href="/outbox">Ver outbox</a>
</div>
<div class="p-3 border-bottom">
<div class="cf-op-alert-grid">
<div class="cf-op-alert"><span>Pendentes</span><strong>{counts['pending']}</strong></div>
<div class="cf-op-alert"><span>Falhadas</span><strong>{counts['failed']}</strong></div>
<div class="cf-op-alert"><span>Bloqueadas</span><strong>{counts['blocked']}</strong></div>
<div class="cf-op-alert"><span>Dry-run</span><strong>{counts['dry_run']}</strong></div>
</div>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Sistema / Ação</th><th>Estado</th><th>Atualizado</th><th>Último erro</th><th class="text-end">Ação</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
</section>
'''
def opportunity_outbox_panel_html(opportunity_id: str, *, target_system: str = "jasmin") -> str:
items = _outbox_items_for_opportunity(opportunity_id, target_system=target_system, limit=12)
if not items:
return ""
rows = ""
for item in items:
retry = ""
if item.get("status") in {"failed", "pending"}:
retry = f'''
<form method="post" action="/outbox/{esc(item.get('id'))}/retry" hx-post="/outbox/{esc(item.get('id'))}/retry" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" class="d-inline">
<input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}">
<button class="btn btn-sm btn-outline-primary" type="submit">Reprocessar</button>
</form>
'''
err = esc(item.get("last_error") or "")
if err and len(err) > 180:
err = err[:180] + ""
rows += f'''
<tr>
<td><code>{esc(item.get('action_type'))}</code><div class="small text-secondary">{esc(fmt_dt(item.get('created_at')))}</div></td>
<td>{operation_status_badge(str(item.get('status') or 'pending'))}</td>
<td class="small text-danger text-break">{err}</td>
<td class="text-end">{retry}</td>
</tr>
'''
return f'''
<div class="p-3 border-top cf-outbox-mini">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<div><strong>Ações Jasmin / outbox</strong><div class="small text-secondary">Processamento automático ou manual das ações pedidas pelos botões.</div></div>
<a class="btn btn-sm btn-outline-secondary" href="/outbox?target_system=jasmin">Ver outbox</a>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table"><thead><tr><th>Ação</th><th>Estado</th><th>Erro</th><th></th></tr></thead><tbody>{rows}</tbody></table>
</div>
</div>
'''
def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str:
try:
from app.commercial_service import list_commercial_documents
docs = list_commercial_documents(opportunity_id=opportunity_id, limit=20)
except Exception as exc:
docs = []
error = str(exc)
else:
error = ""
try:
from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity
jasmin_candidates = find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=8)
except Exception as exc:
jasmin_candidates = []
if not error:
error = f"Erro ao procurar documentos Jasmin existentes: {exc}"
linked_tax_id = ""
try:
from app.commercial_service import get_customer_for_opportunity, normalize_tax_id
linked_customer = get_customer_for_opportunity(opportunity_id)
linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id"))
except Exception:
linked_tax_id = ""
rows = ""
kind_labels = {"quotation": "Orçamento", "proforma": "Orçamento legado", "invoice": "Fatura"}
for doc in docs:
number = commercial_document_display_number(doc, fallback="número por atualizar")
amount = doc.get("total_amount") if doc.get("total_amount") is not None else doc.get("amount")
kind = kind_labels.get(str(doc.get("document_kind") or ""), doc.get("document_kind") or "Documento")
doc_id = str(doc.get("id") or "")
role = str(doc.get("role") or "current")
is_primary = bool(doc.get("is_primary"))
make_primary_action = "" if (role in {"current", "accepted"} and is_primary) else f'<form method="post" action="/commercial-documents/{esc(doc_id)}/role" hx-post="/commercial-documents/{esc(doc_id)}/role" hx-target="#jasmin-documents-panel" hx-swap="outerHTML"><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><input type="hidden" name="role" value="current"><input type="hidden" name="make_primary" value="1"><button class="btn btn-sm btn-outline-success" type="submit">Definir principal</button></form>'
historical_action = "" if role == "historical" else f'<form method="post" action="/commercial-documents/{esc(doc_id)}/role" hx-post="/commercial-documents/{esc(doc_id)}/role" hx-target="#jasmin-documents-panel" hx-swap="outerHTML"><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><input type="hidden" name="role" value="historical"><input type="hidden" name="make_primary" value="0"><button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button></form>'
unlink_action = f'<form method="post" action="/commercial-documents/{esc(doc_id)}/unlink-from-opportunity" hx-post="/commercial-documents/{esc(doc_id)}/unlink-from-opportunity" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Desassociar apenas este documento desta oportunidade? Não apaga nada no Jasmin; remove só a ligação local e as linhas importadas deste documento."><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><input type="hidden" name="remove_imported_lines" value="1"><input type="hidden" name="note" value="Documento pertence a outra compra/processo; desassociado manualmente."><button class="btn btn-sm btn-outline-danger" type="submit">Desassociar este</button></form>'
refresh_action = f'<form method="post" action="/commercial-documents/{esc(doc_id)}/refresh" hx-post="/commercial-documents/{esc(doc_id)}/refresh" hx-target="#jasmin-documents-panel" hx-swap="outerHTML"><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><button class="btn btn-sm btn-outline-secondary" type="submit">Atualizar nº</button></form>'
pdf_action = f'<a class="btn btn-sm btn-outline-primary" href="/commercial-documents/{esc(doc_id)}/pdf" target="_blank" rel="noopener">PDF</a>'
actions = f'<div class="d-flex flex-wrap gap-1 justify-content-end">{make_primary_action}{historical_action}{unlink_action}{refresh_action}{pdf_action}</div>'
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"<div class='small text-secondary text-break'>ref. externa {esc(doc.get('external_id') or '')}</div>"
elif not doc.get("document_number") and doc.get("external_id"):
external_id_html = "<div class='small text-secondary'>ID técnico oculto; usar Atualizar nº.</div>"
rows += (
"<tr>"
f"<td><strong>{esc(kind)}</strong><div class='small text-secondary'>v{esc(doc.get('version_number') or '')}</div><span class='badge {role_class}'>{esc(role_label)}</span></td>"
f"<td><code>{esc(number)}</code>{external_id_html}</td>"
f"<td>{operation_status_badge(str(doc.get('status') or 'created'))}</td>"
f"<td>{money_html(amount or 0)}</td>"
f"<td class='small text-secondary'>{esc(fmt_dt(doc.get('created_at')))}</td>"
f"<td class='text-end'>{actions}</td>"
"</tr>"
)
if not rows:
rows = '<tr><td colspan="6" class="text-secondary py-4">Ainda sem documentos Jasmin nesta oportunidade.</td></tr>'
current_jasmin_docs_exist = bool(docs)
unlink_jasmin_button_html = ""
if current_jasmin_docs_exist:
unlink_jasmin_button_html = f"""
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/unlink" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/unlink" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Desassociar documentos Jasmin desta oportunidade? Não apaga nada no Jasmin; remove apenas documentos/linhas locais ClientFlow e envia candidatos para revisão.">
<button class="btn btn-outline-danger btn-sm" type="submit">Desassociar Jasmin</button>
</form>
"""
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 = (
'<span class="badge text-bg-danger">NIF divergente</span>'
if tax_conflict
else (
'<span class="badge text-bg-success">Aberto/válido</span>'
if is_valid
else '<span class="badge text-bg-secondary">Ignorado</span>'
)
)
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'''
<div class="d-flex flex-column gap-1 align-items-end">
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Associar esta fatura à mesma oportunidade? Usa apenas se é a fatura emitida a partir deste orçamento/processo.">
<button class="btn btn-sm btn-primary" type="submit">Associar fatura</button>
</form>
<form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar esta fatura candidata nesta oportunidade? Não altera o documento no Jasmin.">
<button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button>
</form>
<span class="small text-secondary text-end">Fatura do mesmo cliente/processo. Deve ser associada como documento seguinte, não substituir o orçamento.</span>
</div>
'''
elif current_jasmin_docs_exist:
action_html = f'''
<div class="d-flex flex-column gap-1 align-items-end">
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/replace-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/replace-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Substituir o documento Jasmin atual por este candidato aberto? Mantém histórico no ClientFlow; não apaga nada no Jasmin.">
<button class="btn btn-sm btn-warning" type="submit">Substituir atual</button>
</form>
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Associar este documento adicional a esta oportunidade? Usa apenas se pertence à mesma compra/processo.">
<button class="btn btn-sm btn-outline-primary" type="submit">Associar adicional</button>
</form>
<form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar este candidato Jasmin nesta oportunidade? Não altera o documento no Jasmin.">
<button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button>
</form>
<span class="small text-secondary text-end">Já existe documento atual. Usa Substituir para trocar o principal ou Associar adicional quando pertence à mesma compra.</span>
</div>
'''
else:
action_html = f'''
<div class="d-flex flex-column gap-1 align-items-end">
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Associar este documento Jasmin à oportunidade e importar linhas/valor?">
<button class="btn btn-sm btn-primary" type="submit">Associar e importar</button>
</form>
<form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar este candidato Jasmin nesta oportunidade? Não altera o documento no Jasmin.">
<button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button>
</form>
</div>
'''
else:
action_html = (
'<button class="btn btn-sm btn-outline-danger" type="button" disabled>NIF divergente — rever manualmente</button>'
if tax_conflict
else f'<form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar este candidato Jasmin nesta oportunidade?"><button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button></form>'
)
candidate_customer_meta = (
f'<div class="small text-secondary">NIF {esc(item.get("customer_tax_id") or "")}</div>'
if is_valid
else (
'<div class="small text-secondary">NIF divergente oculto em auditoria</div>'
if tax_conflict
else '<div class="small text-secondary">NIF oculto em auditoria</div>'
)
)
row_html = f'''
<tr class="{'table-warning' if not is_valid else ''}">
<td><strong>{esc(doc_number)}</strong><div class="small text-secondary">{esc(item.get('external_type') or 'jasmin')}</div><div class="small text-secondary">{esc(fmt_dt(item.get('document_date') or item.get('updated_at')))}</div></td>
<td>{money_html(total_amount or 0)}<div class="small text-secondary">{esc(item.get('currency') or 'EUR')}</div></td>
<td>{esc(item.get('customer_name') or '')}{candidate_customer_meta}</td>
<td>{status_badge_html}<div class="small text-secondary">Jasmin: {esc(status_label)} {esc(item.get('jasmin_status_code') or '')}</div><div class="small text-secondary">{esc(invalid_reason)}</div></td>
<td><span class="badge text-bg-light">{esc(match_reason)} {esc(match_score)}</span><div class="small text-secondary">{esc(item.get('line_count') or 0)} linha(s)</div></td>
<td class="text-end">{action_html}</td>
</tr>
'''
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'''
<div class="alert alert-warning py-2 small mx-3 mt-3 mb-0">
<strong>Candidatos Jasmin acionáveis encontrados.</strong><br>
Valida antes de substituir ou associar, especialmente em processos antigos/reconstruídos.
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Documento encontrado</th><th>Valor</th><th>Cliente Jasmin</th><th>Validação</th><th>Match</th><th class="text-end">Ação</th></tr></thead>
<tbody>{candidate_rows}</tbody>
</table>
</div>
'''
else:
candidates_html += '''
<div class="alert alert-info py-2 small mx-3 mt-3 mb-0">
<strong>Candidatos adicionais</strong><br>
Nenhum documento Jasmin candidato seguro encontrado. Documentos antigos, cancelados ou de outro cliente ficam apenas na auditoria.
</div>
'''
if ignored_rows:
candidates_html += f'''
<details class="mx-3 mt-2 mb-0">
<summary class="small fw-bold text-secondary" style="cursor:pointer">Documentos ignorados / auditoria</summary>
<div class="cf-table-wrap border-0 rounded-0 mt-2">
<table class="table cf-table">
<thead><tr><th>Documento</th><th>Valor</th><th>Cliente Jasmin</th><th>Validação</th><th>Match</th><th class="text-end">Ação</th></tr></thead>
<tbody>{ignored_rows}</tbody>
</table>
</div>
</details>
'''
if hidden_other_customer_count:
candidates_html += f'<div class="small text-secondary mx-3 mt-2">{hidden_other_customer_count} documento(s) ignorado(s) de outro NIF ocultados da lista principal.</div>'
notice_html = f'<div class="alert alert-info py-2 small mb-2">{esc(notice)}</div>' if notice else ''
error_notice_html = f'<div class="alert alert-danger py-2 small mb-2"><strong>Não foi possível pedir a ação.</strong><br>{esc(error_notice).replace(chr(10), "<br>")}</div>' if error_notice else ''
error_html = f'<div class="alert alert-warning py-2 small mb-2">{esc(error)}</div>' 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'<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/convert-invoice" '
f'hx-post="/opportunities/{esc(opportunity_id)}/jasmin/convert-invoice" '
'hx-target="#jasmin-documents-panel" hx-swap="outerHTML">'
'<button class="btn btn-outline-primary btn-sm" type="submit">Converter em fatura <span class="htmx-indicator">…</span></button>'
'</form>'
)
else:
# Legacy static-test anchor: É necessário um orçamento ou pró-forma atual para converter
convert_invoice_button_html = '<button class="btn btn-outline-secondary btn-sm" type="button" disabled title="É necessário um orçamento atual para converter.">Converter em fatura</button>'
if current_jasmin_docs_exist:
create_quotation_button_html = f'''
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Esta oportunidade já tem documento Jasmin associado. Criar novo orçamento adicional mesmo assim?">
<button class="btn btn-outline-secondary btn-sm" type="submit">Novo orçamento adicional <span class="htmx-indicator">…</span></button>
</form>
'''
else:
create_quotation_button_html = f'''
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-target="#jasmin-documents-panel" hx-swap="outerHTML">
<button class="btn btn-primary btn-sm" type="submit">Criar orçamento <span class="htmx-indicator">…</span></button>
</form>
'''
return f'''
<section id="jasmin-documents-panel" class="card cf-card cf-live-panel">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h2 class="cf-section-title">Documentos Jasmin</h2>
<div class="small text-secondary">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.</div>
<div class="small text-secondary">Última atualização: {esc(refreshed_at)}. Atualização manual para evitar reconstrução automática da janela.</div>
</div>
<div class="d-flex flex-wrap gap-2">
{unlink_jasmin_button_html}
{create_quotation_button_html}
{convert_invoice_button_html}
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/reimport-details" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/reimport-details" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Reimportar detalhes Jasmin para esta oportunidade? Atualiza documentos, linhas, produtos e valor.">
<button class="btn btn-outline-secondary btn-sm" type="submit">Reimportar detalhes <span class="htmx-indicator">…</span></button>
</form>
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/sync-candidates" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/sync-candidates" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Sincronizar documentos Jasmin recentes antes de escolher candidato?">
<button class="btn btn-outline-secondary btn-sm" type="submit">Sincronizar Jasmin</button>
</form>
<button class="btn btn-outline-secondary btn-sm" type="button" hx-get="/opportunities/{esc(opportunity_id)}/partials/jasmin-documents" hx-target="#jasmin-documents-panel" hx-swap="outerHTML">Atualizar estado</button>
</div>
</div>
<div class="p-3 pb-0">{notice_html}{error_notice_html}{error_html}</div>
<div class="px-3 pt-3 pb-2 border-top">
<h3 class="h6 fw-bold mb-1">Documentos associados</h3>
<div class="small text-secondary">Documentos já ligados a esta oportunidade. Usa candidatos adicionais apenas quando forem da mesma compra/processo.</div>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Tipo</th><th>Número/ID</th><th>Estado</th><th>Valor</th><th>Criado</th><th class="text-end">Ações</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
<div class="px-3 pt-3 pb-2 border-top">
<h3 class="h6 fw-bold mb-1">Candidatos adicionais</h3>
<div class="small text-secondary">Documentos Jasmin encontrados por reconciliação que ainda não estão associados como documento principal.</div>
</div>
{candidates_html}
{outbox_html}
</div>
</section>
'''
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"""
<tr>
<td><strong>{esc(item.get('product_name') or 'Produto')}</strong><div class="small text-secondary">SKU/Odoo <code>{esc(item.get('sku') or '')}</code></div><div class="small text-secondary">Jasmin <code>{esc(item.get('jasmin_sales_item') or '')}</code></div></td>
<td class="text-end">{esc(item.get('quantity') or '1')}</td>
<td class="text-end">{money_html(item.get('unit_price'))}</td>
<td class="text-end">{money_html(item.get('discount_amount'))}</td>
<td class="text-end fw-bold">{money_html(item.get('total_price'))}</td>
<td>{item_status_badge(item.get('status'))}</td>
<td class="text-end">
<form method="post" action="/opportunities/{esc(opportunity_id)}/items/{esc(item.get('id'))}/delete" hx-post="/opportunities/{esc(opportunity_id)}/items/{esc(item.get('id'))}/delete" hx-target="#opportunity-products-panel" hx-swap="outerHTML" onsubmit="return confirm('Remover esta linha?')">
<button class="btn btn-sm btn-outline-danger" type="submit">Remover</button>
</form>
</td>
</tr>
"""
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 = '<tr><td colspan="7" class="text-center text-secondary py-4">Sem produtos atuais manuais nesta oportunidade.</td></tr>'
imported_html = ""
if imported_groups:
groups_html = ""
for label, group_rows in imported_groups.items():
groups_html += f"""
<details class="border rounded p-2 mb-2" open>
<summary class="small fw-bold text-secondary" style="cursor:pointer">{esc(label)}</summary>
<div class="cf-table-wrap border-0 rounded-0 mt-2">
<table class="table cf-table">
<thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th class="text-end">Desc.</th><th class="text-end">Total</th><th>Estado</th><th></th></tr></thead>
<tbody>{group_rows}</tbody>
</table>
</div>
</details>
"""
imported_html = f"""
<details class="p-3 border-top" open>
<summary class="small fw-bold text-secondary" style="cursor:pointer">Linhas importadas agrupadas por origem</summary>
<div class="small text-secondary mt-1 mb-2">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.</div>
{groups_html}
</details>
"""
historical_html = ""
if historical_rows:
historical_html = f"""
<details class="p-3 border-top">
<summary class="small fw-bold text-secondary" style="cursor:pointer">Ver linhas históricas / entregues</summary>
<div class="cf-table-wrap border-0 rounded-0 mt-2">
<table class="table cf-table">
<thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th class="text-end">Desc.</th><th class="text-end">Total</th><th>Estado</th><th></th></tr></thead>
<tbody>{historical_rows}</tbody>
</table>
</div>
</details>
"""
return f"""
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th class="text-end">Desc.</th><th class="text-end">Total</th><th>Estado</th><th></th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
{imported_html}
{historical_html}
"""
def opportunity_add_item_form_html(opportunity_id: str, products: list[dict]) -> str:
options = '<option value="">Selecionar produto...</option>'
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'<option value="{product_id}">{sku}{jasmin or "sem Jasmin"} · {name}</option>'
return f'''
<form method="post" action="/opportunities/{esc(opportunity_id)}/items/add" hx-post="/opportunities/{esc(opportunity_id)}/items/add" hx-target="#opportunity-products-panel" hx-swap="outerHTML" class="row g-2 align-items-end">
<div class="col-lg-6">
<label class="form-label small fw-bold text-secondary">Produto</label>
<select class="form-select" name="product_id" required>{options}</select>
</div>
<div class="col-6 col-lg-2">
<label class="form-label small fw-bold text-secondary">Qtd.</label>
<input class="form-control" name="quantity" value="1" inputmode="decimal">
</div>
<div class="col-6 col-lg-2">
<label class="form-label small fw-bold text-secondary">Preço opcional</label>
<input class="form-control" name="unit_price" placeholder="auto" inputmode="decimal">
</div>
<div class="col-lg-2 d-grid">
<button class="btn btn-primary" type="submit">Adicionar</button>
</div>
</form>
'''
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'<section id="opportunity-products-panel" class="card cf-card"><div class="card-body"><div class="alert alert-danger">Erro ao carregar produtos: {esc(exc)}</div></div></section>'
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'<div class="alert alert-info py-2 small mb-2">{esc(notice)}</div>' if notice else ''
error_html = f'<div class="alert alert-danger py-2 small mb-2">{esc(error_notice)}</div>' 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"<li>{esc(i.get('product_name') or i.get('sku') or 'Produto')} sem Artigo Jasmin.</li>" for i in missing)
validation_html = f'<div class="alert alert-warning py-2 small mb-0"><strong>Atenção:</strong> estes produtos bloqueiam o orçamento Jasmin:<ul class="cf-validation-list">{lis}</ul></div>'
elif non_billable_missing:
lis = "".join(f"<li>{esc(i.get('product_name') or i.get('sku') or 'Linha logística')} configurada como logística/não faturável.</li>" for i in non_billable_missing)
validation_html = f'<div class="alert alert-info py-2 small mb-0"><strong>Linhas logísticas:</strong> não bloqueiam o orçamento/fatura Jasmin.<ul class="cf-validation-list">{lis}</ul></div>'
return f'''
<section id="opportunity-products-panel" class="card cf-card cf-live-panel">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div><h2 class="cf-section-title">Produtos</h2><div class="small text-secondary">Linhas comerciais da oportunidade.</div></div>
<span class="cf-chip cf-chip-green">Total linhas atuais {money_html(total)}</span>
</div>
<div class="p-3 border-bottom bg-light"><h3 class="h6 fw-bold mb-2">Adicionar produto</h3>{notice_html}{error_html}{opportunity_add_item_form_html(opportunity_id, active_products)}</div>
{opportunity_items_table_html(opportunity_id, items)}
<div class="p-3 border-top">{validation_html}</div>
</div>
</section>
'''