"""Central decision service for opportunity next actions. v4928.1.5.60 delegates operational flow to app.domain.opportunity_flow so opportunity pages, tasks and future audits consume the same decision vocabulary. """ from __future__ import annotations from dataclasses import asdict, dataclass from typing import Any, Dict, Optional from sqlalchemy import text from app.db import engine # Backward-compatible static anchors from v1.5.59: quotation_doc, invoice_doc, confirmar pagamento antes de emitir fatura, Pagamento confirmado com base em, Criar/enviar fatura. from app.domain.opportunity_flow import ( OpportunityEvidence, build_opportunity_evidence, decide_opportunity_next_action, load_company_profile, ) @dataclass class OpportunityNextAction: action_code: str label: str description: str priority: str = "normal" target_url: Optional[str] = None can_execute: bool = True reason_if_blocked: Optional[str] = None document_id: Optional[str] = None document_number: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return asdict(self) def _first_row(conn: Any, sql: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]: row = conn.execute(text(sql), params).mappings().first() return dict(row) if row else None def _rows(conn: Any, sql: str, params: Dict[str, Any]) -> list[Dict[str, Any]]: return [dict(r) for r in conn.execute(text(sql), params).mappings().all()] def _operation_snapshot_safe(opportunity_id: str) -> dict[str, Any]: try: from app.operation_service import get_operation_snapshot return get_operation_snapshot(opportunity_id) except Exception: return {"cards": [], "links": []} def _build_db_evidence(opportunity_id: str) -> OpportunityEvidence | None: params = {"opportunity_id": opportunity_id} with engine.begin() as conn: opp = _first_row(conn, """ SELECT id::text, stage, status, title, local_customer_id::text AS fiscal_customer_id, local_customer_id::text AS customer_id, metadata FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) """, params) if not opp: return None tasks = _rows(conn, """ SELECT id::text, action_code, action, note, priority, route, status, due_at, created_at, metadata FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) ORDER BY CASE COALESCE(priority, 'normal') WHEN 'alta' THEN 1 WHEN 'normal' THEN 2 WHEN 'baixa' THEN 3 ELSE 4 END, due_at NULLS LAST, created_at DESC LIMIT 20 """, params) docs = _rows(conn, """ SELECT id::text, external_id, document_kind, document_number, status, total_amount, document_date, role, is_active, is_primary, payload FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' AND COALESCE(is_active, TRUE) = TRUE AND COALESCE(role, 'current') IN ('current', 'accepted', 'historical', 'history') ORDER BY CASE document_kind WHEN 'invoice' THEN 1 WHEN 'quotation' THEN 2 WHEN 'proforma' THEN 3 ELSE 4 END, CASE COALESCE(role, 'current') WHEN 'current' THEN 1 WHEN 'accepted' THEN 2 ELSE 3 END, COALESCE(document_date, created_at::date) DESC, created_at DESC """, params) linked_customer = None customer_id = opp.get("fiscal_customer_id") or opp.get("customer_id") if customer_id: linked_customer = _first_row(conn, """ SELECT id::text, name, tax_id, email, email AS billing_email, street_name AS address, postal_zone AS postal_code, city_name AS city, phone FROM customers WHERE id = CAST(:customer_id AS UUID) """, {"customer_id": customer_id}) candidate = _first_row(conn, """ SELECT id::text, source_system, external_type, document_number, title, confidence FROM reconciliation_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'open' ORDER BY confidence DESC NULLS LAST, created_at DESC LIMIT 1 """, params) snapshot = _operation_snapshot_safe(opportunity_id) fiscal_complete = False if linked_customer: fiscal_complete = bool( linked_customer.get("tax_id") and (linked_customer.get("billing_email") or linked_customer.get("email")) and linked_customer.get("address") and linked_customer.get("postal_code") and linked_customer.get("city") ) return build_opportunity_evidence( opp, linked_documents=docs, tasks=tasks, operation_snapshot=snapshot, linked_customer=linked_customer, fiscal_data_complete=fiscal_complete, has_reconciliation_candidate=bool(candidate), reconciliation_label=(candidate or {}).get("document_number") or (candidate or {}).get("title"), company_profile="blif", ) def get_opportunity_next_action(opportunity_id: str) -> Dict[str, Any]: """Return the recommended operator action for one opportunity. This remains a read-only service and returns the legacy dict shape, but the decision is now produced by the company workflow engine. """ evidence = _build_db_evidence(opportunity_id) if evidence is None: return OpportunityNextAction( action_code="NOT_FOUND", label="Oportunidade não encontrada", description="Não foi possível encontrar esta oportunidade.", priority="baixa", can_execute=False, reason_if_blocked="opportunity_not_found", ).to_dict() decision = decide_opportunity_next_action(evidence, load_company_profile(evidence.company_profile)) return decision.to_dict()