feat(api): extend /api/config/providers/{npi} with recent_claims + recent_activity
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Remittance {
|
||||
|
||||
Reference in New Issue
Block a user