From 0fa80e29d52bed904a24772aac2eecfe90b3b742 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 19:06:10 -0600 Subject: [PATCH] feat(backend): add store iterators (claims, remits, providers, activity) --- backend/src/cyclone/store.py | 124 +++++++++++++++++++++++++++++++++++ backend/tests/test_store.py | 84 ++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 1239952..7f69859 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -59,6 +59,130 @@ class InMemoryStore: with self._lock: return list(self._batches) + def iter_claims( + self, + *, + batch_id: str | None = None, + status: str | None = None, + provider_npi: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + sort: str | None = None, + order: str = "desc", + limit: int = 100, + offset: int = 0, + ) -> Iterable[dict]: + with self._lock: + all_claims: list[dict] = [] + for b in self._batches: + if b.kind != "837p": + continue + if batch_id is not None and b.id != batch_id: + continue + # ParseResult / ParseResult835 union: 837p kinds are ParseResult + result = b.result # type: ignore[assignment] + for c in result.claims: + all_claims.append(to_ui_claim( + c, batch_id=b.id, parsed_at=b.parsed_at, + )) + if status is not None: + all_claims = [c for c in all_claims if c["status"] == status] + if provider_npi is not None: + all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi] + if sort is not None: + all_claims.sort( + key=lambda c: c.get(sort, 0) or 0, + reverse=(order == "desc"), + ) + yield from all_claims[offset:offset + limit] + + def iter_remittances( + self, + *, + batch_id: str | None = None, + payer: str | None = None, + claim_id: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + sort: str | None = None, + order: str = "desc", + limit: int = 100, + offset: int = 0, + ) -> Iterable[dict]: + with self._lock: + all_remits: list[dict] = [] + for b in self._batches: + if b.kind != "835": + continue + if batch_id is not None and b.id != batch_id: + continue + # 835 kinds are ParseResult835; its claims list holds ClaimPayment + result = b.result # type: ignore[assignment] + for cp in result.claims: + all_remits.append(to_ui_remittance( + cp, batch_id=b.id, parsed_at=b.parsed_at, + )) + if payer is not None: + all_remits = [r for r in all_remits if r.get("payerName") == payer] + if claim_id is not None: + all_remits = [r for r in all_remits if r["claimId"] == claim_id] + if sort is not None: + all_remits.sort( + key=lambda r: r.get(sort, 0) or 0, + reverse=(order == "desc"), + ) + yield from all_remits[offset:offset + limit] + + def distinct_providers(self) -> list[dict]: + with self._lock: + by_npi: dict[str, dict] = {} + for b in self._batches: + if b.kind != "837p": + continue + result = b.result # type: ignore[assignment] + if not result.claims: + continue + # billing_provider lives on each claim, not at the batch level + bp = result.claims[0].billing_provider + npi = bp.npi + if npi not in by_npi: + by_npi[npi] = to_ui_provider( + npi=npi, + name=bp.name or "", + tax_id=bp.tax_id, + claim_count=0, + outstanding_ar=0.0, + ) + by_npi[npi]["claimCount"] += len(result.claims) + return list(by_npi.values()) + + def recent_activity(self, *, limit: int = 200) -> list[dict]: + with self._lock: + events: list[dict] = [] + for b in reversed(self._batches): + if b.kind == "837p": + result = b.result # type: ignore[assignment] + for c in result.claims: + events.append(to_activity_event( + id=f"{b.id}:{c.claim_id}", + kind="claim_submitted", + message=f"Claim {c.claim_id} submitted ยท {c.payer.name}", + timestamp=b.parsed_at, + npi=c.billing_provider.npi, + amount=float(c.claim.total_charge or 0.0), + )) + elif b.kind == "835": + result = b.result # type: ignore[assignment] + for cp in result.claims: + events.append(to_activity_event( + id=f"{b.id}:{cp.payer_claim_control_number}", + kind="remit_received", + message=f"Remit {cp.payer_claim_control_number} received", + timestamp=b.parsed_at, + amount=float(cp.total_paid or 0.0), + )) + return events[:limit] + store = InMemoryStore() diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py index 9511dae..71a7a75 100644 --- a/backend/tests/test_store.py +++ b/backend/tests/test_store.py @@ -203,3 +203,87 @@ def test_to_activity_event_uses_iso_timestamp(): assert out["id"] == "a1" assert out["kind"] == "claim_submitted" assert out["amount"] == 100.0 + + +# --------------------------------------------------------------------------- +# Task 3: Iterators +# --------------------------------------------------------------------------- + + +def test_iter_claims_filters_by_status(): + s = InMemoryStore() + result = _make_result() + a = _make_claim("A") + b = _make_claim("B") + b.validation = ValidationReport( + passed=True, errors=[], + warnings=[ValidationIssue(rule="W1", severity="warning", message="x")], + ) + result.claims = [a, b] + s.add(BatchRecord( + id="1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=result, + )) + out = list(s.iter_claims(status="submitted")) + assert all(c["status"] == "submitted" for c in out) + assert any(c["id"] == "A" for c in out) + out_pending = list(s.iter_claims(status="pending")) + assert all(c["status"] == "pending" for c in out_pending) + assert any(c["id"] == "B" for c in out_pending) + + +def test_iter_claims_filters_by_batch_id(): + s = InMemoryStore() + s.add(BatchRecord( + id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=_make_result(), + )) + out = list(s.iter_claims(batch_id="b1")) + assert all(c["batchId"] == "b1" for c in out) + + +def test_iter_claims_sorts_by_billed_amount_desc(): + s = InMemoryStore() + result = _make_result() + result.claims = [ + _make_claim("cheap"), + _make_claim("expensive"), + ] + result.claims[0].claim.total_charge = Decimal("50.00") + result.claims[1].claim.total_charge = Decimal("500.00") + s.add(BatchRecord( + id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=result, + )) + out = list(s.iter_claims(sort="billedAmount", order="desc")) + assert out[0]["id"] == "expensive" + assert out[1]["id"] == "cheap" + + +def test_distinct_providers_aggregates_claims(): + s = InMemoryStore() + result = _make_result() + result.claims = [_make_claim("A"), _make_claim("B")] + s.add(BatchRecord( + id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=result, + )) + out = s.distinct_providers() + assert len(out) == 1 + assert out[0]["npi"] == "1234567890" + assert out[0]["claimCount"] == 2 + + +def test_recent_activity_returns_newest_first(): + s = InMemoryStore() + s.add(BatchRecord( + id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T11:00:00Z", result=_make_result(), + )) + s.add(BatchRecord( + id="b2", kind="837p", input_filename="b.txt", + parsed_at="2026-06-19T12:00:00Z", result=_make_result(), + )) + out = s.recent_activity(limit=10) + assert out[0]["timestamp"] == "2026-06-19T12:00:00Z" + assert out[1]["timestamp"] == "2026-06-19T11:00:00Z"