feat: add BLIF Flow v2 authoritative operational adapter
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user