182 lines
7.1 KiB
Python
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)
|