"""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 app.db import engine
from urllib.parse import quote
import json
import uuid
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()
PAYMENT_TERM_LABELS = {
"before_shipping": "Antes do envio",
"after_delivery": "Após entrega",
"agreement": "Conforme acordo",
"undefined": "A definir",
}
DELIVERY_TERM_LABELS = {
"carrier": "Transportadora",
"pickup": "Levantamento",
"install_partner": "Eletricista/instalador do cliente",
"undefined": "A definir",
}
FISCAL_FIELD_LABELS = {
"name": "nome fiscal",
"tax_id": "NIF",
"email": "email de faturação",
"phone": "telefone",
"street_name": "morada fiscal",
"postal_zone": "código postal",
"city_name": "localidade",
"country": "país",
}
def _field_label_list(fields: list | tuple | set) -> str:
return ", ".join(FISCAL_FIELD_LABELS.get(str(field), str(field)) for field in (fields or []))
# 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"),
("LOST", "Perdido"),
("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",
}
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
_OBSOLETE_AFTER_PAYMENT_TASK_CODES = {
"CONFIRM_PAYMENT",
"FOLLOW_UP_PAYMENT",
"FOLLOW_UP_PROFORMA",
"FOLLOW_UP_QUOTE",
}
def _is_obsolete_after_payment_task(task: dict, payment_confirmed: bool) -> bool:
if not payment_confirmed:
return False
return str(task.get("status") or "").lower() == "pending" and str(task.get("action_code") or "").upper() in _OBSOLETE_AFTER_PAYMENT_TASK_CODES
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 _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_sent = bool(invoice.get("sent_at") or invoice.get("sent") or str(invoice.get("status") or "").lower() in {"sent", "issued_sent"})
if invoice_sent:
detail = "Fatura enviada. Próximo passo: acompanhar produção/preparação ou envio."
else:
detail = "Fatura criada/associada. Envia o PDF ao cliente; depois acompanha produção/preparação."
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."""
if not is_uuid_text(opportunity_id):
return {}
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
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 ""
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 = ""
if not sale:
no_sale_warning = '
Sem venda Odoo ligada. Usa candidatos abaixo ou a reconciliação para ligar a venda correta antes de confiar no fluxo físico.
'
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 _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'
{esc(subtitle)}
' if subtitle else ""
blocker_class = " has-blocker" if blockers else ""
return 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"}
payment_confirmed_for_ui = _opportunity_payment_confirmed(opportunity_id) or stage == "PAYMENT_CONFIRMED"
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_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:
legacy_notice_html = (
'
'
'Registo antigo/reconstruído. '
'A oportunidade foi normalizada a partir de documentos já existentes. '
'Valida pagamento, valor e linhas antes de executar novas ações.'
'
'
)
opportunity_return_to = f"/opportunities/{opportunity_id}"
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 = _safe_opportunity_task_text(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")
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 next_action.get("action_code") == "VALIDATE_FISCAL_CUSTOMER":
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_obsolete = _is_obsolete_after_payment_task(task, payment_confirmed_for_ui)
task_note = compact_text(_safe_opportunity_task_text(task.get('note') or task.get('action') or ''), 70)
if task_obsolete:
task_note = compact_text((task_note + " · obsoleta: pagamento já confirmado").strip(), 95)
task_status_html = status_badge("ignored") if task_obsolete else status_badge(task.get('status'))
task_row_class = "table-light" if task_obsolete else ""
task_rows += f'''
{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'''
Decisão seguinte{esc(primary_action)}{esc(next_action.get('action_code') or (next_task.get('action_code') if next_task else None) or opportunity.get('last_action_code') or 'FOLLOW_UP')}
"""
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.
"""
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"
missing_labels = _field_label_list(jasmin_fiscal_preview.get("missing_fields") or [])
fillable_labels = _field_label_list(fillable)
if fillable:
fillable_text = "Ação disponível: importar " + fillable_labels + "."
fiscal_sync_status = 'pode completar'
button_disabled = ""
elif jasmin_fiscal_preview.get("missing_fields"):
fillable_text = "Jasmin encontrado, mas não contém novos campos para preencher os dados em falta: " + missing_labels + "."
fiscal_sync_status = 'sem novos campos'
button_disabled = " disabled"
else:
fillable_text = "Cliente fiscal e documento Jasmin consistentes; sem campos em falta para importar."
fiscal_sync_status = 'sem campos em falta'
button_disabled = " disabled"
jasmin_fiscal_sync_html = (
'
'
'
'
'
Dados fiscais disponíveis no Jasmin
'
f'
{fiscal_sync_status}
'
'
'
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')}