feat: add BLIF Flow v2 shadow compare cutover layer

This commit is contained in:
plx
2026-08-16 00:12:20 +00:00
parent 3b60c3fb70
commit 32e7773957
7 changed files with 417 additions and 24 deletions

View File

@@ -1544,20 +1544,14 @@ def _parse_opportunity_dt(value: object):
def _opportunity_lifecycle_state(opp: dict) -> str: def _opportunity_lifecycle_state(opp: dict) -> str:
state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active" state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active"
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
pending_call = str(opp.get("pending_follow_up_action_code") or "").strip().upper() == "CALL_CUSTOMER" pending_followup = str(opp.get("pending_follow_up_action_code") or "").strip().upper()
pending_call_due = _parse_opportunity_dt(opp.get("pending_follow_up_due_at")) pending_due = _parse_opportunity_dt(opp.get("pending_follow_up_due_at"))
if pending_call: if pending_followup:
return "follow_up_due" if pending_call_due and pending_call_due <= now else "scheduled_follow_up" return "follow_up_due" if pending_due and pending_due <= now else "scheduled_follow_up"
# Compatibility only: a stale denormalized lifecycle marker cannot invent # Compatibility-only timestamps/lifecycle markers cannot create work. An
# a scheduled call when no active CALL_CUSTOMER task exists. # active pending follow-up task and its due_at are the operational source.
if state == "scheduled_follow_up": if state in {"scheduled_follow_up", "follow_up_due"}:
return "active" return "active"
nurture_until = _parse_opportunity_dt(opp.get("nurture_until"))
next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at"))
if state == "nurture" and nurture_until and nurture_until <= now:
return "follow_up_due"
if state in {"awaiting_customer", "active", "scheduled_follow_up"} and next_follow_up and next_follow_up <= now:
return "follow_up_due"
return state return state

View File

