"""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
from fastapi.responses import PlainTextResponse, RedirectResponse
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from app.db import engine
from urllib.parse import quote
import json
import time
import uuid
from datetime import datetime, timezone
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.opportunity_action_task_materializer import ensure_pending_task_for_next_action
from app.work_center_action_policy import (
canonical_action_code,
reconstructed_review_required,
reconstructed_review_status,
)
from app.opportunity_service import (
LOSS_REASON_LABELS,
OPPORTUNITY_LIFECYCLE_STATES,
lifecycle_label,
mark_opportunity_lost,
set_opportunity_lifecycle,
)
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()
PAYMENT_TERM_LABELS = {
"before_shipping": "Antes do envio",
"after_delivery": "Após entrega",
"agreement": "Conforme acordo",
"undefined": "A definir",
}
_OBSOLETE_AFTER_PAYMENT_TASK_CODES = {
"CONFIRM_PAYMENT",
"FOLLOW_UP_PAYMENT",
"FOLLOW_UP_PROFORMA",
"FOLLOW_UP_QUOTE",
"CONFIRM_DELIVERY",
"RECOVER_OPPORTUNITY",
"REVIEW_NURTURE",
}
def _is_obsolete_after_payment_task(task: dict, payment_confirmed_for_ui: bool) -> bool:
if not payment_confirmed_for_ui:
return False
code = str((task or {}).get("action_code") or "").upper().strip()
if code in _OBSOLETE_AFTER_PAYMENT_TASK_CODES:
metadata = (task or {}).get("metadata") or {}
if isinstance(metadata, dict):
metadata.setdefault("ui_reason", "obsoleta: pagamento já confirmado")
return True
return False
DELIVERY_TERM_LABELS = {
"carrier": "Transportadora",
"pickup": "Levantamento",
"install_partner": "Eletricista/instalador do cliente",
"undefined": "A definir",
}
# UI simplification: commercial phases stay short; financial/Odoo/shipping details
# remain visible as derived evidence cards instead of becoming dozens of manual phases.
COMMERCIAL_STAGE_OPTIONS = [
("NEW_LEAD", "Novo pedido"),
("INFO_SENT", "Informação enviada"),
("QUOTE_SENT", "Orçamento enviado"),
("WAITING_PAYMENT", "A aguardar pagamento"),
("PAYMENT_CONFIRMED", "Pagamento confirmado"),
("ODOO_ORDER_CREATED", "Encomenda confirmada / em execução"),
("WON", "Concluído"),
("REVIEW", "Rever"),
]
_DETAILED_OPERATIONAL_STAGES = {
"INFO_REQUESTED",
"QUOTE_REQUESTED",
"PROFORMA_REQUESTED",
"INVOICE_REQUESTED",
"INVOICE_SENT",
"WAITING_PAYMENT",
"PAYMENT_CONFIRMED",
"IN_PRODUCTION",
"READY_TO_SHIP",
"INVOICED",
"SHIPMENT_CREATED",
"TRACKING_SENT",
"DELIVERED",
"ORDER_PREPARATION",
"SHIPPED",
"NO_INTEREST",
"ARCHIVED",
}
def _opportunity_metadata(opportunity: dict) -> dict:
raw = opportunity.get("metadata") if isinstance(opportunity, dict) else {}
return raw if isinstance(raw, dict) else {}
def _commercial_stage_options_html(current_stage: str) -> str:
current_stage = str(current_stage or "NEW_LEAD").upper()
option_values = {value for value, _label in COMMERCIAL_STAGE_OPTIONS}
html = ""
if current_stage not in option_values and current_stage in OPPORTUNITY_STAGE_LABELS:
html += (
''
)
for value, label in COMMERCIAL_STAGE_OPTIONS:
selected = "selected" if value == current_stage else ""
html += f''
return html
def _option_tags(options: dict, selected_value: str) -> str:
selected_value = str(selected_value or "undefined")
html = ""
for value, label in options.items():
selected = "selected" if value == selected_value else ""
html += f''
return html
def _payment_terms_summary(metadata: dict) -> tuple[str, str]:
# Default BLIF commercial terms: payment before shipping and carrier delivery.
# Operators can still override to after-delivery/agreement/undefined per opportunity.
payment_term = str(metadata.get("payment_terms") or "before_shipping")
delivery_term = str(metadata.get("delivery_terms") or "carrier")
payment_label = PAYMENT_TERM_LABELS.get(payment_term, PAYMENT_TERM_LABELS["undefined"])
delivery_label = DELIVERY_TERM_LABELS.get(delivery_term, DELIVERY_TERM_LABELS["undefined"])
return payment_label, delivery_label
def _safe_opportunity_task_text(value: str) -> str:
"""Normalize legacy/stale task notes before showing them in opportunity UI."""
text_value = str(value or "")
replacements = {
"fatura por emitir": "fatura criada/associada; enviar PDF ao cliente",
"Fatura por emitir": "Fatura criada/associada; enviar PDF ao cliente",
"Processo Odoo reconstruído: encomenda/entrega encontrada e fatura por emitir.": "Processo reconstruído: fatura criada/associada; enviar PDF ao cliente.",
"Preparar e enviar pró-forma para pagamento.": "Preparar e enviar orçamento para pagamento.",
"Enviar pró-forma": "Enviar orçamento para pagamento",
"pró-forma": "orçamento para pagamento",
"Pró-forma": "Orçamento para pagamento",
}
for old, new in replacements.items():
text_value = text_value.replace(old, new)
return text_value
def _opportunity_payment_confirmed(opportunity_id: str) -> bool:
if not is_uuid_text(opportunity_id):
return False
with engine.begin() as conn:
return bool(conn.execute(text('''
SELECT 1
FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'clientflow'
AND external_type = 'payment'
AND status = 'confirmed'
LIMIT 1
'''), {"opportunity_id": opportunity_id}).scalar())
def _opportunity_invoice_sent_evidence(opportunity_id: str, invoice_number: str | None = None) -> bool:
"""Return local evidence that an invoice was sent to the customer.
Commercial documents imported from Jasmin do not always carry a sent flag.
Reconstructed opportunities often have the evidence only as a completed
SEND_INVOICE task, so the UI must use the same evidence model as the
central next-action engine.
"""
if not is_uuid_text(opportunity_id):
return False
normalized_invoice = str(invoice_number or "").strip().upper()
with engine.begin() as conn:
row = conn.execute(text('''
SELECT 1
FROM tasks
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND action_code = 'SEND_INVOICE'
AND LOWER(COALESCE(status, '')) IN ('done','completed','complete','closed','resolved','concluida','concluído','concluída')
AND (
:invoice_number = ''
OR UPPER(COALESCE(action, '') || ' ' || COALESCE(note, '') || ' ' || COALESCE(metadata::text, '')) LIKE '%' || :invoice_number || '%'
OR COALESCE(metadata->>'document_number', metadata->>'invoice_number', '') = ''
)
LIMIT 1
'''), {
"opportunity_id": opportunity_id,
"invoice_number": normalized_invoice,
}).scalar()
return bool(row)
def _document_display_number(doc: dict | None) -> str:
if not doc:
return "—"
return str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento")
def _finance_quick_card_html(opportunity_id: str, linked_documents: list[dict], payment_term: str, payment_term_label: str) -> str:
# BLIF default flow: quotation -> payment -> invoice -> prepare/ship.
quotation = next((d for d in linked_documents if str(d.get("document_kind") or "") in {"quotation", "proforma"} and str(d.get("role") or "current") in {"current", "accepted"}), None)
invoice = next((d for d in linked_documents if str(d.get("document_kind") or "") == "invoice" and str(d.get("role") or "current") in {"current", "accepted"}), None)
payment_confirmed = _opportunity_payment_confirmed(opportunity_id)
base_doc = invoice or quotation
base_doc_label = "Fatura" if invoice else ("Orçamento" if quotation else "Documento")
amount = (base_doc.get("total_amount") or base_doc.get("amount")) if base_doc else None
amount_html = money_html(float(amount or 0)) if amount else "—"
payment_status = "Confirmado" if payment_confirmed else ("Pendente pós-entrega" if payment_term == "after_delivery" else "Por confirmar")
if not base_doc:
action_html = '
Bloqueado: cria/associa primeiro um orçamento ou fatura.
'
elif payment_confirmed:
if not invoice:
action_html = f'''
'''
else:
invoice_payload = invoice.get("payload") if isinstance(invoice.get("payload"), dict) else {}
invoice_sent = bool(
invoice.get("sent_at")
or invoice.get("sent")
or str(invoice.get("status") or "").lower() in {"sent", "issued_sent"}
or invoice_payload.get("clientflow_invoice_sent_evidence")
or invoice_payload.get("invoice_sent_at")
or _opportunity_invoice_sent_evidence(opportunity_id, _document_display_number(invoice))
)
if invoice_sent:
detail = "Fatura enviada e pagamento confirmado. Continua pela próxima ação operacional indicada acima."
else:
detail = "Fatura criada/associada. Envia o PDF ao cliente; depois acompanha preparação/Odoo."
action_html = f'
{esc(detail)}
'
else:
note = "Pagamento validado pelo operador no ClientFlow."
button_label = "Confirmar pagamento"
if payment_term == "after_delivery":
note = "Registar pagamento recebido após entrega/acordo comercial."
button_label = "Confirmar pagamento pós-entrega"
elif quotation and not invoice and payment_term == "before_shipping":
note = "Pagamento confirmado com base no orçamento. Emitir fatura de seguida."
action_html = f'''
'''
return f'''
Financeiro rápido
Ação independente da fase: usa orçamento/fatura associado e a condição comercial.
'''
def _json_payload(value: object) -> str:
return json.dumps(value or {}, ensure_ascii=False, default=str)
def _opportunity_manual_correction_state(opportunity_id: str) -> dict:
"""Return counts that help the operator understand external links before correction.
This card is auxiliary/advanced UI only. It must never make the
opportunity detail page fail if PostgreSQL detects a transient lock cycle
while reconciliation/sync jobs are rebuilding evidence. Retry once and then
return a safe degraded state instead of surfacing a 500.
"""
safe_empty = {
"odoo_links": 0,
"jasmin_links": 0,
"jasmin_documents": 0,
"imported_lines": 0,
"reconciliation_items": 0,
}
if not is_uuid_text(opportunity_id):
return safe_empty
last_lock_error = None
for attempt in range(2):
try:
with engine.begin() as conn:
row = conn.execute(text("""
SELECT
COUNT(*) FILTER (WHERE system = 'odoo')::int AS odoo_links,
COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_links
FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
"""), {"opportunity_id": opportunity_id}).mappings().first() or {}
docs = conn.execute(text("""
SELECT COUNT(*)::int
FROM commercial_documents
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'jasmin'
"""), {"opportunity_id": opportunity_id}).scalar() or 0
imported = conn.execute(text("""
SELECT COUNT(*)::int
FROM opportunity_items
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND (
UPPER(COALESCE(status,'')) IN ('ODOO_IMPORTED','JASMIN_IMPORTED')
OR UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\'
OR COALESCE(metadata->>'source_system','') IN ('odoo','jasmin')
)
"""), {"opportunity_id": opportunity_id}).scalar() or 0
reconciliation = conn.execute(text("""
SELECT COUNT(*)::int
FROM reconciliation_items
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND source_system IN ('odoo','jasmin')
"""), {"opportunity_id": opportunity_id}).scalar() or 0
data = dict(row)
data["jasmin_documents"] = int(docs or 0)
data["imported_lines"] = int(imported or 0)
data["reconciliation_items"] = int(reconciliation or 0)
return data
except OperationalError as exc:
msg = str(exc).lower()
if "deadlock detected" in msg or "lock timeout" in msg or "could not obtain lock" in msg:
last_lock_error = exc
if attempt == 0:
time.sleep(0.25)
continue
degraded = dict(safe_empty)
degraded["unavailable"] = True
degraded["unavailable_reason"] = "lock_timeout"
return degraded
raise
if last_lock_error:
degraded = dict(safe_empty)
degraded["unavailable"] = True
degraded["unavailable_reason"] = "lock_timeout"
return degraded
return safe_empty
def _opportunity_archive_spam_state(opportunity_id: str) -> dict:
"""Best-effort state for the UI-only spam archive card.
The POST action still performs the authoritative safety check. This helper
must never break the opportunity page; deadlocks/reconciliation locks simply
hide the archive card for this request.
"""
if not is_uuid_text(opportunity_id):
return {"can_archive": False, "unavailable": True}
try:
with engine.begin() as conn:
row = conn.execute(text("""
SELECT
(SELECT COUNT(*) FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID)) AS docs,
(SELECT COUNT(*) FROM reconciliation_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND source_system IN ('jasmin','odoo')) AS linked_external,
(SELECT COUNT(*) FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system IN ('jasmin','odoo','packlink')) AS operation_links,
(SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND action_code = 'IGNORE_SPAM') AS spam_tasks,
(SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND route = 'spam') AS spam_route_tasks,
(SELECT COUNT(*) FROM communications WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND classification IN ('IGNORE_SPAM','SPAM')) AS spam_communications,
(SELECT status FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)) AS status
"""), {"opportunity_id": opportunity_id}).mappings().first() or {}
except OperationalError:
return {"can_archive": False, "unavailable": True}
except Exception:
return {"can_archive": False, "unavailable": True}
docs = int(row.get("docs") or 0)
linked_external = int(row.get("linked_external") or 0)
operation_links = int(row.get("operation_links") or 0)
spam_evidence = int(row.get("spam_tasks") or 0) + int(row.get("spam_route_tasks") or 0) + int(row.get("spam_communications") or 0)
status = str(row.get("status") or "").lower()
return {
"can_archive": status != "archived" and docs == 0 and linked_external == 0 and operation_links == 0,
"has_spam_evidence": spam_evidence > 0,
"docs": docs,
"linked_external": linked_external,
"operation_links": operation_links,
"spam_evidence": spam_evidence,
"status": status,
}
def _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id: str):
manual_total = conn.execute(text("""
SELECT COALESCE(SUM(total_price), 0)::numeric
FROM opportunity_items
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND UPPER(COALESCE(status,'')) NOT IN ('REJECTED','CANCELLED','DELIVERED','HISTORICAL','ODOO_IMPORTED','JASMIN_IMPORTED')
AND UPPER(COALESCE(status,'')) NOT LIKE '%\\_IMPORTED' ESCAPE '\\'
AND COALESCE(metadata->>'source_system','manual') NOT IN ('odoo','jasmin')
"""), {"opportunity_id": opportunity_id}).scalar()
return manual_total or 0
def apply_manual_external_correction(
opportunity_id: str,
*,
unlink_odoo: bool,
unlink_jasmin: bool,
remove_imported_lines: bool,
new_stage: str,
note: str,
actor: str = "operator_manual_correction",
) -> dict:
"""Manual override for wrongly linked Odoo/Jasmin evidence.
This is intentionally auditable and local-only: it never deletes data in Odoo,
Jasmin or Chatwoot. It only detaches ClientFlow evidence from the opportunity.
"""
if not is_uuid_text(opportunity_id):
raise ValueError("Identificador de oportunidade inválido.")
new_stage = str(new_stage or "INFO_SENT").strip().upper()
if new_stage not in OPPORTUNITY_STAGE_LABELS:
raise ValueError(f"Fase inválida: {new_stage}")
action_by_stage = {
"INFO_SENT": "SEND_INFO",
"INFO_REQUESTED": "SEND_INFO",
"QUOTE_REQUESTED": "SEND_QUOTE",
"QUOTE_SENT": "SEND_QUOTE",
"PROFORMA_REQUESTED": "SEND_PROFORMA",
"PROFORMA_SENT": "SEND_PROFORMA",
"INVOICE_REQUESTED": "SEND_INVOICE",
"INVOICE_SENT": "SEND_INVOICE",
"WAITING_PAYMENT": "CONFIRM_PAYMENT",
"REVIEW": "REVIEW_MANUALLY",
"LOST": "MARK_NO_INTEREST",
"NO_INTEREST": "MARK_NO_INTEREST",
}
new_action = action_by_stage.get(new_stage, "SEND_INFO")
sources: list[str] = []
if unlink_odoo:
sources.append("odoo")
if unlink_jasmin:
sources.append("jasmin")
if not sources and not new_stage:
return {"changed": 0}
result = {
"operation_links_deleted": 0,
"jasmin_documents_deleted": 0,
"imported_lines_deleted": 0,
"reconciliation_items_unlinked": 0,
"stage": new_stage,
}
note = (note or "Correção manual: associação externa errada removida pelo operador.").strip()
metadata_patch = {
"manual_external_correction": True,
"manual_external_correction_sources": sources,
"manual_external_correction_note": note,
"manual_external_correction_actor": actor,
}
with engine.begin() as conn:
current = conn.execute(text("""
SELECT stage, value_amount, last_action_code
FROM opportunities
WHERE id = CAST(:opportunity_id AS UUID)
"""), {"opportunity_id": opportunity_id}).mappings().first()
if not current:
raise ValueError("Oportunidade não encontrada.")
old_stage = str(current.get("stage") or "NEW_LEAD")
if unlink_odoo:
result["operation_links_deleted"] += conn.execute(text("""
DELETE FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'odoo'
"""), {"opportunity_id": opportunity_id}).rowcount or 0
if unlink_jasmin:
# Commercial document lines are removed by ON DELETE CASCADE.
result["jasmin_documents_deleted"] += conn.execute(text("""
DELETE FROM commercial_documents
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'jasmin'
"""), {"opportunity_id": opportunity_id}).rowcount or 0
result["operation_links_deleted"] += conn.execute(text("""
DELETE FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'jasmin'
"""), {"opportunity_id": opportunity_id}).rowcount or 0
if remove_imported_lines and sources:
result["imported_lines_deleted"] += conn.execute(text("""
DELETE FROM opportunity_items
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND (
(:unlink_odoo IS TRUE AND (COALESCE(metadata->>'source_system','') = 'odoo' OR UPPER(COALESCE(status,'')) = 'ODOO_IMPORTED'))
OR (:unlink_jasmin IS TRUE AND (COALESCE(metadata->>'source_system','') = 'jasmin' OR UPPER(COALESCE(status,'')) = 'JASMIN_IMPORTED'))
OR ((:unlink_odoo IS TRUE OR :unlink_jasmin IS TRUE) AND UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\')
)
"""), {"opportunity_id": opportunity_id, "unlink_odoo": bool(unlink_odoo), "unlink_jasmin": bool(unlink_jasmin)}).rowcount or 0
if sources:
result["reconciliation_items_unlinked"] += conn.execute(text("""
UPDATE reconciliation_items
SET opportunity_id = NULL,
status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END,
resolution_note = COALESCE(resolution_note || ' | ', '') || :note,
resolved_at = NULL,
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
updated_at = now()
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND (
(:unlink_odoo IS TRUE AND source_system = 'odoo')
OR (:unlink_jasmin IS TRUE AND source_system = 'jasmin')
)
"""), {
"opportunity_id": opportunity_id,
"unlink_odoo": bool(unlink_odoo),
"unlink_jasmin": bool(unlink_jasmin),
"note": note,
"payload": _json_payload({"manual_unlinked_from_opportunity_id": opportunity_id, "sources": sources, "actor": actor}),
}).rowcount or 0
manual_total = _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id)
conn.execute(text("""
UPDATE opportunities
SET stage = :stage,
status = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN 'closed' ELSE 'open' END,
last_action_code = :action_code,
value_amount = CAST(:value_amount AS NUMERIC),
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
closed_at = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN COALESCE(closed_at, now()) ELSE NULL END,
updated_at = now()
WHERE id = CAST(:opportunity_id AS UUID)
"""), {
"opportunity_id": opportunity_id,
"stage": new_stage,
"action_code": new_action,
"value_amount": manual_total,
"metadata": _json_payload(metadata_patch),
})
conn.execute(text("""
INSERT INTO opportunity_events (
id, opportunity_id, event_type, action_code, from_stage, to_stage, note, payload, created_by
) VALUES (
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'manual_external_correction',
:action_code, :from_stage, :to_stage, :note, CAST(:payload AS JSONB), :created_by
)
"""), {
"id": str(uuid.uuid4()),
"opportunity_id": opportunity_id,
"action_code": new_action,
"from_stage": old_stage,
"to_stage": new_stage,
"note": note,
"payload": _json_payload(result),
"created_by": actor,
})
return result
def ignore_external_candidate_for_opportunity(opportunity_id: str, item_id: str, *, actor: str = "operator_ui_ignore_candidate") -> int:
if not is_uuid_text(opportunity_id) or not is_uuid_text(item_id):
raise ValueError("Identificador inválido.")
with engine.begin() as conn:
count = conn.execute(text("""
UPDATE reconciliation_items
SET status = 'ignored',
opportunity_id = CASE WHEN opportunity_id = CAST(:opportunity_id AS UUID) THEN NULL ELSE opportunity_id END,
resolution_note = COALESCE(resolution_note || ' | ', '') || 'Ignorado manualmente a partir da oportunidade.',
resolved_at = now(),
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
updated_at = now()
WHERE id = CAST(:item_id AS UUID)
AND source_system IN ('odoo','jasmin')
"""), {
"opportunity_id": opportunity_id,
"item_id": item_id,
"payload": _json_payload({"ignored_from_opportunity_id": opportunity_id, "actor": actor}),
}).rowcount or 0
if count:
conn.execute(text("""
INSERT INTO opportunity_events (
id, opportunity_id, event_type, action_code, note, payload, created_by
) VALUES (
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'external_candidate_ignored',
'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :created_by
)
"""), {
"id": str(uuid.uuid4()),
"opportunity_id": opportunity_id,
"note": "Candidato externo ignorado manualmente.",
"payload": _json_payload({"item_id": item_id}),
"created_by": actor,
})
return int(count or 0)
def unlink_commercial_document_from_opportunity(
opportunity_id: str,
document_id: str,
*,
remove_imported_lines: bool = True,
note: str = "",
actor: str = "operator_ui_document_unlink",
) -> dict:
"""Detach one local commercial document from an opportunity.
This is the granular counterpart to the broad manual external correction.
It does not delete anything in Jasmin/Odoo. It only removes the document
from this ClientFlow opportunity and, when requested, removes imported
opportunity lines that explicitly came from that document reference.
"""
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
raise ValueError("Identificador inválido.")
note = (note or "Documento desassociado manualmente desta oportunidade.").strip()
result = {"document_unlinked": 0, "imported_lines_deleted": 0, "reconciliation_items_unlinked": 0}
with engine.begin() as conn:
doc = conn.execute(text("""
SELECT id::text, opportunity_id::text, system, document_kind, external_id, document_number,
role, is_primary, total_amount, amount
FROM commercial_documents
WHERE id = CAST(:document_id AS UUID)
AND opportunity_id = CAST(:opportunity_id AS UUID)
LIMIT 1
"""), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first()
if not doc:
raise ValueError("Documento não encontrado nesta oportunidade.")
refs = [str(doc.get("document_number") or "").strip(), str(doc.get("external_id") or "").strip()]
refs = [r for r in refs if r]
payload = _json_payload({
"manual_document_unlink": True,
"opportunity_id": opportunity_id,
"document_id": document_id,
"document_number": doc.get("document_number"),
"external_id": doc.get("external_id"),
"actor": actor,
"note": note,
})
result["document_unlinked"] = conn.execute(text("""
UPDATE commercial_documents
SET opportunity_id = NULL,
role = 'detached',
is_primary = FALSE,
is_active = FALSE,
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
updated_at = now()
WHERE id = CAST(:document_id AS UUID)
AND opportunity_id = CAST(:opportunity_id AS UUID)
"""), {"document_id": document_id, "opportunity_id": opportunity_id, "payload": payload}).rowcount or 0
if remove_imported_lines and refs:
result["imported_lines_deleted"] = conn.execute(text("""
DELETE FROM opportunity_items
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND (
metadata->>'source_system' = CAST(:system AS TEXT)
OR (:system = 'jasmin' AND status = 'JASMIN_IMPORTED')
OR (:system = 'odoo' AND status = 'ODOO_IMPORTED')
)
AND (
metadata->>'source_document' = ANY(:refs)
OR metadata->>'source_external_id' = ANY(:refs)
OR source_document = ANY(:refs)
)
"""), {"opportunity_id": opportunity_id, "system": doc.get("system") or "jasmin", "refs": refs}).rowcount or 0
if refs:
result["reconciliation_items_unlinked"] = conn.execute(text("""
UPDATE reconciliation_items
SET opportunity_id = NULL,
status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END,
resolution_note = COALESCE(resolution_note || ' | ', '') || :note,
resolved_at = NULL,
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
updated_at = now()
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND source_system = CAST(:system AS TEXT)
AND (
document_number = ANY(:refs)
OR external_id = ANY(:refs)
OR payload::text ILIKE '%' || CAST(:document_id AS TEXT) || '%'
)
"""), {
"opportunity_id": opportunity_id,
"system": doc.get("system") or "jasmin",
"refs": refs,
"document_id": document_id,
"note": note,
"payload": payload,
}).rowcount or 0
conn.execute(text("""
INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by)
VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_unlinked',
'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor)
"""), {
"id": str(uuid.uuid4()),
"opportunity_id": opportunity_id,
"note": note,
"payload": payload,
"actor": actor,
})
return result
def set_commercial_document_role_for_opportunity(
opportunity_id: str,
document_id: str,
*,
role: str = "current",
make_primary: bool = True,
actor: str = "operator_ui_document_role",
) -> dict:
"""Choose which document belongs to the current process without deleting evidence."""
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
raise ValueError("Identificador inválido.")
role = str(role or "current").strip().lower()
if role not in {"current", "accepted", "related", "historical"}:
raise ValueError("Papel de documento inválido.")
with engine.begin() as conn:
doc = conn.execute(text("""
SELECT id::text, system, document_kind, document_number
FROM commercial_documents
WHERE id = CAST(:document_id AS UUID)
AND opportunity_id = CAST(:opportunity_id AS UUID)
LIMIT 1
"""), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first()
if not doc:
raise ValueError("Documento não encontrado nesta oportunidade.")
if make_primary and role in {"current", "accepted"}:
conn.execute(text("""
UPDATE commercial_documents
SET role = CASE WHEN COALESCE(role, 'current') = 'current' THEN 'historical' ELSE role END,
is_primary = FALSE,
is_active = CASE WHEN COALESCE(role, 'current') = 'current' THEN FALSE ELSE COALESCE(is_active, TRUE) END,
updated_at = now()
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = CAST(:system AS TEXT)
AND document_kind = CAST(:document_kind AS TEXT)
AND id <> CAST(:document_id AS UUID)
"""), {
"opportunity_id": opportunity_id,
"system": doc.get("system"),
"document_kind": doc.get("document_kind"),
"document_id": document_id,
})
conn.execute(text("""
UPDATE commercial_documents
SET role = :role,
is_primary = :is_primary,
is_active = TRUE,
updated_at = now(),
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB)
WHERE id = CAST(:document_id AS UUID)
AND opportunity_id = CAST(:opportunity_id AS UUID)
"""), {
"document_id": document_id,
"opportunity_id": opportunity_id,
"role": role,
"is_primary": bool(make_primary and role in {"current", "accepted"}),
"payload": _json_payload({"manual_document_role": role, "manual_primary": bool(make_primary), "actor": actor}),
})
conn.execute(text("""
INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by)
VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_role_changed',
'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor)
"""), {
"id": str(uuid.uuid4()),
"opportunity_id": opportunity_id,
"note": f"Documento {doc.get('document_number') or document_id} marcado como {role}.",
"payload": _json_payload({"document_id": document_id, "role": role, "make_primary": bool(make_primary)}),
"actor": actor,
})
return {"changed": 1, "role": role, "is_primary": bool(make_primary and role in {"current", "accepted"})}
def _odoo_m2o_label(value) -> str:
if isinstance(value, (list, tuple)) and len(value) >= 2:
return str(value[1] or "")
if isinstance(value, dict):
return str(value.get("name") or value.get("display_name") or value.get("id") or "")
return str(value or "")
def _odoo_status_badge(status: object) -> str:
s = str(status or "").lower()
cls = "text-bg-secondary"
if s in {"done", "shipped", "delivered", "validated", "sale", "created", "order_created", "ready_to_ship"}:
cls = "text-bg-success"
elif s in {"assigned", "confirmed", "waiting", "in_production", "progress", "pending", "sent", "quote_only"}:
cls = "text-bg-warning"
elif s in {"cancel", "cancelled", "failed", "not_found", "blocked"}:
cls = "text-bg-danger"
return f'{esc(status or "—")}'
def _opportunity_odoo_rows(opportunity_id: str) -> tuple[list[dict], list[dict]]:
"""Return linked Odoo operation links and recent reconciliation candidates.
Read-only. The panel must not call Odoo on page load; the operator uses
the explicit sync button to refresh live Odoo state.
"""
with engine.begin() as conn:
links = conn.execute(text("""
SELECT id::text, system, external_type, external_id, external_name,
external_url, status, payload, last_synced_at, updated_at
FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'odoo'
ORDER BY
CASE external_type
WHEN 'sale_order' THEN 1
WHEN 'physical_status' THEN 2
WHEN 'production' THEN 3
WHEN 'physical_validation' THEN 4
ELSE 9
END,
updated_at DESC
"""), {"opportunity_id": opportunity_id}).mappings().all()
candidates = conn.execute(text("""
SELECT id::text, source_system, external_type, external_id,
document_number, title, status, amount, currency,
customer_name, customer_email, customer_tax_id, payload,
opportunity_id::text AS linked_opportunity_id,
created_at, updated_at, resolved_at
FROM reconciliation_items
WHERE source_system = 'odoo'
AND external_type = 'odoo_sale_order'
AND (
opportunity_id = CAST(:opportunity_id AS UUID)
OR (status IN ('open','needs_review','conflict') AND payload::text ILIKE '%' || CAST(:opportunity_id AS TEXT) || '%')
)
ORDER BY
CASE WHEN opportunity_id = CAST(:opportunity_id AS UUID) THEN 0 ELSE 1 END,
updated_at DESC
LIMIT 20
"""), {"opportunity_id": opportunity_id}).mappings().all()
return [dict(r) for r in links], [dict(r) for r in candidates]
def odoo_status_panel_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str:
try:
links, candidates = _opportunity_odoo_rows(opportunity_id)
except Exception as exc:
return f'
Erro ao carregar Odoo: {esc(exc)}
'
by_type = {str(link.get("external_type") or ""): link for link in links}
sale = by_type.get("sale_order") or {}
physical = by_type.get("physical_status") or {}
physical_payload = physical.get("payload") if isinstance(physical.get("payload"), dict) else {}
sale_payload = sale.get("payload") if isinstance(sale.get("payload"), dict) else {}
live_sale = physical_payload.get("sale_order") if isinstance(physical_payload.get("sale_order"), dict) else {}
pickings = physical_payload.get("pickings") if isinstance(physical_payload.get("pickings"), list) else []
productions = physical_payload.get("productions") if isinstance(physical_payload.get("productions"), list) else []
sale_name = live_sale.get("name") or sale.get("external_name") or sale_payload.get("sale_order") or sale.get("external_id") or "—"
sale_state = live_sale.get("state") or sale.get("status") or "—"
sale_amount = live_sale.get("amount_total") or sale_payload.get("amount_total") or ""
partner = _odoo_m2o_label(live_sale.get("partner") or live_sale.get("partner_id") or sale_payload.get("partner") or sale_payload.get("partner_id")) or "—"
last_synced = physical.get("last_synced_at") or sale.get("last_synced_at") or "—"
physical_label = physical_payload.get("label") or physical.get("status") or "Não sincronizado"
physical_reason = physical_payload.get("reason") or "Usa o botão para consultar estado físico no Odoo."
physical_next = physical_payload.get("next_action") or ""
physical_status_value = str(physical.get("status") or physical_payload.get("physical_status") or physical_payload.get("status") or "").strip().lower()
picking_states = {
str(p.get("state") or "").strip().lower()
for p in pickings
if isinstance(p, dict) and str(p.get("state") or "").strip()
}
whout_done = (
bool(physical_payload.get("delivery_done"))
or physical_status_value in {"done", "shipped", "delivered", "validated"}
or (bool(picking_states) and picking_states <= {"done", "cancel"} and "done" in picking_states)
)
whout_ready = (
bool(physical_payload.get("ready_to_ship") or physical_payload.get("delivery_ready"))
or physical_status_value in {"ready_to_ship", "ready", "validated"}
or "assigned" in picking_states
)
if whout_done:
physical_reason = "WH/OUT concluído no Odoo."
physical_next = "Processo pronto para conclusão quando fatura enviada e pagamento confirmado."
elif whout_ready:
physical_label = "Picking reservado — validação física pendente"
physical_reason = "Odoo assigned indica stock reservado; ainda falta confirmar fisicamente a preparação da encomenda."
physical_next = "Validar encomenda física antes de criar envio/tracking."
notice_html = f'
{esc(notice)}
' if notice else ""
error_html = f'
Erro Odoo: {esc(error_notice)}
' if error_notice else ""
sale_url = str(sale.get("external_url") or "").strip()
sale_link = f'Abrir Odoo' if sale_url else ""
no_sale_warning = ""
manual_sale_link_html = ""
if not sale:
no_sale_warning = '
Sem venda Odoo ligada. Regista o número da venda Odoo (ex.: S00308) ou usa candidatos abaixo para ligar a venda correta antes de confiar no fluxo físico.
'
manual_sale_link_html = f'''
Associar venda Odoo manualmente
'''
unlink_odoo_button_html = ""
if sale:
unlink_odoo_button_html = f"""
"""
picking_rows = ""
for pck in pickings[:8]:
picking_rows += f"""
{esc(pck.get('name') or pck.get('id') or 'Entrega')}
{esc(_odoo_m2o_label(pck.get('type')) or pck.get('origin') or '')}
{_odoo_status_badge(pck.get('state'))}
{esc(fmt_dt(pck.get('scheduled_date') or pck.get('date_done')))}
"""
if not picking_rows:
picking_rows = '
Sem entregas/pickings sincronizados.
'
production_rows = ""
for mo in productions[:8]:
production_rows += f"""
{esc(mo.get('name') or mo.get('id') or 'Produção')}
{esc(_odoo_m2o_label(mo.get('product')))}
{_odoo_status_badge(mo.get('state'))}
{esc(mo.get('qty') or '')}
"""
if not production_rows:
production_rows = '
Sem ordens de produção sincronizadas.
'
candidate_rows = ""
for cand in candidates:
linked_here = str(cand.get("linked_opportunity_id") or "") == str(opportunity_id)
if linked_here:
action_html = f'''
"""
if not review.get("ok") or not review.get("identity"):
return f"""
Identidade do email
Ainda não existe identidade extraída para esta oportunidade.
"""
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'{esc(c)}' for c in companies) or 'Sem empresa explícita válida'
phone_html = ", ".join(esc(p) for p in phones) if phones else "—"
evidence_html = "".join(f'
{esc(compact_text(e, 90))}
' for e in evidence[:3])
conflict_html = ""
if conflict:
conflict_html = f"""
Possível conflito fiscal.
O email menciona {esc(', '.join(companies) or 'outra empresa')}, mas a oportunidade está ligada a {esc(review.get('linked_customer_name') or 'outro cliente')}.
"""
suggested_html = ""
if suggested and companies:
suggested_html = f"""
Cliente interno compatível
{esc(suggested.get('nome') or suggested.get('name') or 'Cliente')}
NIF {esc(suggested.get('nif') or suggested.get('tax_id') or '—')}
"""
return f"""
Identidade extraída do email
{esc(identity.get('extraction_method') or identity.get('method') or '—')} · {esc(model)} · confiança {esc(confidence_text)}
{status_badge('conflito') if conflict and 'status_badge' in globals() else ''}
{conflict_html}
Pessoa
{esc(identity.get('person_name') or '—')}
Empresa mencionada
{company_html}
Email / domínio
{esc(identity.get('email') or '—')} · {esc(identity.get('domain') or '—')}
Morada
{esc(identity.get('address') or '—')}
Telefones
{phone_html}
{suggested_html}
{f'
{evidence_html}
' if evidence_html else ''}
"""
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"""
Sem sugestão fiscal externa registada.
"""
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"{esc(status)}"
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"""
"""
rows += f"""
{esc(suggestion.get('suggested_name') or 'Empresa sugerida')}{badge}
Sugestão fiscal · NIF {esc(suggestion.get('suggested_nif') or '—')} · confiança {esc(confidence_text)}
{esc(suggestion.get('match_type') or suggestion.get('lookup_type') or 'match')}
{actions}
"""
if not rows.strip():
return ""
return f"""
Sugestões fiscais por validar
Não é cliente fiscal confirmado. Associar apenas depois de validar nome/NIF.
{rows}
"""
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 e o documento Jasmin atual é orçamento. "
"Isto está correto no fluxo normal BLIF: confirma pagamento com base no orçamento antes de emitir fatura."
)
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: no fluxo normal, a fatura é emitida após confirmação do pagamento."
)
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"
{esc(a)}
" for a in alerts[:3])
return f'''
Verificação de consistência operacional
{items}
'''
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'''
{esc(fmt_dt(doc.get('created_at')))}
derivado
{esc(title)}{operation_status_badge(str(doc.get('status') or 'created'))}
{esc(detail)}
'''
if item_count and not docs:
items += f'''
—
derivado
Produtos na oportunidade
{esc(item_count)} linha(s) comerciais associadas.
'''
return items
def _parse_opportunity_dt(value: object):
if not value:
return None
if isinstance(value, datetime):
dt = value
else:
try:
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except Exception:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _opportunity_lifecycle_state(opp: dict) -> str:
state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active"
now = datetime.now(timezone.utc)
nurture_until = _parse_opportunity_dt(opp.get("nurture_until"))
next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at"))
if state == "nurture" and nurture_until and nurture_until <= now:
return "follow_up_due"
if state in {"awaiting_customer", "active"} and next_follow_up and next_follow_up <= now:
return "follow_up_due"
return state
def _opportunity_last_commercial_activity(opp: dict):
for key in ("last_customer_activity_at", "last_operator_activity_at", "last_commercial_activity_at", "last_message_at"):
dt = _parse_opportunity_dt(opp.get(key))
if dt:
return dt
return None
def _opportunity_inactive(opp: dict) -> bool:
if _opportunity_lifecycle_state(opp) in {"recovery", "nurture"}:
return False
last_activity = _opportunity_last_commercial_activity(opp)
attempts = int(opp.get("follow_up_attempts") or 0)
if not last_activity:
return attempts >= 2
days = (datetime.now(timezone.utc) - last_activity).days
return days >= 10 and attempts >= 2
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 _attach_central_next_actions(opportunities: list[dict]) -> None:
"""Enrich board rows with the same next-action engine used by details.
The board used to derive labels from the stored legacy stage, which made
closed-ready opportunities appear as "Enviar tracking" and WH/MO cases as
"Acompanhar produção". Keep this read-only and best-effort: if the
central engine fails for one card, the card falls back to the legacy text.
"""
for opp in opportunities:
if isinstance(opp.get("clientflow_next_action"), dict):
continue
oid = str(opp.get("id") or "").strip()
if not oid:
continue
try:
decision = get_opportunity_next_action(oid)
except Exception as exc:
decision = {
"action_code": "DECISION_ERROR",
"label": opportunity_next_action_text(opp),
"description": f"Falha ao calcular próxima ação central: {exc}",
}
if isinstance(decision, dict):
opp["clientflow_next_action"] = decision
def _central_next_action_for_card(opp: dict) -> dict:
decision = opp.get("clientflow_next_action")
return decision if isinstance(decision, dict) 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)
_attach_central_next_actions(opportunities)
visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] not in {"closed", "archived"}]
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 in {"closed", "archived"}:
continue
if scope and scope not in {"all", "open"}:
lifecycle_state = _opportunity_lifecycle_state(opportunity)
scope_to_column = {"new": "requests", "quote": "sent", "shipment": "operations"}
if scope == "blocked":
pending = int(opportunity.get("pending_task_count") or 0)
if pending <= 0 and not opportunity_customer_mismatch(opportunity):
continue
elif scope == "active":
if lifecycle_state not in {"active", "awaiting_customer"} or _opportunity_inactive(opportunity):
continue
elif scope == "awaiting_customer":
if lifecycle_state != "awaiting_customer":
continue
elif scope == "follow_up_due":
if lifecycle_state != "follow_up_due":
continue
elif scope == "recovery":
if lifecycle_state != "recovery":
continue
elif scope == "nurture":
if lifecycle_state != "nurture":
continue
elif scope == "inactive":
if not _opportunity_inactive(opportunity):
continue
elif scope == "unvalued":
if float(opportunity.get("value_amount") or 0) > 0:
continue
elif key != scope_to_column.get(scope, 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:
lifecycle_state = _opportunity_lifecycle_state(opp)
pending_follow_up_action = str(opp.get("pending_follow_up_action") or "").strip()
pending_follow_up_code = str(opp.get("pending_follow_up_action_code") or "").strip()
if lifecycle_state == "recovery":
return pending_follow_up_action or "Recuperar oportunidade sem resposta"
if lifecycle_state == "follow_up_due":
return pending_follow_up_action or primary_action_label(pending_follow_up_code, fallback="Executar follow-up vencido")
if lifecycle_state == "nurture":
return pending_follow_up_action or "Aguardar data para retomar contacto"
if lifecycle_state == "awaiting_customer":
due = _parse_opportunity_dt(opp.get("next_follow_up_at"))
return f"Aguardar resposta até {due.strftime('%d/%m')}" if due else "Aguardar resposta do cliente"
metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {}
if reconstructed_review_required(metadata):
return "Validar processo reconstruído"
pending_code = canonical_action_code(opp.get("pending_primary_action_code"))
if pending_code:
return str(opp.get("pending_primary_action") or primary_action_label(pending_code, fallback="Ver tarefa pendente"))
central = _central_next_action_for_card(opp)
if central.get("label"):
return str(central.get("label") or "")
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 the visual column from the same first-safe-action precedence."""
metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {}
if reconstructed_review_required(metadata):
return "requests"
pending_code = canonical_action_code(opp.get("pending_primary_action_code"))
central_code = canonical_action_code(_central_next_action_for_card(opp).get("action_code"))
effective_code = pending_code or central_code
if effective_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT"}:
return "payment"
if effective_code in {
"PREPARE_ORDER", "CREATE_SHIPMENT", "WAIT_PRODUCTION", "WAIT_ODOO",
"CLOSE_OPPORTUNITY", "VALIDATE_PHYSICAL_ORDER",
}:
return "operations"
if effective_code in {"ASSOCIATE_OPPORTUNITY", "REVIEW_ASSOCIATION", "LINK_DOCUMENT", "REVIEW_RECONSTRUCTED_PROCESS"}:
return "requests"
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)
lifecycle_state = _opportunity_lifecycle_state(opp)
state_label = lifecycle_label(lifecycle_state)
state_class = {
"active": "text-bg-success",
"awaiting_customer": "text-bg-info",
"follow_up_due": "text-bg-warning",
"recovery": "text-bg-danger",
"nurture": "text-bg-secondary",
}.get(lifecycle_state, "text-bg-light")
last_customer = _parse_opportunity_dt(opp.get("last_customer_activity_at") or opp.get("last_message_at"))
customer_age = "Sem atividade do cliente registada"
if last_customer:
days = max(0, (datetime.now(timezone.utc) - last_customer).days)
customer_age = "Cliente respondeu hoje" if days == 0 else f"Sem resposta do cliente há {days} dia(s)"
next_follow = _parse_opportunity_dt(opp.get("next_follow_up_at") or opp.get("nurture_until"))
follow_text = f"Próximo contacto: {next_follow.strftime('%d/%m/%Y')}" if next_follow else "Sem próximo contacto agendado"
attempts = int(opp.get("follow_up_attempts") or 0)
value = float(opp.get("value_amount") or 0)
value_text = money_html(value) if value > 0 else "Valor por definir"
cta_label = next_action if pending else "Ver oportunidade"
cta_class = "btn-primary" if pending or lifecycle_state in {"follow_up_due", "recovery"} else "btn-outline-primary"
blocker_html = blocker_alert_html(blockers, empty_text="") if blockers else ""
subtitle_html = f'
{esc(subtitle)}
' if subtitle else ""
blocker_class = " has-blocker" if blockers else ""
return f"""
"""
@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]
active_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) in {"active", "awaiting_customer"} and not _opportunity_inactive(opp))
due_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "follow_up_due")
recovery_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "recovery")
unvalued_count = sum(1 for opp in visible_opportunities if float(opp.get("value_amount") 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''
stage_tabs = ""
filters = [
("all", "Todas"),
("new", "Novas"),
("quote", "Orçamento enviado"),
("payment", "Pagamento pendente"),
("active", "Ativas"),
("awaiting_customer", "A aguardar cliente"),
("follow_up_due", "Follow-up vencido"),
("recovery", "Recuperação"),
("unvalued", "Por valorizar"),
("inactive", "Inativas"),
("nurture", "Acompanhamento futuro"),
("shipment", "Operação/entrega"),
("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'{esc(label)}'
body = f"""
"""
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):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
opportunity = get_opportunity(opportunity_id)
if not opportunity:
return layout("Oportunidade não encontrada", "Pipeline comercial", 'Oportunidade não encontrada.', "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")
terminal_stage = str(opportunity.get("status") or "").lower() == "closed" or stage in {"WON", "LOST", "NO_INTEREST", "DELIVERED"}
all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"]
payment_confirmed_for_ui = stage == "PAYMENT_CONFIRMED"
if terminal_stage:
pending_tasks = [
t for t in all_pending_tasks
if str(t.get("action_code") or "").upper() not in {
"FOLLOW_UP_QUOTE",
"FOLLOW_UP_PROFORMA",
"FOLLOW_UP_PAYMENT",
"FOLLOW_UP_CUSTOMER_REVIEW",
"FOLLOW_UP_GENERIC",
"CONFIRM_DELIVERY",
"RECOVER_OPPORTUNITY",
"REVIEW_NURTURE",
}
]
else:
pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)]
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)
opportunity_for_cockpit = dict(opportunity)
opportunity_for_cockpit["pending_task_count"] = len(pending_tasks)
try:
opportunity_communications = list_communications_for_opportunity(opportunity_id, limit=12)
except Exception:
opportunity_communications = []
notice_html = f'
{esc(notice)}
' if notice else ''
metadata = _opportunity_metadata(opportunity)
payment_term = str(metadata.get("payment_terms") or "before_shipping")
delivery_term = str(metadata.get("delivery_terms") or "carrier")
commercial_terms_note = str(metadata.get("commercial_terms_note") or "")
payment_term_label, delivery_term_label = _payment_terms_summary(metadata)
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:
review_state = reconstructed_review_status(metadata)
if review_state in {"validated", "waived"}:
legacy_notice_html = (
'
'
'Registo reconstruído validado. '
'A oportunidade foi normalizada a partir de documentos existentes e a revisão obrigatória já foi concluída.'
'
'
'Processo reconstruído por validar. '
'Confirma cliente, documento principal, valor e evidência de pagamento antes de executar ações sensíveis.'
'
'
)
else:
legacy_notice_html = (
'
'
'Registo antigo/reconstruído sem estado explícito de revisão. '
'Executa a migração v132 para definir se a revisão está pendente ou já foi concluída.'
'
'
)
opportunity_return_to = f"/opportunities/{opportunity_id}"
try:
next_action = get_opportunity_next_action(opportunity_id)
except Exception:
next_action = {}
lifecycle_state_for_detail = _opportunity_lifecycle_state(opportunity)
lifecycle_task = next((
task for task in pending_tasks
if str(task.get("action_code") or "").upper() in {
"CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA",
"FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC",
"RECOVER_OPPORTUNITY", "REVIEW_NURTURE",
}
), None)
lifecycle_override = False
if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "recovery", "nurture"}:
lifecycle_override = True
next_action = {
"action_code": str(lifecycle_task.get("action_code") or "FOLLOW_UP_GENERIC"),
"label": str(lifecycle_task.get("action") or primary_action_label(lifecycle_task.get("action_code"))),
"description": str(lifecycle_task.get("note") or "Continuar acompanhamento comercial."),
"target_url": f"/tasks/{lifecycle_task.get('id')}",
"source": "lifecycle_task",
}
if reconstructed_review_required(metadata):
review_task = next((
task for task in pending_tasks
if str(task.get("action_code") or "").upper() == "REVIEW_RECONSTRUCTED_PROCESS"
), None)
next_action = {
"action_code": "REVIEW_RECONSTRUCTED_PROCESS",
"label": "Validar processo reconstruído",
"description": "Confirmar cliente, documento principal, valor e evidências antes de executar a ação sensível seguinte.",
"target_url": f"/tasks/{review_task.get('id')}" if review_task else f"/opportunities/{opportunity_id}",
"source": "explicit_reconstructed_review",
}
elif not lifecycle_override and next_task:
next_action = {
"action_code": str(next_task.get("action_code") or "REVIEW_MANUALLY"),
"label": str(next_task.get("action") or primary_action_label(next_task.get("action_code"))),
"description": str(next_task.get("note") or "Executar tarefa pendente."),
"target_url": f"/tasks/{next_task.get('id')}",
"source": "pending_task",
}
# Materialize human-only central actions into actual pending tasks.
# The top-level next action should not be an abstract label when the workbench
# expects an operator to perform it. The helper is idempotent and currently
# creates SEND_INVOICE/FOLLOW_UP_PAYMENT tasks when needed.
try:
materialized_task = ensure_pending_task_for_next_action(
opportunity_id,
next_action if isinstance(next_action, dict) else {},
source="opportunity_detail",
actor="system",
)
except Exception:
materialized_task = {"created": False}
if materialized_task.get("created"):
tasks = list_opportunity_tasks(opportunity_id, limit=100)
all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"]
if terminal_stage:
pending_tasks = [
t for t in all_pending_tasks
if str(t.get("action_code") or "").upper() not in {
"FOLLOW_UP_QUOTE",
"FOLLOW_UP_PROFORMA",
"FOLLOW_UP_PAYMENT",
"FOLLOW_UP_CUSTOMER_REVIEW",
"FOLLOW_UP_GENERIC",
}
]
else:
pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)]
next_task = pending_tasks[0] if pending_tasks else None
opportunity_for_cockpit["pending_task_count"] = len(pending_tasks)
try:
next_action = get_opportunity_next_action(opportunity_id)
except Exception:
pass
if isinstance(next_action, dict):
# v1.5.107: keep the legacy operational cockpit aligned with the
# central decision engine. Without this, CLOSE_OPPORTUNITY could show
# at the top while the cockpit still suggested an old SEND_INVOICE
# action from the legacy workflow plan.
opportunity_for_cockpit["clientflow_next_action"] = dict(next_action)
if next_action:
primary_action = next_action.get("label") or action_label(next_action.get("action_code"))
primary_note = _safe_opportunity_task_text(next_action.get("description") or "Continuar a próxima ação recomendada.")
action_code_upper = str(next_action.get("action_code") or "").upper()
target_url = next_action.get("target_url") or (f"/tasks/{next_task.get('id')}" if next_task else "/tasks?status=pending")
if str(target_url).startswith("/tasks/") and "return_to=" not in str(target_url):
sep = "&" if "?" in str(target_url) else "?"
target_url = f"{target_url}{sep}return_to={quote(opportunity_return_to, safe='')}"
button_label = "Abrir tarefa" if str(target_url).startswith("/tasks/") else "Continuar"
if action_code_upper == "VALIDATE_FISCAL_CUSTOMER":
primary_button = f''
elif action_code_upper == "CLOSE_OPPORTUNITY":
primary_button = (
f''
)
else:
primary_button = f'{esc(button_label)}'
elif next_task:
primary_action = action_label(next_task.get("action_code"))
primary_note = _safe_opportunity_task_text(next_task.get("note") or next_task.get("action") or "Abrir tarefa pendente para continuar.")
primary_button = f'Abrir tarefa'
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 = 'Ver tarefas'
task_rows = ""
for task in tasks[:8]:
task_rows += f'''
{esc(compact_text(_safe_opportunity_task_text(task.get('note') or task.get('action') or ''), 70))}
{route_badge(task.get('route'))}
{status_badge(task.get('status'))}
{esc(fmt_dt(task.get('due_at') or task.get('created_at')))}
'''
if not task_rows:
task_rows = '
Sem tarefas associadas.
'
communication_rows = ""
for communication in opportunity_communications:
action = classification_action(communication.get("classification"))
communication_rows += f'''
Agenda uma tarefa de follow-up. O sistema não cria confirmações de entrega automaticamente; usa “Verificar entrega” apenas quando o histórico sugere um problema real.
'''
lifecycle_state = _opportunity_lifecycle_state(opportunity)
lifecycle_state_label = lifecycle_label(lifecycle_state)
last_customer_dt = _parse_opportunity_dt(opportunity.get("last_customer_activity_at") or opportunity.get("last_message_at"))
last_operator_dt = _parse_opportunity_dt(opportunity.get("last_operator_activity_at"))
next_follow_dt = _parse_opportunity_dt(opportunity.get("next_follow_up_at") or opportunity.get("nurture_until"))
lifecycle_summary = [
f"Última atividade do cliente: {last_customer_dt.strftime('%d/%m/%Y %H:%M') if last_customer_dt else 'sem registo'}",
f"Último contacto do operador: {last_operator_dt.strftime('%d/%m/%Y %H:%M') if last_operator_dt else 'sem registo'}",
f"Próximo contacto: {next_follow_dt.strftime('%d/%m/%Y') if next_follow_dt else 'não agendado'}",
f"Tentativas: {int(opportunity.get('follow_up_attempts') or 0)}",
f"Entrega da última comunicação: {str(opportunity.get('last_delivery_status') or 'não verificada')}",
]
loss_reason_options = ''.join(
f''
for code, label in LOSS_REASON_LABELS.items()
if code != "future_timing"
)
lifecycle_management_html = f'''
Atividade comercial
Controla espera, recuperação, acompanhamento futuro e perda sem usar updated_at técnico como sinal de atividade.
{esc(lifecycle_state_label)}
{''.join(f'
{esc(item)}
' for item in lifecycle_summary)}
'''
correction_state = _opportunity_manual_correction_state(opportunity_id)
if correction_state.get("unavailable"):
correction_badge_html = """
Ligações atuais temporariamente indisponíveis por sincronização/reconciliação em curso. Reabre esta secção dentro de segundos se precisares de corrigir associações.
"""
correction_stage_options = ""
for value in ["INFO_SENT", "INFO_REQUESTED", "QUOTE_REQUESTED", "QUOTE_SENT", "REVIEW", "NO_INTEREST", "LOST"]:
label = OPPORTUNITY_STAGE_LABELS.get(value, value)
selected = "selected" if value == "INFO_SENT" else ""
correction_stage_options += f''
manual_correction_html = f"""
Correção avançada de associação operacional Corrigir associação operacional
Abrir apenas quando Odoo/Jasmin foram associados ao processo errado.
{correction_badge_html}
Zona sensível: não altera Odoo/Jasmin; só limpa a leitura local no ClientFlow e regista auditoria.
"""
archive_spam_state = _opportunity_archive_spam_state(opportunity_id)
archive_spam_html = ""
if archive_spam_state.get("can_archive"):
archive_hint = "Existe evidência de spam nesta oportunidade." if archive_spam_state.get("has_spam_evidence") else "Usa apenas para falso positivo/spam sem documentos nem Odoo/Jasmin."
archive_spam_html = f"""
Arquivar spam/falso positivo
Exclui esta oportunidade do funil sem contar como perdida. Mantém auditoria.
{esc(archive_hint)} A ação é recusada se houver documentos Jasmin, Odoo, Packlink ou reconciliação externa ligada.
"""
technical_html = f'''
ID
{esc(opportunity_id)}
Conversa
{esc(conversation)}
Última action
{esc(opportunity.get('last_action_code') or '—')}
Atualizada
{esc(fmt_dt(opportunity.get('updated_at')))}
'''
# Tarefa ativa folded into "O que fazer agora?" to avoid duplicate cards like
# "Enviar orçamento" appearing twice in the Operation column. Legacy static
# tests still look for the label "Tarefa ativa" to guard the old refresh flow.
next_task_focus_html = ""
payment_term_hint = ""
if payment_term == "after_delivery":
payment_term_hint = '
Pagamento pós-entrega: preparação/envio podem avançar com encomenda confirmada; depois acompanhar fatura/pagamento.
Define a regra do processo sem forçar um fluxo único. Fluxo normal BLIF: orçamento → pagamento → fatura → preparar/enviar encomenda.
'''
stage_control_html = f'''
Alterar fase comercial
Lista curta: detalhes como fatura, pagamento, Odoo, produção e envio devem ser lidos nos cards de contexto.
'''
operation_action_html = f'''
O que fazer agora?
{esc(primary_action)}
{esc(primary_note)}
{primary_button}
'''
jasmin_fiscal_sync_html = ""
if isinstance(jasmin_fiscal_preview, dict) and jasmin_fiscal_preview.get("available"):
candidate = jasmin_fiscal_preview.get("candidate") or {}
document = jasmin_fiscal_preview.get("document") or {}
candidate_line = f"{candidate.get('name') or 'Cliente Jasmin'} · NIF {candidate.get('tax_id') or '—'}"
doc_line = " · ".join(str(x) for x in [document.get('document_number'), document.get('document_kind')] if x)
fillable = jasmin_fiscal_preview.get("fillable_fields") or []
if jasmin_fiscal_preview.get("conflict"):
jasmin_fiscal_sync_html = (
'
'
'Dados Jasmin encontrados, mas a importação está bloqueada por NIF divergente. '
'Revê a associação fiscal antes de importar.
'
)
else:
jasmin_sync_button_label = "Completar com dados Jasmin" if linked_customer else "Associar e completar com Jasmin"
fillable_text = ("Campos a preencher: " + ", ".join(str(x) for x in fillable)) if fillable else "Jasmin encontrado, mas não contém novos campos; associa cliente fiscal com base no documento Jasmin."
jasmin_fiscal_sync_html = (
'
'
'
Dados fiscais disponíveis no Jasmin
'
f'
{esc(candidate_line)}
'
f'
{esc(doc_line or "documento Jasmin associado")}
'
f'
{esc(fillable_text)}
'
f'
'
)
if linked_customer:
linked_customer_label = f"{linked_customer.get('name') or 'Cliente'} · {linked_customer.get('tax_id') or 'sem NIF'}"
linked_customer_email = linked_customer.get("email") or "—"
linked_customer_address_parts = [
linked_customer.get("street_name"),
linked_customer.get("postal_zone"),
linked_customer.get("city_name"),
]
linked_customer_address = " · ".join(str(part) for part in linked_customer_address_parts if part) or "morada fiscal incompleta"
fiscal_missing_inline = fiscal_customer_missing_fields(fiscal_customer)
fiscal_status_badges = (
'associadodados OK'
if not fiscal_missing_inline
else 'associadodados incompletos'
)
fiscal_missing_note = ""
if fiscal_missing_inline:
fiscal_missing_note = '
'''
# "Bloqueios atuais" permanece como conceito de UI/teste, mas o layout agora separa operação e contexto.
body = f'''
← Voltar a oportunidades
{notice_html}
{legacy_notice_html}
{customer_mismatch_alert}
{consistency_alert_html}
Oportunidade
{esc(opportunity.get('title') or 'Oportunidade')}
{esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}