171 lines
6.2 KiB
Python
171 lines
6.2 KiB
Python
"""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()
|