"""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}