diff --git a/backend/src/cyclone/inbox_lanes.py b/backend/src/cyclone/inbox_lanes.py index 2d5ef99..6da30e0 100644 --- a/backend/src/cyclone/inbox_lanes.py +++ b/backend/src/cyclone/inbox_lanes.py @@ -36,7 +36,13 @@ def _isoformat(value) -> str | None: 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 { "id": c.id, "kind": kind, @@ -56,6 +62,7 @@ def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) - "amount": score.amount, "provider": score.provider, } if score else None, + "matched_remittance": matched_remittance, } @@ -100,13 +107,74 @@ class _RemitScoringShim: 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: 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")) + rejected_claims = ( + 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 --- 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.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")) + done_claims = list( + session.query(Claim).filter( + Claim.state.in_(terminal_states), + Claim.state_changed_at >= cutoff, + ).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) --- for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all(): diff --git a/backend/tests/test_inbox_endpoints_sp7.py b/backend/tests/test_inbox_endpoints_sp7.py new file mode 100644 index 0000000..fe35689 --- /dev/null +++ b/backend/tests/test_inbox_endpoints_sp7.py @@ -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