perf: add opportunity detail read context
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user