Files
clientflow_backend/app/admin_ui/pages/opportunities.py

3429 lines
190 KiB
Python

"""Commercial opportunity routes and actions.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter, Request
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 += (
'<option value="' + esc(current_stage) + '" selected>'
+ 'Estado atual detalhado: ' + esc(OPPORTUNITY_STAGE_LABELS.get(current_stage, current_stage))
+ '</option>'
)
for value, label in COMMERCIAL_STAGE_OPTIONS:
selected = "selected" if value == current_stage else ""
html += f'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
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'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
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 = '<button class="btn btn-outline-secondary" disabled>Confirmar pagamento</button><div class="small text-secondary">Bloqueado: cria/associa primeiro um orçamento ou fatura.</div>'
elif payment_confirmed:
if not invoice:
action_html = f'''
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/convert-invoice" class="d-grid gap-2">
<button class="btn btn-primary" type="submit">Criar/enviar fatura</button>
<div class="small text-secondary">Pagamento confirmado. Próximo passo do fluxo normal: emitir fatura.</div>
</form>
'''
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'<button class="btn btn-outline-success" disabled>Pagamento confirmado</button><div class="small text-secondary">{esc(detail)}</div>'
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'''
<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/payment_confirmed" class="d-grid gap-2">
<input type="hidden" name="external_name" value="Pagamento confirmado">
<textarea class="form-control" name="note" rows="2" placeholder="Nota opcional: valor recebido, referência bancária, comprovativo...">{esc(note)}</textarea>
<button class="btn btn-success" type="submit">{esc(button_label)}</button>
</form>
'''
return f'''
<section class="card cf-card cf-opp-finance-quick-card">
<div class="card-body p-4">
<h2 class="cf-section-title mb-2">Financeiro rápido</h2>
<div class="small text-secondary mb-3">Ação independente da fase: usa orçamento/fatura associado e a condição comercial.</div>
<div class="cf-opp-operator-grid mb-3">
<div><span>{esc(base_doc_label)}</span><strong>{esc(_document_display_number(base_doc))}</strong></div>
<div><span>Valor esperado</span><strong>{amount_html}</strong></div>
<div><span>Pagamento</span><strong>{esc(payment_status)}</strong></div>
<div><span>Condição</span><strong>{esc(payment_term_label)}</strong></div>
</div>
{action_html}
</div>
</section>
'''
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'<span class="badge {cls}">{esc(status or "")}</span>'
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'<section id="odoo-status-panel" class="card cf-card"><div class="card-body"><div class="alert alert-danger">Erro ao carregar Odoo: {esc(exc)}</div></div></section>'
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'<div class="alert alert-info py-2 small mb-2">{esc(notice)}</div>' if notice else ""
error_html = f'<div class="alert alert-danger py-2 small mb-2"><strong>Erro Odoo:</strong> {esc(error_notice)}</div>' if error_notice else ""
sale_url = str(sale.get("external_url") or "").strip()
sale_link = f'<a href="{esc(sale_url)}" target="_blank" rel="noopener">Abrir Odoo</a>' if sale_url else ""
no_sale_warning = ""
manual_sale_link_html = ""
if not sale:
no_sale_warning = '<div class="alert alert-warning py-2 small mb-2"><strong>Sem venda Odoo ligada.</strong><br>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.</div>'
manual_sale_link_html = f'''
<div class="cf-soft-box mt-2">
<div class="small text-secondary fw-bold text-uppercase mb-1">Associar venda Odoo manualmente</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/link-sale" hx-post="/opportunities/{esc(opportunity_id)}/odoo/link-sale" hx-target="#odoo-status-panel" hx-swap="outerHTML" class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label small text-secondary mb-1">Nº da venda Odoo</label>
<input class="form-control form-control-sm" name="sale_ref" placeholder="Ex.: S00308" autocomplete="off" required>
</div>
<div class="col-md-4 d-grid">
<button class="btn btn-sm btn-primary" type="submit">Registar venda Odoo</button>
</div>
<div class="col-12 small text-secondary">Não cria nada no Odoo; apenas liga a venda já criada ao processo e sincroniza WH/OUT.</div>
</form>
</div>
'''
unlink_odoo_button_html = ""
if sale:
unlink_odoo_button_html = f"""
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/unlink" hx-post="/opportunities/{esc(opportunity_id)}/odoo/unlink" hx-target="#odoo-status-panel" hx-swap="outerHTML" hx-confirm="Desassociar Odoo desta oportunidade? Não altera a venda no Odoo; remove apenas a ligação local e as linhas Odoo importadas.">
<button class="btn btn-outline-danger btn-sm" type="submit">Desassociar Odoo</button>
</form>
"""
picking_rows = ""
for pck in pickings[:8]:
picking_rows += f"""
<tr>
<td><strong>{esc(pck.get('name') or pck.get('id') or 'Entrega')}</strong><div class="small text-secondary">{esc(_odoo_m2o_label(pck.get('type')) or pck.get('origin') or '')}</div></td>
<td>{_odoo_status_badge(pck.get('state'))}</td>
<td>{esc(fmt_dt(pck.get('scheduled_date') or pck.get('date_done')))}</td>
</tr>"""
if not picking_rows:
picking_rows = '<tr><td colspan="3" class="text-center text-secondary py-3">Sem entregas/pickings sincronizados.</td></tr>'
production_rows = ""
for mo in productions[:8]:
production_rows += f"""
<tr>
<td><strong>{esc(mo.get('name') or mo.get('id') or 'Produção')}</strong><div class="small text-secondary">{esc(_odoo_m2o_label(mo.get('product')))}</div></td>
<td>{_odoo_status_badge(mo.get('state'))}</td>
<td>{esc(mo.get('qty') or '')}</td>
</tr>"""
if not production_rows:
production_rows = '<tr><td colspan="3" class="text-center text-secondary py-3">Sem ordens de produção sincronizadas.</td></tr>'
candidate_rows = ""
for cand in candidates:
linked_here = str(cand.get("linked_opportunity_id") or "") == str(opportunity_id)
if linked_here:
action_html = f'''<div class="d-flex flex-column gap-1 align-items-end"><span class="badge text-bg-success">Ligada</span><form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(cand.get("id"))}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(cand.get("id"))}/ignore" hx-target="#odoo-status-panel" hx-swap="outerHTML" hx-confirm="Ignorar esta venda Odoo candidata/ligada no ClientFlow? Não altera Odoo."><button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button></form></div>'''
else:
action_html = f"""
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/link-candidate/{esc(cand.get('id'))}" hx-post="/opportunities/{esc(opportunity_id)}/odoo/link-candidate/{esc(cand.get('id'))}" hx-target="#odoo-status-panel" hx-swap="outerHTML" hx-confirm="Associar esta venda Odoo à oportunidade e atualizar o fluxo operacional?">
<button class="btn btn-sm btn-outline-primary" type="submit">Associar</button>
</form>"""
candidate_rows += f"""
<tr>
<td><strong>{esc(cand.get('document_number') or cand.get('external_id') or 'Venda Odoo')}</strong><div class="small text-secondary">{esc(cand.get('title') or '')}</div></td>
<td>{money_html(cand.get('amount') or 0)}<div class="small text-secondary">{esc(cand.get('currency') or 'EUR')}</div></td>
<td>{esc(cand.get('customer_name') or '')}<div class="small text-secondary text-break">{esc(cand.get('customer_email') or '')}</div></td>
<td>{_odoo_status_badge(cand.get('status'))}</td>
<td class="text-end">{action_html}</td>
</tr>"""
if not candidate_rows:
candidate_rows = '<tr><td colspan="5" class="text-center text-secondary py-3">Sem vendas Odoo candidatas ligadas a esta oportunidade.</td></tr>'
amount_html = money_html(sale_amount) if sale_amount not in {"", None} else ""
details_open = "open" if candidates else ""
physical_next_html = f'<div class="small text-primary mt-1">{esc(physical_next)}</div>' if physical_next else ""
return f"""
<section id="odoo-status-panel" class="card cf-card cf-live-panel">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h2 class="cf-section-title">Estado Odoo</h2>
<div class="small text-secondary">Venda Odoo e entrega/WH-OUT. Ordens de fabrico ficam em detalhe técnico e não conduzem o fluxo do operador.</div>
<div class="small text-secondary">Última sincronização: {esc(fmt_dt(last_synced))}</div>
</div>
<div class="d-flex flex-wrap gap-2">
{unlink_odoo_button_html}
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/sync-status" hx-post="/opportunities/{esc(opportunity_id)}/odoo/sync-status" hx-target="#odoo-status-panel" hx-swap="outerHTML">
<button class="btn btn-primary btn-sm" type="submit">Sincronizar Odoo agora <span class="htmx-indicator">…</span></button>
</form>
<button class="btn btn-outline-secondary btn-sm" type="button" hx-get="/opportunities/{esc(opportunity_id)}/partials/odoo-status" hx-target="#odoo-status-panel" hx-swap="outerHTML">Atualizar painel</button>
</div>
</div>
<div class="p-3 pb-0">{notice_html}{error_html}{no_sale_warning}{manual_sale_link_html}</div>
<div class="p-3 border-bottom">
<div class="row g-3">
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Venda Odoo</div><strong>{esc(sale_name)}</strong><div>{_odoo_status_badge(sale_state)}</div><div class="small text-secondary">{sale_link}</div></div></div>
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Cliente Odoo</div><strong>{esc(partner)}</strong></div></div>
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Valor Odoo</div><strong>{amount_html}</strong></div></div>
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Estado físico</div><strong>{esc(physical_label)}</strong><div class="small text-secondary">{esc(physical_reason)}</div>{physical_next_html}</div></div>
</div>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table"><thead><tr><th>Entrega / picking</th><th>Estado</th><th>Data</th></tr></thead><tbody>{picking_rows}</tbody></table>
</div>
<details class="p-3 border-top">
<summary class="small fw-bold text-secondary" style="cursor:pointer">Detalhes técnicos Odoo / fabrico</summary>
<div class="small text-secondary mt-1">Informativo. O fluxo do ClientFlow usa a venda Odoo e o estado da entrega/WH-OUT; ordens WH/MO não bloqueiam o fecho comercial.</div>
<div class="cf-table-wrap border-0 rounded-0 mt-2">
<table class="table cf-table"><thead><tr><th>Produção / preparação</th><th>Estado</th><th>Qtd.</th></tr></thead><tbody>{production_rows}</tbody></table>
</div>
</details>
<details class="p-3 border-top" {details_open}>
<summary class="small fw-bold text-secondary" style="cursor:pointer">Vendas Odoo ligadas/candidatas</summary>
<div class="small text-secondary mt-1">Associa candidatos apenas quando representam a mesma venda/processo.</div>
<div class="cf-table-wrap border-0 rounded-0 mt-2"><table class="table cf-table"><thead><tr><th>Venda</th><th>Valor</th><th>Cliente</th><th>Estado</th><th class="text-end">Ação</th></tr></thead><tbody>{candidate_rows}</tbody></table></div>
</details>
</div>
</section>
"""
def _task_href_with_return_to(task_id: str, return_to: str) -> str:
href = f"/tasks/{task_id}"
if return_to:
href += f"?return_to={quote(return_to, safe='')}"
return href
def _render_email_identity_review(opportunity_id: str, linked_customer: dict | None) -> str:
try:
from app.fiscal_enrichment_service import email_identity_review_for_opportunity
review = email_identity_review_for_opportunity(opportunity_id, refresh=False)
except Exception as exc:
return f"""
<div class="cf-soft-box mt-3">
<div class="small text-secondary fw-bold text-uppercase mb-2">Identidade do email</div>
<div class="small text-danger">Erro ao ler identidade extraída: {esc(exc)}</div>
</div>
"""
if not review.get("ok") or not review.get("identity"):
return f"""
<div class="cf-soft-box mt-3">
<div class="small text-secondary fw-bold text-uppercase mb-2">Identidade do email</div>
<div class="small text-secondary mb-2">Ainda não existe identidade extraída para esta oportunidade.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/extract" class="d-grid">
<button class="btn btn-sm btn-outline-primary" type="submit">Extrair identidade do email</button>
</form>
</div>
"""
identity = review.get("identity") or {}
companies = review.get("valid_company_mentions") or identity.get("company_mentions") or []
phones = identity.get("phones") or []
evidence = identity.get("evidence") or []
conflict = bool(review.get("conflict"))
suggested = review.get("suggested_internal_customer") or {}
model = (identity.get("raw_payload") or {}).get("llm_model") if isinstance(identity.get("raw_payload"), dict) else identity.get("llm_model")
model = model or identity.get("llm_model") or ""
confidence = identity.get("confidence")
try:
confidence_value = float(confidence or 0)
confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%"
except Exception:
confidence_text = ""
company_html = "".join(f'<span class="badge text-bg-light me-1 mb-1">{esc(c)}</span>' for c in companies) or '<span class="text-secondary">Sem empresa explícita válida</span>'
phone_html = ", ".join(esc(p) for p in phones) if phones else ""
evidence_html = "".join(f'<li>{esc(compact_text(e, 90))}</li>' for e in evidence[:3])
conflict_html = ""
if conflict:
conflict_html = f"""
<div class="alert alert-warning py-2 small mt-2 mb-2">
<strong>Possível conflito fiscal.</strong><br>
O email menciona {esc(', '.join(companies) or 'outra empresa')}, mas a oportunidade está ligada a {esc(review.get('linked_customer_name') or 'outro cliente')}.
</div>
"""
suggested_html = ""
if suggested and companies:
suggested_html = f"""
<div class="border rounded p-2 small bg-white mt-2">
<div class="text-secondary fw-bold text-uppercase">Cliente interno compatível</div>
<strong>{esc(suggested.get('nome') or suggested.get('name') or 'Cliente')}</strong>
<div class="text-secondary">NIF {esc(suggested.get('nif') or suggested.get('tax_id') or '')}</div>
</div>
"""
return f"""
<div class="cf-soft-box mt-3">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div>
<div class="small text-secondary fw-bold text-uppercase">Identidade extraída do email</div>
<div class="small text-secondary">{esc(identity.get('extraction_method') or identity.get('method') or '')} · {esc(model)} · confiança {esc(confidence_text)}</div>
</div>
{status_badge('conflito') if conflict and 'status_badge' in globals() else ''}
</div>
{conflict_html}
<div class="small text-secondary fw-bold text-uppercase mt-2">Pessoa</div>
<div class="fw-semibold">{esc(identity.get('person_name') or '')}</div>
<div class="small text-secondary fw-bold text-uppercase mt-2">Empresa mencionada</div>
<div>{company_html}</div>
<div class="small text-secondary fw-bold text-uppercase mt-2">Email / domínio</div>
<div class="small text-break">{esc(identity.get('email') or '')} · {esc(identity.get('domain') or '')}</div>
<div class="small text-secondary fw-bold text-uppercase mt-2">Morada</div>
<div class="small">{esc(identity.get('address') or '')}</div>
<div class="small text-secondary fw-bold text-uppercase mt-2">Telefones</div>
<div class="small">{phone_html}</div>
{suggested_html}
{f'<ul class="small text-secondary mt-2 mb-0">{evidence_html}</ul>' if evidence_html else ''}
<div class="d-grid gap-2 mt-3">
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/assist">
<button class="btn btn-sm btn-outline-primary w-100" type="submit">Procurar cliente fiscal por identidade</button>
</form>
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/extract">
<button class="btn btn-sm btn-outline-secondary w-100" type="submit">Reextrair identidade</button>
</form>
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/cleanup-invalid" onsubmit="return confirm('Limpar sugestões/extracções antigas inválidas desta oportunidade?')">
<button class="btn btn-sm btn-outline-danger w-100" type="submit">Limpar identidade inválida</button>
</form>
</div>
</div>
"""
def _local_normalize_fiscal_name(value: object) -> str:
text = " ".join(str(value or "").strip().casefold().replace(",", " ").replace(".", " ").split())
legal = {"lda", "ltd", "sa", "s", "a", "unipessoal", "limitada", "sociedade", "portugal"}
return " ".join(token for token in text.split() if token not in legal)
def _render_fiscal_suggestions(opportunity_id: str, linked_customer: dict | None) -> str:
try:
from app.fiscal_enrichment_service import list_fiscal_suggestions_for_opportunity
suggestions = list_fiscal_suggestions_for_opportunity(opportunity_id, limit=3)
except Exception:
suggestions = []
if linked_customer and not suggestions:
return ""
if not suggestions:
return f"""
<div class=\"cf-soft-box mt-3\">
<div class=\"small text-secondary mb-2\">Sem sugestão fiscal externa registada.</div>
<form method=\"post\" action=\"/opportunities/{esc(opportunity_id)}/fiscal-enrich\" class=\"d-grid\">
<button class=\"btn btn-sm btn-outline-primary\" type=\"submit\">Enriquecer cliente fiscal</button>
</form>
</div>
"""
rows = ""
linked_name_norm = _local_normalize_fiscal_name(linked_customer.get("name") if linked_customer else "")
linked_tax_id = str((linked_customer or {}).get("tax_id") or "").strip()
linked_customer_id = str((linked_customer or {}).get("id") or "").strip()
visible_suggestions = []
for suggestion in suggestions:
status = str(suggestion.get("status") or "pending")
lookup_value = str(suggestion.get("lookup_value") or "").strip().lower()
suggested_nif = str(suggestion.get("suggested_nif") or "").strip()
suggested_name_norm = _local_normalize_fiscal_name(suggestion.get("suggested_name"))
suggested_customer_id = str(suggestion.get("suggested_customer_id") or "").strip()
if lookup_value in {"pt", "com", "net", "org", "www", "http", "https", "mail", "email"}:
continue
# Do not show old accepted suggestions that merely confirm the current fiscal customer.
# The fiscal card already shows the truth; repeating an accepted suggestion with stale
# suggested_nif=NULL is confusing.
same_current_customer = bool(
linked_customer
and status == "accepted"
and (
(suggested_customer_id and linked_customer_id and suggested_customer_id == linked_customer_id)
or (linked_name_norm and suggested_name_norm and linked_name_norm == suggested_name_norm)
or (linked_tax_id and suggested_nif and linked_tax_id == suggested_nif)
)
)
if same_current_customer:
continue
visible_suggestions.append(suggestion)
for suggestion in visible_suggestions:
sid = str(suggestion.get("id") or "")
status = str(suggestion.get("status") or "pending")
badge = status_badge(status) if "status_badge" in globals() else f"<span class='badge text-bg-light'>{esc(status)}</span>"
confidence = suggestion.get("confidence")
if confidence is not None:
try:
confidence_value = float(confidence)
confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%"
except Exception:
confidence_text = ""
else:
confidence_text = ""
actions = ""
if status == "pending" and sid:
actions = f"""
<div class=\"d-flex gap-1 mt-2\">
<form method=\"post\" action=\"/fiscal-suggestions/{esc(sid)}/accept\" class=\"flex-fill\">
<button class=\"btn btn-sm btn-primary w-100\" type=\"submit\">Associar</button>
</form>
<form method=\"post\" action=\"/fiscal-suggestions/{esc(sid)}/reject\">
<button class=\"btn btn-sm btn-outline-secondary\" type=\"submit\">Rejeitar</button>
</form>
</div>
"""
rows += f"""
<div class=\"border rounded p-2 mb-2 bg-white\">
<div class=\"d-flex justify-content-between gap-2\"><strong>{esc(suggestion.get('suggested_name') or 'Empresa sugerida')}</strong>{badge}</div>
<div class=\"small text-secondary\">Sugestão fiscal · NIF {esc(suggestion.get('suggested_nif') or '')} · confiança {esc(confidence_text)}</div>
<div class=\"small text-secondary\">{esc(suggestion.get('match_type') or suggestion.get('lookup_type') or 'match')}</div>
{actions}
</div>
"""
if not rows.strip():
return ""
return f"""
<div class=\"cf-soft-box mt-3\">
<div class=\"small text-secondary fw-bold text-uppercase mb-1\">Sugestões fiscais por validar</div>
<div class=\"small text-secondary mb-2\">Não é cliente fiscal confirmado. Associar apenas depois de validar nome/NIF.</div>
{rows}
<form method=\"post\" action=\"/opportunities/{esc(opportunity_id)}/fiscal-enrich\" class=\"d-grid\">
<button class=\"btn btn-sm btn-outline-primary\" type=\"submit\">Atualizar sugestão</button>
</form>
</div>
"""
def _jasmin_candidate_tax_conflict_message(opportunity_id: str, item_id: str) -> str:
"""Return a blocking message when a Jasmin candidate belongs to another NIF."""
try:
from app.commercial_service import get_customer_for_opportunity, normalize_tax_id
from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity
linked_customer = get_customer_for_opportunity(opportunity_id)
linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id"))
if not linked_tax_id:
return ""
for item in find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=50):
if str(item.get("id") or "") != str(item_id):
continue
candidate_tax = normalize_tax_id(item.get("customer_tax_id"))
if candidate_tax and candidate_tax != linked_tax_id:
return (
"NIF divergente: o documento Jasmin pertence a outro cliente fiscal. "
"Rever manualmente na reconciliação antes de associar/substituir."
)
return ""
except Exception:
# Não bloquear quando não conseguimos confirmar conflito; o serviço de importação
# continua responsável por validar a operação.
return ""
return ""
def _opportunity_jasmin_state(opportunity_id: str) -> dict:
# Small UI helper: summarize current Jasmin evidence imported in ClientFlow.
try:
from sqlalchemy import text
from app.db import engine
with engine.begin() as conn:
row = conn.execute(text("""
SELECT
COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_documents,
COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'quotation')::int AS quotations,
COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'proforma')::int AS proformas,
COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'invoice')::int AS invoices,
(ARRAY_AGG(document_number ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_number,
(ARRAY_AGG(document_kind ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_kind,
(ARRAY_AGG(total_amount ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_total_amount
FROM commercial_documents
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
"""), {"opportunity_id": str(opportunity_id)}).mappings().first()
item_count = conn.execute(text("""
SELECT COUNT(*)::int
FROM opportunity_items
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
"""), {"opportunity_id": str(opportunity_id)}).scalar() or 0
data = dict(row or {})
data["item_count"] = int(item_count or 0)
return data
except Exception:
return {"jasmin_documents": 0, "item_count": 0}
def _opportunity_consistency_alert_html(opportunity: dict, tasks: list[dict], opportunity_items: list[dict], opportunity_id: str) -> str:
# Surface soft inconsistencies without blocking the operator.
state = _opportunity_jasmin_state(opportunity_id)
stage = str(opportunity.get("stage") or "")
pending_action_codes = {str(t.get("action_code") or "") for t in tasks if str(t.get("status") or "") == "pending"}
has_payment_task = bool({"CONFIRM_PAYMENT", "CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT"} & pending_action_codes)
has_quote = int(state.get("quotations") or 0) > 0
has_proforma = int(state.get("proformas") or 0) > 0
has_invoice = int(state.get("invoices") or 0) > 0
has_items = bool(opportunity_items) or int(state.get("item_count") or 0) > 0
alerts = []
if has_payment_task and has_quote and not (has_proforma or has_invoice):
alerts.append(
"Existe tarefa de confirmar pagamento 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"<li>{esc(a)}</li>" for a in alerts[:3])
return f'''
<div class="alert alert-warning py-2 small mb-0">
<strong>Verificação de consistência operacional</strong>
<ul class="mb-0 mt-1">{items}</ul>
</div>
'''
def _derived_timeline_html(opportunity_id: str) -> str:
# Fallback timeline based on current documents/items/tasks when no audit events exist.
try:
from sqlalchemy import text
from app.db import engine
with engine.begin() as conn:
docs = conn.execute(text("""
SELECT document_kind, document_number, total_amount, status, created_at
FROM commercial_documents
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
ORDER BY created_at DESC
LIMIT 3
"""), {"opportunity_id": str(opportunity_id)}).mappings().all()
item_count = conn.execute(text("""
SELECT COUNT(*)::int
FROM opportunity_items
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
"""), {"opportunity_id": str(opportunity_id)}).scalar() or 0
except Exception:
docs, item_count = [], 0
items = ""
for doc in docs:
title = "Documento Jasmin importado"
detail = f"{doc.get('document_number') or 'documento'} · {money_html(doc.get('total_amount') or 0)}"
items += f'''
<div class="cf-timeline-item">
<div class="small text-secondary">{esc(fmt_dt(doc.get('created_at')))}<div class="mt-1"><span class="badge text-bg-light">derivado</span></div></div>
<div><div class="d-flex flex-wrap align-items-center gap-2"><strong>{esc(title)}</strong>{operation_status_badge(str(doc.get('status') or 'created'))}</div><div class="small text-secondary text-break">{esc(detail)}</div></div>
</div>
'''
if item_count and not docs:
items += f'''
<div class="cf-timeline-item">
<div class="small text-secondary">—<div class="mt-1"><span class="badge text-bg-light">derivado</span></div></div>
<div><strong>Produtos na oportunidade</strong><div class="small text-secondary">{esc(item_count)} linha(s) comerciais associadas.</div></div>
</div>
'''
return items
def _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'<div class="cf-opportunity-card-subtitle">{esc(subtitle)}</div>' if subtitle else ""
blocker_class = " has-blocker" if blockers else ""
return f"""
<article class="cf-opportunity-card-clean cf-opportunity-card-compact{blocker_class}">
<a class="cf-opportunity-card-main text-decoration-none text-reset" href="/opportunities/{esc(oid)}">
<div class="d-flex flex-wrap justify-content-between gap-2 mb-1"><span class="badge {esc(state_class)}">{esc(state_label)}</span><strong class="small">{value_text}</strong></div>
<div class="cf-opportunity-card-title">{esc(title)}</div>
{subtitle_html}
<div class="cf-opportunity-card-subject">{esc(subject)}</div>
<div class="small text-secondary mt-2">{esc(customer_age)} · {esc(follow_text)} · {attempts} tentativa(s)</div>
{blocker_html}
<div class="cf-opportunity-next-action">
<span>Próxima ação</span>
<strong>{esc(next_action)}</strong>
</div>
</a>
<a class="btn btn-sm {esc(cta_class)} w-100 cf-opportunity-card-cta" href="/opportunities/{esc(oid)}">{esc(cta_label)}</a>
</article>
"""
def render_opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str:
visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit)
board_html = ""
for key, label, _stages in visible_board_columns:
cards = "".join(_render_opportunity_card(opp) for opp in grouped.get(key, []))
if not cards:
cards = '<div class="border border-dashed rounded p-4 text-center text-secondary bg-white">Sem oportunidades nesta etapa.</div>'
board_html += f"""
<section class="cf-opportunity-stage" id="stage-{esc(key)}">
<header class="cf-opportunity-stage-header">
<strong>{esc(label)}</strong>
<span>{len(grouped.get(key, []))}</span>
</header>
<div class="cf-opportunity-stage-body">{cards}</div>
</section>
"""
return f"""
<div id="opportunities-board" class="cf-live-panel cf-opportunities-board" aria-live="polite">
<div class="cf-opportunities-board-meta">
<span>{len(visible_opportunities)} resultado(s)</span>
<span class="htmx-indicator" id="opportunities-loading">A atualizar…</span>
</div>
<div class="cf-opportunities-board-scroll">
<div class="cf-opportunities-board-grid">{board_html}</div>
</div>
</div>
"""
@router.get("/opportunities/partials/board", response_class=HTMLResponse)
async def opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300):
return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit))
@router.get("/opportunities", response_class=HTMLResponse)
@router.get("/oportunidades", response_class=HTMLResponse)
async def opportunities_page(
request: Request,
q: Optional[str] = None,
status: Optional[str] = "open",
scope: Optional[str] = "all",
limit: int = 300,
):
# Quadro operacional em Bootstrap 5. v4.7.4 adds an HTMX board partial
# while preserving the same opportunity query and card semantics.
if (status or "open") == "closed":
status = "open"
visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit)
total_open = sum(1 for opp in visible_opportunities if str(opp.get("status") or "") == "open")
total_pending = sum(int(opp.get("pending_task_count") or 0) for opp in visible_opportunities)
total_value = sum(float(opp.get("value_amount") or 0) for opp in visible_opportunities)
attention = [opp for opp in visible_opportunities if int(opp.get("pending_task_count") or 0) > 0]
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'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
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'<a class="btn btn-sm {active}" href="{esc(href)}" hx-get="{esc(partial_href)}" hx-target="#opportunities-board" hx-swap="outerHTML" hx-push-url="{esc(href)}" hx-indicator="#opportunities-loading">{esc(label)}</a>'
body = f"""
<section class="row g-3 row-cols-1 row-cols-sm-2 row-cols-xxl-4 mb-3">
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?scope=active"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Ativas</div><div class="h4 fw-bold mb-1">{active_count}</div><div class="small">com contacto em curso</div></div></a></div>
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?scope=follow_up_due"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Follow-up vencido</div><div class="h4 fw-bold mb-1">{due_count}</div><div class="small">contactar agora</div></div></a></div>
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?scope=recovery"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Recuperação</div><div class="h4 fw-bold mb-1">{recovery_count}</div><div class="small">decisão comercial necessária</div></div></a></div>
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?scope=unvalued"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Por valorizar</div><div class="h4 fw-bold mb-1">{unvalued_count}</div><div class="small">de {total_open} abertas · {money_html(total_value)}</div></div></a></div>
</section>
<section class="card cf-card mb-3"><div class="card-body"><form class="row g-3 align-items-end" method="get" action="/opportunities" hx-get="/opportunities/partials/board" hx-target="#opportunities-board" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#opportunities-loading"><div class="col-lg-7"><label class="form-label small fw-bold text-secondary">Procurar oportunidade</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="cliente, email, produto, conversa..."></div><div class="col-lg-2"><label class="form-label small fw-bold text-secondary">Estado</label><select class="form-select" name="status">{status_options}</select><input type="hidden" name="scope" value="{esc(scope or 'all')}"></div><div class="col-lg-3 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/opportunities">Limpar</a></div></form></div></section>
<section class="card cf-card mb-3"><div class="card-body d-flex flex-wrap gap-2 align-items-center"><strong class="me-1">Filtros:</strong>{stage_tabs}</div></section>
<section class="card cf-card">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div><h2 class="cf-section-title mb-1">Quadro de oportunidades</h2><div class="small text-secondary">Cards por etapa, com identificação clara, assunto, próxima ação e bloqueios relevantes. Filtros atualizam por HTMX.</div></div>
</div>
<div class="p-3">{render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)}</div>
</div>
</section>
"""
return layout("Oportunidades", "Pipeline comercial com foco na próxima ação", body, active="opportunities")
@router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse)
async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = None):
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", '<section class="cf-empty">Oportunidade não encontrada.</section>', "opportunities")
tasks = list_opportunity_tasks(opportunity_id, limit=100)
events = list_opportunity_events(opportunity_id, limit=100)
stage = str(opportunity.get("stage") or "NEW_LEAD")
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'<div class="alert alert-info">{esc(notice)}</div>' 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 = (
'<div class="alert alert-info border-0">'
'<strong>Registo reconstruído validado.</strong><br>'
'A oportunidade foi normalizada a partir de documentos existentes e a revisão obrigatória já foi concluída.'
'</div>'
)
elif review_state == "required":
legacy_notice_html = (
'<div class="alert alert-warning border-0">'
'<strong>Processo reconstruído por validar.</strong><br>'
'Confirma cliente, documento principal, valor e evidência de pagamento antes de executar ações sensíveis.'
'</div>'
)
else:
legacy_notice_html = (
'<div class="alert alert-warning border-0">'
'<strong>Registo antigo/reconstruído sem estado explícito de revisão.</strong><br>'
'Executa a migração v132 para definir se a revisão está pendente ou já foi concluída.'
'</div>'
)
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'<form method="post" action="/opportunities/{esc(opportunity_id)}/fiscal-enrich"><button class="btn btn-primary" type="submit">Enriquecer cliente fiscal</button></form>'
elif action_code_upper == "CLOSE_OPPORTUNITY":
primary_button = (
f'<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/delivered" class="d-grid gap-2">'
'<input type="hidden" name="external_name" value="Oportunidade concluída">'
'<input type="hidden" name="note" value="Fatura enviada, pagamento confirmado e Odoo/WH-OUT concluído.">'
'<button class="btn btn-success" type="submit">Concluir oportunidade</button>'
'</form>'
)
else:
primary_button = f'<a class="btn btn-primary" href="{esc(target_url)}">{esc(button_label)}</a>'
elif next_task:
primary_action = action_label(next_task.get("action_code"))
primary_note = _safe_opportunity_task_text(next_task.get("note") or next_task.get("action") or "Abrir tarefa pendente para continuar.")
primary_button = f'<a class="btn btn-primary" href="{esc(_task_href_with_return_to(str(next_task.get("id") or ""), opportunity_return_to))}">Abrir tarefa</a>'
else:
primary_action = opportunity_next_action_text(opportunity)
primary_note = "Não existe tarefa pendente ligada. Atualiza o estado ou acompanha a oportunidade."
primary_button = '<a class="btn btn-outline-primary" href="/tasks?status=pending">Ver tarefas</a>'
task_rows = ""
for task in tasks[:8]:
task_rows += f'''
<tr>
<td><a class="cf-row-link" href="{esc(_task_href_with_return_to(str(task.get('id') or ''), opportunity_return_to))}">{esc(action_label(task.get('action_code')))}</a><div class="small text-secondary">{esc(compact_text(_safe_opportunity_task_text(task.get('note') or task.get('action') or ''), 70))}</div></td>
<td>{route_badge(task.get('route'))}</td>
<td>{status_badge(task.get('status'))}</td>
<td>{esc(fmt_dt(task.get('due_at') or task.get('created_at')))}</td>
</tr>
'''
if not task_rows:
task_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem tarefas associadas.</td></tr>'
communication_rows = ""
for communication in opportunity_communications:
action = classification_action(communication.get("classification"))
communication_rows += f'''
<tr>
<td><a class="cf-row-link" href="/communications/{esc(communication.get('id'))}">{esc(communication.get('subject') or 'Sem assunto')}</a><div class="small text-secondary text-break">{esc(communication.get('sender_name') or communication.get('sender_email') or '')}</div></td>
<td><span class="cf-chip {esc(action.get('chip'))}">{esc(communication.get('classification') or 'por classificar')}</span></td>
<td>{status_badge(communication.get('status'))}</td>
<td class="text-secondary small">{esc(fmt_dt(communication.get('created_at')))}</td>
</tr>
'''
if not communication_rows:
conv = str(opportunity.get("conversation_id") or "").strip()
if conv:
communication_rows = f'''
<tr class="table-warning">
<td><strong>Conversa Chatwoot #{esc(conv)}</strong><div class="small text-secondary">Ainda não há mensagens indexadas/ligadas nesta oportunidade.</div></td>
<td><span class="cf-chip cf-chip-orange">por sincronizar</span></td>
<td><span class="cf-chip cf-chip-gray">sem ligação local</span></td>
<td class="text-secondary small">—</td>
</tr>
'''
else:
communication_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem comunicações associadas à oportunidade.</td></tr>'
timeline_items = ""
try:
unified_timeline = list_unified_opportunity_timeline(opportunity_id, limit=14)
except Exception:
unified_timeline = []
for event in unified_timeline:
status = event.get("status")
status_html = status_badge(status) if status else ""
source = event.get("source") or "event"
detail = compact_text(event.get("detail") or "", 140)
timeline_items += f'''
<div class="cf-timeline-item">
<div class="small text-secondary">{esc(fmt_dt(event.get('created_at')))}<div class="mt-1"><span class="badge text-bg-light">{esc(source)}</span></div></div>
<div><div class="d-flex flex-wrap align-items-center gap-2"><strong>{esc(event.get('title') or 'Evento')}</strong>{status_html}</div><div class="small text-secondary text-break">{esc(detail or '')}</div></div>
</div>
'''
if not timeline_items:
timeline_items = _derived_timeline_html(opportunity_id)
if not timeline_items:
timeline_items = '<div class="text-secondary">Sem eventos registados.</div>'
# Mostrar poucas fases comerciais. Estados financeiros/Odoo/envio continuam
# visíveis como evidência derivada, mas deixam de dominar o dropdown.
stage_options = _commercial_stage_options_html(stage)
customer_name = opportunity_customer_name(opportunity)
contact_name = opportunity_contact_name(opportunity)
customer_email = opportunity.get("customer_email") or ""
customer_phone = opportunity.get("customer_phone") or ""
conversation = opportunity.get("conversation_id") or ""
# v4.6.2: não mostrar aviso por divergência de nome. Contacto pessoal e
# cliente fiscal/empresa podem ser diferentes e ainda assim estar corretos.
customer_mismatch_alert = ""
linked_customer = None
customer_options = '<option value="">Selecionar cliente...</option>'
try:
from app.commercial_service import get_customer_for_opportunity, list_customers
linked_customer = get_customer_for_opportunity(opportunity_id)
for c in list_customers(limit=150):
selected = "selected" if linked_customer and str(c.get("id")) == str(linked_customer.get("id")) else ""
label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or 'sem NIF'}"
customer_options += f'<option value="{esc(c.get("id"))}" {selected}>{esc(label)}</option>'
except Exception:
linked_customer = None
fiscal_suggestions_html = _render_fiscal_suggestions(opportunity_id, linked_customer)
email_identity_html = _render_email_identity_review(opportunity_id, linked_customer)
try:
from app.jasmin_fiscal_sync_service import get_jasmin_fiscal_sync_preview
jasmin_fiscal_preview = get_jasmin_fiscal_sync_preview(opportunity_id)
except Exception:
jasmin_fiscal_preview = {"available": False}
fiscal_customer = opportunity_context_customer(opportunity, linked_customer)
fiscal_customer_href = f"/customers/{esc(fiscal_customer.get('id'))}" if fiscal_customer and fiscal_customer.get("id") else ""
fiscal_contact_html = fiscal_contact_panel_html(
fiscal_customer=fiscal_customer,
contact_name=contact_name,
contact_email=customer_email,
contact_phone=customer_phone,
conversation_id=opportunity.get("conversation_id"),
contact_id=opportunity.get("contact_id"),
customer_href=fiscal_customer_href,
)
next_action_code = (next_action.get("action_code") if isinstance(next_action, dict) else None) or opportunity.get("last_action_code")
current_blockers = opportunity_blockers(opportunity, linked_customer, action_code=next_action_code)
document_already_issued = bool(
primary_document
or linked_documents
or stage in {"QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", "WON"}
)
blockers_html = (
'<div class="alert alert-warning border-0 mb-0"><strong>Avisos para revisão</strong>'
+ '<ul class="mb-0 mt-2">'
+ ''.join(f"<li>{esc(item)}</li>" for item in current_blockers)
+ '</ul><div class="small text-secondary mt-2">Existe documento emitido/ligado; estes dados devem ser revistos para próximos documentos ou correção administrativa.</div></div>'
if current_blockers and document_already_issued
else blocker_alert_html(current_blockers)
)
fiscal_readiness_html = readiness_checklist_html(
title="Prontidão para documentos",
missing=fiscal_customer_missing_fields(fiscal_customer),
ok_text="Cliente fiscal pronto para orçamento ou fatura.",
blocked_text=("Dados fiscais incompletos no ClientFlow; rever para próximos documentos." if document_already_issued else "Dados fiscais incompletos no ClientFlow; rever antes de emitir novo documento."),
)
shipment_readiness_html = readiness_checklist_html(
title="Prontidão para envio",
missing=shipment_missing_fields(fiscal_customer, opportunity),
ok_text="Dados mínimos de envio completos.",
blocked_text="Envio deve aguardar correção destes dados.",
)
consistency_alert_html = _opportunity_consistency_alert_html(opportunity, tasks, opportunity_items, opportunity_id)
if primary_document:
document_label = commercial_document_display_number(primary_document, fallback="número por atualizar")
document_kind = {
"quotation": "Orçamento",
"proforma": "Orçamento legado",
"invoice": "Fatura",
}.get(str(primary_document.get("document_kind") or ""), "Documento")
document_state = f"{document_kind} · {document_label}"
document_chip = '<span class="cf-chip cf-chip-green">ligado</span>'
else:
document_state = "Sem documento principal"
document_chip = '<span class="cf-chip cf-chip-orange">pendente</span>'
fiscal_state = (linked_customer.get("name") if linked_customer else "Por associar")
fiscal_missing_for_chip = fiscal_customer_missing_fields(fiscal_customer) if linked_customer else []
if linked_customer and not fiscal_missing_for_chip:
fiscal_chip = '<span class="cf-chip cf-chip-green">OK</span>'
elif linked_customer:
fiscal_chip = '<span class="cf-chip cf-chip-orange">associado · incompleto</span>'
else:
fiscal_chip = '<span class="cf-chip cf-chip-orange">sem cliente</span>'
task_state = f"{len(pending_tasks)} pendente(s)" if pending_tasks else "Sem tarefas pendentes"
task_chip = '<span class="cf-chip cf-chip-orange">requer ação</span>' if pending_tasks else '<span class="cf-chip cf-chip-green">limpo</span>'
display_next_action_code = str((next_action.get('action_code') if isinstance(next_action, dict) else None) or (next_task.get('action_code') if next_task else None) or opportunity.get('last_action_code') or 'FOLLOW_UP').upper()
if display_next_action_code in {'WAIT_PRODUCTION', 'WAIT_ODOO'} and stage in {'READY_TO_SHIP', 'SHIPMENT_CREATED'}:
display_next_action_code = 'SHIP_ORDER'
if isinstance(next_action, dict) and str(next_action.get('action_code') or '').upper() == 'SEND_INVOICE':
display_next_action_code = 'SEND_INVOICE'
operator_summary_html = f'''
<section class="card cf-card cf-opp-operator-card">
<div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div><h2 class="cf-section-title mb-1">Mapa operacional</h2><div class="small text-secondary">Leitura rápida do processo: cliente fiscal, documento principal, task e próxima ação.</div></div>
<a class="btn btn-sm btn-outline-primary" href="/reconciliation?status=open">Ver reconciliação</a>
</div>
<div class="cf-opp-operator-grid">
<div><span>Cliente fiscal</span><strong>{esc(fiscal_state)}</strong>{fiscal_chip}</div>
<div><span>Documento principal</span><strong>{esc(document_state)}</strong>{document_chip}</div>
<div><span>Tasks</span><strong>{esc(task_state)}</strong>{task_chip}</div>
<div><span>Decisão seguinte</span><strong>{esc(primary_action)}</strong><span class="cf-chip cf-chip-blue">{esc(display_next_action_code)}</span></div>
</div>
<details class="cf-advanced-actions mt-3">
<summary>Ações avançadas</summary>
<div class="d-flex flex-wrap gap-2 mt-2">
<a class="btn btn-sm btn-outline-secondary" href="#documentos">Documentos Jasmin</a>
<a class="btn btn-sm btn-outline-secondary" href="#tecnico">Detalhes técnicos</a>
<a class="btn btn-sm btn-outline-secondary" href="/tasks?status=pending&q={esc(opportunity_id)}">Tasks desta oportunidade</a>
</div>
</details>
</div>
</section>
'''
manual_follow_up_html = f'''
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Criar follow-up</h2>
<div class="small text-secondary mb-3">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.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/follow-up" class="d-grid gap-2">
<select class="form-select" name="follow_up_type">
<option value="quote">Orçamento enviado</option>
<option value="payment">Pagamento pendente</option>
<option value="review">Cliente a analisar</option>
<option value="generic">Follow-up genérico</option>
<option value="delivery">Verificar entrega (manual)</option>
</select>
<select class="form-select" name="delay_days">
<option value="1">Amanhã</option>
<option value="2">Daqui a 2 dias</option>
<option value="3" selected>Daqui a 3 dias</option>
<option value="5">Daqui a 5 dias</option>
<option value="7">Daqui a 7 dias</option>
<option value="14">Daqui a 14 dias</option>
</select>
<textarea class="form-control" name="note" rows="2" placeholder="Nota opcional para o operador"></textarea>
<button class="btn btn-outline-primary" type="submit">Agendar follow-up</button>
</form>
</div></section>
'''
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'<option value="{esc(code)}">{esc(label)}</option>'
for code, label in LOSS_REASON_LABELS.items()
if code != "future_timing"
)
lifecycle_management_html = f'''
<section class="card cf-card"><div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-2">
<div><h2 class="cf-section-title mb-1">Atividade comercial</h2><div class="small text-secondary">Controla espera, recuperação, acompanhamento futuro e perda sem usar updated_at técnico como sinal de atividade.</div></div>
<span class="badge text-bg-light">{esc(lifecycle_state_label)}</span>
</div>
<ul class="small text-secondary mb-3">{''.join(f'<li>{esc(item)}</li>' for item in lifecycle_summary)}</ul>
<div class="row g-3">
<div class="col-lg-6">
<form method="post" action="/opportunities/{esc(opportunity_id)}/lifecycle" class="d-grid gap-2">
<label class="form-label small fw-bold text-secondary mb-0">Estado operacional</label>
<select class="form-select" name="state">
<option value="active">Ativa</option>
<option value="recovery">Recuperação</option>
<option value="nurture">Acompanhamento futuro</option>
</select>
<input class="form-control" type="date" name="nurture_until" aria-label="Data para retomar contacto">
<textarea class="form-control" name="reason" rows="2" placeholder="Motivo ou próxima abordagem"></textarea>
<button class="btn btn-outline-primary" type="submit">Guardar estado operacional</button>
</form>
</div>
<div class="col-lg-6">
<form method="post" action="/opportunities/{esc(opportunity_id)}/lost" class="d-grid gap-2" onsubmit="return confirm('Marcar esta oportunidade como perdida?');">
<label class="form-label small fw-bold text-secondary mb-0">Fechar como perdida</label>
<select class="form-select" name="reason_code" required><option value="">Selecionar motivo…</option>{loss_reason_options}</select>
<textarea class="form-control" name="note" rows="2" placeholder="Detalhe opcional"></textarea>
<button class="btn btn-outline-danger" type="submit">Marcar como perdida</button>
</form>
</div>
</div>
</div></section>
'''
correction_state = _opportunity_manual_correction_state(opportunity_id)
if correction_state.get("unavailable"):
correction_badge_html = """
<div class="small text-warning mb-2">
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.
</div>
"""
else:
correction_badge_html = f"""
<div class="small text-secondary mb-2">
Ligações atuais: Odoo {esc(correction_state.get('odoo_links', 0))} · Jasmin docs {esc(correction_state.get('jasmin_documents', 0))} · linhas importadas {esc(correction_state.get('imported_lines', 0))} · candidatos ligados {esc(correction_state.get('reconciliation_items', 0))}
</div>
"""
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'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
manual_correction_html = f"""
<details class="card cf-card border-warning cf-advanced-actions">
<summary class="card-body p-4 fw-bold text-primary" style="cursor:pointer">
Correção avançada de associação operacional <span class="visually-hidden">Corrigir associação operacional</span>
<div class="small text-secondary fw-normal mt-1">Abrir apenas quando Odoo/Jasmin foram associados ao processo errado.</div>
{correction_badge_html}
</summary>
<div class="card-body border-top p-4">
<div class="alert alert-warning py-2 small">Zona sensível: não altera Odoo/Jasmin; só limpa a leitura local no ClientFlow e regista auditoria.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/manual-correction" class="d-grid gap-2" onsubmit="return confirm('Confirmar correção manual? Isto remove ligações locais Odoo/Jasmin desta oportunidade e altera a fase escolhida. Não apaga nada no Odoo/Jasmin.')">
<label class="form-check small"><input class="form-check-input" type="checkbox" name="unlink_odoo" value="1" checked> Desassociar Odoo desta oportunidade</label>
<label class="form-check small"><input class="form-check-input" type="checkbox" name="unlink_jasmin" value="1" checked> Desassociar documentos/candidatos Jasmin ligados</label>
<label class="form-check small"><input class="form-check-input" type="checkbox" name="remove_imported_lines" value="1" checked> Remover linhas importadas Odoo/Jasmin da oportunidade</label>
<label class="form-label small fw-bold text-secondary mb-0 mt-1">Nova fase após correção</label>
<select class="form-select" name="stage">{correction_stage_options}</select>
<textarea class="form-control" name="note" rows="3" placeholder="Motivo: Odoo/Jasmin pertencem a outro cliente; classificar como informação enviada."></textarea>
<button class="btn btn-warning" type="submit">Corrigir e classificar</button>
</form>
</div>
</details>
"""
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"""
<details class="card cf-card border-danger cf-advanced-actions">
<summary class="card-body p-4 fw-bold text-danger" style="cursor:pointer">
Arquivar spam/falso positivo
<div class="small text-secondary fw-normal mt-1">Exclui esta oportunidade do funil sem contar como perdida. Mantém auditoria.</div>
</summary>
<div class="card-body border-top p-4">
<div class="alert alert-danger py-2 small">{esc(archive_hint)} A ação é recusada se houver documentos Jasmin, Odoo, Packlink ou reconciliação externa ligada.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/archive-spam" class="d-grid gap-2" onsubmit="return confirm('Arquivar esta oportunidade como spam/falso positivo? Não conta como perdida e sai do funil.');">
<textarea class="form-control" name="reason" rows="2" placeholder="Motivo opcional">spam/falso positivo</textarea>
<button class="btn btn-outline-danger" type="submit">Arquivar como spam</button>
</form>
</div>
</details>
"""
technical_html = f'''
<div class="row g-3">
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">ID</div><code>{esc(opportunity_id)}</code></div></div>
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Conversa</div><code>{esc(conversation)}</code></div></div>
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Última action</div><code>{esc(opportunity.get('last_action_code') or '')}</code></div></div>
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Atualizada</div><strong>{esc(fmt_dt(opportunity.get('updated_at')))}</strong></div></div>
</div>
'''
# 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 = '<div class="alert alert-info py-2 px-3 small mb-0">Pagamento pós-entrega: preparação/envio podem avançar com encomenda confirmada; depois acompanhar fatura/pagamento.</div>'
elif payment_term == "before_shipping":
payment_term_hint = '<div class="alert alert-warning py-2 px-3 small mb-0">Pagamento antes do envio: confirmar pagamento com base no orçamento; emitir fatura só depois do pagamento confirmado.</div>'
finance_quick_card_html = _finance_quick_card_html(opportunity_id, linked_documents, payment_term, payment_term_label)
commercial_terms_card_html = f'''
<section class="card cf-card cf-opp-commercial-terms-card">
<div class="card-body p-4">
<h2 class="cf-section-title mb-2">Condições comerciais</h2>
<div class="small text-secondary mb-3">Define a regra do processo sem forçar um fluxo único. Fluxo normal BLIF: orçamento → pagamento → fatura → preparar/enviar encomenda.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/commercial-terms" class="d-grid gap-2">
<label class="form-label small fw-bold text-secondary mb-0">Pagamento</label>
<select class="form-select" name="payment_terms">{_option_tags(PAYMENT_TERM_LABELS, payment_term)}</select>
<label class="form-label small fw-bold text-secondary mb-0 mt-1">Entrega</label>
<select class="form-select" name="delivery_terms">{_option_tags(DELIVERY_TERM_LABELS, delivery_term)}</select>
<textarea class="form-control" name="note" rows="2" placeholder="Nota: pagamento a 30 dias, cliente habitual, acordo especial...">{esc(commercial_terms_note)}</textarea>
{payment_term_hint}
<button class="btn btn-outline-primary" type="submit">Guardar condições</button>
</form>
</div>
</section>
'''
stage_control_html = f'''
<section class="card cf-card">
<div class="card-body p-4">
<h2 class="cf-section-title mb-2">Alterar fase comercial</h2>
<div class="small text-secondary mb-3">Lista curta: detalhes como fatura, pagamento, Odoo, produção e envio devem ser lidos nos cards de contexto.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/stage" class="d-grid gap-2">
<select class="form-select" name="stage">{stage_options}</select>
<textarea class="form-control" name="note" rows="3" placeholder="Nota opcional"></textarea>
<button class="btn btn-primary" type="submit">Guardar fase</button>
</form>
</div>
</section>
'''
operation_action_html = f'''
<section id="operacao" class="card cf-card cf-opp-now-card">
<div class="card-body p-4">
<div class="small text-secondary fw-bold text-uppercase">O que fazer agora?</div>
<h2 class="h4 fw-bold mb-2">{esc(primary_action)}</h2>
<div class="text-secondary mb-3">{esc(primary_note)}</div>
<div class="d-grid gap-2">{primary_button}</div>
</div>
</section>
'''
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 = (
'<div class="alert alert-warning py-2 px-3 small mt-3 mb-0">'
'Dados Jasmin encontrados, mas a importação está bloqueada por NIF divergente. '
'Revê a associação fiscal antes de importar.</div>'
)
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 = (
'<div class="cf-soft-box mt-3">'
'<div class="small text-secondary fw-bold">Dados fiscais disponíveis no Jasmin</div>'
f'<div class="fw-bold cf-text-break">{esc(candidate_line)}</div>'
f'<div class="small text-secondary cf-text-break">{esc(doc_line or "documento Jasmin associado")}</div>'
f'<div class="small text-secondary mt-1">{esc(fillable_text)}</div>'
f'<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/complete-fiscal" class="mt-2">'
f'<button class="btn btn-sm btn-outline-primary" type="submit">{esc(jasmin_sync_button_label)}</button>'
'</form></div>'
)
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 = (
'<span class="cf-mini-badge cf-mini-badge-ok">associado</span><span class="cf-mini-badge cf-mini-badge-ok">dados OK</span>'
if not fiscal_missing_inline
else '<span class="cf-mini-badge cf-mini-badge-ok">associado</span><span class="cf-mini-badge cf-mini-badge-warn">dados incompletos</span>'
)
fiscal_missing_note = ""
if fiscal_missing_inline:
fiscal_missing_note = '<div class="small text-secondary mt-2">Faltam: ' + esc(", ".join(fiscal_missing_inline)) + '.</div>'
fiscal_customer_url = f"/customers/{esc(linked_customer.get('id'))}" if linked_customer.get("id") else "/customers"
fiscal_association_card_html = f'''
<section class="card cf-card cf-opp-fiscal-action-card">
<div class="card-body p-4">
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-3">
<div class="min-w-0">
<h2 class="cf-section-title mb-1">Cliente fiscal</h2>
<div class="small text-secondary">Ficha fiscal associada à oportunidade. <span class="visually-hidden">Associar cliente fiscal</span></div>
</div>
<div class="cf-mini-badge-row">{fiscal_status_badges}</div>
</div>
<div class="cf-linked-customer-box">
<div class="fw-bold cf-text-break">{esc(linked_customer.get('name') or 'Cliente fiscal associado')}</div>
<div class="small text-secondary cf-text-break">NIF {esc(linked_customer.get('tax_id') or '')} · {esc(linked_customer_email)}</div>
<div class="small text-secondary cf-text-break">{esc(linked_customer_address)}</div>
{fiscal_missing_note}
</div>
{jasmin_fiscal_sync_html}
<div class="d-flex flex-wrap gap-2 mt-3">
<a class="btn btn-sm btn-outline-primary" href="{fiscal_customer_url}">Ver ficha</a>
<a class="btn btn-sm btn-outline-secondary" href="/customers?q={quote(str(customer_email or customer_name or linked_customer.get('name') or ''))}">Alterar/pesquisar</a>
<form method="post" action="/opportunities/{esc(opportunity_id)}/customer" class="d-inline" onsubmit="return confirm('Desassociar cliente fiscal desta oportunidade?')">
<input type="hidden" name="customer_id" value="">
<button class="btn btn-sm btn-outline-danger" type="submit">Desassociar</button>
</form>
</div>
<div class="small text-secondary mt-3">Sugestões de identidade continuam disponíveis na secção de contexto quando houver candidatos por validar.</div>
</div>
</section>
'''
else:
fiscal_association_card_html = f'''
<section class="card cf-card cf-opp-fiscal-action-card">
<div class="card-body p-4">
<h2 class="cf-section-title mb-2">Associar cliente fiscal</h2>
<div class="small text-secondary mb-3">Resolve o bloqueio fiscal desta oportunidade. Escolhe uma ficha existente ou deixa vazio para desassociar.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/customer" class="d-grid gap-2">
<select class="form-select" name="customer_id">{customer_options}</select>
<button class="btn btn-outline-primary" type="submit">Guardar cliente fiscal</button>
</form>
{jasmin_fiscal_sync_html}
<div class="d-grid gap-2 mt-3">
<a class="btn btn-outline-secondary" href="/customers?q={quote(str(customer_email or customer_name or ''))}">Pesquisar clientes</a>
<a class="btn btn-outline-secondary" href="/customers/new">Criar cliente</a>
</div>
<details class="cf-advanced-actions mt-3" open>
<summary>Sugestões e identidade extraída</summary>
<div class="mt-3 d-grid gap-3">{email_identity_html}{fiscal_suggestions_html}</div>
</details>
</div>
</section>
'''
# "Bloqueios atuais" permanece como conceito de UI/teste, mas o layout agora separa operação e contexto.
body = f'''
<style>
.cf-opp-detail-grid {{ display:grid; grid-template-columns:minmax(0,1fr) minmax(320px,400px); grid-template-areas:'context operation'; gap:1rem; align-items:start; max-width:100%; }}
.cf-opp-detail-grid > * {{ min-width:0; }}
.cf-opp-hero {{ background:linear-gradient(135deg,#eff6ff,#fff); border:1px solid #bfdbfe; border-radius:1.25rem; box-shadow:var(--cf-shadow); }}
.cf-opp-hero h1 {{ overflow-wrap:anywhere; }}
.cf-opp-hero-meta {{ display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.75rem; }}
.cf-opp-hero-meta>div {{ background:#fff; border:1px solid #dbeafe; border-radius:.9rem; padding:.75rem; min-width:0; }}
.cf-opp-hero-meta span {{ display:block; color:#64748b; font-size:.72rem; font-weight:900; text-transform:uppercase; letter-spacing:.04em; }}
.cf-opp-hero-meta strong {{ display:block; overflow-wrap:anywhere; margin-top:.15rem; }}
.cf-opp-now-card {{ border-left:4px solid var(--cf-primary); }}
.cf-opp-facts {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.75rem; }}
.cf-opp-fact {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; }}
.cf-opp-fact span {{ color:#64748b; font-size:.74rem; font-weight:900; text-transform:uppercase; }}
.cf-opp-fact strong {{ display:block; margin-top:.2rem; }}
.cf-opp-operator-card {{ border-left:4px solid #38bdf8; }}
.cf-opp-operator-grid {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.75rem; }}
.cf-opp-operator-grid>div {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; display:grid; gap:.35rem; align-content:start; }}
.cf-opp-operator-grid span:first-child {{ color:#64748b; font-size:.72rem; font-weight:900; text-transform:uppercase; letter-spacing:.04em; }}
.cf-opp-operator-grid strong {{ overflow-wrap:anywhere; }}
.cf-advanced-actions summary {{ cursor:pointer; font-weight:800; color:var(--cf-primary); }}
.cf-opp-operation-column {{ grid-area:operation; position:sticky; top:1rem; display:grid; gap:1rem; min-width:0; align-self:start; }}
.cf-opp-context-column {{ grid-area:context; display:grid; gap:1rem; min-width:0; }}
.cf-opp-column-title {{ display:flex; align-items:center; justify-content:space-between; gap:.75rem; color:#64748b; font-size:.78rem; font-weight:900; text-transform:uppercase; letter-spacing:.06em; }}
.cf-opp-column-title::after {{ content:""; flex:1; height:1px; background:#e2e8f0; }}
.cf-opp-fiscal-action-card {{ border-left:4px solid #0ea5e9; }}
.cf-text-break {{ overflow-wrap:anywhere; word-break:break-word; }}
.cf-linked-customer-box {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; min-width:0; }}
.cf-mini-badge-row {{ display:flex; flex-wrap:wrap; gap:.35rem; justify-content:flex-end; max-width:100%; }}
.cf-mini-badge {{ display:inline-flex; align-items:center; border-radius:999px; padding:.25rem .55rem; font-size:.72rem; line-height:1.05; font-weight:900; max-width:100%; overflow-wrap:anywhere; }}
.cf-mini-badge-ok {{ background:#dcfce7; color:#166534; border:1px solid #bbf7d0; }}
.cf-mini-badge-warn {{ background:#ffedd5; color:#9a3412; border:1px solid #fed7aa; }}
.cf-opp-commercial-terms-card {{ border-left:4px solid #f59e0b; }}
.cf-opp-finance-quick-card {{ border-left:4px solid #22c55e; }}
@media (max-width: 1400px) {{ .cf-opp-detail-grid {{ grid-template-columns:1fr; grid-template-areas:'operation' 'context'; }} .cf-opp-operation-column {{ position:static; }} }}
@media (max-width: 1050px) {{ .cf-opp-hero-meta {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} .cf-opp-operator-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} }}
@media (max-width: 720px) {{ .cf-opp-facts,.cf-opp-operator-grid,.cf-opp-hero-meta {{ grid-template-columns:1fr; }} }}
</style>
<a class="cf-row-link d-inline-flex mb-3" href="/opportunities">← Voltar a oportunidades</a>
{notice_html}
{legacy_notice_html}
{customer_mismatch_alert}
{consistency_alert_html}
<section class="cf-opp-hero p-4 mb-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<div class="text-primary fw-bold mb-1">Oportunidade</div>
<h1 class="h3 fw-bold mb-2">{esc(opportunity.get('title') or 'Oportunidade')}</h1>
<div class="text-secondary">{esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}</div>
</div>
<div class="d-flex flex-wrap gap-2">{opportunity_stage_badge(stage)}{opportunity_priority_chip(opportunity)}</div>
</div>
<div class="cf-opp-hero-meta">
<div><span>Próxima ação</span><strong>{esc(primary_action)}</strong></div>
<div><span>Cliente fiscal</span><strong>{esc(fiscal_state)}</strong></div>
<div><span>Documento</span><strong>{esc(document_state)}</strong></div>
<div><span>Valor</span><strong>{money_html(estimated_value)}</strong></div>
<div><span>Tasks</span><strong>{esc(task_state)}</strong></div>
</div>
</section>
<!-- Legacy test anchors kept for static regression compatibility; not rendered as duplicate tab navigation: href="#comunicacoes" <a href="#odoo">Odoo</a> -->
<div class="cf-opp-detail-grid">
<!-- Desktop: context/evidence on the left; operation/sticky decision panel on the right. No duplicated tabs; sections render as vertical cards. DOM keeps operation first so mobile still starts with action. -->
<aside class="cf-opp-operation-column">
<div class="cf-opp-column-title">Operação</div>
{operation_action_html}
{operator_summary_html}
{finance_quick_card_html}
{commercial_terms_card_html}
{fiscal_association_card_html}
{f'<section class="card cf-card"><div class="card-body p-4">{blockers_html}</div></section>' if current_blockers else ''}
{next_task_focus_html}
{manual_follow_up_html}
{lifecycle_management_html}
{stage_control_html}
{archive_spam_html}
{manual_correction_html}
</aside>
<main class="cf-opp-context-column">
<div class="cf-opp-column-title">Contexto e evidência</div>
<!-- Associação fiscal: contexto fiscal e contacto Chatwoot -->
<div id="cliente">{fiscal_contact_html}</div>
<div class="row g-3"><div class="col-xl-6">{fiscal_readiness_html}</div><div class="col-xl-6">{shipment_readiness_html}</div></div>
<section id="resumo" class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Resumo essencial</h2><div class="cf-opp-facts"><div class="cf-opp-fact"><span>{'Valor principal' if document_value else ('Valor reconstruído' if legacy_mode else 'Valor estimado')}</span><strong>{money_html(estimated_value)}</strong><div class="small text-secondary">{esc(value_source)}</div></div><div class="cf-opp-fact"><span>Tarefas pendentes</span><strong>{len(pending_tasks)}</strong></div><div class="cf-opp-fact"><span>Atualizada</span><strong>{esc(fmt_dt(opportunity.get('updated_at')))}</strong></div></div></div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Pipeline</h2>{stage_progress_html(stage)}</div></section>
{operation_cockpit_html(opportunity_id, opportunity_for_cockpit, operation_snapshot)}
<div id="documentos">{jasmin_documents_html(opportunity_id)}</div>
<div id="produtos">{opportunity_products_panel_html(opportunity_id)}</div>
<div id="odoo">{odoo_status_panel_html(opportunity_id)}</div>
<div id="outbox">{opportunity_integrations_panel_html(opportunity_id)}</div>
<section id="tasks-list" class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Tasks relacionadas</h2><div class="small text-secondary">Ações humanas já criadas para esta oportunidade.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Fila</th><th>Estado</th><th>Data</th></tr></thead><tbody>{task_rows}</tbody></table></div></div></section>
<section id="comunicacoes" class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom d-flex justify-content-between align-items-center"><div><h2 class="cf-section-title">Mensagens Chatwoot</h2><div class="small text-secondary">Mensagens relevantes ligadas a esta oportunidade. A resposta continua no Chatwoot.</div></div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Mensagem</th><th>Classificação</th><th>Estado</th><th>Recebida</th></tr></thead><tbody>{communication_rows}</tbody></table></div></div></section>
<section id="timeline" class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Timeline recente</h2><div class="cf-timeline">{timeline_items}</div></div></section>
<details id="tecnico" class="card cf-card"><summary class="card-body p-4 fw-bold text-primary" style="cursor:pointer">Ver detalhes técnicos e edição avançada</summary><div class="card-body border-top p-4 d-grid gap-3">{technical_html}</div></details>
</main>
</div>
'''
return layout(str(opportunity.get("title") or "Oportunidade"), "Detalhe comercial com informação essencial", body, "opportunities")
@router.get("/opportunities/{opportunity_id}/partials/odoo-status", response_class=HTMLResponse)
async def opportunity_odoo_status_partial(opportunity_id: str):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
return HTMLResponse(odoo_status_panel_html(opportunity_id))
@router.get("/opportunities/{opportunity_id}/partials/jasmin-documents", response_class=HTMLResponse)
async def opportunity_jasmin_documents_partial(opportunity_id: str):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
return HTMLResponse(jasmin_documents_html(opportunity_id))
@router.get("/opportunities/{opportunity_id}/partials/products", response_class=HTMLResponse)
async def opportunity_products_partial(opportunity_id: str):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
return HTMLResponse(opportunity_products_panel_html(opportunity_id))
@router.post("/commercial-documents/{document_id}/unlink-from-opportunity")
async def commercial_document_unlink_from_opportunity_action(document_id: str, request: Request):
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
remove_lines = str(form.get("remove_imported_lines") or "1") == "1"
note = str(form.get("note") or "").strip() or "Documento removido manualmente desta oportunidade; pertence a outra compra/processo."
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
return PlainTextResponse("Identificador inválido.", status_code=422)
try:
result = unlink_commercial_document_from_opportunity(
opportunity_id,
document_id,
remove_imported_lines=remove_lines,
note=note,
actor="operator_ui_document_unlink",
)
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar documento: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao desassociar documento: {exc}", status_code=500)
notice = (
"Documento desassociado desta oportunidade. "
f"Linhas importadas removidas: {result.get('imported_lines_deleted', 0)}."
)
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/commercial-documents/{document_id}/role")
async def commercial_document_role_action(document_id: str, request: Request):
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
role = str(form.get("role") or "current").strip().lower()
make_primary = str(form.get("make_primary") or "1") == "1"
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
return PlainTextResponse("Identificador inválido.", status_code=422)
try:
result = set_commercial_document_role_for_opportunity(
opportunity_id,
document_id,
role=role,
make_primary=make_primary,
actor="operator_ui_document_role",
)
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar papel do documento: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao atualizar papel do documento: {exc}", status_code=500)
label = {"current": "atual", "accepted": "aceite", "related": "relacionado", "historical": "histórico"}.get(result.get("role"), role)
notice = f"Documento marcado como {label}."
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/commercial-documents/{document_id}/refresh")
async def commercial_document_refresh(document_id: str, request: Request):
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
try:
from app.jasmin_service import refresh_commercial_document_from_jasmin
await refresh_commercial_document_from_jasmin(document_id)
except Exception as exc:
if opportunity_id and is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar documento: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao atualizar documento: {exc}", status_code=500)
if opportunity_id and is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Documento atualizado a partir do Jasmin."))
return RedirectResponse(f"/opportunities/{opportunity_id}" if opportunity_id else "/outbox", status_code=303)
@router.get("/commercial-documents/{document_id}/pdf")
async def commercial_document_pdf(document_id: str):
try:
from app.jasmin_service import get_commercial_document_pdf
doc, data, content_type = await get_commercial_document_pdf(document_id)
except Exception as exc:
return PlainTextResponse(f"Erro ao obter PDF Jasmin: {exc}", status_code=500)
name = doc.get("document_number") or doc.get("external_id") or document_id
safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(name))[:80] or "documento"
headers = {"Content-Disposition": f'inline; filename="{safe_name}.pdf"'}
return Response(content=data, media_type=content_type or "application/pdf", headers=headers)
@router.post("/opportunities/{opportunity_id}/archive-spam")
async def opportunity_archive_spam_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
reason = str(form.get("reason") or "spam/falso positivo").strip()
try:
from app.opportunity_service import archive_spam_opportunity_if_safe
result = archive_spam_opportunity_if_safe(
opportunity_id,
reason=reason or "spam/falso positivo",
actor="operator_ui_archive_spam",
)
except Exception as exc:
notice = quote(f"Não foi possível arquivar spam: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
if not result.get("ok"):
msg = result.get("reason") or "não permitido"
if msg == "has_commercial_or_external_evidence":
msg = "A oportunidade tem documentos ou ligações externas; não foi arquivada automaticamente."
notice = quote(f"Arquivar spam recusado: {msg}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
notice = quote("Oportunidade arquivada como spam/falso positivo e excluída do funil.")
return RedirectResponse(f"/opportunities?notice={notice}", status_code=303)
@router.post("/opportunities/{opportunity_id}/manual-correction")
async def opportunity_manual_correction_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
unlink_odoo = str(form.get("unlink_odoo") or "") == "1"
unlink_jasmin = str(form.get("unlink_jasmin") or "") == "1"
remove_imported_lines = str(form.get("remove_imported_lines") or "") == "1"
stage = str(form.get("stage") or "INFO_SENT").strip().upper()
note = str(form.get("note") or "").strip() or "Correção manual: Odoo/Jasmin pertenciam a outro processo; classificado como informação enviada."
try:
result = apply_manual_external_correction(
opportunity_id,
unlink_odoo=unlink_odoo,
unlink_jasmin=unlink_jasmin,
remove_imported_lines=remove_imported_lines,
new_stage=stage,
note=note,
actor="operator_ui_manual_correction",
)
except Exception as exc:
if is_htmx(request):
return PlainTextResponse(f"Erro na correção manual: {exc}", status_code=409)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote('Erro na correção manual: ' + str(exc))}", status_code=303)
notice = (
"Correção aplicada: "
f"Odoo/Jasmin desligados; {result.get('imported_lines_deleted', 0)} linha(s) importada(s) removida(s); "
f"fase definida como {OPPORTUNITY_STAGE_LABELS.get(stage, stage)}."
)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/odoo/unlink")
async def opportunity_odoo_unlink_action(opportunity_id: str, request: Request):
try:
result = apply_manual_external_correction(
opportunity_id,
unlink_odoo=True,
unlink_jasmin=False,
remove_imported_lines=True,
new_stage="INFO_SENT",
note="Correção manual: venda Odoo desassociada da oportunidade.",
actor="operator_ui_odoo_unlink",
)
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo desassociado. Linhas removidas: {result.get('imported_lines_deleted', 0)}"))
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao desassociar Odoo: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao desassociar Odoo: {exc}", status_code=500)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Odoo%20desassociado", status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/unlink")
async def opportunity_jasmin_unlink_action(opportunity_id: str, request: Request):
try:
result = apply_manual_external_correction(
opportunity_id,
unlink_odoo=False,
unlink_jasmin=True,
remove_imported_lines=True,
new_stage="INFO_SENT",
note="Correção manual: documentos/candidatos Jasmin desassociados da oportunidade.",
actor="operator_ui_jasmin_unlink",
)
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=f"Jasmin desassociado. Documentos removidos: {result.get('jasmin_documents_deleted', 0)} · linhas removidas: {result.get('imported_lines_deleted', 0)}"))
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar Jasmin: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao desassociar Jasmin: {exc}", status_code=500)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20desassociado", status_code=303)
@router.post("/opportunities/{opportunity_id}/external-candidate/{item_id}/ignore")
async def opportunity_ignore_external_candidate_action(opportunity_id: str, item_id: str, request: Request):
source_system = ""
try:
with engine.begin() as conn:
source_system = str(conn.execute(text("""
SELECT source_system FROM reconciliation_items WHERE id = CAST(:item_id AS UUID)
"""), {"item_id": item_id}).scalar() or "")
count = ignore_external_candidate_for_opportunity(opportunity_id, item_id)
except Exception as exc:
if request.headers.get("hx-request"):
if source_system == "odoo":
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409)
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao ignorar candidato: {exc}", status_code=500)
notice = "Candidato ignorado." if count else "Candidato não encontrado ou já ignorado."
if request.headers.get("hx-request"):
if source_system == "odoo":
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice))
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/follow-up")
async def create_opportunity_follow_up_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
follow_up_type = str(form.get("follow_up_type") or "generic").strip()
note = str(form.get("note") or "").strip()
try:
delay_days = int(str(form.get("delay_days") or "3"))
except Exception:
delay_days = 3
try:
from app.followup_service import create_manual_follow_up_for_opportunity
result = create_manual_follow_up_for_opportunity(
opportunity_id=opportunity_id,
follow_up_type=follow_up_type,
delay_days=delay_days,
note=note,
created_by="operator",
)
notice = "Follow-up agendado." if result.get("ok") else "Não foi possível agendar follow-up."
except Exception as exc:
notice = f"Erro ao agendar follow-up: {exc}"
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/commercial-terms")
async def update_opportunity_commercial_terms_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
payment_terms = str(form.get("payment_terms") or "before_shipping").strip()
delivery_terms = str(form.get("delivery_terms") or "carrier").strip()
note = str(form.get("note") or "").strip()
if payment_terms not in PAYMENT_TERM_LABELS:
return PlainTextResponse("Condição de pagamento inválida.", status_code=422)
if delivery_terms not in DELIVERY_TERM_LABELS:
return PlainTextResponse("Condição de entrega inválida.", status_code=422)
payload = {
"payment_terms": payment_terms,
"delivery_terms": delivery_terms,
"commercial_terms_note": note,
"commercial_terms_updated_by": "operator",
}
try:
with engine.begin() as conn:
exists = conn.execute(text("""
SELECT 1 FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) LIMIT 1
"""), {"opportunity_id": opportunity_id}).scalar()
if not exists:
return PlainTextResponse("Oportunidade não encontrada.", status_code=404)
conn.execute(text("""
UPDATE opportunities
SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:payload AS JSONB),
updated_at = now()
WHERE id = CAST(:opportunity_id AS UUID)
"""), {
"opportunity_id": opportunity_id,
"payload": _json_payload(payload),
})
except Exception as exc:
notice = quote(f"Não foi possível guardar condições comerciais: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
notice = quote("Condições comerciais guardadas.")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
@router.post("/opportunities/{opportunity_id}/lifecycle")
async def update_opportunity_lifecycle_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
state = str(form.get("state") or "active").strip().lower()
nurture_until = str(form.get("nurture_until") or "").strip()
reason = str(form.get("reason") or "").strip()
if state == "nurture" and not nurture_until:
return PlainTextResponse("Indica a data para retomar o contacto.", status_code=422)
try:
changed = set_opportunity_lifecycle(
opportunity_id,
state,
nurture_until=nurture_until or None,
reason=reason,
created_by="operator",
)
except ValueError as exc:
return PlainTextResponse(str(exc), status_code=422)
except Exception as exc:
notice = quote(f"Não foi possível alterar o estado operacional: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
if not changed:
return PlainTextResponse("Oportunidade não encontrada.", status_code=404)
try:
from app.followup_service import cancel_pending_followups_for_opportunity, create_follow_up_task
cancel_pending_followups_for_opportunity(
opportunity_id=opportunity_id,
reason=f"manual_lifecycle_change:{state}",
created_by="operator",
)
if state == "recovery":
create_follow_up_task(
opportunity_id=opportunity_id,
action_code="RECOVER_OPPORTUNITY",
route="vendas",
action="Recuperar oportunidade sem resposta",
note=reason or "Rever canal, abordagem, timing e decidir entre nova tentativa, acompanhamento futuro ou perda.",
reason="MANUAL_RECOVERY",
delay_days=1,
created_by="operator",
idempotency_suffix=f"manual-recovery:{time.time_ns()}",
follow_up_family="generic",
follow_up_stage=99,
follow_up_max_stage=99,
cascade=False,
contact_purpose="recovery_review",
)
elif state == "nurture":
create_follow_up_task(
opportunity_id=opportunity_id,
action_code="REVIEW_NURTURE",
route="vendas",
action="Rever oportunidade em acompanhamento futuro",
note=reason or "Retomar contacto na data acordada ou rever se o timing continua válido.",
reason="NURTURE_REVIEW",
delay_days=1,
created_by="operator",
idempotency_suffix=f"nurture:{nurture_until}:{time.time_ns()}",
follow_up_family="generic",
follow_up_stage=99,
follow_up_max_stage=99,
cascade=False,
contact_purpose="nurture_review",
due_at_override=_parse_opportunity_dt(nurture_until),
)
except Exception:
pass
notice = quote(f"Estado operacional alterado para {lifecycle_label(state)}.")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
@router.post("/opportunities/{opportunity_id}/lost")
async def mark_opportunity_lost_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
reason_code = str(form.get("reason_code") or "").strip().lower()
note = str(form.get("note") or "").strip()
try:
changed = mark_opportunity_lost(
opportunity_id,
reason_code=reason_code,
note=note,
created_by="operator",
)
except ValueError as exc:
return PlainTextResponse(str(exc), status_code=422)
except Exception as exc:
notice = quote(f"Não foi possível marcar como perdida: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
if not changed:
return PlainTextResponse("Oportunidade não encontrada.", status_code=404)
return RedirectResponse("/opportunities?status=open&scope=recovery", status_code=303)
@router.post("/opportunities/{opportunity_id}/stage")
async def update_opportunity_stage_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
stage = str(form.get("stage") or "").strip().upper()
# Backwards-compatible alias used by older UI/tests.
# The canonical ClientFlow stage is NEW_LEAD.
stage_aliases = {"NEW": "NEW_LEAD"}
stage = stage_aliases.get(stage, stage)
note = str(form.get("note") or "").strip()
if not stage or stage not in OPPORTUNITY_STAGE_LABELS:
return PlainTextResponse("Fase de oportunidade inválida.", status_code=422)
if stage in {"LOST", "NO_INTEREST"}:
return PlainTextResponse("Usa a ação 'Fechar como perdida' e indica o motivo.", status_code=422)
try:
set_opportunity_stage(opportunity_id, stage, note=note, created_by="operator")
except ValueError as exc:
return PlainTextResponse(f"Transição de fase inválida: {exc}", status_code=409)
except Exception as exc:
notice = quote(f"Não foi possível alterar fase: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
@router.post("/opportunities/{opportunity_id}/customer")
async def opportunity_link_customer_action(opportunity_id: str, request: Request):
form = await request.form()
customer_id = str(form.get("customer_id") or "").strip()
try:
from app.commercial_service import link_customer_to_opportunity, unlink_customer_from_opportunity
if customer_id:
link_customer_to_opportunity(customer_id, opportunity_id)
else:
unlink_customer_from_opportunity(opportunity_id)
except Exception as exc:
return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500)
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
@router.post("/opportunities/{opportunity_id}/email-identity/extract")
async def opportunity_email_identity_extract_action(opportunity_id: str, request: Request):
try:
from app.email_identity_extraction_service import extract_identity_for_opportunity
result = extract_identity_for_opportunity(opportunity_id, refresh=True, use_llm=True)
except Exception as exc:
return PlainTextResponse(f"Erro ao extrair identidade do email: {exc}", status_code=500)
if not result:
notice = "Sem mensagem associada para extrair identidade."
else:
companies = result.get("company_mentions") or []
notice = "Identidade extraída" + (f": {', '.join(companies[:2])}" if companies else ".")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/email-identity/assist")
async def opportunity_email_identity_assist_action(opportunity_id: str, request: Request):
try:
from app.fiscal_enrichment_service import assist_email_identity_enrichment
result = assist_email_identity_enrichment(opportunity_id, refresh=True, apply_safe=False)
except Exception as exc:
return PlainTextResponse(f"Erro ao procurar cliente fiscal por identidade: {exc}", status_code=500)
if result.get("conflict"):
notice = "Possível conflito fiscal detetado pela identidade extraída."
elif result.get("status") == "email_identity_matches_current_fiscal_customer":
notice = "Identidade extraída confirma o cliente fiscal atual."
elif result.get("suggested"):
notice = "Sugestão fiscal criada a partir da identidade extraída."
else:
notice = "Identidade extraída, mas sem cliente fiscal compatível encontrado."
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/email-identity/cleanup-invalid")
async def opportunity_email_identity_cleanup_invalid_action(opportunity_id: str, request: Request):
try:
from app.email_identity_cleanup_service import cleanup_invalid_email_identity_state
result = cleanup_invalid_email_identity_state(
opportunity_id=opportunity_id,
include_accepted=True,
fix_extractions=True,
apply=True,
)
except Exception as exc:
return PlainTextResponse(f"Erro ao limpar identidade inválida: {exc}", status_code=500)
notice = (
f"Limpeza de identidade: {result.get('rejected', 0)} sugestão(ões) rejeitada(s), "
f"{result.get('fixed_extractions', 0)} extração(ões) corrigida(s)."
)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/fiscal-enrich")
async def opportunity_fiscal_enrich_action(opportunity_id: str, request: Request):
try:
from app.fiscal_enrichment_service import enrich_opportunity
result = enrich_opportunity(opportunity_id, apply_safe=True)
except Exception as exc:
return PlainTextResponse(f"Erro ao enriquecer cliente fiscal: {exc}", status_code=500)
if result.get("auto_applied"):
notice = "Cliente fiscal auto-associado por enriquecimento."
elif result.get("suggested"):
notice = "Sugestão fiscal criada para revisão."
else:
notice = f"Sem sugestão fiscal: {result.get('reason') or 'sem correspondência'}"
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
@router.post("/fiscal-suggestions/{suggestion_id}/accept")
async def fiscal_suggestion_accept_action(suggestion_id: str, request: Request):
try:
from app.fiscal_enrichment_service import apply_fiscal_suggestion
result = apply_fiscal_suggestion(suggestion_id, actor="operator_ui")
except Exception as exc:
return PlainTextResponse(f"Erro ao aplicar sugestão fiscal: {exc}", status_code=500)
opportunity_id = result.get("opportunity_id") or ""
if not result.get("applied"):
return PlainTextResponse(f"Sugestão não aplicada: {result.get('reason')}", status_code=409)
return RedirectResponse(f"/opportunities/{esc(opportunity_id)}?notice=Sugest%C3%A3o%20fiscal%20aplicada", status_code=303)
@router.post("/fiscal-suggestions/{suggestion_id}/reject")
async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request):
from app.admin_auth import safe_local_redirect
try:
from app.fiscal_enrichment_service import reject_fiscal_suggestion
reject_fiscal_suggestion(suggestion_id, actor="operator_ui")
except Exception as exc:
return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500)
redirect_target = safe_local_redirect(
request.headers.get("referer"),
fallback="/opportunities",
)
return RedirectResponse(redirect_target, status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/complete-fiscal")
async def opportunity_jasmin_complete_fiscal_action(opportunity_id: str, request: Request):
try:
from app.jasmin_fiscal_sync_service import apply_jasmin_fiscal_sync
result = apply_jasmin_fiscal_sync(opportunity_id, actor="operator_ui_jasmin_fiscal_sync")
filled = result.get("filled_fields") or []
if filled:
notice = "Dados fiscais completados com Jasmin: " + ", ".join(str(x) for x in filled)
else:
notice = "Cliente fiscal associado/completado com dados Jasmin."
except Exception as exc:
notice = "Erro ao completar dados fiscais com Jasmin: " + str(exc)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/sync-candidates")
async def opportunity_jasmin_sync_candidates_action(opportunity_id: str, request: Request):
try:
from app.external_reconciliation_sync import sync_jasmin_reconciliation_candidates
result = await sync_jasmin_reconciliation_candidates(limit=100, days=30)
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao sincronizar Jasmin: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao sincronizar Jasmin: {exc}", status_code=500)
seen = result.get("seen", 0)
created = result.get("created_or_updated", 0)
notice = f"Jasmin sincronizado: {seen} documento(s) visto(s), {created} criado(s)/atualizado(s)."
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20sincronizado", status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/reimport-details")
async def opportunity_jasmin_reimport_details_action(opportunity_id: str, request: Request):
try:
from app.jasmin_backfill_service import backfill_jasmin_opportunity_details_async
result = await backfill_jasmin_opportunity_details_async(
opportunity_id=opportunity_id,
fetch_detail=True,
actor="operator_ui_reimport",
dry_run=False,
)
except Exception as exc:
if is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao reimportar detalhes Jasmin: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao reimportar detalhes Jasmin: {exc}", status_code=500)
if not result.get("ok"):
msg = result.get("error") or "sem itens Jasmin para reimportar"
if is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível reimportar: {msg}"), status_code=409)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20reimportar%20Jasmin", status_code=303)
import_result = result.get("import_result") or {}
docs = int(import_result.get("documents") or 0)
lines = int(import_result.get("lines") or 0)
notice = f"Detalhes Jasmin reimportados: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar produtos/valor no topo."
if is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/link-candidate/{item_id}")
async def opportunity_jasmin_link_candidate_action(opportunity_id: str, item_id: str, request: Request):
conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id)
if conflict_msg:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409)
return PlainTextResponse(conflict_msg, status_code=409)
try:
from app.jasmin_backfill_service import link_and_import_jasmin_candidate_async
result = await link_and_import_jasmin_candidate_async(
opportunity_id=opportunity_id,
item_id=item_id,
actor="operator_ui_link_existing_jasmin",
)
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao associar documento Jasmin: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao associar documento Jasmin: {exc}", status_code=500)
if not result.get("ok"):
msg = result.get("error") or "não foi possível associar documento Jasmin"
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível associar: {msg}"), status_code=409)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20associar%20Jasmin", status_code=303)
import_result = result.get("import_result") or {}
docs = import_result.get("documents", 0)
lines = import_result.get("lines", 0)
notice = f"Documento Jasmin associado e importado: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar valor/produtos no topo."
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20associado", status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/replace-candidate/{item_id}")
async def opportunity_jasmin_replace_candidate_action(opportunity_id: str, item_id: str, request: Request):
conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id)
if conflict_msg:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409)
return PlainTextResponse(conflict_msg, status_code=409)
try:
from app.jasmin_backfill_service import replace_jasmin_document_for_opportunity_async
result = await replace_jasmin_document_for_opportunity_async(
opportunity_id=opportunity_id,
item_id=item_id,
actor="operator_ui_replace_existing_jasmin",
dry_run=False,
)
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao substituir documento Jasmin: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao substituir documento Jasmin: {exc}", status_code=500)
if not result.get("ok"):
msg = result.get("error") or "não foi possível substituir documento Jasmin"
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível substituir: {msg}"), status_code=409)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20substituir%20Jasmin", status_code=303)
import_result = result.get("import_result") or {}
docs = import_result.get("documents", 0)
lines = import_result.get("lines", 0)
removed_docs = result.get("removed_documents", 0)
notice = f"Documento Jasmin substituído: {removed_docs} anterior(es) removido(s), {docs} documento(s), {lines} linha(s) importada(s). Recarregue a página para atualizar valor/produtos no topo."
if request.headers.get("hx-request"):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20substitu%C3%ADdo", status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/create-quotation")
async def opportunity_jasmin_create_quotation(opportunity_id: str, request: Request):
try:
if settings.jasmin_enabled:
from app.jasmin_service import enqueue_create_quotation
enqueue_create_quotation(opportunity_id, created_by="operator")
else:
return PlainTextResponse("JASMIN_ENABLED=false", status_code=409)
except Exception as exc:
print(f"ClientFlow Jasmin create quotation failed: {exc}", flush=True)
if is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409)
notice = quote(f"Não foi possível criar orçamento Jasmin: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
if is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de orçamento enviado para a outbox Jasmin."))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20or%C3%A7amento%20enviado%20para%20a%20outbox%20Jasmin", status_code=303)
@router.post("/opportunities/{opportunity_id}/jasmin/convert-invoice")
async def opportunity_jasmin_convert_invoice(opportunity_id: str, request: Request):
try:
if settings.jasmin_enabled:
from app.jasmin_service import enqueue_convert_latest_to_invoice
enqueue_convert_latest_to_invoice(opportunity_id, created_by="operator")
else:
return PlainTextResponse("JASMIN_ENABLED=false", status_code=409)
except Exception as exc:
print(f"ClientFlow Jasmin convert invoice failed: {exc}", flush=True)
if is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409)
notice = quote(f"Não foi possível criar fatura Jasmin: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
if is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de fatura enviado para a outbox Jasmin."))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20fatura%20enviado%20para%20a%20outbox%20Jasmin", status_code=303)
@router.post("/opportunities/{opportunity_id}/operations/{action_key}")
async def opportunity_operation_action(opportunity_id: str, action_key: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
# Accept legacy/semantic action names used by older UI buttons and E2E audits.
action_aliases = {
"prepare_order": "odoo_sale_order",
"prepare_shipping": "packlink_shipment",
"send_followup": "tracking_sent",
}
action_key = action_aliases.get(str(action_key or ""), str(action_key or ""))
form = await request.form()
external_id = str(form.get("external_id") or "").strip()
external_name = str(form.get("external_name") or form.get("external_ref") or form.get("title") or "").strip()
external_url = str(form.get("external_url") or "").strip()
note = str(form.get("note") or "").strip()
try:
# Jasmin e Packlink, sem referência manual, criam itens de outbox para a API real.
# Se o operador preencher external_id/external_name, mantém o modo manual/fallback.
if action_key == "jasmin_quotation" and not external_id and not external_name:
if settings.jasmin_enabled:
from app.jasmin_service import enqueue_create_quotation
enqueue_create_quotation(opportunity_id, created_by="operator")
else:
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
elif action_key == "packlink_shipment" and not external_id and not external_name:
if settings.packlink_enabled:
from app.packlink_service import enqueue_packlink_shipment
enqueue_packlink_shipment(opportunity_id, created_by="operator")
else:
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
else:
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
except OperationActionBlocked as exc:
# Ações operacionais incompatíveis com o estado da oportunidade são bloqueios reais,
# não sucesso silencioso. Devolve 409 para testes/API e HTMX; a UI mostra a razão.
return PlainTextResponse(f"Ação bloqueada: {exc}", status_code=409)
except Exception as exc:
print(f"ClientFlow operation action failed: {exc}", flush=True)
if is_htmx(request):
return PlainTextResponse(f"Erro ao registar ação: {exc}", status_code=409)
notice = quote(f"Não foi possível registar ação: {exc}")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
@router.post("/opportunities/{opportunity_id}/odoo/link-sale")
async def opportunity_odoo_link_sale_number_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
form = await request.form()
sale_ref = str(form.get("sale_ref") or form.get("external_name") or form.get("external_ref") or "").strip()
if not sale_ref:
msg = "Indica o nº da venda Odoo, por exemplo S00308."
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=422)
return PlainTextResponse(msg, status_code=422)
sale_ref = sale_ref.upper() if sale_ref.lower().startswith("s") else sale_ref
external_id = sale_ref if sale_ref.isdigit() else ""
external_name = sale_ref
notice = f"Venda Odoo {sale_ref} registada na oportunidade."
try:
from app.operation_service import register_operation_action
register_operation_action(
opportunity_id,
"odoo_sale_order",
external_id=external_id,
external_name=external_name,
note=f"Venda Odoo {sale_ref} associada manualmente pelo operador.",
payload={"manual_odoo_sale_ref": sale_ref},
created_by="operator_ui_odoo_sale_ref",
)
with engine.begin() as conn:
conn.execute(text("""
UPDATE tasks
SET status = 'done',
note = COALESCE(note, '') || CAST(:note AS TEXT),
updated_at = NOW()
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND action_code = 'PREPARE_ORDER'
AND status = 'pending'
"""), {
"opportunity_id": opportunity_id,
"note": f"\n\nConcluída automaticamente: venda Odoo {sale_ref} associada manualmente.",
})
try:
sync_opportunity_odoo_status(opportunity_id)
notice = f"Venda Odoo {sale_ref} associada e estado WH/OUT sincronizado."
except Exception as sync_exc:
notice = f"Venda Odoo {sale_ref} associada. Sincronização Odoo falhou: {sync_exc}"
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice))
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
except OperationActionBlocked as exc:
msg = f"Ação bloqueada: {exc}"
except Exception as exc:
msg = f"Erro ao associar venda Odoo: {exc}"
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=409)
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(msg)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/odoo/sync-status")
async def opportunity_odoo_sync_status_action(opportunity_id: str, request: Request):
try:
result = sync_opportunity_odoo_status(opportunity_id)
label = result.get("label") or result.get("physical_status") or "estado Odoo atualizado"
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo sincronizado: {label}"))
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Erro%20ao%20sincronizar%20Odoo", status_code=303)
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Odoo%20sincronizado", status_code=303)
@router.post("/opportunities/{opportunity_id}/odoo/link-candidate/{item_id}")
async def opportunity_odoo_link_candidate_action(opportunity_id: str, item_id: str, request: Request):
try:
from app.reconciliation_service import link_reconciliation_to_opportunity
link_reconciliation_to_opportunity(item_id, opportunity_id, actor="operator_ui_odoo_panel")
try:
sync_opportunity_odoo_status(opportunity_id)
except Exception:
pass
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice="Venda Odoo associada à oportunidade."))
except Exception as exc:
if request.headers.get("hx-request"):
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao associar venda Odoo: {exc}"), status_code=409)
return PlainTextResponse(f"Erro ao associar venda Odoo: {exc}", status_code=500)
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Venda%20Odoo%20associada", status_code=303)