Files
clientflow_backend/app/reconciliation_decision_service.py
2026-06-09 22:55:58 +01:00

182 lines
7.1 KiB
Python

"""Read-only reconciliation decision helpers for the operator UI.
v4.9.28 keeps reconciliation decisions explicit without adding hard database
constraints. The service classifies loose evidence into operational buckets so
UI code can show the safest next decision: link, create, review, historical or
ignore.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Iterable, List
OPEN_STATUSES = {"open", "needs_review", "conflict"}
RESOLVED_STATUSES = {"linked", "resolved"}
def _to_decimal(value: Any) -> Decimal | None:
if value is None or value == "":
return None
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return None
def _confidence_value(value: Any) -> Decimal | None:
confidence = _to_decimal(value)
if confidence is None:
return None
if confidence > 1:
confidence = confidence / Decimal("100")
return confidence
def classify_reconciliation_item(item: Dict[str, Any]) -> Dict[str, Any]:
"""Classify one reconciliation item into a human decision bucket."""
status = str(item.get("status") or "open").strip().lower()
external_type = str(item.get("external_type") or "external_record")
suggestions = item.get("operation_suggestions") or []
priority = str(item.get("priority") or "normal").lower()
confidence = _confidence_value(item.get("confidence"))
has_suggestion = bool(suggestions)
has_opportunity = bool(item.get("opportunity_id"))
if status in RESOLVED_STATUSES:
return {
"bucket": "resolved",
"label": "Resolvido",
"description": "Já foi ligado ou fechado.",
"primary_decision": "Sem ação",
"chip_class": "cf-chip-green",
}
if status == "historical":
return {
"bucket": "historical",
"label": "Histórico",
"description": "Documento mantido para auditoria, sem ação operacional.",
"primary_decision": "Sem ação operacional",
"chip_class": "cf-chip-gray",
}
if status == "ignored":
return {
"bucket": "ignored",
"label": "Ignorado",
"description": "Retirado da fila operacional.",
"primary_decision": "Sem ação",
"chip_class": "cf-chip-gray",
}
if status == "conflict":
return {
"bucket": "review",
"label": "Conflito",
"description": "Há sinais contraditórios; requer revisão manual antes de ligar/criar.",
"primary_decision": "Rever manualmente",
"chip_class": "cf-chip-red",
}
if status == "needs_review":
return {
"bucket": "review",
"label": "Revisão",
"description": "Ainda não há confiança suficiente para aplicar uma decisão direta.",
"primary_decision": "Rever manualmente",
"chip_class": "cf-chip-orange",
}
high_confidence = confidence is not None and confidence >= Decimal("0.75")
important_evidence = external_type in {"jasmin_invoice", "jasmin_proforma", "payment_proof", "odoo_sale_order"}
if has_suggestion:
return {
"bucket": "actionable",
"label": "Ação recomendada",
"description": "Existe oportunidade sugerida para ligação.",
"primary_decision": "Ligar à oportunidade sugerida",
"chip_class": "cf-chip-blue",
}
if has_opportunity:
return {
"bucket": "actionable",
"label": "Confirmar ligação",
"description": "O item já tem oportunidade associada, mas ainda está em aberto.",
"primary_decision": "Confirmar documento",
"chip_class": "cf-chip-blue",
}
if priority in {"alta", "high"} or high_confidence or important_evidence:
return {
"bucket": "actionable",
"label": "Decidir agora",
"description": "Evidência recente/importante; criar oportunidade ou ligar manualmente.",
"primary_decision": "Criar ou ligar oportunidade",
"chip_class": "cf-chip-blue",
}
return {
"bucket": "review",
"label": "Revisão",
"description": "Faltam sinais fortes para ação direta.",
"primary_decision": "Rever manualmente",
"chip_class": "cf-chip-orange",
}
def classify_reconciliation_process(candidate: Dict[str, Any]) -> Dict[str, Any]:
"""Classify a reconstructed process candidate."""
review_status = str(candidate.get("review_status") or "needs_review").lower()
suggestions = candidate.get("suggestions") or []
confidence = str(candidate.get("confidence") or "média").lower()
risks = candidate.get("risks") or []
if review_status == "conflict" or risks:
return {
"bucket": "review",
"label": "Requer revisão",
"description": "Processo com risco de associação errada.",
"primary_decision": "Rever antes de aplicar",
"chip_class": "cf-chip-orange" if review_status != "conflict" else "cf-chip-red",
}
if review_status == "ready" or confidence == "alta" or suggestions:
return {
"bucket": "actionable",
"label": "Ação recomendada",
"description": "Processo reconstruído com evidência suficiente para decisão explícita.",
"primary_decision": "Ligar ou criar oportunidade",
"chip_class": "cf-chip-blue",
}
return {
"bucket": "review",
"label": "Requer revisão",
"description": "Processo reconstruído, mas ainda sem confiança operacional alta.",
"primary_decision": "Rever manualmente",
"chip_class": "cf-chip-orange",
}
def reconciliation_decision_summary(items: Iterable[Dict[str, Any]], candidates: Iterable[Dict[str, Any]]) -> Dict[str, int]:
summary = {"actionable": 0, "review": 0, "historical": 0, "ignored": 0, "resolved": 0}
for item in items:
decision = classify_reconciliation_item(item)
bucket = decision.get("bucket") or "review"
summary[bucket] = summary.get(bucket, 0) + 1
for candidate in candidates:
decision = classify_reconciliation_process(candidate)
bucket = decision.get("bucket") or "review"
summary[bucket] = summary.get(bucket, 0) + 1
return summary
def sort_items_for_operator(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Show actionable evidence before review/noise while preserving status filters."""
order = {"actionable": 0, "review": 1, "historical": 2, "ignored": 3, "resolved": 4}
priority_order = {"alta": 0, "high": 0, "normal": 1, "baixa": 2, "low": 2}
def key(item: Dict[str, Any]):
decision = classify_reconciliation_item(item)
return (
order.get(str(decision.get("bucket") or "review"), 9),
priority_order.get(str(item.get("priority") or "normal").lower(), 5),
str(item.get("updated_at") or item.get("created_at") or ""),
)
return sorted(items, key=key)