@@ -102,14 +102,15 @@ def rebuild_blif_flow_v2_projection(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Idempotently rebuild projection rows and state-change transitions. """Idempotently rebuild projection rows and state-change transitions.
``off`` is a no-op. ``shadow`` writes only additive projection tables. ``off`` is a no-op. ``shadow`` and ``compare`` write only the same additive
Compare/authoritative behavior is deliberately not implemented. projection tables. Compare affects observation at the read boundary, never
the persisted business rows. Authoritative remains deliberately disabled.
""" """
selected_mode = str(mode or settings.blif_flow_v2_mode or "off").strip().lower() selected_mode = str(mode or settings.blif_flow_v2_mode or "off").strip().lower()
if selected_mode == "off": if selected_mode == "off":
return {"mode": "off", "projection_count": 0, "transitions_written": 0, "disabled": True} return {"mode": "off", "projection_count": 0, "transitions_written": 0, "disabled": True}
if selected_mode != "shadow": if selected_mode not in {"shadow", "compare"}:
raise RuntimeError(f"BLIF Flow v2 mode {selected_mode!r} is not implemented; only off/shadow are safe") raise RuntimeError(f"BLIF Flow v2 mode {selected_mode!r} is disabled; only off/shadow/compare are safe")
if not re.fullmatch(r"[a-z_][a-z0-9_]*", target_schema): if not re.fullmatch(r"[a-z_][a-z0-9_]*", target_schema):
raise ValueError("invalid target_schema") raise ValueError("invalid target_schema")
@@ -208,7 +209,7 @@ def rebuild_blif_flow_v2_projection(
conn.close() conn.close()
return { return {
"mode": "shadow", "projection_count": len(values), "mode": selected_mode, "projection_count": len(values),
"canonical_count": sum(not value["is_duplicate_representation"] for value in values), "canonical_count": sum(not value["is_duplicate_representation"] for value in values),
"duplicate_count": sum(value["is_duplicate_representation"] for value in values), "duplicate_count": sum(value["is_duplicate_representation"] for value in values),
"transitions_written": transitions_written, "disabled": False, "transitions_written": transitions_written, "disabled": False,

View File

@@ -21,8 +21,9 @@ class Settings(BaseSettings):
# TIMESTAMPTZ nem os índices usados pelo schema core. # TIMESTAMPTZ nem os índices usados pelo schema core.
database_url: str database_url: str
clientflow_persist: bool = True clientflow_persist: bool = True
# Flow v2 is additive shadow scaffolding only. Compare/authoritative values # Flow v2 remains non-authoritative. Compare observes V1/V2 differences
# are reserved for future work and are not activated by this implementation. # while returning V1; authoritative is accepted by config only to fail
# closed at the switching boundary.
blif_flow_v2_mode: Literal["off", "shadow", "compare", "authoritative"] = "off" blif_flow_v2_mode: Literal["off", "shadow", "compare", "authoritative"] = "off"
clientflow_webhook_secret: str = "" clientflow_webhook_secret: str = ""

View File

@@ -7,11 +7,14 @@ from __future__ import annotations
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from collections import defaultdict from collections import defaultdict
import json
import logging
from typing import Any, Dict, Iterable, Optional from typing import Any, Dict, Iterable, Optional
from sqlalchemy import bindparam, text from sqlalchemy import bindparam, text
from app.db import engine from app.db import engine
from app.config import settings
# 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. # 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 ( from app.domain.opportunity_flow import (
OpportunityEvidence, OpportunityEvidence,
@@ -21,6 +24,9 @@ from app.domain.opportunity_flow import (
) )
logger = logging.getLogger(__name__)
@dataclass @dataclass
class OpportunityNextAction: class OpportunityNextAction:
action_code: str action_code: str
@@ -37,6 +43,67 @@ class OpportunityNextAction:
return asdict(self) return asdict(self)
def _flow_v2_mode() -> str:
return str(settings.blif_flow_v2_mode or "off").strip().lower()
def _load_v2_comparison_rows(opportunity_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Read the persisted projection only; comparison must never derive writes."""
if not opportunity_ids:
return {}
with engine.connect() as conn:
rows = conn.execute(text("""
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, flow_version
FROM opportunity_flow_state_v2
WHERE opportunity_id::text IN :opportunity_ids
""").bindparams(bindparam("opportunity_ids", expanding=True)),
{"opportunity_ids": opportunity_ids}).mappings().all()
return {str(row["opportunity_id"]): dict(row) for row in rows}
def compare_v1_v2_decisions(
v1_decisions: Dict[str, Dict[str, Any]],
v2_rows: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
"""Return payload-safe structured comparisons without message content."""
comparisons = []
for opportunity_id, v1 in v1_decisions.items():
v2 = v2_rows.get(opportunity_id)
v1_action = str(v1.get("action_code") or "") or None
v2_action = (v2 or {}).get("business_next_action")
comparisons.append({
"opportunity_id": opportunity_id,
"material_process_key": (v2 or {}).get("material_process_key"),
"canonical_opportunity_id": (v2 or {}).get("canonical_opportunity_id"),
"is_duplicate_representation": bool((v2 or {}).get("is_duplicate_representation")),
"v1_action": v1_action,
"v1_reason_code": v1.get("reason_if_blocked") or v1.get("decision_version"),
"v2_business_state": (v2 or {}).get("business_state"),
"v2_business_next_action": v2_action,
"v2_reason_code": (v2 or {}).get("reason_code"),
"v2_diagnostic_status": (v2 or {}).get("diagnostic_status"),
"v2_confidence": (v2 or {}).get("confidence"),
"projection_present": v2 is not None,
"action_agrees": v2 is not None and v1_action == v2_action,
"returned_source": "v1",
})
return comparisons
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))
for comparison in compare_v1_v2_decisions(v1_decisions, rows):
logger.info("blif_flow_v2_compare %s", json.dumps(comparison, sort_keys=True, default=str))
def _first_row(conn: Any, sql: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _first_row(conn: Any, sql: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
row = conn.execute(text(sql), params).mappings().first() row = conn.execute(text(sql), params).mappings().first()
return dict(row) if row else None return dict(row) if row else None
@@ -160,6 +227,8 @@ def get_opportunity_next_action(
decision is now produced by the company workflow engine. decision is now produced by the company workflow engine.
""" """
if _flow_v2_mode() == "authoritative":
raise RuntimeError("BLIF Flow v2 authoritative mode is disabled; cutover boundary is fail-closed")
evidence = (_build_db_evidence(opportunity_id, preloaded=preloaded) evidence = (_build_db_evidence(opportunity_id, preloaded=preloaded)
if preloaded is not None else _build_db_evidence(opportunity_id)) if preloaded is not None else _build_db_evidence(opportunity_id))
if evidence is None: if evidence is None:
@@ -172,8 +241,9 @@ def get_opportunity_next_action(
reason_if_blocked="opportunity_not_found", reason_if_blocked="opportunity_not_found",
).to_dict() ).to_dict()
decision = decide_opportunity_next_action(evidence, load_company_profile(evidence.company_profile)) decision = decide_opportunity_next_action(evidence, load_company_profile(evidence.company_profile)).to_dict()
return decision.to_dict() _observe_flow_v2({opportunity_id: decision})
return decision
def _bulk_statement(sql: str): def _bulk_statement(sql: str):
@@ -298,6 +368,8 @@ def _bulk_operation_snapshots(
def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]: 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.""" """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())) ids = list(dict.fromkeys(str(value).strip() for value in opportunity_ids if str(value).strip()))
if not ids: if not ids:
return {} return {}
@@ -376,4 +448,5 @@ def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Di
company_profile="blif", company_profile="blif",
) )
decisions[oid] = decide_opportunity_next_action(evidence, profile).to_dict() decisions[oid] = decide_opportunity_next_action(evidence, profile).to_dict()
_observe_flow_v2(decisions)
return decisions return decisions

