feat(sp7): inbox lanes matched_remittance includes matched/total line counts
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user