Files
clientflow_backend/app/admin_dashboard.py

1806 lines
94 KiB
Python

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'<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>
'''