Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -1,17 +1,23 @@
|
||||
"""Central decision service for opportunity next actions.
|
||||
|
||||
v4.9.27 keeps this deliberately small and read-only: it does not replace
|
||||
existing task workflow yet, but gives UI/reconciliation a single vocabulary for
|
||||
what the operator should do next.
|
||||
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 dataclass, asdict
|
||||
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
|
||||
@@ -35,61 +41,79 @@ def _first_row(conn: Any, sql: str, params: Dict[str, Any]) -> Optional[Dict[str
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_opportunity_next_action(opportunity_id: str) -> Dict[str, Any]:
|
||||
"""Return the recommended operator action for one opportunity.
|
||||
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()]
|
||||
|
||||
Priority order is intentionally conservative:
|
||||
1. unresolved pending task;
|
||||
2. missing fiscal customer;
|
||||
3. pending reconciliation evidence;
|
||||
4. Jasmin commercial document state;
|
||||
5. generic follow-up.
|
||||
"""
|
||||
|
||||
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, fiscal_customer_id::text, customer_id::text, metadata
|
||||
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 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()
|
||||
return None
|
||||
|
||||
task = _first_row(conn, """
|
||||
SELECT id::text, action_code, action, note, priority, route, due_at, created_at
|
||||
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)
|
||||
AND status = 'pending'
|
||||
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 1
|
||||
LIMIT 20
|
||||
""", params)
|
||||
if task:
|
||||
return OpportunityNextAction(
|
||||
action_code=str(task.get("action_code") or "OPEN_TASK"),
|
||||
label=str(task.get("action") or task.get("action_code") or "Abrir tarefa pendente"),
|
||||
description=str(task.get("note") or "Abrir a tarefa pendente para continuar o processo."),
|
||||
priority=str(task.get("priority") or "normal"),
|
||||
target_url=f"/tasks/{task.get('id')}",
|
||||
).to_dict()
|
||||
|
||||
if not opp.get("fiscal_customer_id"):
|
||||
return OpportunityNextAction(
|
||||
action_code="VALIDATE_FISCAL_CUSTOMER",
|
||||
label="Validar cliente fiscal",
|
||||
description="Antes de emitir documentos oficiais, confirma ou associa o cliente fiscal correto.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{opportunity_id}/fiscal-enrich",
|
||||
).to_dict()
|
||||
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
|
||||
@@ -99,96 +123,48 @@ def get_opportunity_next_action(opportunity_id: str) -> Dict[str, Any]:
|
||||
ORDER BY confidence DESC NULLS LAST, created_at DESC
|
||||
LIMIT 1
|
||||
""", params)
|
||||
if candidate:
|
||||
doc_ref = candidate.get("document_number") or candidate.get("title") or "documento encontrado"
|
||||
return OpportunityNextAction(
|
||||
action_code="RECONCILE_DOCUMENTS",
|
||||
label="Confirmar documento encontrado",
|
||||
description=f"Existe evidência por decidir: {doc_ref}.",
|
||||
priority="alta",
|
||||
target_url="/reconciliation",
|
||||
document_number=str(candidate.get("document_number") or "") or None,
|
||||
).to_dict()
|
||||
|
||||
current_doc = _first_row(conn, """
|
||||
SELECT id::text, document_kind, document_number, status, total_amount, document_date, role
|
||||
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')
|
||||
ORDER BY
|
||||
CASE document_kind WHEN 'invoice' THEN 1 WHEN 'proforma' THEN 2 WHEN 'quotation' THEN 3 ELSE 4 END,
|
||||
COALESCE(document_date, created_at::date) DESC,
|
||||
created_at DESC
|
||||
LIMIT 1
|
||||
""", params)
|
||||
if not current_doc:
|
||||
return OpportunityNextAction(
|
||||
action_code="CREATE_JASMIN_QUOTE",
|
||||
label="Criar orçamento no Jasmin",
|
||||
description="A oportunidade tem cliente fiscal, mas ainda não tem orçamento Jasmin ligado.",
|
||||
priority="normal",
|
||||
target_url=f"/opportunities/{opportunity_id}#documentos",
|
||||
).to_dict()
|
||||
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")
|
||||
)
|
||||
|
||||
kind = str(current_doc.get("document_kind") or "")
|
||||
number = current_doc.get("document_number") or "documento"
|
||||
if kind == "quotation":
|
||||
return OpportunityNextAction(
|
||||
action_code="SEND_PROFORMA",
|
||||
label="Avançar a partir do orçamento",
|
||||
description=f"Orçamento atual {number}. Se o cliente aceitou, emitir ou enviar pró-forma.",
|
||||
priority="normal",
|
||||
target_url=f"/opportunities/{opportunity_id}#documentos",
|
||||
document_id=current_doc.get("id"),
|
||||
document_number=number,
|
||||
).to_dict()
|
||||
payment_confirmed = bool(_first_row(conn, """
|
||||
SELECT id::text
|
||||
FROM operation_links
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND system = 'clientflow'
|
||||
AND external_type = 'payment'
|
||||
AND status = 'confirmed'
|
||||
LIMIT 1
|
||||
""", params))
|
||||
if kind == "proforma":
|
||||
return OpportunityNextAction(
|
||||
action_code="CONFIRM_PAYMENT",
|
||||
label="Confirmar pagamento",
|
||||
description=f"Pró-forma {number} ligada. Confirmar pagamento ou acompanhar o cliente.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{opportunity_id}#tasks",
|
||||
document_id=current_doc.get("id"),
|
||||
document_number=number,
|
||||
).to_dict()
|
||||
if kind == "invoice":
|
||||
if not payment_confirmed:
|
||||
legacy_note = " Se for um registo antigo/reconstruído, rever pagamento e marcar como concluído quando validado."
|
||||
return OpportunityNextAction(
|
||||
action_code="CONFIRM_PAYMENT",
|
||||
label="Aguardar / confirmar pagamento",
|
||||
description=f"Fatura {number} ligada. Confirmar pagamento ou acompanhar o cliente." + legacy_note,
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{opportunity_id}#tasks",
|
||||
document_id=current_doc.get("id"),
|
||||
document_number=number,
|
||||
).to_dict()
|
||||
return OpportunityNextAction(
|
||||
action_code="PREPARE_ORDER",
|
||||
label="Preparar encomenda / envio",
|
||||
description=f"Fatura {number} ligada e pagamento confirmado. Validar preparação, Odoo e envio.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{opportunity_id}#tasks",
|
||||
document_id=current_doc.get("id"),
|
||||
document_number=number,
|
||||
).to_dict()
|
||||
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="FOLLOW_UP",
|
||||
label="Acompanhar oportunidade",
|
||||
description="Processo sem bloqueios óbvios. Rever estado e próximo contacto.",
|
||||
action_code="NOT_FOUND",
|
||||
label="Oportunidade não encontrada",
|
||||
description="Não foi possível encontrar esta oportunidade.",
|
||||
priority="baixa",
|
||||
target_url=f"/opportunities/{opportunity_id}",
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user