View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Read-only compatibility/cutover audit for BLIF Flow v2."""
from __future__ import annotations
import json
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from sqlalchemy import text
from app.db import engine
from app.opportunity_next_action_service import (
_load_v2_comparison_rows, compare_v1_v2_decisions, get_opportunity_next_actions,
)
from scripts.simulate_blif_flow_v2 import collect
EXPECTED_DATABASE = "clientflow_codex_test"
CUTOVER = Path("/tmp/blif_flow_v2_cutover_report.json")
INVENTORY = Path("/tmp/blif_flow_v2_legacy_field_inventory.json")
COMPARE = Path("/tmp/blif_flow_v2_compare_report.json")
FIELD_INVENTORY = [
{
"field": "stage", "kind": "derived_compatibility", "factual": False,
"writers": ["app/opportunity_service.py", "app/admin_ui/pages/opportunities.py",
"app/reconciliation_service.py", "app/odoo_service.py", "app/external_reconciliation_sync.py"],
"readers": ["app/domain/opportunity_flow/evidence.py (V1 only)", "app/workflow_guard.py",
"app/admin_ui/pages/opportunities.py", "app/admin_ui/pages/orders.py",
"app/admin_dashboard.py", "app/revenue_forecast_service.py"],
"flow_v2_replacement": "opportunity_flow_state_v2.business_state",
"compatibility_required": True, "one_time_migration_required": False,
"eventually_deprecatable": True,
"notes": "Keep stored value initially for V1 boards, reports, forecasts and action guards; never use as factual V2 input. Consumers need code migration, not historical mass rewrite.",
},
{
"field": "lifecycle_state", "kind": "operational_compatibility", "factual": False,
"writers": ["app/followup_service.py", "app/opportunity_service.py"],
"readers": ["app/operational_eligibility.py", "app/operations_service.py",
"app/admin_ui/pages/opportunities.py"],
"flow_v2_replacement": "business state plus active operational obligations",
"compatibility_required": True, "one_time_migration_required": False,
"eventually_deprecatable": True,
"notes": "Awaiting/recovery/nurture presentation remains V1 compatibility. It cannot create a scheduled obligation without an active task.",
},
{
"field": "next_follow_up_at", "kind": "denormalized_followup_compatibility", "factual": False,
"writers": ["app/followup_service.py", "app/opportunity_service.py"],
"readers": ["app/operations_service.py", "app/admin_ui/pages/opportunities.py"],
"flow_v2_replacement": "pending follow-up task action_code/due_at",
"compatibility_required": True, "one_time_migration_required": False,
"eventually_deprecatable": True,
"notes": "Historical timestamp is inert without a pending follow-up task; retain all 44 values for now.",
},
{
"field": "nurture_until", "kind": "denormalized_schedule_compatibility", "factual": False,
"writers": ["app/opportunity_service.py"],
"readers": ["app/admin_ui/pages/opportunities.py"],
"flow_v2_replacement": "explicit pending REVIEW_NURTURE/follow-up task due_at",
"compatibility_required": True, "one_time_migration_required": False,
"eventually_deprecatable": True,
},
{
"field": "follow_up_attempts", "kind": "historical_counter", "factual": False,
"writers": ["app/opportunity_service.py", "app/followup_service.py"],
"readers": ["app/admin_ui/pages/opportunities.py"],
"flow_v2_replacement": "event/task history aggregation", "compatibility_required": True,
"one_time_migration_required": False, "eventually_deprecatable": True,
},
{
"field": "last_action_code", "kind": "last-known-action_compatibility", "factual": False,
"writers": ["app/opportunity_service.py", "app/admin_ui/pages/opportunities.py",
"app/reconciliation_service.py", "app/odoo_service.py"],
"readers": ["app/workflow_guard.py", "app/admin_dashboard.py",
"app/admin_ui/pages/opportunities.py", "app/action_prompt.py"],
"flow_v2_replacement": "opportunity_flow_state_v2.business_next_action plus OperationalEligibility",
"compatibility_required": True, "one_time_migration_required": False,
"eventually_deprecatable": True,
},
{
"field": "last_task_id", "kind": "historical_pointer", "factual": False,
"writers": ["app/opportunity_service.py", "app/reconciliation_service.py",
"app/company_opportunity_linking.py"],
"readers": ["app/workflow_guard.py"],
"flow_v2_replacement": "active canonical task lookup", "compatibility_required": True,
"one_time_migration_required": False, "eventually_deprecatable": True,
},
{
"field": "pending_primary_* / pending_follow_up_*", "kind": "runtime_read_model", "factual": False,
"writers": ["none (SQL projections in app/opportunity_service.py)"],
"readers": ["app/admin_ui/pages/opportunities.py"],
"flow_v2_replacement": "active task lookup remains an explicit operational overlay",
"compatibility_required": True, "one_time_migration_required": False,
"eventually_deprecatable": False,
"notes": "These virtual fields correctly derive active obligations and are not stored opportunity state.",
},
]
SOURCE_OF_TRUTH = [
["business process state", "opportunity_flow_state_v2.business_state"],
["business next action", "opportunity_flow_state_v2.business_next_action"],
["time-sensitive operational queue", "OperationalEligibility"],
["scheduled follow-up", "pending task action_code + due_at"],
["formal document state", "commercial_documents + active opportunity_document_links"],
["payment", "confirmed factual payment evidence/operation link"],
["Odoo execution", "operation_links / factual Odoo evidence"],
["messages/customer response", "messages and communications chronology"],
["legacy stage", "compatibility only"],
["tasks", "operator obligations/history; never business fact proof"],
]
def _write(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
def _identity_and_ids() -> tuple[dict[str, str], list[str], int]:
with engine.connect() as conn:
conn.exec_driver_sql("BEGIN READ ONLY")
try:
identity = conn.execute(text("SELECT current_database(),current_user,current_setting('transaction_read_only')")).one()
if identity[0] != EXPECTED_DATABASE or identity[2] != "on":
raise RuntimeError(f"refusing unexpected/non-read-only database: {identity!r}")
ids = [str(value) for value in conn.execute(text("SELECT opportunity_id FROM opportunity_flow_state_v2 ORDER BY opportunity_id")).scalars()]
historical = conn.execute(text("""
SELECT count(*) FROM opportunities o
WHERE o.next_follow_up_at IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM tasks t WHERE t.opportunity_id=o.id AND t.status='pending'
AND (t.action_code LIKE 'FOLLOW_UP_%' OR t.action_code IN
('CALL_CUSTOMER','CONFIRM_DELIVERY','RECOVER_OPPORTUNITY','REVIEW_NURTURE'))
)
""")).scalar_one()
finally:
conn.rollback()
return {"database": identity[0], "user": identity[1], "transaction_read_only": identity[2]}, ids, int(historical)
def main() -> None:
identity, ids, historical_timestamps = _identity_and_ids()
if len(ids) != 328:
raise RuntimeError(f"expected 328 persisted Flow v2 opportunities, found {len(ids)}")
v1 = get_opportunity_next_actions(ids)
v2 = _load_v2_comparison_rows(ids)
comparisons = compare_v1_v2_decisions(v1, v2)
counts = Counter("agree" if row["action_agrees"] else "different" for row in comparisons)
counts["missing_projection"] = sum(not row["projection_present"] for row in comparisons)
compare_report = {
"generated_at": datetime.now(timezone.utc), "database": identity,
"mode_semantics": "V1 returned; persisted V2 observed; no business mutation",
"opportunity_count": len(comparisons), "counts": dict(counts), "comparisons": comparisons,
}
_write(COMPARE, compare_report)
inventory = {
"generated_at": datetime.now(timezone.utc), "database": identity,
"fields": FIELD_INVENTORY,
"summary": {
"fields_requiring_data_migration": [],
"compatibility_only_fields": [row["field"] for row in FIELD_INVENTORY if row["compatibility_required"]],
"safe_to_deprecate_after_consumer_migration": [row["field"] for row in FIELD_INVENTORY if row["eventually_deprecatable"]],
"historical_followup_timestamps_preserved": historical_timestamps,
},
}
_write(INVENTORY, inventory)
projection = collect(expected_database=EXPECTED_DATABASE, expected_user=identity["user"], require_read_only=False)
named = {}
for name in ("INSTALBEIRA", "PANORAMIC SUCCESS", "ENGEXICON", "CONSTRURECUP", "X MAT", "RZSOLAR"):
matches = [row for row in projection["opportunities"]
if name.casefold() in f"{row.get('title')} {row.get('customer')}".casefold()]
named[name] = [{"opportunity_id": row["opportunity_id"], "material_process_key": row["material_process_key"],
"canonical_process_id": row["canonical_process_id"], "business_state": row["safe_v2"]["business_state"],
"effective_action": row["safe_v2"]["effective_operational_action"],
"queue": row["safe_v2"]["effective_operational_queue"],
"duplicate_suppressed": row["safe_v2"]["precedence"] == "duplicate_representation"}
for row in matches]
cutover = {
"generated_at": datetime.now(timezone.utc), "database": identity,
"source_of_truth": [{"concern": concern, "source": source} for concern, source in SOURCE_OF_TRUTH],
"data_migration": {"opportunities_requiring_mutation_before_shadow": 0,
"opportunities_requiring_no_mutation_before_shadow": len(ids),
"broad_repair_required": False,
"stage_write_migration_required": False,
"lifecycle_write_migration_required": False,
"followup_timestamp_cleanup_required": False},
"mode_contract": {
"off": "No Flow v2 derivation or projection writes are triggered by runtime reads.",
"shadow": "V1 returned; explicit projection rebuild is additive/idempotent; no tasks, stage, or UI behavior changed.",
"compare": "V1 returned; V2 projection read and structured comparison logged; no disagreement writes.",
"authoritative": "Disabled and fail-closed. No activation performed.",
},
"production_shadow_blockers": [
"Production migration 011 presence was not and must not be checked from this environment.",
"Deployment must provide an explicit projection rebuild cadence and comparison-log monitoring/retention.",
],
"mode_off_deployment_blockers": [],
"shadow_compare_activation_prerequisites": [
"Install migration 011 through the normal controlled production migration process.",
"Run mode=off first, then explicitly rebuild projection in shadow.",
"Monitor structured disagreement rates and missing projections before compare enablement.",
],
"authoritative_activation_blockers": [
"Authoritative switch intentionally raises and has no enabled code path.",
"Operations/UI must consume V2 business state/action followed by OperationalEligibility.",
"Legacy stage consumers in orders, forecasts, dashboard, workflow guards and opportunity columns require migration or explicit compatibility adapters.",
"Explicit operational override precedence must be implemented for CALL_CUSTOMER, due follow-up, SUPPORT, SEND_INFO, manual review and blockers.",
"Production shadow/compare observation, rollback criteria and zero-false-negative acceptance must be completed.",
],
"legacy_findings_are": "field-level compatibility findings, not required row mutations",
"historical_followup_timestamps_preserved": historical_timestamps,
"compare_summary": compare_report["counts"], "named_cases": named,
}
_write(CUTOVER, cutover)
print(json.dumps({"database": identity, "opportunities": len(ids),
"compare": compare_report["counts"], "historical_timestamps": historical_timestamps,
"mutation_required": 0, "outputs": [str(CUTOVER), str(INVENTORY), str(COMPARE)]}, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
from datetime import datetime, timedelta, timezone
from inspect import getsource
from types import SimpleNamespace
import pytest
import app.opportunity_next_action_service as service
from app.admin_ui.pages.opportunities import _opportunity_lifecycle_state
from app.domain.opportunity_flow.v2 import EffectiveOperationalDecision, suppress_duplicate_representation
class Decision:
def to_dict(self):
return {"action_code": "V1_ACTION", "label": "V1", "description": "legacy"}
def test_compare_mode_returns_v1_behavior(monkeypatch, caplog):
caplog.set_level("INFO")
monkeypatch.setattr(service.settings, "blif_flow_v2_mode", "compare")
monkeypatch.setattr(service, "_build_db_evidence", lambda *args, **kwargs: SimpleNamespace(company_profile="blif"))
monkeypatch.setattr(service, "load_company_profile", lambda *_: object())
monkeypatch.setattr(service, "decide_opportunity_next_action", lambda *_: Decision())
monkeypatch.setattr(service, "_load_v2_comparison_rows", lambda ids: {
"opp": {"business_state": "COMPLETED", "business_next_action": None,
"reason_code": "BUSINESS_TRANSITION"}
})
assert service.get_opportunity_next_action("opp", preloaded={})["action_code"] == "V1_ACTION"
assert "blif_flow_v2_compare" in caplog.text
def test_compare_mode_has_no_business_mutation_sql():
source = getsource(service._load_v2_comparison_rows).upper() + getsource(service._observe_flow_v2).upper()
assert "UPDATE " not in source
assert "INSERT " not in source
assert "DELETE " not in source
def test_stale_legacy_stage_cannot_override_v2_factual_state():
rows = service.compare_v1_v2_decisions(
{"opp": {"action_code": "CREATE_JASMIN_QUOTE"}},
{"opp": {"business_state": "PROFORMA_REQUIRED", "business_next_action": "CREATE_PROFORMA",
"reason_code": "BUSINESS_TRANSITION"}},
)
assert rows[0]["v2_business_state"] == "PROFORMA_REQUIRED"
assert rows[0]["returned_source"] == "v1"
def test_historical_next_followup_without_active_task_creates_no_work():
assert _opportunity_lifecycle_state({
"lifecycle_state": "active", "next_follow_up_at": "2020-01-01T00:00:00+00:00",
"pending_follow_up_action_code": None,
}) == "active"
def test_due_active_followup_still_creates_work():
assert _opportunity_lifecycle_state({
"lifecycle_state": "awaiting_customer", "next_follow_up_at": "2020-01-01T00:00:00+00:00",
"pending_follow_up_action_code": "FOLLOW_UP_CUSTOMER_REVIEW",
"pending_follow_up_due_at": datetime.now(timezone.utc) - timedelta(days=1),
}) == "follow_up_due"
def test_future_active_followup_remains_scheduled():
assert _opportunity_lifecycle_state({
"lifecycle_state": "awaiting_customer",
"pending_follow_up_action_code": "FOLLOW_UP_CUSTOMER_REVIEW",
"pending_follow_up_due_at": datetime.now(timezone.utc) + timedelta(days=1),
}) == "scheduled_follow_up"
def test_duplicate_representation_stays_suppressed():
raw = EffectiveOperationalDecision("REVIEW_REQUIRED", "REVIEW_REQUIRED", "REVIEW_REQUIRED",
"review", "review", "medium", "business_transition")
suppressed = suppress_duplicate_representation(raw, canonical_process_id="canonical")
assert suppressed.effective_operational_action is None
assert suppressed.effective_operational_queue == "not_current"
def test_authoritative_mode_is_fail_closed(monkeypatch):
monkeypatch.setattr(service.settings, "blif_flow_v2_mode", "authoritative")
with pytest.raises(RuntimeError, match="disabled"):
service.get_opportunity_next_actions([])
def test_shadow_mode_has_no_decision_side_effect(monkeypatch):
monkeypatch.setattr(service.settings, "blif_flow_v2_mode", "shadow")
monkeypatch.setattr(service, "_load_v2_comparison_rows",
lambda ids: pytest.fail("shadow must not read comparison projection"))
service._observe_flow_v2({"opp": {"action_code": "V1"}})

View File

@@ -80,7 +80,14 @@ def test_off_mode_is_a_noop_without_deriving_or_connecting():
} }
@pytest.mark.parametrize("mode", ["compare", "authoritative", "invalid"]) @pytest.mark.parametrize("mode", ["authoritative", "invalid"])
def test_unimplemented_modes_fail_closed(mode): def test_unimplemented_modes_fail_closed(mode):
with pytest.raises(RuntimeError, match="not implemented"): with pytest.raises(RuntimeError, match="disabled"):
rebuild_blif_flow_v2_projection(mode=mode, derived_rows=[]) rebuild_blif_flow_v2_projection(mode=mode, derived_rows=[])
def test_compare_mode_uses_the_same_additive_projection_path():
from inspect import getsource
source = getsource(rebuild_blif_flow_v2_projection)
assert 'selected_mode not in {"shadow", "compare"}' in source
assert '"mode": selected_mode' in source