1283 lines
76 KiB
Python
1283 lines
76 KiB
Python
"""Commercial opportunity routes and actions.
|
|
|
|
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
|
|
legacy helpers to keep this refactor behavior-preserving.
|
|
"""
|
|
from fastapi import APIRouter, Request
|
|
import app.admin_dashboard as legacy
|
|
from app.admin_dashboard import * # noqa: F401,F403
|
|
from app.admin_ui.labels import primary_action_label
|
|
from app.operation_noise import is_noise_operation_item
|
|
from app.opportunity_next_action_service import get_opportunity_next_action
|
|
from app.admin_ui.guidance import (
|
|
blocker_alert_html,
|
|
fiscal_contact_inline_html,
|
|
fiscal_contact_panel_html,
|
|
fiscal_customer_missing_fields,
|
|
opportunity_blockers,
|
|
opportunity_context_customer,
|
|
readiness_checklist_html,
|
|
shipment_missing_fields,
|
|
stage_requires_fiscal_customer,
|
|
)
|
|
|
|
_opportunity_board_column_for_stage = legacy._opportunity_board_column_for_stage
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _render_email_identity_review(opportunity_id: str, linked_customer: dict | None) -> str:
|
|
try:
|
|
from app.fiscal_enrichment_service import email_identity_review_for_opportunity
|
|
review = email_identity_review_for_opportunity(opportunity_id, refresh=False)
|
|
except Exception as exc:
|
|
return f"""
|
|
<div class="cf-soft-box mt-3">
|
|
<div class="small text-secondary fw-bold text-uppercase mb-2">Identidade do email</div>
|
|
<div class="small text-danger">Erro ao ler identidade extraída: {esc(exc)}</div>
|
|
</div>
|
|
"""
|
|
if not review.get("ok") or not review.get("identity"):
|
|
return f"""
|
|
<div class="cf-soft-box mt-3">
|
|
<div class="small text-secondary fw-bold text-uppercase mb-2">Identidade do email</div>
|
|
<div class="small text-secondary mb-2">Ainda não existe identidade extraída para esta oportunidade.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/extract" class="d-grid">
|
|
<button class="btn btn-sm btn-outline-primary" type="submit">Extrair identidade do email</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
|
|
identity = review.get("identity") or {}
|
|
companies = review.get("valid_company_mentions") or identity.get("company_mentions") or []
|
|
phones = identity.get("phones") or []
|
|
evidence = identity.get("evidence") or []
|
|
conflict = bool(review.get("conflict"))
|
|
suggested = review.get("suggested_internal_customer") or {}
|
|
model = (identity.get("raw_payload") or {}).get("llm_model") if isinstance(identity.get("raw_payload"), dict) else identity.get("llm_model")
|
|
model = model or identity.get("llm_model") or "—"
|
|
confidence = identity.get("confidence")
|
|
try:
|
|
confidence_value = float(confidence or 0)
|
|
confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%"
|
|
except Exception:
|
|
confidence_text = "—"
|
|
company_html = "".join(f'<span class="badge text-bg-light me-1 mb-1">{esc(c)}</span>' for c in companies) or '<span class="text-secondary">Sem empresa explícita válida</span>'
|
|
phone_html = ", ".join(esc(p) for p in phones) if phones else "—"
|
|
evidence_html = "".join(f'<li>{esc(compact_text(e, 90))}</li>' for e in evidence[:3])
|
|
conflict_html = ""
|
|
if conflict:
|
|
conflict_html = f"""
|
|
<div class="alert alert-warning py-2 small mt-2 mb-2">
|
|
<strong>Possível conflito fiscal.</strong><br>
|
|
O email menciona {esc(', '.join(companies) or 'outra empresa')}, mas a oportunidade está ligada a {esc(review.get('linked_customer_name') or 'outro cliente')}.
|
|
</div>
|
|
"""
|
|
suggested_html = ""
|
|
if suggested and companies:
|
|
suggested_html = f"""
|
|
<div class="border rounded p-2 small bg-white mt-2">
|
|
<div class="text-secondary fw-bold text-uppercase">Cliente interno compatível</div>
|
|
<strong>{esc(suggested.get('nome') or suggested.get('name') or 'Cliente')}</strong>
|
|
<div class="text-secondary">NIF {esc(suggested.get('nif') or suggested.get('tax_id') or '—')}</div>
|
|
</div>
|
|
"""
|
|
return f"""
|
|
<div class="cf-soft-box mt-3">
|
|
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
|
|
<div>
|
|
<div class="small text-secondary fw-bold text-uppercase">Identidade extraída do email</div>
|
|
<div class="small text-secondary">{esc(identity.get('extraction_method') or identity.get('method') or '—')} · {esc(model)} · confiança {esc(confidence_text)}</div>
|
|
</div>
|
|
{status_badge('conflito') if conflict and 'status_badge' in globals() else ''}
|
|
</div>
|
|
{conflict_html}
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Pessoa</div>
|
|
<div class="fw-semibold">{esc(identity.get('person_name') or '—')}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Empresa mencionada</div>
|
|
<div>{company_html}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Email / domínio</div>
|
|
<div class="small text-break">{esc(identity.get('email') or '—')} · {esc(identity.get('domain') or '—')}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Morada</div>
|
|
<div class="small">{esc(identity.get('address') or '—')}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Telefones</div>
|
|
<div class="small">{phone_html}</div>
|
|
{suggested_html}
|
|
{f'<ul class="small text-secondary mt-2 mb-0">{evidence_html}</ul>' if evidence_html else ''}
|
|
<div class="d-grid gap-2 mt-3">
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/assist">
|
|
<button class="btn btn-sm btn-outline-primary w-100" type="submit">Procurar cliente fiscal por identidade</button>
|
|
</form>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/extract">
|
|
<button class="btn btn-sm btn-outline-secondary w-100" type="submit">Reextrair identidade</button>
|
|
</form>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/cleanup-invalid" onsubmit="return confirm('Limpar sugestões/extracções antigas inválidas desta oportunidade?')">
|
|
<button class="btn btn-sm btn-outline-danger w-100" type="submit">Limpar identidade inválida</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
|
|
def _local_normalize_fiscal_name(value: object) -> str:
|
|
text = " ".join(str(value or "").strip().casefold().replace(",", " ").replace(".", " ").split())
|
|
legal = {"lda", "ltd", "sa", "s", "a", "unipessoal", "limitada", "sociedade", "portugal"}
|
|
return " ".join(token for token in text.split() if token not in legal)
|
|
|
|
def _render_fiscal_suggestions(opportunity_id: str, linked_customer: dict | None) -> str:
|
|
try:
|
|
from app.fiscal_enrichment_service import list_fiscal_suggestions_for_opportunity
|
|
suggestions = list_fiscal_suggestions_for_opportunity(opportunity_id, limit=3)
|
|
except Exception:
|
|
suggestions = []
|
|
if linked_customer and not suggestions:
|
|
return ""
|
|
if not suggestions:
|
|
return f"""
|
|
<div class=\"cf-soft-box mt-3\">
|
|
<div class=\"small text-secondary mb-2\">Sem sugestão fiscal externa registada.</div>
|
|
<form method=\"post\" action=\"/opportunities/{esc(opportunity_id)}/fiscal-enrich\" class=\"d-grid\">
|
|
<button class=\"btn btn-sm btn-outline-primary\" type=\"submit\">Enriquecer cliente fiscal</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
rows = ""
|
|
linked_name_norm = _local_normalize_fiscal_name(linked_customer.get("name") if linked_customer else "")
|
|
linked_tax_id = str((linked_customer or {}).get("tax_id") or "").strip()
|
|
linked_customer_id = str((linked_customer or {}).get("id") or "").strip()
|
|
visible_suggestions = []
|
|
for suggestion in suggestions:
|
|
status = str(suggestion.get("status") or "pending")
|
|
lookup_value = str(suggestion.get("lookup_value") or "").strip().lower()
|
|
suggested_nif = str(suggestion.get("suggested_nif") or "").strip()
|
|
suggested_name_norm = _local_normalize_fiscal_name(suggestion.get("suggested_name"))
|
|
suggested_customer_id = str(suggestion.get("suggested_customer_id") or "").strip()
|
|
if lookup_value in {"pt", "com", "net", "org", "www", "http", "https", "mail", "email"}:
|
|
continue
|
|
# Do not show old accepted suggestions that merely confirm the current fiscal customer.
|
|
# The fiscal card already shows the truth; repeating an accepted suggestion with stale
|
|
# suggested_nif=NULL is confusing.
|
|
same_current_customer = bool(
|
|
linked_customer
|
|
and status == "accepted"
|
|
and (
|
|
(suggested_customer_id and linked_customer_id and suggested_customer_id == linked_customer_id)
|
|
or (linked_name_norm and suggested_name_norm and linked_name_norm == suggested_name_norm)
|
|
or (linked_tax_id and suggested_nif and linked_tax_id == suggested_nif)
|
|
)
|
|
)
|
|
if same_current_customer:
|
|
continue
|
|
visible_suggestions.append(suggestion)
|
|
for suggestion in visible_suggestions:
|
|
sid = str(suggestion.get("id") or "")
|
|
status = str(suggestion.get("status") or "pending")
|
|
badge = status_badge(status) if "status_badge" in globals() else f"<span class='badge text-bg-light'>{esc(status)}</span>"
|
|
confidence = suggestion.get("confidence")
|
|
if confidence is not None:
|
|
try:
|
|
confidence_value = float(confidence)
|
|
confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%"
|
|
except Exception:
|
|
confidence_text = "—"
|
|
else:
|
|
confidence_text = "—"
|
|
actions = ""
|
|
if status == "pending" and sid:
|
|
actions = f"""
|
|
<div class=\"d-flex gap-1 mt-2\">
|
|
<form method=\"post\" action=\"/fiscal-suggestions/{esc(sid)}/accept\" class=\"flex-fill\">
|
|
<button class=\"btn btn-sm btn-primary w-100\" type=\"submit\">Associar</button>
|
|
</form>
|
|
<form method=\"post\" action=\"/fiscal-suggestions/{esc(sid)}/reject\">
|
|
<button class=\"btn btn-sm btn-outline-secondary\" type=\"submit\">Rejeitar</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
rows += f"""
|
|
<div class=\"border rounded p-2 mb-2 bg-white\">
|
|
<div class=\"d-flex justify-content-between gap-2\"><strong>{esc(suggestion.get('suggested_name') or 'Empresa sugerida')}</strong>{badge}</div>
|
|
<div class=\"small text-secondary\">Sugestão fiscal · NIF {esc(suggestion.get('suggested_nif') or '—')} · confiança {esc(confidence_text)}</div>
|
|
<div class=\"small text-secondary\">{esc(suggestion.get('match_type') or suggestion.get('lookup_type') or 'match')}</div>
|
|
{actions}
|
|
</div>
|
|
"""
|
|
if not rows.strip():
|
|
return ""
|
|
return f"""
|
|
<div class=\"cf-soft-box mt-3\">
|
|
<div class=\"small text-secondary fw-bold text-uppercase mb-1\">Sugestões fiscais por validar</div>
|
|
<div class=\"small text-secondary mb-2\">Não é cliente fiscal confirmado. Associar apenas depois de validar nome/NIF.</div>
|
|
{rows}
|
|
<form method=\"post\" action=\"/opportunities/{esc(opportunity_id)}/fiscal-enrich\" class=\"d-grid\">
|
|
<button class=\"btn btn-sm btn-outline-primary\" type=\"submit\">Atualizar sugestão</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
|
|
|
|
def _jasmin_candidate_tax_conflict_message(opportunity_id: str, item_id: str) -> str:
|
|
"""Return a blocking message when a Jasmin candidate belongs to another NIF."""
|
|
try:
|
|
from app.commercial_service import get_customer_for_opportunity, normalize_tax_id
|
|
from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity
|
|
|
|
linked_customer = get_customer_for_opportunity(opportunity_id)
|
|
linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id"))
|
|
if not linked_tax_id:
|
|
return ""
|
|
for item in find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=50):
|
|
if str(item.get("id") or "") != str(item_id):
|
|
continue
|
|
candidate_tax = normalize_tax_id(item.get("customer_tax_id"))
|
|
if candidate_tax and candidate_tax != linked_tax_id:
|
|
return (
|
|
"NIF divergente: o documento Jasmin pertence a outro cliente fiscal. "
|
|
"Rever manualmente na reconciliação antes de associar/substituir."
|
|
)
|
|
return ""
|
|
except Exception:
|
|
# Não bloquear quando não conseguimos confirmar conflito; o serviço de importação
|
|
# continua responsável por validar a operação.
|
|
return ""
|
|
return ""
|
|
|
|
|
|
def _opportunity_jasmin_state(opportunity_id: str) -> dict:
|
|
# Small UI helper: summarize current Jasmin evidence imported in ClientFlow.
|
|
try:
|
|
from sqlalchemy import text
|
|
from app.db import engine
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_documents,
|
|
COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'quotation')::int AS quotations,
|
|
COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'proforma')::int AS proformas,
|
|
COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'invoice')::int AS invoices,
|
|
(ARRAY_AGG(document_number ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_number,
|
|
(ARRAY_AGG(document_kind ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_kind,
|
|
(ARRAY_AGG(total_amount ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_total_amount
|
|
FROM commercial_documents
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": str(opportunity_id)}).mappings().first()
|
|
item_count = conn.execute(text("""
|
|
SELECT COUNT(*)::int
|
|
FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": str(opportunity_id)}).scalar() or 0
|
|
data = dict(row or {})
|
|
data["item_count"] = int(item_count or 0)
|
|
return data
|
|
except Exception:
|
|
return {"jasmin_documents": 0, "item_count": 0}
|
|
|
|
|
|
def _opportunity_consistency_alert_html(opportunity: dict, tasks: list[dict], opportunity_items: list[dict], opportunity_id: str) -> str:
|
|
# Surface soft inconsistencies without blocking the operator.
|
|
state = _opportunity_jasmin_state(opportunity_id)
|
|
stage = str(opportunity.get("stage") or "")
|
|
pending_action_codes = {str(t.get("action_code") or "") for t in tasks if str(t.get("status") or "") == "pending"}
|
|
has_payment_task = bool({"CONFIRM_PAYMENT", "CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT"} & pending_action_codes)
|
|
has_quote = int(state.get("quotations") or 0) > 0
|
|
has_proforma = int(state.get("proformas") or 0) > 0
|
|
has_invoice = int(state.get("invoices") or 0) > 0
|
|
has_items = bool(opportunity_items) or int(state.get("item_count") or 0) > 0
|
|
|
|
alerts = []
|
|
if has_payment_task and has_quote and not (has_proforma or has_invoice):
|
|
alerts.append(
|
|
"Existe tarefa de confirmar pagamento, mas o documento Jasmin atual ainda é orçamento. "
|
|
"Antes de concluir a tarefa, confirma que o cliente recebeu pedido de pagamento/pró-forma ou que o pagamento foi efetivamente indicado."
|
|
)
|
|
if stage == "WAITING_PAYMENT" and has_quote and not (has_proforma or has_invoice):
|
|
alerts.append(
|
|
"A fase está em pagamento com apenas orçamento Jasmin importado. Isto pode estar correto se o cliente já aceitou/pagou, "
|
|
"mas a fase documental ainda não mostra pró-forma/fatura."
|
|
)
|
|
if has_items and int(state.get("jasmin_documents") or 0) <= 0:
|
|
alerts.append(
|
|
"A oportunidade tem produtos, mas ainda não tem documento Jasmin importado. Usa Reimportar detalhes ou Criar orçamento."
|
|
)
|
|
|
|
if not alerts:
|
|
return ""
|
|
items = "".join(f"<li>{esc(a)}</li>" for a in alerts[:3])
|
|
return f'''
|
|
<div class="alert alert-warning py-2 small mb-0">
|
|
<strong>Verificação de consistência operacional</strong>
|
|
<ul class="mb-0 mt-1">{items}</ul>
|
|
</div>
|
|
'''
|
|
|
|
|
|
def _derived_timeline_html(opportunity_id: str) -> str:
|
|
# Fallback timeline based on current documents/items/tasks when no audit events exist.
|
|
try:
|
|
from sqlalchemy import text
|
|
from app.db import engine
|
|
with engine.begin() as conn:
|
|
docs = conn.execute(text("""
|
|
SELECT document_kind, document_number, total_amount, status, created_at
|
|
FROM commercial_documents
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
ORDER BY created_at DESC
|
|
LIMIT 3
|
|
"""), {"opportunity_id": str(opportunity_id)}).mappings().all()
|
|
item_count = conn.execute(text("""
|
|
SELECT COUNT(*)::int
|
|
FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": str(opportunity_id)}).scalar() or 0
|
|
except Exception:
|
|
docs, item_count = [], 0
|
|
|
|
items = ""
|
|
for doc in docs:
|
|
title = "Documento Jasmin importado"
|
|
detail = f"{doc.get('document_number') or 'documento'} · {money_html(doc.get('total_amount') or 0)}"
|
|
items += f'''
|
|
<div class="cf-timeline-item">
|
|
<div class="small text-secondary">{esc(fmt_dt(doc.get('created_at')))}<div class="mt-1"><span class="badge text-bg-light">derivado</span></div></div>
|
|
<div><div class="d-flex flex-wrap align-items-center gap-2"><strong>{esc(title)}</strong>{operation_status_badge(str(doc.get('status') or 'created'))}</div><div class="small text-secondary text-break">{esc(detail)}</div></div>
|
|
</div>
|
|
'''
|
|
if item_count and not docs:
|
|
items += f'''
|
|
<div class="cf-timeline-item">
|
|
<div class="small text-secondary">—<div class="mt-1"><span class="badge text-bg-light">derivado</span></div></div>
|
|
<div><strong>Produtos na oportunidade</strong><div class="small text-secondary">{esc(item_count)} linha(s) comerciais associadas.</div></div>
|
|
</div>
|
|
'''
|
|
return items
|
|
|
|
def _opportunity_query_string(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str:
|
|
parts = []
|
|
if q:
|
|
parts.append(f"q={esc(q)}")
|
|
if status and status != "open":
|
|
parts.append(f"status={esc(status)}")
|
|
if scope and scope != "all":
|
|
parts.append(f"scope={esc(scope)}")
|
|
if limit and int(limit) != 300:
|
|
parts.append(f"limit={int(limit)}")
|
|
return ("?" + "&".join(parts)) if parts else ""
|
|
|
|
|
|
def _opportunity_visible_set(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> tuple[list[dict], dict, list[tuple[str, str, object]]]:
|
|
if (status or "open") == "closed":
|
|
status = "open"
|
|
opportunities = list_opportunities(q=q, status=status or "open", limit=limit)
|
|
visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] != "closed"]
|
|
grouped = {key: [] for key, _label, _stages in visible_board_columns}
|
|
visible = []
|
|
for opportunity in opportunities:
|
|
if _is_noise_opportunity(opportunity):
|
|
continue
|
|
key = _opportunity_board_column_for_opportunity(opportunity)
|
|
if key == "closed":
|
|
continue
|
|
if scope and scope not in {"all", "open"}:
|
|
if scope == "blocked":
|
|
pending = int(opportunity.get("pending_task_count") or 0)
|
|
if pending <= 0 and not opportunity_customer_mismatch(opportunity):
|
|
continue
|
|
elif key != scope:
|
|
continue
|
|
visible.append(opportunity)
|
|
grouped.setdefault(key, []).append(opportunity)
|
|
return visible, grouped, visible_board_columns
|
|
|
|
|
|
def _compact_identity(value: object) -> str:
|
|
value = compact_text(str(value or "").strip(), 42)
|
|
if value.casefold() in {"", "geral", "cliente", "contacto"} or value.isdigit():
|
|
return ""
|
|
return value
|
|
|
|
|
|
def _opportunity_card_identity(opp: dict) -> tuple[str, str]:
|
|
fiscal = _compact_identity(opp.get("linked_customer_name"))
|
|
contact_name = _compact_identity(opp.get("customer_name"))
|
|
contact_email = _compact_identity(opp.get("customer_email"))
|
|
if fiscal:
|
|
subtitle = contact_email or contact_name
|
|
return fiscal, (f"Contacto: {subtitle}" if subtitle and subtitle != fiscal else "")
|
|
if contact_name:
|
|
return contact_name, contact_email if contact_email and contact_email != contact_name else ""
|
|
if contact_email:
|
|
return contact_email, ""
|
|
conversation = str(opp.get("conversation_id") or "").strip()
|
|
return "Contacto sem identificação", (f"Conversa Chatwoot #{conversation}" if conversation else "")
|
|
|
|
|
|
# Legacy regression context: cta_label = "Concluir tarefa pendente" if pending else "Ver oportunidade".
|
|
# v4.8.5 replaces that generic CTA with a specific action label.
|
|
def _opportunity_card_next_action(opp: dict) -> str:
|
|
if int(opp.get("pending_task_count") or 0) > 0:
|
|
action_code = str(opp.get("last_action_code") or "").strip()
|
|
return primary_action_label(action_code, fallback="Ver tarefa pendente")
|
|
return opportunity_next_action_text(opp)
|
|
|
|
|
|
def _is_noise_opportunity(opp: dict) -> bool:
|
|
"""Hide old bounce/NDR opportunities from the commercial board.
|
|
|
|
Operations already hides technical mailbox noise; the opportunity board must
|
|
use the same guard so legacy Mail Delivery/postmaster opportunities do not
|
|
keep appearing as commercial work.
|
|
"""
|
|
return is_noise_operation_item({
|
|
"customer_name": opp.get("customer_name"),
|
|
"contact_display_name": opp.get("customer_name"),
|
|
"fiscal_customer_name": opp.get("linked_customer_name"),
|
|
"message_subject": opp.get("product_interest"),
|
|
"title": opp.get("title"),
|
|
"detail": opp.get("product_interest"),
|
|
"request_text": (opp.get("metadata") or {}).get("request_text") if isinstance(opp.get("metadata"), dict) else "",
|
|
"source_system": opp.get("source_system"),
|
|
"action_code": opp.get("last_action_code"),
|
|
"no_opportunity_reason": (opp.get("metadata") or {}).get("no_opportunity_reason") if isinstance(opp.get("metadata"), dict) else "",
|
|
"status": opp.get("status"),
|
|
})
|
|
|
|
|
|
def _opportunity_board_column_for_opportunity(opp: dict) -> str:
|
|
"""Choose a visual board column from stage plus next pending action.
|
|
|
|
The stored stage remains unchanged. This only avoids showing opportunities
|
|
with a financial/logistics next step under the initial "Pedidos" column.
|
|
"""
|
|
action_code = str(opp.get("last_action_code") or "").upper().strip()
|
|
if int(opp.get("pending_task_count") or 0) > 0:
|
|
if action_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT"}:
|
|
return "payment"
|
|
if action_code in {"PREPARE_ORDER", "CREATE_SHIPMENT"}:
|
|
return "operations"
|
|
return _opportunity_board_column_for_stage(opp.get("stage"))
|
|
|
|
|
|
def _render_opportunity_card(opp: dict) -> str:
|
|
oid = str(opp.get("id") or "")
|
|
title, subtitle = _opportunity_card_identity(opp)
|
|
subject = compact_text(opp.get("product_interest") or opp.get("title") or "Pedido comercial", 64)
|
|
next_action = compact_text(_opportunity_card_next_action(opp), 72)
|
|
pending = int(opp.get("pending_task_count") or 0)
|
|
blockers = opportunity_blockers(opp)
|
|
cta_label = next_action if pending else "Ver oportunidade"
|
|
cta_class = "btn-primary" if pending else "btn-outline-primary"
|
|
blocker_html = blocker_alert_html(blockers, empty_text="") if blockers else ""
|
|
subtitle_html = f'<div class="cf-opportunity-card-subtitle">{esc(subtitle)}</div>' if subtitle else ""
|
|
blocker_class = " has-blocker" if blockers else ""
|
|
return f"""
|
|
<article class="cf-opportunity-card-clean cf-opportunity-card-compact{blocker_class}">
|
|
<a class="cf-opportunity-card-main text-decoration-none text-reset" href="/opportunities/{esc(oid)}">
|
|
<div class="cf-opportunity-card-title">{esc(title)}</div>
|
|
{subtitle_html}
|
|
<div class="cf-opportunity-card-subject">{esc(subject)}</div>
|
|
{blocker_html}
|
|
<div class="cf-opportunity-next-action">
|
|
<span>Próxima ação</span>
|
|
<strong>{esc(next_action)}</strong>
|
|
</div>
|
|
</a>
|
|
<a class="btn btn-sm {esc(cta_class)} w-100 cf-opportunity-card-cta" href="/opportunities/{esc(oid)}">{esc(cta_label)}</a>
|
|
</article>
|
|
"""
|
|
|
|
|
|
def render_opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str:
|
|
visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit)
|
|
board_html = ""
|
|
for key, label, _stages in visible_board_columns:
|
|
cards = "".join(_render_opportunity_card(opp) for opp in grouped.get(key, []))
|
|
if not cards:
|
|
cards = '<div class="border border-dashed rounded p-4 text-center text-secondary bg-white">Sem oportunidades nesta etapa.</div>'
|
|
board_html += f"""
|
|
<section class="cf-opportunity-stage" id="stage-{esc(key)}">
|
|
<header class="cf-opportunity-stage-header">
|
|
<strong>{esc(label)}</strong>
|
|
<span>{len(grouped.get(key, []))}</span>
|
|
</header>
|
|
<div class="cf-opportunity-stage-body">{cards}</div>
|
|
</section>
|
|
"""
|
|
return f"""
|
|
<div id="opportunities-board" class="cf-live-panel cf-opportunities-board" aria-live="polite">
|
|
<div class="cf-opportunities-board-meta">
|
|
<span>{len(visible_opportunities)} resultado(s)</span>
|
|
<span class="htmx-indicator" id="opportunities-loading">A atualizar…</span>
|
|
</div>
|
|
<div class="cf-opportunities-board-scroll">
|
|
<div class="cf-opportunities-board-grid">{board_html}</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
@router.get("/opportunities/partials/board", response_class=HTMLResponse)
|
|
async def opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300):
|
|
return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit))
|
|
|
|
|
|
@router.get("/opportunities", response_class=HTMLResponse)
|
|
@router.get("/oportunidades", response_class=HTMLResponse)
|
|
async def opportunities_page(
|
|
request: Request,
|
|
q: Optional[str] = None,
|
|
status: Optional[str] = "open",
|
|
scope: Optional[str] = "all",
|
|
limit: int = 300,
|
|
):
|
|
# Quadro operacional em Bootstrap 5. v4.7.4 adds an HTMX board partial
|
|
# while preserving the same opportunity query and card semantics.
|
|
if (status or "open") == "closed":
|
|
status = "open"
|
|
|
|
visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit)
|
|
total_open = sum(1 for opp in visible_opportunities if str(opp.get("status") or "") == "open")
|
|
total_pending = sum(int(opp.get("pending_task_count") or 0) for opp in visible_opportunities)
|
|
total_value = sum(float(opp.get("value_amount") or 0) for opp in visible_opportunities)
|
|
attention = [opp for opp in visible_opportunities if int(opp.get("pending_task_count") or 0) > 0]
|
|
|
|
if is_htmx(request):
|
|
return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit))
|
|
|
|
status_options = ""
|
|
for value, label in [("open", "Abertas"), ("all", "Todas")]:
|
|
selected = "selected" if (status or "open") == value else ""
|
|
status_options += f'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
|
|
|
stage_tabs = ""
|
|
filters = [("all", "Todas"), ("new", "Novas"), ("quote", "Orçamento enviado"), ("proforma", "Pró-forma enviada"), ("payment", "Pagamento pendente"), ("shipment", "Enviadas"), ("blocked", "Bloqueadas")]
|
|
for key, label in filters:
|
|
href = "/opportunities" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit)
|
|
partial_href = "/opportunities/partials/board" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit)
|
|
active = "btn-primary" if (scope or "all") == key else "btn-outline-secondary"
|
|
stage_tabs += f'<a class="btn btn-sm {active}" href="{esc(href)}" hx-get="{esc(partial_href)}" hx-target="#opportunities-board" hx-swap="outerHTML" hx-push-url="{esc(href)}" hx-indicator="#opportunities-loading">{esc(label)}</a>'
|
|
|
|
body = f"""
|
|
<section class="row g-3 row-cols-1 row-cols-sm-2 row-cols-xxl-4 mb-3">
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=open"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Abertas</div><div class="h4 fw-bold mb-1">{total_open}</div><div class="small">em acompanhamento</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=open"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Com tarefa</div><div class="h4 fw-bold mb-1">{len(attention)}</div><div class="small">requerem ação</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=open"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Tarefas pendentes</div><div class="h4 fw-bold mb-1">{total_pending}</div><div class="small">ligadas a vendas</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=all"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Valor estimado</div><div class="h4 fw-bold mb-1">{money_html(total_value)}</div><div class="small">lista atual</div></div></a></div>
|
|
</section>
|
|
|
|
<section class="card cf-card mb-3"><div class="card-body"><form class="row g-3 align-items-end" method="get" action="/opportunities" hx-get="/opportunities/partials/board" hx-target="#opportunities-board" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#opportunities-loading"><div class="col-lg-7"><label class="form-label small fw-bold text-secondary">Procurar oportunidade</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="cliente, email, produto, conversa..."></div><div class="col-lg-2"><label class="form-label small fw-bold text-secondary">Estado</label><select class="form-select" name="status">{status_options}</select><input type="hidden" name="scope" value="{esc(scope or 'all')}"></div><div class="col-lg-3 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/opportunities">Limpar</a></div></form></div></section>
|
|
|
|
<section class="card cf-card mb-3"><div class="card-body d-flex flex-wrap gap-2 align-items-center"><strong class="me-1">Filtros:</strong>{stage_tabs}</div></section>
|
|
|
|
<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 mb-1">Quadro de oportunidades</h2><div class="small text-secondary">Cards por etapa, com identificação clara, assunto, próxima ação e bloqueios relevantes. Filtros atualizam por HTMX.</div></div>
|
|
<span class="badge rounded-pill text-bg-light">{len(visible_opportunities)} resultado(s)</span>
|
|
</div>
|
|
<div class="p-3">{render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)}</div>
|
|
</div>
|
|
</section>
|
|
"""
|
|
return layout("Oportunidades", "Pipeline comercial com foco na próxima ação", body, active="opportunities")
|
|
|
|
|
|
@router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse)
|
|
async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = None):
|
|
opportunity = get_opportunity(opportunity_id)
|
|
if not opportunity:
|
|
return layout("Oportunidade não encontrada", "Pipeline comercial", '<section class="cf-empty">Oportunidade não encontrada.</section>', "opportunities")
|
|
|
|
tasks = list_opportunity_tasks(opportunity_id, limit=100)
|
|
events = list_opportunity_events(opportunity_id, limit=100)
|
|
stage = str(opportunity.get("stage") or "NEW_LEAD")
|
|
pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"]
|
|
next_task = pending_tasks[0] if pending_tasks else None
|
|
opportunity_items = list_opportunity_items(opportunity_id)
|
|
active_products = list_products(active="true", limit=200)
|
|
try:
|
|
from app.commercial_service import list_commercial_documents
|
|
linked_documents = list_commercial_documents(opportunity_id=opportunity_id, limit=8)
|
|
except Exception:
|
|
linked_documents = []
|
|
primary_document = next(
|
|
(
|
|
doc for doc in linked_documents
|
|
if str(doc.get("document_kind") or "") == "invoice"
|
|
and str(doc.get("role") or "current") in {"current", "accepted"}
|
|
and bool(doc.get("is_primary", True))
|
|
),
|
|
next(
|
|
(
|
|
doc for doc in linked_documents
|
|
if str(doc.get("role") or "current") in {"current", "accepted"}
|
|
and bool(doc.get("is_primary", True))
|
|
),
|
|
linked_documents[0] if linked_documents else None,
|
|
),
|
|
)
|
|
opportunity_items_total = sum(
|
|
float(item.get("total_price") or 0)
|
|
for item in opportunity_items
|
|
if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL"}
|
|
)
|
|
document_value = float(primary_document.get("total_amount") or primary_document.get("amount") or 0) if primary_document else 0
|
|
estimated_value = document_value or opportunity_items_total or float(opportunity.get("value_amount") or 0)
|
|
value_source = "documento principal" if document_value else ("linhas atuais" if opportunity_items_total else "oportunidade")
|
|
operation_snapshot = get_operation_snapshot(opportunity_id)
|
|
try:
|
|
opportunity_communications = list_communications_for_opportunity(opportunity_id, limit=12)
|
|
except Exception:
|
|
opportunity_communications = []
|
|
notice_html = f'<div class="alert alert-info">{esc(notice)}</div>' if notice else ''
|
|
metadata = opportunity.get("metadata") if isinstance(opportunity.get("metadata"), dict) else {}
|
|
record_mode = str(metadata.get("clientflow_record_mode") or "")
|
|
legacy_mode = record_mode in {"reconstructed_invoice_review", "historical_reconstructed", "legacy_review"}
|
|
legacy_notice_html = ""
|
|
if legacy_mode:
|
|
legacy_notice_html = (
|
|
'<div class="alert alert-warning border-0">'
|
|
'<strong>Registo antigo/reconstruído.</strong><br>'
|
|
'A oportunidade foi normalizada a partir de documentos já existentes. '
|
|
'Valida pagamento, valor e linhas antes de executar novas ações.'
|
|
'</div>'
|
|
)
|
|
|
|
try:
|
|
next_action = get_opportunity_next_action(opportunity_id)
|
|
except Exception:
|
|
next_action = {}
|
|
if next_action:
|
|
primary_action = next_action.get("label") or action_label(next_action.get("action_code"))
|
|
primary_note = next_action.get("description") or "Continuar a próxima ação recomendada."
|
|
target_url = next_action.get("target_url") or (f"/tasks/{next_task.get('id')}" if next_task else "/tasks?status=pending")
|
|
button_label = "Abrir tarefa" if str(target_url).startswith("/tasks/") else "Continuar"
|
|
if next_action.get("action_code") == "VALIDATE_FISCAL_CUSTOMER":
|
|
primary_button = f'<form method="post" action="/opportunities/{esc(opportunity_id)}/fiscal-enrich"><button class="btn btn-primary" type="submit">Enriquecer cliente fiscal</button></form>'
|
|
else:
|
|
primary_button = f'<a class="btn btn-primary" href="{esc(target_url)}">{esc(button_label)}</a>'
|
|
elif next_task:
|
|
primary_action = action_label(next_task.get("action_code"))
|
|
primary_note = next_task.get("note") or next_task.get("action") or "Abrir tarefa pendente para continuar."
|
|
primary_button = f'<a class="btn btn-primary" href="/tasks/{esc(next_task.get("id"))}">Abrir tarefa</a>'
|
|
else:
|
|
primary_action = opportunity_next_action_text(opportunity)
|
|
primary_note = "Não existe tarefa pendente ligada. Atualiza o estado ou acompanha a oportunidade."
|
|
primary_button = '<a class="btn btn-outline-primary" href="/tasks?status=pending">Ver tarefas</a>'
|
|
|
|
task_rows = ""
|
|
for task in tasks[:8]:
|
|
task_rows += f'''
|
|
<tr>
|
|
<td><a class="cf-row-link" href="/tasks/{esc(task.get('id'))}">{esc(action_label(task.get('action_code')))}</a><div class="small text-secondary">{esc(compact_text(task.get('note') or task.get('action') or '', 70))}</div></td>
|
|
<td>{route_badge(task.get('route'))}</td>
|
|
<td>{status_badge(task.get('status'))}</td>
|
|
<td>{esc(fmt_dt(task.get('created_at')))}</td>
|
|
</tr>
|
|
'''
|
|
if not task_rows:
|
|
task_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem tarefas associadas.</td></tr>'
|
|
|
|
communication_rows = ""
|
|
for communication in opportunity_communications:
|
|
action = classification_action(communication.get("classification"))
|
|
communication_rows += f'''
|
|
<tr>
|
|
<td><a class="cf-row-link" href="/communications/{esc(communication.get('id'))}">{esc(communication.get('subject') or 'Sem assunto')}</a><div class="small text-secondary text-break">{esc(communication.get('sender_name') or communication.get('sender_email') or '—')}</div></td>
|
|
<td><span class="cf-chip {esc(action.get('chip'))}">{esc(communication.get('classification') or 'por classificar')}</span></td>
|
|
<td>{status_badge(communication.get('status'))}</td>
|
|
<td class="text-secondary small">{esc(fmt_dt(communication.get('created_at')))}</td>
|
|
</tr>
|
|
'''
|
|
if not communication_rows:
|
|
conv = str(opportunity.get("conversation_id") or "").strip()
|
|
if conv:
|
|
communication_rows = f'''
|
|
<tr class="table-warning">
|
|
<td><strong>Conversa Chatwoot #{esc(conv)}</strong><div class="small text-secondary">Ainda não há mensagens indexadas/ligadas nesta oportunidade.</div></td>
|
|
<td><span class="cf-chip cf-chip-orange">por sincronizar</span></td>
|
|
<td><span class="cf-chip cf-chip-gray">sem ligação local</span></td>
|
|
<td class="text-secondary small">—</td>
|
|
</tr>
|
|
'''
|
|
else:
|
|
communication_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem comunicações associadas à oportunidade.</td></tr>'
|
|
|
|
timeline_items = ""
|
|
try:
|
|
unified_timeline = list_unified_opportunity_timeline(opportunity_id, limit=14)
|
|
except Exception:
|
|
unified_timeline = []
|
|
for event in unified_timeline:
|
|
status = event.get("status")
|
|
status_html = status_badge(status) if status else ""
|
|
source = event.get("source") or "event"
|
|
detail = compact_text(event.get("detail") or "", 140)
|
|
timeline_items += f'''
|
|
<div class="cf-timeline-item">
|
|
<div class="small text-secondary">{esc(fmt_dt(event.get('created_at')))}<div class="mt-1"><span class="badge text-bg-light">{esc(source)}</span></div></div>
|
|
<div><div class="d-flex flex-wrap align-items-center gap-2"><strong>{esc(event.get('title') or 'Evento')}</strong>{status_html}</div><div class="small text-secondary text-break">{esc(detail or '—')}</div></div>
|
|
</div>
|
|
'''
|
|
if not timeline_items:
|
|
timeline_items = _derived_timeline_html(opportunity_id)
|
|
if not timeline_items:
|
|
timeline_items = '<div class="text-secondary">Sem eventos registados.</div>'
|
|
|
|
stage_options = ""
|
|
for value, label in OPPORTUNITY_STAGE_LABELS.items():
|
|
selected = "selected" if value == opportunity.get("stage") else ""
|
|
stage_options += f'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
|
|
|
customer_name = opportunity_customer_name(opportunity)
|
|
contact_name = opportunity_contact_name(opportunity)
|
|
customer_email = opportunity.get("customer_email") or ""
|
|
customer_phone = opportunity.get("customer_phone") or ""
|
|
conversation = opportunity.get("conversation_id") or "—"
|
|
# v4.6.2: não mostrar aviso por divergência de nome. Contacto pessoal e
|
|
# cliente fiscal/empresa podem ser diferentes e ainda assim estar corretos.
|
|
customer_mismatch_alert = ""
|
|
|
|
linked_customer = None
|
|
customer_options = '<option value="">Selecionar cliente...</option>'
|
|
try:
|
|
from app.commercial_service import get_customer_for_opportunity, list_customers
|
|
linked_customer = get_customer_for_opportunity(opportunity_id)
|
|
for c in list_customers(limit=150):
|
|
selected = "selected" if linked_customer and str(c.get("id")) == str(linked_customer.get("id")) else ""
|
|
label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or 'sem NIF'}"
|
|
customer_options += f'<option value="{esc(c.get("id"))}" {selected}>{esc(label)}</option>'
|
|
except Exception:
|
|
linked_customer = None
|
|
|
|
fiscal_suggestions_html = _render_fiscal_suggestions(opportunity_id, linked_customer)
|
|
email_identity_html = _render_email_identity_review(opportunity_id, linked_customer)
|
|
|
|
fiscal_customer = opportunity_context_customer(opportunity, linked_customer)
|
|
fiscal_customer_href = f"/customers/{esc(fiscal_customer.get('id'))}" if fiscal_customer and fiscal_customer.get("id") else ""
|
|
fiscal_contact_html = fiscal_contact_panel_html(
|
|
fiscal_customer=fiscal_customer,
|
|
contact_name=contact_name,
|
|
contact_email=customer_email,
|
|
contact_phone=customer_phone,
|
|
conversation_id=opportunity.get("conversation_id"),
|
|
contact_id=opportunity.get("contact_id"),
|
|
customer_href=fiscal_customer_href,
|
|
)
|
|
next_action_code = (next_action.get("action_code") if isinstance(next_action, dict) else None) or opportunity.get("last_action_code")
|
|
current_blockers = opportunity_blockers(opportunity, linked_customer, action_code=next_action_code)
|
|
document_already_issued = bool(
|
|
primary_document
|
|
or linked_documents
|
|
or stage in {"QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", "WON"}
|
|
)
|
|
blockers_html = (
|
|
'<div class="alert alert-warning border-0 mb-0"><strong>Avisos para revisão</strong>'
|
|
+ '<ul class="mb-0 mt-2">'
|
|
+ ''.join(f"<li>{esc(item)}</li>" for item in current_blockers)
|
|
+ '</ul><div class="small text-secondary mt-2">Existe documento emitido/ligado; estes dados devem ser revistos para próximos documentos ou correção administrativa.</div></div>'
|
|
if current_blockers and document_already_issued
|
|
else blocker_alert_html(current_blockers)
|
|
)
|
|
fiscal_readiness_html = readiness_checklist_html(
|
|
title="Prontidão para documentos",
|
|
missing=fiscal_customer_missing_fields(fiscal_customer),
|
|
ok_text="Cliente fiscal pronto para orçamento, pró-forma ou fatura.",
|
|
blocked_text=("Dados fiscais incompletos no ClientFlow; rever para próximos documentos." if document_already_issued else "Dados fiscais incompletos no ClientFlow; rever antes de emitir novo documento."),
|
|
)
|
|
shipment_readiness_html = readiness_checklist_html(
|
|
title="Prontidão para envio",
|
|
missing=shipment_missing_fields(fiscal_customer, opportunity),
|
|
ok_text="Dados mínimos de envio completos.",
|
|
blocked_text="Envio deve aguardar correção destes dados.",
|
|
)
|
|
consistency_alert_html = _opportunity_consistency_alert_html(opportunity, tasks, opportunity_items, opportunity_id)
|
|
if primary_document:
|
|
document_label = commercial_document_display_number(primary_document, fallback="número por atualizar")
|
|
document_kind = {
|
|
"quotation": "Orçamento",
|
|
"proforma": "Pró-forma",
|
|
"invoice": "Fatura",
|
|
}.get(str(primary_document.get("document_kind") or ""), "Documento")
|
|
document_state = f"{document_kind} · {document_label}"
|
|
document_chip = '<span class="cf-chip cf-chip-green">ligado</span>'
|
|
else:
|
|
document_state = "Sem documento principal"
|
|
document_chip = '<span class="cf-chip cf-chip-orange">pendente</span>'
|
|
fiscal_state = (linked_customer.get("name") if linked_customer else "Por associar")
|
|
fiscal_chip = '<span class="cf-chip cf-chip-green">validado</span>' if linked_customer else '<span class="cf-chip cf-chip-orange">bloqueia documentos</span>'
|
|
task_state = f"{len(pending_tasks)} pendente(s)" if pending_tasks else "Sem tarefas pendentes"
|
|
task_chip = '<span class="cf-chip cf-chip-orange">requer ação</span>' if pending_tasks else '<span class="cf-chip cf-chip-green">limpo</span>'
|
|
operator_summary_html = f'''
|
|
<section class="card cf-card cf-opp-operator-card">
|
|
<div class="card-body p-4">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
|
<div><h2 class="cf-section-title mb-1">Mapa operacional</h2><div class="small text-secondary">Leitura rápida do processo: cliente fiscal, documento principal, task e próxima ação.</div></div>
|
|
<a class="btn btn-sm btn-outline-primary" href="/reconciliation?status=open">Ver reconciliação</a>
|
|
</div>
|
|
<div class="cf-opp-operator-grid">
|
|
<div><span>Cliente fiscal</span><strong>{esc(fiscal_state)}</strong>{fiscal_chip}</div>
|
|
<div><span>Documento principal</span><strong>{esc(document_state)}</strong>{document_chip}</div>
|
|
<div><span>Tasks</span><strong>{esc(task_state)}</strong>{task_chip}</div>
|
|
<div><span>Decisão seguinte</span><strong>{esc(primary_action)}</strong><span class="cf-chip cf-chip-blue">{esc(next_action.get('action_code') or opportunity.get('last_action_code') or 'FOLLOW_UP')}</span></div>
|
|
</div>
|
|
<details class="cf-advanced-actions mt-3">
|
|
<summary>Ações avançadas</summary>
|
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
|
<a class="btn btn-sm btn-outline-secondary" href="#documentos">Documentos Jasmin</a>
|
|
<a class="btn btn-sm btn-outline-secondary" href="#tecnico">Detalhes técnicos</a>
|
|
<a class="btn btn-sm btn-outline-secondary" href="/tasks?status=pending&q={esc(opportunity_id)}">Tasks desta oportunidade</a>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
'''
|
|
|
|
technical_html = f'''
|
|
<div class="row g-3">
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">ID</div><code>{esc(opportunity_id)}</code></div></div>
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Conversa</div><code>{esc(conversation)}</code></div></div>
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Última action</div><code>{esc(opportunity.get('last_action_code') or '—')}</code></div></div>
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Atualizada</div><strong>{esc(fmt_dt(opportunity.get('updated_at')))}</strong></div></div>
|
|
</div>
|
|
'''
|
|
|
|
# "Bloqueios atuais" permanece como conceito de UI/teste, mas o título duplicado foi removido.
|
|
body = f'''
|
|
<style>
|
|
.cf-opp-detail-grid {{ display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,340px); gap:1rem; align-items:start; max-width:100%; }}
|
|
.cf-opp-detail-grid > * {{ min-width:0; }}
|
|
.cf-opp-hero {{ background:linear-gradient(135deg,#eff6ff,#fff); border:1px solid #bfdbfe; border-radius:1.25rem; box-shadow:var(--cf-shadow); }}
|
|
.cf-opp-action {{ background:#fff; border:1px solid #dbeafe; border-radius:1rem; padding:1rem; }}
|
|
.cf-opp-facts {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.75rem; }}
|
|
.cf-opp-fact {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; }}
|
|
.cf-opp-fact span {{ color:#64748b; font-size:.74rem; font-weight:900; text-transform:uppercase; }}
|
|
.cf-opp-fact strong {{ display:block; margin-top:.2rem; }}
|
|
.cf-opp-operator-card {{ border-left:4px solid var(--cf-primary); }}
|
|
.cf-opp-operator-grid {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.75rem; }}
|
|
.cf-opp-operator-grid>div {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; display:grid; gap:.35rem; align-content:start; }}
|
|
.cf-opp-operator-grid span:first-child {{ color:#64748b; font-size:.72rem; font-weight:900; text-transform:uppercase; letter-spacing:.04em; }}
|
|
.cf-opp-operator-grid strong {{ overflow-wrap:anywhere; }}
|
|
.cf-advanced-actions summary {{ cursor:pointer; font-weight:800; color:var(--cf-primary); }}
|
|
.cf-opp-side-sticky {{ position:sticky; top:1rem; display:grid; gap:1rem; min-width:0; }}
|
|
.cf-opp-hero h1 {{ overflow-wrap:anywhere; }}
|
|
@media (max-width: 1400px) {{ .cf-opp-detail-grid {{ grid-template-columns:1fr; }} .cf-opp-side-sticky {{ position:static; }} }}
|
|
@media (max-width: 1050px) {{ .cf-opp-operator-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} }}
|
|
@media (max-width: 720px) {{ .cf-opp-facts,.cf-opp-operator-grid {{ grid-template-columns:1fr; }} }}
|
|
</style>
|
|
|
|
<a class="cf-row-link d-inline-flex mb-3" href="/opportunities">← Voltar a oportunidades</a>
|
|
{notice_html}
|
|
{legacy_notice_html}
|
|
{customer_mismatch_alert}
|
|
{consistency_alert_html}
|
|
<nav class="cf-ia-tabs" aria-label="Secções da oportunidade">
|
|
<a href="#resumo">Resumo</a>
|
|
<a href="#produtos">Produtos</a>
|
|
<a href="#documentos">Documentos</a>
|
|
<a href="#tasks">Tasks</a>
|
|
<a href="#comunicacoes">Mensagens</a>
|
|
<a href="#outbox">Outbox</a>
|
|
<a href="#timeline">Timeline</a>
|
|
<a href="#tecnico">Técnico</a>
|
|
</nav>
|
|
|
|
<div class="cf-opp-detail-grid">
|
|
<main class="d-grid gap-3">
|
|
<section class="cf-opp-hero p-4"><div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary fw-bold mb-1">Oportunidade</div><h1 class="h3 fw-bold mb-2">{esc(opportunity.get('title') or 'Oportunidade')}</h1><div class="text-secondary">{esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}</div></div><div class="d-flex flex-wrap gap-2">{opportunity_stage_badge(stage)}{opportunity_priority_chip(opportunity)}</div></div><div class="cf-opp-action"><div class="small text-secondary fw-bold text-uppercase">Próxima ação</div><div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mt-1"><div><h2 class="h4 fw-bold mb-1">{esc(primary_action)}</h2><div class="text-secondary">{esc(primary_note)}</div></div><div>{primary_button}</div></div></div></section>
|
|
|
|
{operator_summary_html}
|
|
|
|
{f'<section class="card cf-card"><div class="card-body p-4">{blockers_html}</div></section>' if current_blockers else ''}
|
|
|
|
{fiscal_contact_html}
|
|
|
|
<div class="row g-3"><div class="col-xl-6">{fiscal_readiness_html}</div><div class="col-xl-6">{shipment_readiness_html}</div></div>
|
|
|
|
<section id="resumo" class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Resumo essencial</h2><div class="cf-opp-facts"><div class="cf-opp-fact"><span>{'Valor principal' if document_value else ('Valor reconstruído' if legacy_mode else 'Valor estimado')}</span><strong>{money_html(estimated_value)}</strong><div class="small text-secondary">{esc(value_source)}</div></div><div class="cf-opp-fact"><span>Tarefas pendentes</span><strong>{len(pending_tasks)}</strong></div><div class="cf-opp-fact"><span>Atualizada</span><strong>{esc(fmt_dt(opportunity.get('updated_at')))}</strong></div></div></div></section>
|
|
|
|
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Pipeline</h2>{stage_progress_html(stage)}</div></section>
|
|
|
|
{operation_cockpit_html(opportunity_id, opportunity, operation_snapshot)}
|
|
|
|
<div id="outbox">{opportunity_integrations_panel_html(opportunity_id)}</div>
|
|
|
|
<div id="documentos">{jasmin_documents_html(opportunity_id)}</div>
|
|
|
|
<div id="produtos">{opportunity_products_panel_html(opportunity_id)}</div>
|
|
|
|
<section id="tasks" class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Tasks relacionadas</h2><div class="small text-secondary">Ações humanas já criadas para esta oportunidade.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Fila</th><th>Estado</th><th>Criada</th></tr></thead><tbody>{task_rows}</tbody></table></div></div></section>
|
|
|
|
<section id="comunicacoes" class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom d-flex justify-content-between align-items-center"><div><h2 class="cf-section-title">Mensagens Chatwoot</h2><div class="small text-secondary">Mensagens relevantes ligadas a esta oportunidade. A resposta continua no Chatwoot.</div></div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Mensagem</th><th>Classificação</th><th>Estado</th><th>Recebida</th></tr></thead><tbody>{communication_rows}</tbody></table></div></div></section>
|
|
|
|
<section id="timeline" class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Timeline recente</h2><div class="cf-timeline">{timeline_items}</div></div></section>
|
|
|
|
<details id="tecnico" class="card cf-card"><summary class="card-body p-4 fw-bold text-primary" style="cursor:pointer">Ver detalhes técnicos e edição avançada</summary><div class="card-body border-top p-4 d-grid gap-3">{technical_html}</div></details>
|
|
</main>
|
|
|
|
<aside class="cf-opp-side-sticky">
|
|
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Associação fiscal</h2>
|
|
<div class="small text-secondary fw-bold text-uppercase">Cliente fiscal</div>
|
|
{f'<strong>{esc(linked_customer.get("name") or "Cliente")}</strong><div class="small text-secondary">NIF {esc(linked_customer.get("tax_id") or "—")}</div><div class="small text-secondary text-break mb-3">{esc(linked_customer.get("email") or "—")}</div><a class="btn btn-outline-secondary w-100 mb-3" href="/customers/{esc(linked_customer.get("id"))}">Ver ficha de cliente</a>' if linked_customer else f'<strong>Por associar</strong><div class="alert alert-warning py-2 small mt-2">Sem cliente fiscal associado. Associa uma ficha antes de emitir documentos Jasmin.</div>'}
|
|
<div class="small text-secondary fw-bold text-uppercase mt-3">Contacto Chatwoot</div>
|
|
<div class="fw-semibold">{esc(contact_name)}</div><div class="small text-secondary text-break">{esc(customer_email or "—")}</div><div class="small text-secondary mb-3">{esc(customer_phone or "")}</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/customer" class="d-grid gap-2">
|
|
<select class="form-select" name="customer_id">{customer_options}</select>
|
|
<button class="btn btn-outline-primary" type="submit">Associar cliente</button>
|
|
</form>
|
|
{email_identity_html}
|
|
{fiscal_suggestions_html}
|
|
<div class="d-grid gap-2 mt-2">{f'<a class="btn btn-outline-primary" href="/tasks/{esc(next_task.get("id"))}">Abrir tarefa pendente</a>' if next_task else ''}</div>
|
|
</div></section>
|
|
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Alterar fase</h2><form method="post" action="/opportunities/{esc(opportunity_id)}/stage" class="d-grid gap-2"><select class="form-select" name="stage">{stage_options}</select><textarea class="form-control" name="note" rows="3" placeholder="Nota opcional"></textarea><button class="btn btn-primary" type="submit">Guardar fase</button></form></div></section>
|
|
</aside>
|
|
</div>
|
|
'''
|
|
return layout(str(opportunity.get("title") or "Oportunidade"), "Detalhe comercial com informação essencial", body, "opportunities")
|
|
|
|
|
|
@router.get("/opportunities/{opportunity_id}/partials/jasmin-documents", response_class=HTMLResponse)
|
|
async def opportunity_jasmin_documents_partial(opportunity_id: str):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id))
|
|
|
|
|
|
@router.get("/opportunities/{opportunity_id}/partials/products", response_class=HTMLResponse)
|
|
async def opportunity_products_partial(opportunity_id: str):
|
|
return HTMLResponse(opportunity_products_panel_html(opportunity_id))
|
|
|
|
|
|
@router.post("/commercial-documents/{document_id}/refresh")
|
|
async def commercial_document_refresh(document_id: str, request: Request):
|
|
form = await request.form()
|
|
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
|
try:
|
|
from app.jasmin_service import refresh_commercial_document_from_jasmin
|
|
await refresh_commercial_document_from_jasmin(document_id)
|
|
except Exception as exc:
|
|
if opportunity_id and is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar documento: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao atualizar documento: {exc}", status_code=500)
|
|
if opportunity_id and is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Documento atualizado a partir do Jasmin."))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}" if opportunity_id else "/outbox", status_code=303)
|
|
|
|
|
|
@router.get("/commercial-documents/{document_id}/pdf")
|
|
async def commercial_document_pdf(document_id: str):
|
|
try:
|
|
from app.jasmin_service import get_commercial_document_pdf
|
|
doc, data, content_type = await get_commercial_document_pdf(document_id)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao obter PDF Jasmin: {exc}", status_code=500)
|
|
name = doc.get("document_number") or doc.get("external_id") or document_id
|
|
safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(name))[:80] or "documento"
|
|
headers = {"Content-Disposition": f'inline; filename="{safe_name}.pdf"'}
|
|
return Response(content=data, media_type=content_type or "application/pdf", headers=headers)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/stage")
|
|
async def update_opportunity_stage_action(opportunity_id: str, request: Request):
|
|
form = await request.form()
|
|
stage = str(form.get("stage") or "").strip()
|
|
note = str(form.get("note") or "").strip()
|
|
if stage:
|
|
set_opportunity_stage(opportunity_id, stage, note=note, created_by="operator")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/customer")
|
|
async def opportunity_link_customer_action(opportunity_id: str, request: Request):
|
|
form = await request.form()
|
|
customer_id = str(form.get("customer_id") or "").strip()
|
|
try:
|
|
from app.commercial_service import link_customer_to_opportunity, unlink_customer_from_opportunity
|
|
if customer_id:
|
|
link_customer_to_opportunity(customer_id, opportunity_id)
|
|
else:
|
|
unlink_customer_from_opportunity(opportunity_id)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/email-identity/extract")
|
|
async def opportunity_email_identity_extract_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.email_identity_extraction_service import extract_identity_for_opportunity
|
|
result = extract_identity_for_opportunity(opportunity_id, refresh=True, use_llm=True)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao extrair identidade do email: {exc}", status_code=500)
|
|
if not result:
|
|
notice = "Sem mensagem associada para extrair identidade."
|
|
else:
|
|
companies = result.get("company_mentions") or []
|
|
notice = "Identidade extraída" + (f": {', '.join(companies[:2])}" if companies else ".")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/email-identity/assist")
|
|
async def opportunity_email_identity_assist_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.fiscal_enrichment_service import assist_email_identity_enrichment
|
|
result = assist_email_identity_enrichment(opportunity_id, refresh=True, apply_safe=False)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao procurar cliente fiscal por identidade: {exc}", status_code=500)
|
|
if result.get("conflict"):
|
|
notice = "Possível conflito fiscal detetado pela identidade extraída."
|
|
elif result.get("status") == "email_identity_matches_current_fiscal_customer":
|
|
notice = "Identidade extraída confirma o cliente fiscal atual."
|
|
elif result.get("suggested"):
|
|
notice = "Sugestão fiscal criada a partir da identidade extraída."
|
|
else:
|
|
notice = "Identidade extraída, mas sem cliente fiscal compatível encontrado."
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/email-identity/cleanup-invalid")
|
|
async def opportunity_email_identity_cleanup_invalid_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.email_identity_cleanup_service import cleanup_invalid_email_identity_state
|
|
result = cleanup_invalid_email_identity_state(
|
|
opportunity_id=opportunity_id,
|
|
include_accepted=True,
|
|
fix_extractions=True,
|
|
apply=True,
|
|
)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao limpar identidade inválida: {exc}", status_code=500)
|
|
notice = (
|
|
f"Limpeza de identidade: {result.get('rejected', 0)} sugestão(ões) rejeitada(s), "
|
|
f"{result.get('fixed_extractions', 0)} extração(ões) corrigida(s)."
|
|
)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/fiscal-enrich")
|
|
async def opportunity_fiscal_enrich_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.fiscal_enrichment_service import enrich_opportunity
|
|
result = enrich_opportunity(opportunity_id, apply_safe=True)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao enriquecer cliente fiscal: {exc}", status_code=500)
|
|
if result.get("auto_applied"):
|
|
notice = "Cliente fiscal auto-associado por enriquecimento."
|
|
elif result.get("suggested"):
|
|
notice = "Sugestão fiscal criada para revisão."
|
|
else:
|
|
notice = f"Sem sugestão fiscal: {result.get('reason') or 'sem correspondência'}"
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/fiscal-suggestions/{suggestion_id}/accept")
|
|
async def fiscal_suggestion_accept_action(suggestion_id: str, request: Request):
|
|
try:
|
|
from app.fiscal_enrichment_service import apply_fiscal_suggestion
|
|
result = apply_fiscal_suggestion(suggestion_id, actor="operator_ui")
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao aplicar sugestão fiscal: {exc}", status_code=500)
|
|
opportunity_id = result.get("opportunity_id") or ""
|
|
if not result.get("applied"):
|
|
return PlainTextResponse(f"Sugestão não aplicada: {result.get('reason')}", status_code=409)
|
|
return RedirectResponse(f"/opportunities/{esc(opportunity_id)}?notice=Sugest%C3%A3o%20fiscal%20aplicada", status_code=303)
|
|
|
|
|
|
@router.post("/fiscal-suggestions/{suggestion_id}/reject")
|
|
async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request):
|
|
try:
|
|
from app.fiscal_enrichment_service import reject_fiscal_suggestion
|
|
reject_fiscal_suggestion(suggestion_id, actor="operator_ui")
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500)
|
|
referer = request.headers.get("referer") or "/opportunities"
|
|
return RedirectResponse(referer, status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/sync-candidates")
|
|
async def opportunity_jasmin_sync_candidates_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.external_reconciliation_sync import sync_jasmin_reconciliation_candidates
|
|
result = await sync_jasmin_reconciliation_candidates(limit=100, days=30)
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao sincronizar Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao sincronizar Jasmin: {exc}", status_code=500)
|
|
seen = result.get("seen", 0)
|
|
created = result.get("created_or_updated", 0)
|
|
notice = f"Jasmin sincronizado: {seen} documento(s) visto(s), {created} criado(s)/atualizado(s)."
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20sincronizado", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/reimport-details")
|
|
async def opportunity_jasmin_reimport_details_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.jasmin_backfill_service import backfill_jasmin_opportunity_details_async
|
|
result = await backfill_jasmin_opportunity_details_async(
|
|
opportunity_id=opportunity_id,
|
|
fetch_detail=True,
|
|
actor="operator_ui_reimport",
|
|
dry_run=False,
|
|
)
|
|
except Exception as exc:
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao reimportar detalhes Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao reimportar detalhes Jasmin: {exc}", status_code=500)
|
|
if not result.get("ok"):
|
|
msg = result.get("error") or "sem itens Jasmin para reimportar"
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível reimportar: {msg}"), status_code=409)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20reimportar%20Jasmin", status_code=303)
|
|
import_result = result.get("import_result") or {}
|
|
docs = int(import_result.get("documents") or 0)
|
|
lines = int(import_result.get("lines") or 0)
|
|
notice = f"Detalhes Jasmin reimportados: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar produtos/valor no topo."
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/link-candidate/{item_id}")
|
|
async def opportunity_jasmin_link_candidate_action(opportunity_id: str, item_id: str, request: Request):
|
|
conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id)
|
|
if conflict_msg:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409)
|
|
return PlainTextResponse(conflict_msg, status_code=409)
|
|
try:
|
|
from app.jasmin_backfill_service import link_and_import_jasmin_candidate_async
|
|
result = await link_and_import_jasmin_candidate_async(
|
|
opportunity_id=opportunity_id,
|
|
item_id=item_id,
|
|
actor="operator_ui_link_existing_jasmin",
|
|
)
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao associar documento Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao associar documento Jasmin: {exc}", status_code=500)
|
|
if not result.get("ok"):
|
|
msg = result.get("error") or "não foi possível associar documento Jasmin"
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível associar: {msg}"), status_code=409)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20associar%20Jasmin", status_code=303)
|
|
import_result = result.get("import_result") or {}
|
|
docs = import_result.get("documents", 0)
|
|
lines = import_result.get("lines", 0)
|
|
notice = f"Documento Jasmin associado e importado: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar valor/produtos no topo."
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20associado", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/replace-candidate/{item_id}")
|
|
async def opportunity_jasmin_replace_candidate_action(opportunity_id: str, item_id: str, request: Request):
|
|
conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id)
|
|
if conflict_msg:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409)
|
|
return PlainTextResponse(conflict_msg, status_code=409)
|
|
try:
|
|
from app.jasmin_backfill_service import replace_jasmin_document_for_opportunity_async
|
|
result = await replace_jasmin_document_for_opportunity_async(
|
|
opportunity_id=opportunity_id,
|
|
item_id=item_id,
|
|
actor="operator_ui_replace_existing_jasmin",
|
|
dry_run=False,
|
|
)
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao substituir documento Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao substituir documento Jasmin: {exc}", status_code=500)
|
|
if not result.get("ok"):
|
|
msg = result.get("error") or "não foi possível substituir documento Jasmin"
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível substituir: {msg}"), status_code=409)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20substituir%20Jasmin", status_code=303)
|
|
import_result = result.get("import_result") or {}
|
|
docs = import_result.get("documents", 0)
|
|
lines = import_result.get("lines", 0)
|
|
removed_docs = result.get("removed_documents", 0)
|
|
notice = f"Documento Jasmin substituído: {removed_docs} anterior(es) removido(s), {docs} documento(s), {lines} linha(s) importada(s). Recarregue a página para atualizar valor/produtos no topo."
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20substitu%C3%ADdo", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/create-quotation")
|
|
async def opportunity_jasmin_create_quotation(opportunity_id: str, request: Request):
|
|
try:
|
|
if settings.jasmin_enabled:
|
|
from app.jasmin_service import enqueue_create_quotation
|
|
enqueue_create_quotation(opportunity_id, created_by="operator")
|
|
else:
|
|
return PlainTextResponse("JASMIN_ENABLED=false", status_code=409)
|
|
except Exception as exc:
|
|
print(f"ClientFlow Jasmin create quotation failed: {exc}", flush=True)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
|
return PlainTextResponse(f"Erro ao criar pedido de orçamento Jasmin: {exc}", status_code=500)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de orçamento enviado para a outbox Jasmin."))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20or%C3%A7amento%20enviado%20para%20a%20outbox%20Jasmin", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/convert-invoice")
|
|
async def opportunity_jasmin_convert_invoice(opportunity_id: str, request: Request):
|
|
try:
|
|
if settings.jasmin_enabled:
|
|
from app.jasmin_service import enqueue_convert_latest_to_invoice
|
|
enqueue_convert_latest_to_invoice(opportunity_id, created_by="operator")
|
|
else:
|
|
return PlainTextResponse("JASMIN_ENABLED=false", status_code=409)
|
|
except Exception as exc:
|
|
print(f"ClientFlow Jasmin convert invoice failed: {exc}", flush=True)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
|
return PlainTextResponse(f"Erro ao criar pedido de fatura Jasmin: {exc}", status_code=500)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de fatura enviado para a outbox Jasmin."))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20fatura%20enviado%20para%20a%20outbox%20Jasmin", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/operations/{action_key}")
|
|
async def opportunity_operation_action(opportunity_id: str, action_key: str, request: Request):
|
|
form = await request.form()
|
|
external_id = str(form.get("external_id") or "").strip()
|
|
external_name = str(form.get("external_name") or form.get("external_ref") or form.get("title") or "").strip()
|
|
external_url = str(form.get("external_url") or "").strip()
|
|
note = str(form.get("note") or "").strip()
|
|
try:
|
|
# Jasmin e Packlink, sem referência manual, criam itens de outbox para a API real.
|
|
# Se o operador preencher external_id/external_name, mantém o modo manual/fallback.
|
|
if action_key == "jasmin_quotation" and not external_id and not external_name:
|
|
if settings.jasmin_enabled:
|
|
from app.jasmin_service import enqueue_create_quotation
|
|
enqueue_create_quotation(opportunity_id, created_by="operator")
|
|
else:
|
|
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
|
|
elif action_key == "packlink_shipment" and not external_id and not external_name:
|
|
if settings.packlink_enabled:
|
|
from app.packlink_service import enqueue_packlink_shipment
|
|
enqueue_packlink_shipment(opportunity_id, created_by="operator")
|
|
else:
|
|
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
|
|
else:
|
|
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
|
|
except OperationActionBlocked as exc:
|
|
return PlainTextResponse(f"Ação bloqueada: {exc}", status_code=409)
|
|
except Exception as exc:
|
|
print(f"ClientFlow operation action failed: {exc}", flush=True)
|
|
return PlainTextResponse(f"Erro ao registar ação: {exc}", status_code=500)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/odoo/sync-status")
|
|
async def opportunity_odoo_sync_status_action(opportunity_id: str, request: Request):
|
|
try:
|
|
sync_opportunity_odoo_status(opportunity_id)
|
|
except Exception:
|
|
pass
|
|
return RedirectResponse(url=f"/opportunities/{opportunity_id}", status_code=303)
|
|
|
|
|