feat: add BLIF Flow v2 data repair planner
This commit is contained in:
159
app/domain/opportunity_flow/repair.py
Normal file
159
app/domain/opportunity_flow/repair.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Pure BLIF Flow v2 historical repair classification and simulation helpers.
|
||||
|
||||
The functions in this module never access the database and never mutate business
|
||||
state. They deliberately treat tasks as obligations/history, not factual proof.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
TASK_CLASSIFICATIONS = {
|
||||
"VALID_CURRENT", "SATISFIED_BY_EVENT", "SUPERSEDED", "DUPLICATE",
|
||||
"PREMATURE", "AMBIGUOUS",
|
||||
}
|
||||
|
||||
FOLLOWUP_ACTIONS = {
|
||||
"FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA",
|
||||
"FOLLOW_UP_PAYMENT", "CALL_CUSTOMER",
|
||||
}
|
||||
|
||||
STAGE_RANK = {
|
||||
"INQUIRY": 0, "AWAITING_CUSTOMER": 1, "PROFORMA_REQUIRED": 2,
|
||||
"PROFORMA_CREATED": 3, "AWAITING_PAYMENT": 4, "INVOICE_REQUIRED": 5,
|
||||
"INVOICE_CREATED": 6, "ODOO_ORDER_REQUIRED": 6,
|
||||
"ODOO_ORDER_CREATED": 7, "ODOO_ORDER_VALIDATED": 8, "COMPLETED": 9,
|
||||
}
|
||||
|
||||
ACTION_REQUIRED_RANK = {
|
||||
"SEND_INFO": 0, "SEND_QUOTE": 0, "CREATE_PROFORMA": 2,
|
||||
"SEND_PROFORMA": 3, "FOLLOW_UP_PROFORMA": 4, "CONFIRM_PAYMENT": 4,
|
||||
"FOLLOW_UP_PAYMENT": 4, "CREATE_INVOICE": 5, "SEND_INVOICE": 6,
|
||||
"PREPARE_ORDER": 6, "VALIDATE_ODOO_ORDER": 7,
|
||||
"COMPLETE_OPPORTUNITY": 8,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskRepairContext:
|
||||
task_id: str
|
||||
opportunity_id: str | None
|
||||
action_code: str
|
||||
created_at: datetime | None = None
|
||||
due_at: datetime | None = None
|
||||
business_state: str | None = None
|
||||
business_next_action: str | None = None
|
||||
material_process_key: str | None = None
|
||||
is_duplicate_representation: bool = False
|
||||
canonical_opportunity_id: str | None = None
|
||||
later_inbound_event: dict[str, Any] | None = None
|
||||
later_outbound_event: dict[str, Any] | None = None
|
||||
proforma_exists: bool = False
|
||||
proforma_sent: bool = False
|
||||
payment_confirmed: bool = False
|
||||
invoice_exists: bool = False
|
||||
invoice_sent: bool = False
|
||||
odoo_order_exists: bool = False
|
||||
odoo_order_validated: bool = False
|
||||
terminal: bool = False
|
||||
same_obligation_task_id: str | None = None
|
||||
evidence_refs: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskRepairDecision:
|
||||
classification: str
|
||||
resolution_code: str | None
|
||||
reason: str
|
||||
confidence: str
|
||||
safety_tier: str
|
||||
auto_repair_safe: bool
|
||||
human_review_required: bool
|
||||
resolved_by_event_id: str | None = None
|
||||
superseded_by_task_id: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _decision(classification: str, reason: str, *, event: dict[str, Any] | None = None,
|
||||
superseded_by: str | None = None, tier: str = "HIGH") -> TaskRepairDecision:
|
||||
if classification not in TASK_CLASSIFICATIONS:
|
||||
raise ValueError(classification)
|
||||
auto = tier == "HIGH" and classification != "VALID_CURRENT"
|
||||
return TaskRepairDecision(
|
||||
classification, None if classification == "VALID_CURRENT" else classification,
|
||||
reason, "high" if tier == "HIGH" else "medium" if tier == "MEDIUM" else "low",
|
||||
tier, auto, tier != "HIGH",
|
||||
(event or {}).get("opportunity_event_id"), superseded_by,
|
||||
)
|
||||
|
||||
|
||||
def classify_pending_task(context: TaskRepairContext) -> TaskRepairDecision:
|
||||
"""Classify one pending task using facts available at the audit instant."""
|
||||
action = context.action_code.upper()
|
||||
state = (context.business_state or "").upper()
|
||||
|
||||
if context.is_duplicate_representation:
|
||||
return _decision(
|
||||
"DUPLICATE",
|
||||
f"Obligation belongs only to a duplicate representation of canonical process {context.canonical_opportunity_id}.",
|
||||
)
|
||||
if context.same_obligation_task_id:
|
||||
return _decision(
|
||||
"DUPLICATE", "The same material obligation has another canonical pending task.",
|
||||
superseded_by=context.same_obligation_task_id,
|
||||
)
|
||||
|
||||
if action in FOLLOWUP_ACTIONS:
|
||||
event = context.later_inbound_event
|
||||
if action == "FOLLOW_UP_PAYMENT" and context.payment_confirmed:
|
||||
return _decision("SATISFIED_BY_EVENT", "Confirmed payment fact satisfies the payment follow-up.")
|
||||
if event:
|
||||
return _decision("SATISFIED_BY_EVENT", "A later inbound customer event satisfies the follow-up.", event=event)
|
||||
return _decision("VALID_CURRENT", "No later satisfying event exists; age or overdue status alone never closes a follow-up.")
|
||||
|
||||
satisfied = {
|
||||
"SEND_INFO": context.later_outbound_event,
|
||||
"SEND_QUOTE": context.later_outbound_event,
|
||||
"CREATE_PROFORMA": context.proforma_exists,
|
||||
"SEND_PROFORMA": context.proforma_sent,
|
||||
"CONFIRM_PAYMENT": context.payment_confirmed,
|
||||
"CREATE_INVOICE": context.invoice_exists,
|
||||
"SEND_INVOICE": context.invoice_sent,
|
||||
"PREPARE_ORDER": context.odoo_order_exists,
|
||||
"VALIDATE_ODOO_ORDER": context.odoo_order_validated,
|
||||
"COMPLETE_OPPORTUNITY": context.terminal,
|
||||
}.get(action, False)
|
||||
if satisfied:
|
||||
event = satisfied if isinstance(satisfied, dict) else None
|
||||
return _decision("SATISFIED_BY_EVENT", f"Later factual evidence satisfies {action}.", event=event)
|
||||
|
||||
required = ACTION_REQUIRED_RANK.get(action)
|
||||
rank = STAGE_RANK.get(state)
|
||||
if required is not None and rank is not None:
|
||||
if rank > required:
|
||||
return _decision("SUPERSEDED", f"Factual process advanced to {state}, beyond the {action} obligation.")
|
||||
if rank < required:
|
||||
return _decision("PREMATURE", f"{action} requires prerequisites not present in factual state {state}.")
|
||||
|
||||
if action == "SEND_PROFORMA" and not context.proforma_exists:
|
||||
return _decision("PREMATURE", "No structured current proforma exists; a send task is not document evidence.")
|
||||
if action == "SEND_INVOICE" and not context.invoice_exists:
|
||||
return _decision("PREMATURE", "No structured invoice exists and protected proforma/payment prerequisites are absent.")
|
||||
if action == context.business_next_action:
|
||||
return _decision("VALID_CURRENT", "Task matches the current factual Flow v2 obligation.")
|
||||
if action in {"SUPPORT", "MARK_NO_INTEREST", "REVIEW_MANUALLY", "REVIEW_REQUIRED", "REVIEW_RECONSTRUCTED_PROCESS"}:
|
||||
return _decision("VALID_CURRENT", "Historically valid operator obligation has no factual evidence of satisfaction or supersession.")
|
||||
if not context.opportunity_id:
|
||||
return _decision("VALID_CURRENT", "Standalone obligation is outside opportunity business transitions and is preserved.")
|
||||
return _decision("AMBIGUOUS", "Available factual evidence does not deterministically establish validity or safe removal.", tier="LOW")
|
||||
|
||||
|
||||
def simulate_high_repairs(task_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
removed = [row for row in task_rows if row["safety_tier"] == "HIGH" and row["auto_repair_safe"]]
|
||||
counts = {name: sum(row["classification"] == name for row in removed) for name in TASK_CLASSIFICATIONS}
|
||||
return {"pending_before": len(task_rows), "pending_after": len(task_rows) - len(removed),
|
||||
"removed": removed, "removed_by_classification": counts}
|
||||
Reference in New Issue
Block a user