"""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)