Release v4928.1.4.2 stable
This commit is contained in:
181
app/reconciliation_decision_service.py
Normal file
181
app/reconciliation_decision_service.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user