feat: add operational eligibility and scheduled follow-up
This commit is contained in:
347
tests/test_operational_eligibility_p1b.py
Normal file
347
tests/test_operational_eligibility_p1b.py
Normal file
@@ -0,0 +1,347 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.operational_eligibility import evaluate_operational_eligibility
|
||||
from app.canonical_operations import canonicalize_operations, partition_canonical_items
|
||||
from app.timezone_utils import format_operator_datetime, parse_operator_local_datetime
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 15, 12, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_lisbon_summer_and_winter_schedule_conversion_and_display():
|
||||
summer = parse_operator_local_datetime("2026-08-21T10:00")
|
||||
winter = parse_operator_local_datetime("2026-12-21T10:00")
|
||||
assert summer == datetime(2026, 8, 21, 9, tzinfo=timezone.utc)
|
||||
assert winter == datetime(2026, 12, 21, 10, tzinfo=timezone.utc)
|
||||
assert format_operator_datetime(summer) == "21/08/2026 10:00"
|
||||
assert format_operator_datetime(winter) == "21/12/2026 10:00"
|
||||
|
||||
|
||||
def test_lisbon_scheduled_due_comparison_uses_same_utc_instant():
|
||||
due = parse_operator_local_datetime("2026-08-21T10:00")
|
||||
before = evaluate_operational_eligibility(item("CALL_CUSTOMER", refs=[task_ref("CALL_CUSTOMER")], due_at=due),
|
||||
now=datetime(2026, 8, 21, 8, 59, tzinfo=timezone.utc))
|
||||
at_due = evaluate_operational_eligibility(item("CALL_CUSTOMER", refs=[task_ref("CALL_CUSTOMER")], due_at=due),
|
||||
now=datetime(2026, 8, 21, 9, 0, tzinfo=timezone.utc))
|
||||
assert before.reason_code == "FOLLOW_UP_NOT_DUE"
|
||||
assert at_due.reason_code == "FOLLOW_UP_DUE"
|
||||
|
||||
|
||||
def item(action, *, refs=(), due_at=None, **extra):
|
||||
value = {
|
||||
"current_action_code": action,
|
||||
"action_code": action,
|
||||
"source_refs": list(refs),
|
||||
"due_at": due_at,
|
||||
"operational_queue": "do_now",
|
||||
}
|
||||
value.update(extra)
|
||||
return value
|
||||
|
||||
|
||||
def task_ref(action):
|
||||
return {"source": "task", "id": f"task-{action}", "status": "pending", "action_code": action}
|
||||
|
||||
|
||||
def test_future_and_due_customer_call_are_waiting_then_do_now():
|
||||
future = evaluate_operational_eligibility(
|
||||
item("CALL_CUSTOMER", refs=[task_ref("CALL_CUSTOMER")], due_at=NOW + timedelta(days=1)), now=NOW,
|
||||
)
|
||||
assert (future.queue, future.reason_code, future.eligible) == ("waiting", "FOLLOW_UP_NOT_DUE", True)
|
||||
due = evaluate_operational_eligibility(
|
||||
item("CALL_CUSTOMER", refs=[task_ref("CALL_CUSTOMER")], due_at=NOW), now=NOW,
|
||||
)
|
||||
assert (due.queue, due.reason_code) == ("do_now", "FOLLOW_UP_DUE")
|
||||
|
||||
|
||||
def test_answered_standalone_info_and_support_are_not_current():
|
||||
for action in ("SEND_INFO", "SUPPORT"):
|
||||
result = evaluate_operational_eligibility(item(
|
||||
action, latest_public_inbound=NOW - timedelta(hours=2),
|
||||
latest_public_outbound=NOW - timedelta(hours=1),
|
||||
), now=NOW)
|
||||
assert (result.queue, result.reason_code, result.eligible) == ("not_current", "ALREADY_ANSWERED", False)
|
||||
|
||||
|
||||
def test_newer_inbound_keeps_response_work_current():
|
||||
result = evaluate_operational_eligibility(item(
|
||||
"SEND_INFO", latest_public_inbound=NOW, latest_public_outbound=NOW - timedelta(hours=1),
|
||||
), now=NOW)
|
||||
assert result.queue == "do_now"
|
||||
|
||||
|
||||
def test_review_and_no_interest_are_not_resolved_by_outbound():
|
||||
for action in ("REVIEW_MANUALLY", "MARK_NO_INTEREST"):
|
||||
result = evaluate_operational_eligibility(item(
|
||||
action, latest_public_inbound=NOW - timedelta(hours=2),
|
||||
latest_public_outbound=NOW - timedelta(hours=1),
|
||||
), now=NOW)
|
||||
assert result.queue == "review"
|
||||
|
||||
|
||||
def test_explicit_response_task_is_not_hidden_by_timeline():
|
||||
result = evaluate_operational_eligibility(item(
|
||||
"SEND_INFO", refs=[task_ref("SEND_INFO")],
|
||||
latest_public_inbound=NOW - timedelta(hours=2), latest_public_outbound=NOW,
|
||||
), now=NOW)
|
||||
assert (result.queue, result.reason_code) == ("do_now", "EXPLICIT_PENDING_TASK")
|
||||
|
||||
|
||||
def test_fiscal_prerequisite_is_backlog_unless_structurally_blocking():
|
||||
hygiene = evaluate_operational_eligibility(item(
|
||||
"VALIDATE_FISCAL_CUSTOMER", decision={"financial_state": "no_document"},
|
||||
), now=NOW)
|
||||
assert (hygiene.queue, hygiene.reason_code) == ("backlog", "FISCAL_DATA_NOT_CURRENTLY_BLOCKING")
|
||||
blocked = evaluate_operational_eligibility(item(
|
||||
"VALIDATE_FISCAL_CUSTOMER", refs=[task_ref("SEND_INVOICE")],
|
||||
decision={"financial_state": "payment_confirmed"},
|
||||
), now=NOW)
|
||||
assert (blocked.queue, blocked.reason_code) == ("do_now", "CURRENT_ACTION_BLOCKED_BY_FISCAL_IDENTITY")
|
||||
|
||||
|
||||
def test_reconciliation_hygiene_vs_current_blocker():
|
||||
hygiene = evaluate_operational_eligibility(item(
|
||||
"RECONCILE_DOCUMENTS",
|
||||
refs=[{"source": "reconciliation", "id": "r1", "status": "open", "action_code": "RECONCILE_DOCUMENTS"}],
|
||||
), now=NOW)
|
||||
assert hygiene.queue == "backlog"
|
||||
blocker = evaluate_operational_eligibility(item(
|
||||
"RECONCILE_DOCUMENTS", refs=[task_ref("SEND_INVOICE")],
|
||||
), now=NOW)
|
||||
assert blocker.queue == "review"
|
||||
|
||||
|
||||
def reconciliation_ref(status="open", action="SEND_INVOICE"):
|
||||
return {"source": "reconciliation", "id": f"rec-{action}", "status": status, "action_code": action}
|
||||
|
||||
|
||||
def test_reconciliation_only_open_invoice_and_proforma_are_backlog():
|
||||
for action in ("SEND_INVOICE", "SEND_PROFORMA"):
|
||||
result = evaluate_operational_eligibility(item(
|
||||
action, refs=[reconciliation_ref("open", action)], source="reconciliation",
|
||||
), now=NOW)
|
||||
assert (result.queue, result.reason_code, result.eligible) == (
|
||||
"backlog", "DOCUMENT_RECONCILIATION_BACKLOG", False,
|
||||
)
|
||||
|
||||
|
||||
def test_reconciliation_only_review_invoice_and_payment_are_review():
|
||||
for action in ("SEND_INVOICE", "CONFIRM_PAYMENT"):
|
||||
result = evaluate_operational_eligibility(item(
|
||||
action, refs=[reconciliation_ref("needs_review", action)], source="reconciliation",
|
||||
), now=NOW)
|
||||
assert (result.queue, result.reason_code) == (
|
||||
"review", "RECONCILIATION_ASSOCIATION_REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_task_or_opportunity_document_action_wins_over_reconciliation_backlog():
|
||||
for action in ("SEND_INVOICE", "SEND_PROFORMA"):
|
||||
task_backed = evaluate_operational_eligibility(item(
|
||||
action, refs=[reconciliation_ref("open", action), task_ref(action)],
|
||||
source="task", opportunity_id="opp-1",
|
||||
), now=NOW)
|
||||
assert (task_backed.queue, task_backed.reason_code) == ("do_now", "EXPLICIT_PENDING_TASK")
|
||||
|
||||
opportunity_backed = evaluate_operational_eligibility(item(
|
||||
action, refs=[reconciliation_ref("open", action)], source="opportunity",
|
||||
opportunity_id="opp-1", current_downstream_obligation=True,
|
||||
), now=NOW)
|
||||
assert opportunity_backed.queue == "do_now"
|
||||
|
||||
|
||||
def test_reconciliation_backlog_keeps_canonical_identity_unique():
|
||||
source = {
|
||||
"source": "reconciliation", "source_system": "jasmin", "id": "rec-1",
|
||||
"status": "open", "action_code": "SEND_PROFORMA", "created_at": NOW,
|
||||
"priority": "normal", "queue": "rever",
|
||||
}
|
||||
projected = canonicalize_operations([source, dict(source)], {})
|
||||
assert len(projected["all_items"]) == 1
|
||||
work = projected["all_items"][0]
|
||||
assert work["work_item_key"] == "reconciliation:rec-1:action:SEND_PROFORMA"
|
||||
assert (work["operational_queue"], work["eligibility_reason_code"]) == (
|
||||
"backlog", "DOCUMENT_RECONCILIATION_BACKLOG",
|
||||
)
|
||||
|
||||
|
||||
def test_waiting_and_backlog_never_become_immediate_by_age():
|
||||
from app.admin_ui.view_models.operations import is_high_priority, is_overdue
|
||||
|
||||
old = item("CALL_CUSTOMER", due_at=NOW + timedelta(days=1), operational_queue="waiting",
|
||||
created_at=NOW - timedelta(days=100), priority="alta")
|
||||
assert is_overdue(old) is False
|
||||
assert is_high_priority(old) is False
|
||||
|
||||
|
||||
def test_outbox_exception_remains_visible():
|
||||
result = evaluate_operational_eligibility(item("JASMIN_SYNC", source="outbox", status="failed"), now=NOW)
|
||||
assert (result.queue, result.reason_code, result.eligible) == ("exception", "INTEGRATION_FAILURE", True)
|
||||
|
||||
|
||||
def test_waiting_customer_without_explicit_task_is_not_immediate_work():
|
||||
result = evaluate_operational_eligibility(item(
|
||||
"CREATE_JASMIN_QUOTE", opportunity_lifecycle_state="awaiting_customer",
|
||||
), now=NOW)
|
||||
assert (result.queue, result.reason_code) == ("waiting", "WAITING_CUSTOMER")
|
||||
|
||||
|
||||
def test_scheduled_call_overrides_theoretical_presentation_without_changing_process_identity():
|
||||
task = {
|
||||
"source": "task", "id": "task-call", "status": "pending",
|
||||
"action_code": "CALL_CUSTOMER", "opportunity_id": "opp-1",
|
||||
"due_at": datetime(2099, 1, 1, tzinfo=timezone.utc), "created_at": NOW - timedelta(days=2),
|
||||
"priority": "normal", "queue": "vendas", "href": "/tasks/task-call",
|
||||
"title": "Ligar ao cliente", "detail": "Conforme combinado",
|
||||
}
|
||||
projected = canonicalize_operations([task], {"opp-1": {
|
||||
"action_code": "CREATE_JASMIN_QUOTE", "label": "Criar orçamento",
|
||||
"reason": "Passo teórico", "priority": "normal",
|
||||
}})
|
||||
assert projected["canonical_count"] == 1
|
||||
work = projected["items"][0]
|
||||
assert work["process_key"] == "opportunity:opp-1"
|
||||
assert work["work_item_key"] == "opportunity:opp-1:action:CALL_CUSTOMER"
|
||||
assert work["operational_queue"] == "waiting"
|
||||
partition = partition_canonical_items(projected["all_items"], display_limit=20)
|
||||
assert partition["work_queue_total"] == 0
|
||||
assert partition["waiting_total"] == 1
|
||||
|
||||
|
||||
def test_due_scheduled_call_appears_once_and_preserves_quote_stage_evidence():
|
||||
task = {
|
||||
"source": "task", "id": "task-call", "status": "pending",
|
||||
"action_code": "CALL_CUSTOMER", "opportunity_id": "opp-1", "opportunity_stage": "QUOTE_SENT",
|
||||
"due_at": datetime(2020, 1, 1, tzinfo=timezone.utc), "created_at": NOW - timedelta(days=2),
|
||||
"priority": "normal", "queue": "vendas", "href": "/tasks/task-call",
|
||||
}
|
||||
projected = canonicalize_operations([task, dict(task)], {"opp-1": {"action_code": "CONFIRM_PAYMENT"}})
|
||||
assert len(projected["all_items"]) == 1
|
||||
assert projected["items"][0]["operational_queue"] == "do_now"
|
||||
assert projected["items"][0]["opportunity_stage"] == "QUOTE_SENT"
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, rows=()):
|
||||
self.rows = list(rows)
|
||||
|
||||
def mappings(self):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
def all(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class _FollowupConnection:
|
||||
def __init__(self, existing=True):
|
||||
self.existing = existing
|
||||
self.sql = []
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
sql = str(statement)
|
||||
self.sql.append((sql, params or {}))
|
||||
if "SELECT id::text FROM tasks" in sql:
|
||||
return _Result([{"id": "00000000-0000-0000-0000-000000000010"}] if self.existing else [])
|
||||
if "UPDATE tasks SET status='skipped'" in sql:
|
||||
return _Result([{"id": "00000000-0000-0000-0000-000000000010"}])
|
||||
return _Result()
|
||||
|
||||
|
||||
class _Context:
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def __enter__(self):
|
||||
return self.conn
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
class _Engine:
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def begin(self):
|
||||
return _Context(self.conn)
|
||||
|
||||
|
||||
def test_reschedule_customer_call_updates_existing_without_duplicate(monkeypatch):
|
||||
import app.followup_service as followups
|
||||
|
||||
conn = _FollowupConnection(existing=True)
|
||||
monkeypatch.setattr(followups, "engine", _Engine(conn))
|
||||
monkeypatch.setattr(followups, "_load_opportunity_context", lambda _id: {"id": _id, "stage": "QUOTE_SENT"})
|
||||
monkeypatch.setattr(followups, "create_follow_up_task", lambda **kw: (_ for _ in ()).throw(AssertionError("duplicate created")))
|
||||
result = followups.schedule_customer_call(
|
||||
opportunity_id="00000000-0000-0000-0000-000000000001",
|
||||
due_at=NOW + timedelta(days=2), note="Ligar de manhã",
|
||||
)
|
||||
assert result["status"] == "rescheduled"
|
||||
assert sum("UPDATE tasks SET due_at" in sql for sql, _ in conn.sql) == 1
|
||||
assert any("lifecycle_state='scheduled_follow_up'" in sql for sql, _ in conn.sql)
|
||||
|
||||
|
||||
def test_cancel_customer_call_removes_scheduled_condition(monkeypatch):
|
||||
import app.followup_service as followups
|
||||
|
||||
conn = _FollowupConnection(existing=True)
|
||||
monkeypatch.setattr(followups, "engine", _Engine(conn))
|
||||
result = followups.cancel_scheduled_customer_call(
|
||||
opportunity_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
assert result["cancelled"] == 1
|
||||
assert any("status='skipped'" in sql for sql, _ in conn.sql)
|
||||
assert any("next_follow_up_at=NULL" in sql for sql, _ in conn.sql)
|
||||
|
||||
|
||||
def test_stale_scheduled_lifecycle_without_active_task_is_ignored():
|
||||
from app.admin_ui.pages.opportunities import _opportunity_lifecycle_state
|
||||
|
||||
assert _opportunity_lifecycle_state({
|
||||
"lifecycle_state": "scheduled_follow_up",
|
||||
"next_follow_up_at": "2099-01-01T10:00:00+00:00",
|
||||
"pending_follow_up_action_code": None,
|
||||
}) == "active"
|
||||
|
||||
|
||||
def test_missing_cancelled_or_completed_call_never_produces_scheduled_work():
|
||||
cases = [
|
||||
[],
|
||||
[{"source": "task", "id": "cancelled", "status": "cancelled", "action_code": "CALL_CUSTOMER"}],
|
||||
[{"source": "task", "id": "done", "status": "done", "action_code": "CALL_CUSTOMER"}],
|
||||
]
|
||||
for refs in cases:
|
||||
result = evaluate_operational_eligibility(item(
|
||||
"CALL_CUSTOMER", refs=refs, due_at=datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||
opportunity_lifecycle_state="scheduled_follow_up",
|
||||
), now=NOW)
|
||||
assert (result.eligible, result.queue, result.reason_code) == (
|
||||
False, "not_current", "NO_ACTIVE_SCHEDULED_CALL",
|
||||
)
|
||||
|
||||
|
||||
def test_scheduled_call_read_helpers_contain_no_repair_write():
|
||||
from inspect import getsource
|
||||
from app.admin_ui.pages.opportunities import _opportunity_lifecycle_state
|
||||
|
||||
source = getsource(_opportunity_lifecycle_state).upper()
|
||||
assert "UPDATE " not in source and "INSERT " not in source and "DELETE " not in source
|
||||
|
||||
|
||||
def test_active_call_task_wins_when_lifecycle_state_is_stale():
|
||||
from app.admin_ui.pages.opportunities import _opportunity_lifecycle_state
|
||||
|
||||
assert _opportunity_lifecycle_state({
|
||||
"lifecycle_state": "active",
|
||||
"pending_follow_up_action_code": "CALL_CUSTOMER",
|
||||
"pending_follow_up_due_at": "2099-01-01T10:00:00+00:00",
|
||||
}) == "scheduled_follow_up"
|
||||
assert _opportunity_lifecycle_state({
|
||||
"lifecycle_state": "active",
|
||||
"pending_follow_up_action_code": "CALL_CUSTOMER",
|
||||
"pending_follow_up_due_at": "2020-01-01T10:00:00+00:00",
|
||||
}) == "follow_up_due"
|
||||
Reference in New Issue
Block a user