1785 lines
71 KiB
Python
1785 lines
71 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 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 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
|
|
|
|
|
|
# ADMIN_UI_CSS moved to app.admin_ui.styles in v4.7.
|
|
# Route handlers moved to app.admin_ui.pages.* in v4.7.2.
|
|
|
|
|
|
|
|
|
|
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:
|
|
return
|
|
received = (
|
|
request.headers.get("X-ClientFlow-Admin-Token")
|
|
or request.cookies.get("clientflow_admin_token")
|
|
or request.query_params.get("admin_token")
|
|
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": [
|
|
"Fatura pró-forma emitida/enviada ao cliente.",
|
|
"Pró-forma enviada; 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.",
|
|
],
|
|
"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.",
|
|
],
|
|
"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 ""
|
|
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 emitir/enviar a fatura pró-forma.
|
|
|
|
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 pró-forma",
|
|
"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",
|
|
}
|
|
|
|
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": "Recolher dados de faturação e emitir/enviar a pró-forma.",
|
|
"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.",
|
|
}
|
|
|
|
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"],
|
|
}
|
|
|
|
PIPELINE_STEPS = [
|
|
("NEW_LEAD", "Novo pedido"),
|
|
("INFO_SENT", "Info enviada"),
|
|
("QUOTE_SENT", "Proposta"),
|
|
("PROFORMA_SENT", "Pró-forma"),
|
|
("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 pró-forma",
|
|
"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": "Acompanhar produção",
|
|
"READY_TO_SHIP": "Criar envio",
|
|
"INVOICED": "Criar/validar envio",
|
|
"SHIPMENT_CREATED": "Enviar tracking",
|
|
"SHIPPED": "Acompanhar entrega",
|
|
"TRACKING_SENT": "Acompanhar entrega",
|
|
"DELIVERED": "Fechar oportunidade",
|
|
"WON": "Concluída",
|
|
"LOST": "Perdida",
|
|
"NO_INTEREST": "Sem interesse",
|
|
"REVIEW": "Rever manualmente",
|
|
}
|
|
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"}:
|
|
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)
|
|
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 (snapshot.get("cards") or [])
|
|
)
|
|
|
|
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 ""
|
|
|
|
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 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>'
|
|
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"}:
|
|
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": "Pró-forma",
|
|
"odoo_sale_order": "Venda",
|
|
"odoo_production": "Produção",
|
|
"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 snapshot.get("cards", []):
|
|
badge_class, mark = step_visual(card.get("status"))
|
|
key = str(card.get("key") or "")
|
|
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 task_age_minutes(task: dict) -> int:
|
|
created_at = task.get("created_at")
|
|
|
|
if not created_at:
|
|
return 0
|
|
|
|
if isinstance(created_at, str):
|
|
try:
|
|
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
except Exception:
|
|
return 0
|
|
|
|
if created_at.tzinfo is None:
|
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
|
|
return max(0, int((datetime.now(timezone.utc) - created_at).total_seconds() // 60))
|
|
|
|
|
|
def is_task_overdue(task: dict) -> bool:
|
|
if task.get("status") != "pending":
|
|
return False
|
|
|
|
return task_age_minutes(task) > task_sla_minutes(task.get("route"))
|
|
|
|
|
|
def is_task_today(task: dict) -> bool:
|
|
created_at = task.get("created_at")
|
|
|
|
if not created_at:
|
|
return False
|
|
|
|
if isinstance(created_at, str):
|
|
try:
|
|
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
except Exception:
|
|
return False
|
|
|
|
if created_at.tzinfo is None:
|
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
return created_at.date() == now.date()
|
|
|
|
|
|
def sla_badge_html(task: dict) -> str:
|
|
if task.get("status") != "pending":
|
|
return ""
|
|
|
|
age = task_age_minutes(task)
|
|
sla = task_sla_minutes(task.get("route"))
|
|
|
|
if age > sla:
|
|
overdue = age - sla
|
|
if overdue >= 60:
|
|
label = f"Atrasada {overdue // 60}h"
|
|
else:
|
|
label = f"Atrasada {overdue}m"
|
|
return f'<span class="sla-badge sla-overdue">{esc(label)}</span>'
|
|
|
|
remaining = sla - age
|
|
if remaining >= 60:
|
|
label = f"SLA {remaining // 60}h"
|
|
else:
|
|
label = 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",
|
|
}
|
|
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": "Pró-forma", "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 "")
|
|
actions = f"""
|
|
<div class="d-flex flex-wrap gap-1 justify-content-end">
|
|
<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>
|
|
<a class="btn btn-sm btn-outline-primary" href="/commercial-documents/{esc(doc_id)}/pdf" target="_blank" rel="noopener">PDF</a>
|
|
</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)
|
|
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:
|
|
if 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>
|
|
<span class="small text-secondary text-end">Já existe documento atual. A associação direta fica bloqueada para evitar duplicados.</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 aberto à oportunidade e importar linhas/valor?">
|
|
<button class="btn btn-sm btn-primary" type="submit">Associar e importar</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 '<button class="btn btn-sm btn-outline-secondary" type="button" disabled>Não associar</button>'
|
|
)
|
|
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>Nenhum orçamento/pró-forma aberto elegível encontrado.</strong><br>
|
|
Documentos antigos, fechados ou de outro cliente não são apresentados como ação principal.
|
|
</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">Ver 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:
|
|
convert_invoice_button_html = '<button class="btn btn-outline-secondary btn-sm" type="button" disabled title="É necessário um orçamento ou pró-forma 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">Antes de criar novo orçamento, valida candidatos Jasmin abertos/mais recentes para evitar duplicados.</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">
|
|
{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>
|
|
{candidates_html}
|
|
<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>
|
|
{outbox_html}
|
|
</div>
|
|
</section>
|
|
'''
|
|
|
|
def opportunity_items_table_html(opportunity_id: str, items: list[dict]) -> str:
|
|
rows = ""
|
|
historical_rows = ""
|
|
for item in items:
|
|
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 str(item.get('status') or '').upper() in {"DELIVERED", "HISTORICAL"}:
|
|
historical_rows += row_html
|
|
else:
|
|
rows += row_html
|
|
if not rows:
|
|
rows = '<tr><td colspan="7" class="text-center text-secondary py-4">Sem produtos atuais nesta oportunidade.</td></tr>'
|
|
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>
|
|
{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"})
|
|
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")]
|
|
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>'
|
|
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>
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|