feat(sp6): compute_lanes — 4 lanes on read with scoring
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Compute the four Inbox lanes from the DB on read.
|
||||
|
||||
SP6 T6.
|
||||
|
||||
Lanes:
|
||||
- rejected: claims whose 999 AK5 set-level response was R/E
|
||||
- candidates: remits without a matched claim, with scoreable claims
|
||||
- unmatched: claims still SUBMITTED with no remittance in flight
|
||||
- done_today: claims that reached a terminal state in the last 24h
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from cyclone.scoring import score_pair, ScoreBreakdown
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lanes:
|
||||
rejected: list[dict] = field(default_factory=list)
|
||||
candidates: list[dict] = field(default_factory=list)
|
||||
unmatched: list[dict] = field(default_factory=list)
|
||||
done_today: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _isoformat(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"kind": kind,
|
||||
"patient_control_number": c.patient_control_number,
|
||||
"charge_amount": float(c.charge_amount) if c.charge_amount is not None else None,
|
||||
"payer_id": c.payer_id,
|
||||
"provider_npi": c.provider_npi,
|
||||
"state": c.state.value if hasattr(c.state, "value") else str(c.state),
|
||||
"rejection_reason": c.rejection_reason,
|
||||
"rejected_at": _isoformat(c.rejected_at),
|
||||
"service_date_from": _isoformat(c.service_date_from),
|
||||
"score": score.total if score else None,
|
||||
"score_tier": score.tier if score else None,
|
||||
"score_breakdown": {
|
||||
"patient": score.patient,
|
||||
"date": score.date,
|
||||
"amount": score.amount,
|
||||
"provider": score.provider,
|
||||
} if score else None,
|
||||
}
|
||||
|
||||
|
||||
def _remit_to_row(r: Remittance, *, candidates: list[tuple[Claim, ScoreBreakdown]]) -> dict:
|
||||
raw = r.raw_json or {}
|
||||
return {
|
||||
"id": r.id,
|
||||
"kind": "remit",
|
||||
"payer_claim_control_number": r.payer_claim_control_number,
|
||||
"charge_amount": float(r.total_charge) if r.total_charge is not None else None,
|
||||
"payer_id": raw.get("payer_id"),
|
||||
"rendering_provider_npi": raw.get("rendering_provider_npi"),
|
||||
"service_date": _isoformat(r.service_date),
|
||||
"candidates": [
|
||||
{
|
||||
"claim_id": c.id,
|
||||
"score": s.total,
|
||||
"tier": s.tier,
|
||||
"breakdown": {
|
||||
"patient": s.patient, "date": s.date,
|
||||
"amount": s.amount, "provider": s.provider,
|
||||
},
|
||||
}
|
||||
for c, s in candidates[:5]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class _RemitScoringShim:
|
||||
"""Adapt a Remittance to the duck-typed protocol score_pair expects.
|
||||
|
||||
The 835 parser stores service_date on the ORM column. PCN, NPI, payer_id
|
||||
aren't on Remittance; we read them from raw_json.
|
||||
"""
|
||||
def __init__(self, r: Remittance):
|
||||
raw = r.raw_json or {}
|
||||
self.patient_control_number = (
|
||||
raw.get("patient_control_number") or r.payer_claim_control_number
|
||||
)
|
||||
self.service_date = r.service_date
|
||||
self.charge_amount = r.total_charge
|
||||
self.rendering_provider_npi = raw.get("rendering_provider_npi")
|
||||
|
||||
|
||||
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
|
||||
lanes = Lanes()
|
||||
dismissed = set(dismissed_pairs)
|
||||
|
||||
# --- Rejected ---
|
||||
for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all():
|
||||
lanes.rejected.append(_claim_to_row(c, kind="claim"))
|
||||
|
||||
# --- Done today ---
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
terminal_states = {
|
||||
ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED,
|
||||
ClaimState.RECONCILED, ClaimState.REVERSED,
|
||||
}
|
||||
for c in session.query(Claim).filter(
|
||||
Claim.state.in_(terminal_states),
|
||||
Claim.state_changed_at >= cutoff,
|
||||
).all():
|
||||
lanes.done_today.append(_claim_to_row(c, kind="claim"))
|
||||
|
||||
# --- Unmatched (claims still submitted, no remittance in flight) ---
|
||||
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all():
|
||||
lanes.unmatched.append(_claim_to_row(c, kind="claim"))
|
||||
|
||||
# --- Candidates (remits not yet matched, with scoreable claims) ---
|
||||
unmatched_remits = session.query(Remittance).filter(
|
||||
Remittance.claim_id.is_(None),
|
||||
).all()
|
||||
|
||||
submitted_claims = list(
|
||||
session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all()
|
||||
)
|
||||
|
||||
for r in unmatched_remits:
|
||||
if r.claim_id:
|
||||
continue
|
||||
candidates: list[tuple[Claim, ScoreBreakdown]] = []
|
||||
shim = _RemitScoringShim(r)
|
||||
for c in submitted_claims:
|
||||
if c.payer_id and r.raw_json and (r.raw_json.get("payer_id") != c.payer_id):
|
||||
continue
|
||||
score = score_pair(c, shim)
|
||||
if score.tier == "hidden":
|
||||
continue
|
||||
if frozenset({c.id, r.id}) in dismissed:
|
||||
continue
|
||||
candidates.append((c, score))
|
||||
candidates.sort(key=lambda cs: -cs[1].total)
|
||||
if not candidates:
|
||||
continue
|
||||
lanes.candidates.append(_remit_to_row(r, candidates=candidates))
|
||||
|
||||
return lanes
|
||||
@@ -0,0 +1,144 @@
|
||||
"""TDD: compute_lanes — four Inbox lanes from the DB on read.
|
||||
|
||||
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
|
||||
Task 6 (T6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.db import (
|
||||
Base, Batch, Claim, ClaimState, Remittance, init_db,
|
||||
)
|
||||
from cyclone.inbox_lanes import compute_lanes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/lanes.db")
|
||||
db._reset_for_tests()
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _add_batch() -> None:
|
||||
with db.SessionLocal()() as s:
|
||||
if s.get(Batch, "B-1") is not None:
|
||||
return
|
||||
s.add(Batch(
|
||||
id="B-1", kind="837p", input_filename="x.txt",
|
||||
parsed_at=datetime.now(timezone.utc),
|
||||
totals_json={"total_claims": 1},
|
||||
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||
raw_result_json={"_": "stub"},
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def _add_claim(*, claim_id: str, pcn: str = "PCN",
|
||||
state: ClaimState = ClaimState.SUBMITTED,
|
||||
charge: Decimal = Decimal("100.00"),
|
||||
provider_npi: str | None = "1234567890",
|
||||
payer_id: str | None = "PAYER-A",
|
||||
service_date=None) -> None:
|
||||
_add_batch()
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Claim(
|
||||
id=claim_id, batch_id="B-1",
|
||||
patient_control_number=pcn, state=state,
|
||||
charge_amount=charge, provider_npi=provider_npi,
|
||||
payer_id=payer_id, service_date_from=service_date,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def _add_remit(*, remit_id: str, pcn: str = "PCN",
|
||||
charge: Decimal = Decimal("100.00"),
|
||||
claim_id: str | None = None,
|
||||
payer_id: str | None = "PAYER-A",
|
||||
service_date=None) -> None:
|
||||
_add_batch()
|
||||
with db.SessionLocal()() as s:
|
||||
r = Remittance(
|
||||
id=remit_id, batch_id="B-1",
|
||||
payer_claim_control_number=pcn,
|
||||
claim_id=claim_id,
|
||||
status_code="1", status_label="Processed",
|
||||
total_charge=charge,
|
||||
total_paid=Decimal("0"),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
service_date=service_date,
|
||||
)
|
||||
# Stash rendering_provider_npi and payer_id in raw_json for the
|
||||
# 835 fields that aren't on the ORM model.
|
||||
r.raw_json = {
|
||||
"rendering_provider_npi": "1234567890",
|
||||
"payer_id": payer_id,
|
||||
"patient_control_number": pcn,
|
||||
}
|
||||
s.add(r)
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_rejected_lane_includes_rejected_claims():
|
||||
_add_claim(claim_id="C1", state=ClaimState.REJECTED)
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
assert "C1" in {r["id"] for r in lanes.rejected}
|
||||
|
||||
|
||||
def test_unmatched_lane_includes_submitted_claims_with_no_remit():
|
||||
_add_claim(claim_id="C1", state=ClaimState.SUBMITTED)
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
assert "C1" in {r["id"] for r in lanes.unmatched}
|
||||
|
||||
|
||||
def test_unmatched_remit_appears_with_candidates():
|
||||
_add_claim(claim_id="C1", pcn="PCN-1", service_date=None)
|
||||
_add_remit(remit_id="R1", pcn="PCN-1") # exact match → candidate
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
assert any(r["id"] == "R1" for r in lanes.candidates)
|
||||
|
||||
|
||||
def test_candidate_below_threshold_is_hidden():
|
||||
_add_claim(claim_id="C1", pcn="A", charge=Decimal("100.00"))
|
||||
_add_remit(remit_id="R1", pcn="Z",
|
||||
charge=Decimal("999.00")) # everything mismatched
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
assert not any(r["id"] == "R1" for r in lanes.candidates)
|
||||
|
||||
|
||||
def test_dismissed_pair_excluded_from_candidates():
|
||||
_add_claim(claim_id="C1", pcn="PCN-1")
|
||||
_add_remit(remit_id="R1", pcn="PCN-1")
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(
|
||||
s, dismissed_pairs={frozenset({"C1", "R1"})},
|
||||
)
|
||||
assert not any(r["id"] == "R1" for r in lanes.candidates)
|
||||
|
||||
|
||||
def test_done_today_includes_recent_terminal_states():
|
||||
_add_claim(claim_id="C1", state=ClaimState.PAID)
|
||||
_add_claim(claim_id="C2", state=ClaimState.PAID)
|
||||
# Force C1's state_changed_at to be recent, C2 to be old.
|
||||
now = datetime.now(timezone.utc)
|
||||
with db.SessionLocal()() as s:
|
||||
c1 = s.get(Claim, "C1")
|
||||
c1.state_changed_at = now - timedelta(hours=2)
|
||||
c2 = s.get(Claim, "C2")
|
||||
c2.state_changed_at = now - timedelta(hours=30)
|
||||
s.commit()
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
ids = {r["id"] for r in lanes.done_today}
|
||||
assert "C1" in ids
|
||||
assert "C2" not in ids
|
||||
Reference in New Issue
Block a user