feat(sp7): inbox lanes matched_remittance includes matched/total line counts

This commit is contained in:
Tyler
2026-06-20 19:43:43 -06:00
parent 6ef59b5d1d
commit b7bda0662f
2 changed files with 234 additions and 8 deletions
+90 -8
View File
@@ -36,7 +36,13 @@ def _isoformat(value) -> str | None:
return str(value) return str(value)
def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -> dict: def _claim_to_row(
c: Claim,
*,
kind: str,
score: ScoreBreakdown | None = None,
matched_remittance: dict | None = None,
) -> dict:
return { return {
"id": c.id, "id": c.id,
"kind": kind, "kind": kind,
@@ -56,6 +62,7 @@ def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -
"amount": score.amount, "amount": score.amount,
"provider": score.provider, "provider": score.provider,
} if score else None, } if score else None,
"matched_remittance": matched_remittance,
} }
@@ -100,13 +107,74 @@ class _RemitScoringShim:
self.rendering_provider_npi = raw.get("rendering_provider_npi") self.rendering_provider_npi = raw.get("rendering_provider_npi")
def _matched_remittance_block(
session: Session,
claim: Claim,
matched_counts: dict,
total_lines_by_claim: dict,
) -> dict | None:
"""Build the ``matched_remittance`` block for a paired claim, or None.
SP7 §5.3: carries the matched remittance id plus per-line counts so
the ``MatchedRemitCard`` can show a ``3/4 matched lines`` badge.
"""
if claim.matched_remittance_id is None:
return None
return {
"id": claim.matched_remittance_id,
"matched_lines": int(matched_counts.get(claim.id, 0)),
"total_lines": int(total_lines_by_claim.get(claim.id, 0)),
}
def _line_count_lookup(session: Session, claims: list[Claim]) -> tuple[dict, dict]:
"""Return ({claim_id: matched_count}, {claim_id: total_lines}) for the given claims.
matched_count = number of LineReconciliation rows with status="matched"
tied to this claim.
total_lines = len(Claim.raw_json["service_lines"]) — 837 service-line
count stored inside the JSON blob.
"""
from cyclone.db import LineReconciliation
claim_ids = [c.id for c in claims]
matched_counts: dict = {}
total_lines_by_claim: dict = {}
if not claim_ids:
return matched_counts, total_lines_by_claim
matched_rows = (
session.query(LineReconciliation.claim_id)
.filter(LineReconciliation.claim_id.in_(claim_ids))
.filter(LineReconciliation.status == "matched")
.all()
)
for (cid,) in matched_rows:
matched_counts[cid] = matched_counts.get(cid, 0) + 1
# total_lines: from raw_json (837 service lines are stored there).
for c in claims:
raw = c.raw_json or {}
total_lines_by_claim[c.id] = len(raw.get("service_lines") or [])
return matched_counts, total_lines_by_claim
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes: def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
lanes = Lanes() lanes = Lanes()
dismissed = set(dismissed_pairs) dismissed = set(dismissed_pairs)
# --- Rejected --- # --- Rejected ---
for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all(): rejected_claims = (
lanes.rejected.append(_claim_to_row(c, kind="claim")) session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all()
)
matched_counts, total_lines_by_claim = _line_count_lookup(session, rejected_claims)
for c in rejected_claims:
lanes.rejected.append(_claim_to_row(
c, kind="claim",
matched_remittance=_matched_remittance_block(
session, c, matched_counts, total_lines_by_claim,
),
))
# --- Done today --- # --- Done today ---
cutoff = datetime.now(timezone.utc) - timedelta(hours=24) cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
@@ -114,11 +182,25 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) ->
ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED, ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED,
ClaimState.RECONCILED, ClaimState.REVERSED, ClaimState.RECONCILED, ClaimState.REVERSED,
} }
for c in session.query(Claim).filter( done_claims = list(
Claim.state.in_(terminal_states), session.query(Claim).filter(
Claim.state_changed_at >= cutoff, Claim.state.in_(terminal_states),
).all(): Claim.state_changed_at >= cutoff,
lanes.done_today.append(_claim_to_row(c, kind="claim")) ).all()
)
# Merge counts from the rejected query — these are different claim
# sets so the dicts can be combined.
if done_claims:
dm, dt = _line_count_lookup(session, done_claims)
matched_counts.update(dm)
total_lines_by_claim.update(dt)
for c in done_claims:
lanes.done_today.append(_claim_to_row(
c, kind="claim",
matched_remittance=_matched_remittance_block(
session, c, matched_counts, total_lines_by_claim,
),
))
# --- Unmatched (claims still submitted, no remittance in flight) --- # --- Unmatched (claims still submitted, no remittance in flight) ---
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all(): for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all():
+144
View File
@@ -0,0 +1,144 @@
"""SP7 — inbox lanes expose matched_remittance line counts.
For each claim row in the rejected / done_today lanes that has a
matched remittance, the row must include a ``matched_remittance`` block
with ``id``, ``matched_lines``, and ``total_lines``. These power the
``MatchedRemitCard`` badge in T17.
Architecture note: 837 service lines live in ``Claim.raw_json``, so
``total_lines`` is read from the JSON blob.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
import uuid
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.parsers.models_835 import ClaimPayment, ServicePayment
from cyclone.reconcile import run as reconcile_run
from cyclone.store import _persist_835_remit, _remittance_835_row
@pytest.fixture(autouse=True)
def _clear_app_state():
yield
if hasattr(app.state, "dismissed_pairs"):
app.state.dismissed_pairs = set()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _seed_pair_with_two_lines():
"""One 837 claim (2 service lines) + one 835 remit (1 svc composite
matching line 1). After reconcile: 1 matched, 1 unmatched_837_only.
Returns the claim_id (and the remittance_id via matched_remittance).
"""
bid_837 = str(uuid.uuid4())
bid_835 = str(uuid.uuid4())
claim_id = "CLM-INBOX-1"
svc_lines_json = [
{
"line_number": 1,
"procedure": {"qualifier": "HC", "code": "99213", "modifiers": []},
"charge": "100.00",
"unit_type": "UN",
"units": "1",
"place_of_service": "11",
"service_date": "2026-06-01",
"provider_reference": None,
},
{
"line_number": 2,
"procedure": {"qualifier": "HC", "code": "99214", "modifiers": []},
"charge": "100.00",
"unit_type": "UN",
"units": "1",
"place_of_service": "11",
"service_date": "2026-06-01",
"provider_reference": None,
},
]
with db.SessionLocal()() as s:
# 837 batch + claim
s.add(db.Batch(
id=bid_837, kind="837p", input_filename="x.837",
parsed_at=datetime.now(timezone.utc),
totals_json={}, validation_json={}, raw_result_json={},
))
s.add(db.Claim(
id=claim_id,
patient_control_number="PCN-INBOX",
charge_amount=Decimal("200.00"),
provider_npi="1234567890",
payer_id="SKCO0",
service_date_from=date(2026, 6, 1),
service_date_to=date(2026, 6, 1),
state=db.ClaimState.SUBMITTED,
batch_id=bid_837,
raw_json={"service_lines": svc_lines_json},
))
# 835 batch + remit
s.add(db.Batch(
id=bid_835, kind="835", input_filename="x.835",
parsed_at=datetime.now(timezone.utc),
totals_json={}, validation_json={}, raw_result_json={},
))
cp = ClaimPayment(
payer_claim_control_number="PCN-INBOX",
status_code="1",
total_charge=Decimal("100.00"),
total_paid=Decimal("80.00"),
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC",
procedure_code="99213",
charge=Decimal("100.00"),
payment=Decimal("80.00"),
units=Decimal("1"),
service_date=date(2026, 6, 1),
),
],
)
remit = _remittance_835_row(cp, bid_835)
s.add(remit)
s.flush()
_persist_835_remit(s, cp, remit.id)
# Force the claim to a terminal state so it lands in done_today.
# Run reconcile which auto-matches by PCN + sets state to PAID.
reconcile_run(s, bid_835)
s.commit()
return claim_id
def test_done_today_lane_includes_matched_remittance_line_counts(client):
claim_id = _seed_pair_with_two_lines()
# Backfill state_changed_at so the claim lands in the done_today lane
# (reconcile.run() doesn't write it; the API surface is exercised by
# the existing manual-match flow which sets it).
from sqlalchemy.orm import Session
with db.SessionLocal()() as s:
cl = s.get(db.Claim, claim_id)
cl.state_changed_at = datetime.now(timezone.utc)
s.commit()
r = client.get("/api/inbox/lanes")
assert r.status_code == 200
body = r.json()
# The seeded claim should be in done_today (auto-reconciled by run()).
ids = [row["id"] for row in body["done_today"]]
assert claim_id in ids, f"expected claim in done_today, got ids={ids}"
row = next(r for r in body["done_today"] if r["id"] == claim_id)
mr = row.get("matched_remittance")
assert mr is not None, "expected matched_remittance block on done_today claim row"
assert "id" in mr
assert mr["total_lines"] == 2 # two 837 service lines
assert mr["matched_lines"] == 1 # only line 1 has a matching SVC composite