"""Authoritative BLIF Flow v2 -> operator decision adapter. The persisted projection owns factual business state. Pending tasks are only operational obligations and may override that state when this policy proves that they are current. This module is deliberately pure and performs no I/O. """ from __future__ import annotations from dataclasses import asdict, dataclass from datetime import datetime, timezone from typing import Any, Iterable, Mapping from app.admin_ui.labels import action_label FORMAL_ACTIONS = {"CREATE_PROFORMA", "CREATE_INVOICE"} REVIEW_ACTIONS = {"REVIEW", "REVIEW_MANUALLY", "REVIEW_REQUIRED", "REVIEW_RECONSTRUCTED_PROCESS"} PAYMENT_FOLLOWUPS = {"FOLLOW_UP_PAYMENT", "FOLLOW_UP_PROFORMA"} VALID_OVERRIDES = REVIEW_ACTIONS | {"SUPPORT", "SEND_INFO", "CALL_CUSTOMER", "FOLLOW_UP_CUSTOMER_REVIEW"} | PAYMENT_FOLLOWUPS TERMINAL_STATES = {"COMPLETED", "LOST", "NO_INTEREST"} @dataclass(frozen=True) class AuthoritativeOperationalDecision: opportunity_id: str canonical_opportunity_id: str business_state: str business_next_action: str | None effective_action: str | None queue: str eligible: bool reason_code: str reason_text: str blocking_action_code: str | None = None obligation_source_refs: tuple[dict[str, Any], ...] = () confidence: str = "high" def to_dict(self) -> dict[str, Any]: result = asdict(self) result["obligation_source_refs"] = list(self.obligation_source_refs) # Shared legacy presentation contract consumed by Operations and detail. result.update({ "action_code": self.effective_action or "NO_ACTION", "label": action_label(self.effective_action, "Sem ação"), "description": self.reason_text, "priority": "alta" if self.queue in {"review", "blocked", "exception"} else "normal", "can_execute": self.eligible and self.effective_action is not None, "reason_if_blocked": self.reason_code if not self.eligible else None, "operational_queue": self.queue, "authoritative_v2": True, "is_duplicate_representation": self.reason_code == "DUPLICATE_SUPPRESSED", "suppress_current_card": self.reason_code == "DUPLICATE_SUPPRESSED", }) return result def _code(value: Any) -> str: return str(value or "").strip().upper() def _dt(value: Any) -> datetime | None: if not value: return None if isinstance(value, datetime): parsed = value else: try: parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) except ValueError: return None return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) def _active_obligations(obligations: Iterable[Mapping[str, Any]]) -> list[Mapping[str, Any]]: return [row for row in obligations if str(row.get("status") or "").lower() == "pending" and not row.get("resolved_at") and not row.get("superseded_by_task_id")] def _unanswered_inbound(row: Mapping[str, Any]) -> bool: inbound = _dt(row.get("latest_public_inbound")) outbound = _dt(row.get("latest_public_outbound")) return bool(inbound and (outbound is None or outbound <= inbound)) def _ref(row: Mapping[str, Any]) -> dict[str, Any]: return {"source": "task", "id": str(row.get("id") or ""), "status": "pending", "action_code": _code(row.get("action_code")), "source_system": str(row.get("source_system") or "")} def decide_authoritative_operation( projection: Mapping[str, Any] | None, *, obligations: Iterable[Mapping[str, Any]] = (), now: datetime | None = None, fiscal_complete: bool = True, reconciliation_blocking: bool = False, hard_blocker: str | None = None, ) -> AuthoritativeOperationalDecision: """Return the sole operator decision, failing closed without a projection.""" now = now or datetime.now(timezone.utc) if projection is None: return AuthoritativeOperationalDecision( "", "", "MISSING_PROJECTION", None, "REVIEW_REQUIRED", "review", True, "MISSING_V2_PROJECTION", "A projeção Flow v2 está em falta; é necessária revisão, sem recorrer ao V1.", confidence="low", ) oid = str(projection.get("opportunity_id") or "") canonical = str(projection.get("canonical_opportunity_id") or oid) state = _code(projection.get("business_state")) business_action = _code(projection.get("business_next_action")) or None confidence = str(projection.get("confidence") or "low") if projection.get("is_duplicate_representation"): return AuthoritativeOperationalDecision( oid, canonical, state, business_action, None, "not_current", False, "DUPLICATE_SUPPRESSED", f"Representação duplicada do processo material canónico {canonical}.", confidence="high", ) if hard_blocker: return AuthoritativeOperationalDecision( oid, canonical, state, business_action, hard_blocker, "blocked", True, "HARD_FACTUAL_BLOCKER", "Um bloqueio factual ou de sistema impede a ação atual.", hard_blocker, confidence=confidence, ) # Terminal factual state cannot be reopened by a leftover operational task. if state in TERMINAL_STATES: return AuthoritativeOperationalDecision(oid, canonical, state, business_action, None, "not_current", False, "TERMINAL_BUSINESS_STATE", "O processo factual está concluído.", confidence=confidence) active = _active_obligations(obligations) by_code: dict[str, list[Mapping[str, Any]]] = {} for row in active: by_code.setdefault(_code(row.get("action_code")), []).append(row) # A formal-document prerequisite blocks only a transition which needs it. if business_action in FORMAL_ACTIONS and not fiscal_complete: return AuthoritativeOperationalDecision( oid, canonical, state, business_action, "VALIDATE_FISCAL_CUSTOMER", "blocked", True, "FISCAL_IDENTITY_REQUIRED", "Validar os dados fiscais antes de criar o documento oficial.", "VALIDATE_FISCAL_CUSTOMER", confidence=confidence, ) if business_action in FORMAL_ACTIONS and reconciliation_blocking: return AuthoritativeOperationalDecision( oid, canonical, state, business_action, "RECONCILE_DOCUMENTS", "blocked", True, "DOCUMENT_RECONCILIATION_REQUIRED", "Confirmar a ligação do documento formal atual.", "RECONCILE_DOCUMENTS", confidence=confidence, ) def choose(codes: Iterable[str], queue: str, reason: str): for code in codes: rows = by_code.get(code, []) if rows: return AuthoritativeOperationalDecision( oid, canonical, state, business_action, code, queue, True, reason, "Existe uma obrigação operacional pendente e válida.", obligation_source_refs=tuple(_ref(row) for row in rows), confidence=confidence, ) return None picked = choose(("REVIEW_MANUALLY", "REVIEW_REQUIRED", "REVIEW", "REVIEW_RECONSTRUCTED_PROCESS"), "review", "ACTIVE_REVIEW_OBLIGATION") picked = picked or choose(("SUPPORT",), "do_now", "ACTIVE_SUPPORT_OBLIGATION") valid_send_info = [row for row in by_code.get("SEND_INFO", []) if ( state != "AWAITING_CUSTOMER" or _unanswered_inbound(row) )] if valid_send_info and (state in {"INQUIRY", "AWAITING_CUSTOMER"} or business_action == "SEND_INFO"): by_code["SEND_INFO"] = valid_send_info picked = picked or choose(("SEND_INFO",), "do_now", "ACTIVE_RESPONSE_OBLIGATION") picked = picked or choose(("CALL_CUSTOMER",), "do_now", "EXPLICIT_CALL_OBLIGATION") if picked: return picked waiting_state = state in {"AWAITING_CUSTOMER", "AWAITING_PAYMENT"} followup_order = ("FOLLOW_UP_CUSTOMER_REVIEW",) if state == "AWAITING_CUSTOMER" else tuple(PAYMENT_FOLLOWUPS) if waiting_state: for code in followup_order: rows = by_code.get(code, []) if not rows: continue due_rows = [row for row in rows if _dt(row.get("due_at")) is None or _dt(row.get("due_at")) <= now] if due_rows: return AuthoritativeOperationalDecision( oid, canonical, state, business_action, code, "do_now", True, "DUE_FOLLOW_UP", "O follow-up ativo chegou à data e continua por satisfazer.", obligation_source_refs=tuple(_ref(row) for row in due_rows), confidence=confidence, ) return AuthoritativeOperationalDecision( oid, canonical, state, business_action, None, "waiting", False, "FOLLOW_UP_NOT_DUE", "O follow-up ativo ainda não chegou à data.", obligation_source_refs=tuple(_ref(row) for row in rows), confidence=confidence, ) if business_action: queue = "review" if business_action in REVIEW_ACTIONS else "do_now" return AuthoritativeOperationalDecision(oid, canonical, state, business_action, business_action, queue, True, "BUSINESS_NEXT_ACTION", str(projection.get("reason_text") or "A ação decorre do estado factual Flow v2."), confidence=confidence) if waiting_state: return AuthoritativeOperationalDecision(oid, canonical, state, None, None, "waiting", False, "WAITING_EXTERNAL_EVENT", "O processo aguarda um evento externo.", confidence=confidence) return AuthoritativeOperationalDecision(oid, canonical, state, None, None, "backlog", False, "NO_CURRENT_INTERNAL_ACTION", "Não existe ação interna atual.", confidence=confidence)