perf: add opportunity detail read context

This commit is contained in:
plx
2026-08-14 13:24:37 +00:00
parent 07dc9db198
commit 7d138eccdc
13 changed files with 484 additions and 121 deletions

View File

@@ -12,13 +12,14 @@ from urllib.parse import quote
import json
import time
import uuid
from dataclasses import dataclass, field
from typing import Optional
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, get_opportunity_next_actions
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,
@@ -48,6 +49,119 @@ _opportunity_board_column_for_stage = legacy._opportunity_board_column_for_stage
router = APIRouter()
@dataclass
class OpportunityDetailContext:
"""Request-scoped, read-only data shared by opportunity-detail renderers."""
opportunity_id: str
opportunity: dict
customer: Optional[dict] = None
tasks: list[dict] = field(default_factory=list)
events: list[dict] = field(default_factory=list)
opportunity_items: list[dict] = field(default_factory=list)
active_products: list[dict] = field(default_factory=list)
resolved_documents: list[dict] = field(default_factory=list)
operation_links: list[dict] = field(default_factory=list)
operation_snapshot: dict = field(default_factory=dict)
communications: list[dict] = field(default_factory=list)
jasmin_candidates: list[dict] = field(default_factory=list)
odoo_candidates: list[dict] = field(default_factory=list)
outbox_items: list[dict] = field(default_factory=list)
def next_action_preloaded(self) -> dict:
evidence_opportunity = dict(self.opportunity)
evidence_opportunity["fiscal_customer_id"] = (
evidence_opportunity.get("local_customer_id")
or evidence_opportunity.get("linked_customer_id")
)
evidence_opportunity["customer_id"] = evidence_opportunity.get("fiscal_customer_id")
evidence_customer = None
if self.customer:
evidence_customer = {
**self.customer,
"billing_email": self.customer.get("email"),
"address": self.customer.get("street_name"),
"postal_code": self.customer.get("postal_zone"),
"city": self.customer.get("city_name"),
}
return {
"opportunity": evidence_opportunity,
"customer": evidence_customer,
"resolved_documents": self.resolved_documents,
"operation_snapshot": self.operation_snapshot,
}
def load_opportunity_detail_context(opportunity_id: str) -> Optional[OpportunityDetailContext]:
"""Load each reusable opportunity-detail dataset at most once."""
opportunity = get_opportunity(opportunity_id)
if not opportunity:
return None
from app.commercial_service import get_customer_for_opportunity, list_commercial_documents
from app.integration_outbox_service import list_outbox
from app.operation_service import get_operation_links, get_operation_snapshot
from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity
customer = get_customer_for_opportunity(opportunity_id)
tasks = list_opportunity_tasks(opportunity_id, limit=100)
events = list_opportunity_events(opportunity_id, limit=100)
items = list_opportunity_items(opportunity_id)
products = list_products(active="true", limit=300)
documents = list_commercial_documents(opportunity_id=opportunity_id, limit=100)
operation_links = get_operation_links(opportunity_id)
snapshot = get_operation_snapshot(
opportunity_id, links=operation_links, resolved_documents=documents,
)
try:
communications = list_communications_for_opportunity(opportunity_id, limit=12)
except Exception:
communications = []
try:
outbox_all = list_outbox(limit=300)
outbox_items = []
for item in outbox_all:
payload = item.get("payload") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
if str(payload.get("opportunity_id") or "") == opportunity_id:
data = dict(item)
data["payload"] = payload
outbox_items.append(data)
except Exception:
outbox_items = []
candidate_opportunity = {
**opportunity,
"fiscal_customer_name": (customer or {}).get("name"),
"fiscal_customer_tax_id": (customer or {}).get("tax_id"),
"fiscal_customer_email": (customer or {}).get("email"),
}
try:
jasmin_candidates = find_jasmin_document_candidates_for_opportunity(
opportunity_id, limit=8, opportunity=candidate_opportunity,
current_documents=documents,
)
except Exception:
jasmin_candidates = []
odoo_links = [row for row in operation_links if row.get("system") == "odoo"]
try:
_links, odoo_candidates = _opportunity_odoo_rows(
opportunity_id, preloaded_links=odoo_links,
)
except Exception:
odoo_candidates = []
return OpportunityDetailContext(
opportunity_id=opportunity_id, opportunity=opportunity, customer=customer,
tasks=tasks, events=events, opportunity_items=items, active_products=products,
resolved_documents=documents, operation_links=operation_links,
operation_snapshot=snapshot, communications=communications,
jasmin_candidates=jasmin_candidates, odoo_candidates=odoo_candidates,
outbox_items=outbox_items,
)
def _authenticated_actor(request: Request) -> str:
actor = str(getattr(request.state, "clientflow_admin_user", "") or "").strip()
if not actor:
@@ -838,14 +952,16 @@ def _odoo_status_badge(status: object) -> str:
return f'<span class="badge {cls}">{esc(status or "—")}</span>'
def _opportunity_odoo_rows(opportunity_id: str) -> tuple[list[dict], list[dict]]:
def _opportunity_odoo_rows(
opportunity_id: str, *, preloaded_links: Optional[list[dict]] = None,
) -> 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("""
links = (preloaded_links if preloaded_links is not None else 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
@@ -860,7 +976,7 @@ def _opportunity_odoo_rows(opportunity_id: str) -> tuple[list[dict], list[dict]]
ELSE 9
END,
updated_at DESC
"""), {"opportunity_id": opportunity_id}).mappings().all()
"""), {"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,
@@ -882,9 +998,17 @@ def _opportunity_odoo_rows(opportunity_id: str) -> tuple[list[dict], list[dict]]
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:
def odoo_status_panel_html(
opportunity_id: str, *, notice: str = "", error_notice: str = "",
preloaded_links: Optional[list[dict]] = None,
preloaded_candidates: Optional[list[dict]] = None,
) -> str:
try:
links, candidates = _opportunity_odoo_rows(opportunity_id)
if preloaded_candidates is None:
links, candidates = _opportunity_odoo_rows(opportunity_id, preloaded_links=preloaded_links)
else:
links = list(preloaded_links or [])
candidates = list(preloaded_candidates)
except Exception as exc:
return f'<section id="odoo-status-panel" class="card cf-card"><div class="card-body"><div class="alert alert-danger">Erro ao carregar Odoo: {esc(exc)}</div></div></section>'
@@ -1059,10 +1183,14 @@ def _task_href_with_return_to(task_id: str, return_to: str) -> str:
return href
def _render_email_identity_review(opportunity_id: str, linked_customer: dict | None) -> str:
def _render_email_identity_review(
opportunity_id: str, linked_customer: dict | None, *, opportunity: Optional[dict] = None,
) -> str:
try:
from app.fiscal_enrichment_service import email_identity_review_for_opportunity
review = email_identity_review_for_opportunity(opportunity_id, refresh=False)
review = email_identity_review_for_opportunity(
opportunity_id, refresh=False, opportunity=opportunity,
)
except Exception as exc:
return f"""
<div class="cf-soft-box mt-3">
@@ -1307,9 +1435,22 @@ def _opportunity_jasmin_state(opportunity_id: str) -> dict:
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:
def _opportunity_consistency_alert_html(
opportunity: dict, tasks: list[dict], opportunity_items: list[dict], opportunity_id: str,
*, preloaded_documents: Optional[list[dict]] = None,
) -> str:
# Surface soft inconsistencies without blocking the operator.
state = _opportunity_jasmin_state(opportunity_id)
if preloaded_documents is None:
state = _opportunity_jasmin_state(opportunity_id)
else:
jasmin_docs = [row for row in preloaded_documents if row.get("system") == "jasmin"]
by_kind = {kind: sum(row.get("document_kind") == kind for row in jasmin_docs)
for kind in ("quotation", "proforma", "invoice")}
state = {
"jasmin_documents": len(jasmin_docs), "quotations": by_kind["quotation"],
"proformas": by_kind["proforma"], "invoices": by_kind["invoice"],
"item_count": len(opportunity_items),
}
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)
@@ -1794,12 +1935,12 @@ async def opportunities_page(
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:
context = load_opportunity_detail_context(opportunity_id)
if context is None:
return layout("Oportunidade não encontrada", "Pipeline comercial", '<section class="cf-empty">Oportunidade não encontrada.</section>', "opportunities")
tasks = list_opportunity_tasks(opportunity_id, limit=100)
events = list_opportunity_events(opportunity_id, limit=100)
opportunity = context.opportunity
tasks = context.tasks
events = context.events
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"]
@@ -1821,13 +1962,9 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
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 = []
opportunity_items = context.opportunity_items
active_products = context.active_products[:200]
linked_documents = context.resolved_documents[:8]
primary_document = next(
(
doc for doc in linked_documents
@@ -1854,13 +1991,10 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
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)
operation_snapshot = context.operation_snapshot
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 = []
opportunity_communications = context.communications
notice_html = f'<div class="alert alert-info">{esc(notice)}</div>' if notice else ''
metadata = _opportunity_metadata(opportunity)
payment_term = str(metadata.get("payment_terms") or "before_shipping")
@@ -1897,7 +2031,9 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
opportunity_return_to = f"/opportunities/{opportunity_id}"
try:
next_action = get_opportunity_next_action(opportunity_id)
next_action = get_opportunity_next_action(
opportunity_id, preloaded=context.next_action_preloaded(),
)
except Exception:
next_action = {}
@@ -1942,41 +2078,8 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
"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
# GET detail is strictly read-only. SEND_INVOICE task materialization remains
# available to explicit write/repair workflows, never during page rendering.
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
@@ -2090,7 +2193,7 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
customer_options = '<option value="">Selecionar cliente...</option>'
try:
from app.commercial_service import get_customer_for_opportunity, list_customers
linked_customer = get_customer_for_opportunity(opportunity_id)
linked_customer = context.customer
for c in list_customers(limit=150):
selected = "selected" if linked_customer and str(c.get("id")) == str(linked_customer.get("id")) else ""
label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or 'sem NIF'}"
@@ -2099,7 +2202,9 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
linked_customer = None
fiscal_suggestions_html = _render_fiscal_suggestions(opportunity_id, linked_customer)
email_identity_html = _render_email_identity_review(opportunity_id, linked_customer)
email_identity_html = _render_email_identity_review(
opportunity_id, linked_customer, opportunity=opportunity,
)
try:
from app.jasmin_fiscal_sync_service import get_jasmin_fiscal_sync_preview
jasmin_fiscal_preview = get_jasmin_fiscal_sync_preview(opportunity_id)
@@ -2144,7 +2249,10 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
ok_text="Dados mínimos de envio completos.",
blocked_text="Envio deve aguardar correção destes dados.",
)
consistency_alert_html = _opportunity_consistency_alert_html(opportunity, tasks, opportunity_items, opportunity_id)
consistency_alert_html = _opportunity_consistency_alert_html(
opportunity, tasks, opportunity_items, opportunity_id,
preloaded_documents=context.resolved_documents,
)
if primary_document:
document_label = commercial_document_display_number(primary_document, fallback="número por atualizar")
document_kind = {
@@ -2589,13 +2697,26 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
{operation_cockpit_html(opportunity_id, opportunity_for_cockpit, operation_snapshot)}
<div id="documentos">{jasmin_documents_html(opportunity_id)}</div>
<div id="documentos">{jasmin_documents_html(
opportunity_id, preloaded_documents=context.resolved_documents,
preloaded_candidates=context.jasmin_candidates,
preloaded_customer=context.customer, preloaded_outbox=context.outbox_items,
)}</div>
<div id="produtos">{opportunity_products_panel_html(opportunity_id)}</div>
<div id="produtos">{opportunity_products_panel_html(
opportunity_id, preloaded_items=context.opportunity_items,
preloaded_products=context.active_products,
)}</div>
<div id="odoo">{odoo_status_panel_html(opportunity_id)}</div>
<div id="odoo">{odoo_status_panel_html(
opportunity_id,
preloaded_links=[row for row in context.operation_links if row.get("system") == "odoo"],
preloaded_candidates=context.odoo_candidates,
)}</div>
<div id="outbox">{opportunity_integrations_panel_html(opportunity_id)}</div>
<div id="outbox">{opportunity_integrations_panel_html(
opportunity_id, preloaded_items=context.outbox_items,
)}</div>
<section id="tasks-list" class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Tasks relacionadas</h2><div class="small text-secondary">Ações humanas já criadas para esta oportunidade.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Fila</th><th>Estado</th><th>Data</th></tr></thead><tbody>{task_rows}</tbody></table></div></div></section>