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 import __version__, db
|
||||||
from cyclone.db import Claim, ClaimState, Remittance
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
|
from sqlalchemy import desc, or_
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
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)
|
p = store.get_provider(npi)
|
||||||
if p is None:
|
if p is None:
|
||||||
raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
|
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")
|
@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;
|
phone: string;
|
||||||
claimCount: number;
|
claimCount: number;
|
||||||
outstandingAr: 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 {
|
export interface Remittance {
|
||||||
|
|||||||
Reference in New Issue
Block a user