1806 lines
94 KiB
Python
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>
|
|
'''
|