Files
clientflow_backend/app/authoritative_operational_adapter.py

187 lines
9.2 KiB
Python

"""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 _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")
if state == "INQUIRY" or business_action == "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)