feat: add BLIF Flow v2 authoritative operational adapter

This commit is contained in:
plx
2026-08-16 01:15:26 +00:00
parent 7cc9fecbdc
commit fc092a96b6
10 changed files with 531 additions and 23 deletions

View File

@@ -15,6 +15,7 @@ from sqlalchemy import bindparam, text
from app.db import engine
from app.config import settings
from app.authoritative_operational_adapter import decide_authoritative_operation
# Backward-compatible static anchors from v1.5.59: quotation_doc, invoice_doc, confirmar pagamento antes de emitir fatura, Pagamento confirmado com base em, Criar/enviar fatura.
from app.domain.opportunity_flow import (
OpportunityEvidence,
@@ -51,6 +52,11 @@ def _load_v2_comparison_rows(opportunity_ids: list[str]) -> dict[str, dict[str,
"""Read the persisted projection only; comparison must never derive writes."""
if not opportunity_ids:
return {}
# Lightweight repository fakes used by pure unit tests intentionally expose
# only ``begin``. Comparison observation is optional and must not alter the
# V1 contract or its query count when that read capability is absent.
if not hasattr(engine, "connect"):
return {}
with engine.connect() as conn:
rows = conn.execute(text("""
SELECT opportunity_id::text, material_process_key,
@@ -95,8 +101,6 @@ def compare_v1_v2_decisions(
def _observe_flow_v2(v1_decisions: Dict[str, Dict[str, Any]]) -> None:
mode = _flow_v2_mode()
if mode == "authoritative":
raise RuntimeError("BLIF Flow v2 authoritative mode is disabled; cutover boundary is fail-closed")
if mode != "compare" or not v1_decisions:
return
rows = _load_v2_comparison_rows(list(v1_decisions))
@@ -228,7 +232,7 @@ def get_opportunity_next_action(
"""
if _flow_v2_mode() == "authoritative":
raise RuntimeError("BLIF Flow v2 authoritative mode is disabled; cutover boundary is fail-closed")
return get_opportunity_next_actions([opportunity_id]).get(opportunity_id) or decide_authoritative_operation(None).to_dict()
evidence = (_build_db_evidence(opportunity_id, preloaded=preloaded)
if preloaded is not None else _build_db_evidence(opportunity_id))
if evidence is None:
@@ -368,12 +372,13 @@ def _bulk_operation_snapshots(
def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]:
"""Return the same decisions as the single-item API with a fixed query count."""
if _flow_v2_mode() == "authoritative":
raise RuntimeError("BLIF Flow v2 authoritative mode is disabled; cutover boundary is fail-closed")
ids = list(dict.fromkeys(str(value).strip() for value in opportunity_ids if str(value).strip()))
if not ids:
return {}
if _flow_v2_mode() == "authoritative":
return _get_authoritative_opportunity_decisions(ids)
# Read path only: schema creation belongs to startup/migrations. In
# particular, GET /operations must never perform DDL while calculating its
# canonical projection.
@@ -450,3 +455,56 @@ def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Di
decisions[oid] = decide_opportunity_next_action(evidence, profile).to_dict()
_observe_flow_v2(decisions)
return decisions
def _get_authoritative_opportunity_decisions(ids: list[str]) -> Dict[str, Dict[str, Any]]:
"""Read-only set-oriented adapter input loader; never derives or persists V2."""
with engine.connect() as conn:
projections = _bulk_rows(conn, """
SELECT opportunity_id::text, material_process_key,
canonical_opportunity_id::text, is_duplicate_representation,
business_state, business_next_action, diagnostic_status,
confidence, reason_code, reason_text
FROM opportunity_flow_state_v2 WHERE opportunity_id::text IN :opportunity_ids
""", ids)
tasks = _bulk_rows(conn, """
SELECT id::text, opportunity_id::text, action_code, status, due_at,
resolved_at, superseded_by_task_id::text, created_at, metadata
FROM tasks WHERE opportunity_id::text IN :opportunity_ids AND status = 'pending'
""", ids)
customers = _bulk_rows(conn, """
SELECT o.id::text AS opportunity_id,
(c.tax_id IS NOT NULL AND c.tax_id <> '' AND c.email IS NOT NULL AND c.email <> ''
AND c.street_name IS NOT NULL AND c.street_name <> ''
AND c.postal_zone IS NOT NULL AND c.postal_zone <> ''
AND c.city_name IS NOT NULL AND c.city_name <> '') AS fiscal_complete
FROM opportunities o LEFT JOIN customers c ON c.id = o.local_customer_id
WHERE o.id::text IN :opportunity_ids
""", ids)
reconciliation = _bulk_rows(conn, """
SELECT opportunity_id::text
FROM reconciliation_items
WHERE opportunity_id::text IN :opportunity_ids
AND status IN ('needs_review','conflict')
AND COALESCE((payload->>'reconciliation_blocks_current_action')::boolean, false)
""", ids)
projection_by_id = {str(row["opportunity_id"]): row for row in projections}
tasks_by_id = _group_by_opportunity(tasks)
fiscal_by_id = {str(row["opportunity_id"]): bool(row.get("fiscal_complete")) for row in customers}
reconciliation_ids = {str(row["opportunity_id"]) for row in reconciliation}
decisions = {}
for oid in ids:
projection = projection_by_id.get(oid)
decision = decide_authoritative_operation(
projection, obligations=tasks_by_id.get(oid, []),
fiscal_complete=fiscal_by_id.get(oid, False),
reconciliation_blocking=oid in reconciliation_ids,
)
value = decision.to_dict()
if projection is None:
value["opportunity_id"] = oid
value["target_url"] = f"/opportunities/{oid}"
else:
value["target_url"] = f"/opportunities/{decision.canonical_opportunity_id or oid}"
decisions[oid] = value
return decisions