feat: add BLIF Flow v2 authoritative operational adapter
This commit is contained in:
182
app/authoritative_operational_adapter.py
Normal file
182
app/authoritative_operational_adapter.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""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,
|
||||
"suppress_current_card": self.queue == "not_current",
|
||||
})
|
||||
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"))}
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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")
|
||||
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 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)
|
||||
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)
|
||||
Reference in New Issue
Block a user