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