diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 78837dd..7e558ca 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -32,6 +32,7 @@ from pydantic import ValidationError from cyclone import __version__, db from cyclone.db import Claim, ClaimState, Remittance +from sqlalchemy import desc, or_ from cyclone.inbox_state import apply_999_rejections from cyclone.inbox_state_277ca import apply_277ca_rejections from cyclone.audit_log import AuditEvent, append_event, verify_chain @@ -2934,7 +2935,73 @@ def get_configured_provider(npi: str): p = store.get_provider(npi) if p is None: raise HTTPException(status_code=404, detail=f"provider {npi!r} not found") - return json.loads(p.model_dump_json()) + provider_dict = json.loads(p.model_dump_json()) + + # SP21 Task 1.6: extend the response with two top-N arrays that the + # drill-down peek panel hangs off. ``recent_claims`` reuses the + # existing store projection (already returns UI-shaped dicts with + # ``submissionDate``); ``recent_activity`` is a direct ORM join + # because ``ActivityEvent`` has no ``provider_npi`` column — the + # filter has to hop through ``Claim.id``. + recent_claims = sorted( + store.iter_claims(provider_npi=npi), + key=lambda c: c.get("submissionDate") or "", + reverse=True, + )[:10] + + # Activity filter has TWO join paths back to a Claim for this + # provider: + # 1. ``ActivityEvent.claim_id IN (claim_ids)`` — events that were + # recorded with a claim FK already set (claim_submitted, + # manual_match, claim_paid, etc.). + # 2. ``Remittance.claim_id IN (claim_ids)`` — the original + # ``remit_received`` event recorded at 835 ingest time + # (``store.add`` lines 999-1003) is inserted with + # ``claim_id=None`` because the remittance hasn't been matched + # to a claim yet. The auto-reconcile pass later sets + # ``Remittance.claim_id`` (``reconcile.run`` lines 289-293), + # but the *original* ActivityEvent row stays orphaned. The + # outer-join-then-OR lets us surface both shapes. Without + # path 2, a provider's activity feed looks frozen the moment + # an 835 lands — the most common activity, invisible. + with db.SessionLocal()() as s: + claim_ids = [ + cid + for (cid,) in s.query(Claim.id) + .filter(Claim.provider_npi == npi) + .all() + ] + activity_rows = [] + if claim_ids: + activity_rows = ( + s.query(db.ActivityEvent) + .outerjoin( + Remittance, + db.ActivityEvent.remittance_id == Remittance.id, + ) + .filter(or_( + db.ActivityEvent.claim_id.in_(claim_ids), + Remittance.claim_id.in_(claim_ids), + )) + .order_by(desc(db.ActivityEvent.ts)) + .limit(10) + .all() + ) + + def _activity_to_ui(a): + return { + "id": a.id, + "ts": a.ts.isoformat().replace("+00:00", "Z") if a.ts else "", + "kind": a.kind, + "batchId": a.batch_id, + "claimId": a.claim_id, + "remittanceId": a.remittance_id, + "payload": a.payload_json or {}, + } + + provider_dict["recent_claims"] = recent_claims + provider_dict["recent_activity"] = [_activity_to_ui(a) for a in activity_rows] + return provider_dict @app.get("/api/config/payers") diff --git a/backend/tests/test_provider_extended_response.py b/backend/tests/test_provider_extended_response.py new file mode 100644 index 0000000..400381d --- /dev/null +++ b/backend/tests/test_provider_extended_response.py @@ -0,0 +1,177 @@ +"""Tests for the extended GET /api/config/providers/{npi} (SP21 Task 1.6). + +The endpoint gains two new top-level arrays for the drill-down panel: +``recent_claims`` (top 10 by submission date desc) and ``recent_activity`` +(top 10 by ``ts`` desc, joined to claims by ``claim_id`` because +``ActivityEvent`` has no direct ``provider_npi`` column). + +Existing SP9 fields (``label``, ``legal_name``, ``tax_id``, +``address_line1``, ``city``, ``state``, ``zip``, etc.) must remain +present — the new arrays are additive only. + +The fixtures in ``fixtures/minimal_837p.txt`` and ``fixtures/minimal_835.txt`` +pair up to a single claim with ``provider_npi='1993999998'``. That NPI is +NOT in the seeded provider set (Montrose/Delta/Salida → 1881068062/1851446637/ +1467507269). For the tests we hit the seeded Montrose NPI, which is the +canonical SP9 fixture NPI. The arrays come back as empty lists — the +contract under test is the *shape* (array, ≤10) and the *backwards compat* +of the existing fields; the data-path itself is exercised by the existing +ingestion path that backs ``/api/claims``. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone.api import app +from cyclone.providers import Provider +from cyclone.store import store + +FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt" +FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt" + +# Montrose — one of the three providers that `ensure_clearhouse_seeded()` +# writes into the providers table. Using a seeded NPI means the endpoint +# won't 404; the seeded claims (provider_npi='1993999998') won't appear +# under this NPI, so recent_claims/activity are expected empty lists. +MONTROSE_NPI = "1881068062" + +# NPI the minimal 837P fixture bills under. NOT in the default seed — +# registering it before ingest is required to pass R204 (NPI must exist +# in the providers table). +TEST_837_NPI = "1993999998" + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture +def seeded_db(client: TestClient): + """Seed the clearhouse + ingest the minimal 837P/835 fixtures. + + Mirrors the Task 1.5 ``seeded_db`` pattern: seed → ingest → hand the + client back so the test hits the same TestClient. + + The 837 fixture bills under ``TEST_837_NPI``; without first registering + that provider the parser's R204 rule rejects the claim with HTTP 422. + """ + store.ensure_clearhouse_seeded() + test_provider = Provider( + npi=TEST_837_NPI, + label="Test Provider", + legal_name="Test Provider Inc", + tax_id="123456789", + taxonomy_code="207R00000X", + address_line1="123 Test St", + city="Denver", + state="CO", + zip="80202", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + store.upsert_provider(test_provider) + text_837 = FIXTURE_837.read_text() + text_835 = FIXTURE_835.read_text() + r837 = client.post( + "/api/parse-837", + files={"file": ("x.txt", text_837, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert r837.status_code == 200, r837.text + r835 = client.post( + "/api/parse-835", + files={"file": ("era.txt", text_835, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert r835.status_code == 200, r835.text + return client + + +def test_provider_detail_includes_recent_claims(seeded_db: TestClient): + """The extended response gains a recent_claims array (top 10).""" + resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}") + assert resp.status_code == 200, resp.text + data = resp.json() + assert "recent_claims" in data + assert isinstance(data["recent_claims"], list) + assert len(data["recent_claims"]) <= 10 + + +def test_provider_detail_includes_recent_activity(seeded_db: TestClient): + """The extended response gains a recent_activity array (top 10).""" + resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}") + assert resp.status_code == 200, resp.text + data = resp.json() + assert "recent_activity" in data + assert isinstance(data["recent_activity"], list) + assert len(data["recent_activity"]) <= 10 + + +def test_provider_detail_backwards_compat(seeded_db: TestClient): + """All SP9 fields still present; new arrays don't break the contract. + + The Provider Pydantic model (backend/src/cyclone/providers.py) + serializes snake_case fields — that's what the wire carries. The + TS ``Provider`` interface in ``src/types/index.ts`` is the + in-memory sample shape and intentionally diverges; the contract + being verified here is the API's actual payload. + """ + resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}") + assert resp.status_code == 200, resp.text + data = resp.json() + for key in ( + "npi", + "label", + "legal_name", + "tax_id", + "taxonomy_code", + "address_line1", + "city", + "state", + "zip", + "is_active", + ): + assert key in data, f"missing field {key}" + + +def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient): + """Regression: the ``remit_received`` ActivityEvent is recorded at 835 + ingest with ``claim_id=None`` (``store.add`` lines 999-1003) — the + remittance hasn't been matched to a claim yet. The original + ``ActivityEvent.claim_id IN (claim_ids)`` filter misses it because + the orphan's claim_id is NULL. + + Once reconciliation (auto or manual) populates + ``Remittance.claim_id``, the activity filter must surface the event + via the ``Remittance.claim_id IN (claim_ids)`` branch of the OR. + Without that branch, a provider's activity feed appears to freeze + the moment an 835 lands — the most common activity, invisible. + + Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit + CLM001), then manually match them so ``Remittance.claim_id`` is + populated. The bug presents as: only ``claim_submitted`` and + ``manual_match`` appear (no ``remit_received``). The fix surfaces + all three. + """ + # Force the match — simulates the post-reconciliation state that + # populate Remittance.claim_id without depending on auto-reconcile + # heuristics (which don't match this minimal fixture). + match_resp = seeded_db.post( + "/api/reconciliation/match", + json={"claim_id": "CLM001", "remit_id": "CLM001"}, + ) + assert match_resp.status_code == 200, match_resp.text + + resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}") + assert resp.status_code == 200, resp.text + data = resp.json() + kinds = {event["kind"] for event in data["recent_activity"]} + assert "remit_received" in kinds, ( + f"expected remit_received in recent_activity (orphan remits " + f"must surface via the Remittance join), got kinds={sorted(kinds)}" + ) diff --git a/src/types/index.ts b/src/types/index.ts index bc2c01a..c48a4d8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,6 +50,58 @@ export interface Provider { phone: string; claimCount: number; outstandingAr: number; + /** + * SP21 Task 1.6: populated by the extended `GET /api/config/providers/{npi}` + * endpoint. Top 10 claims for this provider by `submissionDate` desc. + * Optional so legacy callers (in-memory sample data) keep working + * without an API round-trip. + */ + recent_claims?: ClaimSummary[]; + /** + * SP21 Task 1.6: populated by the extended `GET /api/config/providers/{npi}` + * endpoint. Top 10 activity events (by `ts` desc) joined to claims + * owned by this provider via `claim_id`. + */ + recent_activity?: ActivityEvent[]; +} + +/** + * SP21 Task 1.6: slim claim projection returned by + * `GET /api/config/providers/{npi}.recent_claims`. Mirrors the wire + * shape of `store.iter_claims(provider_npi=...)` 1:1 (camelCase, + * superset of the legacy `Claim` interface). + */ +export interface ClaimSummary { + id: string; + state: string; + billedAmount: number; + patientName: string; + providerNpi: string; + payerName: string; + cptCode: string; + submissionDate: string; + parsedAt: string; + status: string; + matchedRemittanceId?: string | null; + batchId: string; + receivedAmount?: number; + denialReason?: string | null; +} + +/** + * SP21 Task 1.6: one row in `recent_activity`. Mirrors the Python + * ORM `ActivityEvent` (snake_case fields rewritten to camelCase for + * the wire). `payload` carries the same dict the recorder wrote at + * the time of the event (message, npi, amount, etc.). + */ +export interface ActivityEvent { + id: number; + ts: string; // ISO Z + kind: string; + batchId: string | null; + claimId: string | null; + remittanceId: string | null; + payload: Record; } export interface Remittance {