Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
"""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.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from typing import Any, Dict, Optional
from sqlalchemy import text
from app.db import engine
@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 get_opportunity_next_action(opportunity_id: str) -> Dict[str, Any]:
"""Return the recommended operator action for one opportunity.
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.
"""
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
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()
task = _first_row(conn, """
SELECT id::text, action_code, action, note, priority, route, due_at, created_at
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
""", 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()
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)
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()
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 OpportunityNextAction(
action_code="FOLLOW_UP",
label="Acompanhar oportunidade",
description="Processo sem bloqueios óbvios. Rever estado e próximo contacto.",
priority="baixa",
target_url=f"/opportunities/{opportunity_id}",
).to_dict()