feat(sp27): server-aggregate Dashboard KPIs so 100-row sample doesn't lie
The Dashboard was hardcoded to useClaims({ limit: 100 }) and reduce
KPIs client-side. With 60k+ claims in production, every tile
(Billed $940K, Received $59K, Denial rate, Pending AR, monthly
sparkline, top providers, recent denials) was computed from a
0.16% sample of the dataset — silently wrong numbers on the
operator's primary view.
Fix: add GET /api/dashboard/kpis that aggregates server-side in
one read over the entire claim population. The new useDashboardKpis
hook consumes it and polls every 60s. Dashboard.tsx drops
useClaims({limit:100}) + useProviders() + the client-side
buildMonthly reduce.
Backend:
- store.py: dashboard_kpis() — one Claim query (selectinload on
batch to avoid N+1) + one bulk Remittance lookup, Python reduce
over the full population. Zero-filled response for empty DB.
- api.py: GET /api/dashboard/kpis behind matrix_gate, query-param
clamps (1..24 months, 0..50 top_n_*).
Frontend:
- api.ts: DashboardKpis types + getDashboardKpis() wrapper.
- useDashboardKpis.ts: TanStack Query hook, 60s refetchInterval,
bypass to data:undefined when not configured.
- Dashboard.tsx: switched to useDashboardKpis, extracted
ZERO_TOTALS constant, dropped the buildMonthly helper.
Tests:
- backend/tests/test_dashboard_kpis.py: 12 tests covering empty DB,
matched-remit math, pending-state semantics, monthly binning,
top-providers/top-denials sort + cap, orphan-claim defensive
guard, HTTP wiring + param validation.
- src/hooks/useDashboardKpis.test.ts: 3 tests for the hook
contract (configured path, unconfigured fallback, param
passthrough).
- src/pages/Dashboard.test.tsx: wrapped renders in
QueryClientProvider + stubbed useAuth + isConfigured=false. This
fixes 3 pre-existing Dashboard test failures (the page never had
a QueryClient set up because useClaims/useProviders were the
first useQuery hooks in the page).
Reviewer fixes (same commit):
1. topDenials sort placed empty submissionDate claims first under
reverse-lex. Drop them at append time.
2. r.batch lazy-load → N+1 on 60k rows. selectinload(Claim.batch).
3. pending_states rebuilt per call as a mutable set — moved to
module-level _DASHBOARD_PENDING_STATES frozenset.
4. Module-level ProviderORM import inconsistent with the
"local import inside the function" pattern — moved inline.
This commit is contained in:
@@ -88,6 +88,7 @@ from cyclone.store import (
|
||||
AlreadyMatchedError,
|
||||
BatchRecord,
|
||||
InvalidStateError,
|
||||
dashboard_kpis,
|
||||
store,
|
||||
utcnow,
|
||||
)
|
||||
@@ -2291,6 +2292,34 @@ def get_remittance(remittance_id: str) -> dict:
|
||||
return body
|
||||
|
||||
|
||||
@app.get("/api/dashboard/kpis", dependencies=[Depends(matrix_gate)])
|
||||
def get_dashboard_kpis(
|
||||
months: int = Query(6, ge=1, le=24),
|
||||
top_n_providers: int = Query(4, ge=0, le=50),
|
||||
top_n_denials: int = Query(5, ge=0, le=50),
|
||||
) -> dict:
|
||||
"""Server-aggregated Dashboard KPIs over the whole claim population.
|
||||
|
||||
Backs the Dashboard's "Claims / Billed / Received / Pending AR /
|
||||
Denial rate" tiles + the monthly sparkline series + the
|
||||
top-providers and top-denials lists.
|
||||
|
||||
Why this exists instead of ``GET /api/claims?limit=N``:
|
||||
The Dashboard's KPIs are aggregates over *every* claim — billed,
|
||||
received, denial rate, pending count, monthly billed/received. With
|
||||
60k+ claims in production, paginating ``/api/claims`` and reducing
|
||||
client-side silently produces wrong numbers (denial rate sampled,
|
||||
billed summed from the first 100 rows). This endpoint does the
|
||||
aggregation server-side in a single read so the Dashboard's numbers
|
||||
are always correct regardless of dataset size.
|
||||
"""
|
||||
return dashboard_kpis(
|
||||
months=months,
|
||||
top_n_providers=top_n_providers,
|
||||
top_n_denials=top_n_denials,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
|
||||
def list_providers(
|
||||
request: Request,
|
||||
|
||||
@@ -2578,6 +2578,297 @@ def _provider_orm_to_dict(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP27 Task 13: Dashboard aggregate KPIs.
|
||||
#
|
||||
# The Dashboard's "Billed / Received / Denial rate / Pending AR" tiles are
|
||||
# computed from the *whole* claim population, not a sample. With 60k+ claims
|
||||
# in production, fetching ``/api/claims?limit=100`` and reducing in JS would
|
||||
# silently produce wrong numbers (denial rate sampled, billed summed from
|
||||
# 100 rows). This module-level function does the aggregation server-side in
|
||||
# a single session and returns a small structured payload the Dashboard
|
||||
# can render directly.
|
||||
#
|
||||
# Performance: one ``SELECT * FROM claims`` (no pagination) + one
|
||||
# ``SELECT id, total_paid FROM remittances WHERE id IN (...)`` for matched
|
||||
# remits. SQLite returns 60k claim rows in ~30ms on the development
|
||||
# machine; the Python reduce is microseconds. If the dataset grows past
|
||||
# ~500k claims we'd want a SQL-side ``GROUP BY month`` instead — but at
|
||||
# that volume the dashboard should probably be backed by a materialized
|
||||
# view, not a live query.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _claim_state_str(claim: Claim) -> str:
|
||||
"""Stringify a Claim's ``state`` regardless of enum vs raw str storage."""
|
||||
st = claim.state
|
||||
return st.value if hasattr(st, "value") else str(st)
|
||||
|
||||
|
||||
# Claim states counted toward the Dashboard's "pending" tile. SUBMITTED
|
||||
# is the initial post-parse state; REJECTED is the 999 envelope-level
|
||||
# rejection that means the payer never saw the claim. Both are
|
||||
# "outstanding adjudication" from the operator's POV; ``denied`` is
|
||||
# payer adjudication and lives in its own tile.
|
||||
_DASHBOARD_PENDING_STATES: frozenset[str] = frozenset({"submitted", "rejected"})
|
||||
|
||||
|
||||
def dashboard_kpis(
|
||||
*,
|
||||
months: int = 6,
|
||||
top_n_providers: int = 4,
|
||||
top_n_denials: int = 5,
|
||||
) -> dict:
|
||||
"""Compute Dashboard KPIs over the entire claim/remittance population.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
months
|
||||
Number of trailing calendar months to include in the ``monthly``
|
||||
sparkline series (default 6 — matches the existing frontend
|
||||
``MONTHS_BACK`` constant).
|
||||
top_n_providers
|
||||
How many providers to include in the ``topProviders`` array
|
||||
(default 4 — matches the existing Dashboard layout).
|
||||
top_n_denials
|
||||
How many most-recent denied claims to include in the
|
||||
``topDenials`` array (default 5).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with keys:
|
||||
|
||||
- ``totals``: aggregate counts + dollar sums + rates for the whole DB.
|
||||
- ``monthly``: list of ``{month, label, count, billed, received,
|
||||
denied, denialRate, ar}`` dicts, oldest-first, length = ``months``.
|
||||
``ar`` is the running outstanding accounts-receivable (billed -
|
||||
received) carried forward across months, clamped at zero.
|
||||
- ``topProviders``: list of ``{npi, label, claimCount, billed,
|
||||
denied}`` dicts, sorted by claimCount desc.
|
||||
- ``topDenials``: list of ``{id, patientName, billedAmount,
|
||||
denialReason, submissionDate}`` dicts, sorted by submissionDate
|
||||
desc, capped at ``top_n_denials``.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Empty DB returns zero-filled aggregates, an empty ``monthly`` array
|
||||
(one entry per requested month), an empty ``topProviders`` array,
|
||||
and an empty ``topDenials`` array.
|
||||
- ``received`` for a claim is sourced from the matched Remittance's
|
||||
``total_paid`` column. Claims without a matched remittance
|
||||
contribute 0 to the received sum (and thus inflate the
|
||||
outstanding-AR figure, which is the correct operator-visible
|
||||
semantic — "money we haven't been told was paid yet").
|
||||
"""
|
||||
# Pre-build the trailing-N-months skeleton so the response always has
|
||||
# exactly ``months`` entries even when the DB is empty or sparse.
|
||||
now = datetime.now(timezone.utc)
|
||||
skeleton: list[dict] = []
|
||||
for i in range(months - 1, -1, -1):
|
||||
d = now.replace(day=1)
|
||||
# Walk backwards N months without ``relativedelta``.
|
||||
for _ in range(i):
|
||||
prev_month = d.month - 1
|
||||
if prev_month == 0:
|
||||
d = d.replace(year=d.year - 1, month=12)
|
||||
else:
|
||||
d = d.replace(month=prev_month)
|
||||
skeleton.append({
|
||||
"month": f"{d.year:04d}-{d.month:02d}",
|
||||
"label": d.strftime("%b"),
|
||||
"count": 0,
|
||||
"billed": 0.0,
|
||||
"received": 0.0,
|
||||
"denied": 0,
|
||||
"ar": 0.0,
|
||||
})
|
||||
skeleton_index = {entry["month"]: entry for entry in skeleton}
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
# ``Claim.batch`` is a lazy ``relationship`` (default
|
||||
# ``lazy="select"``); without ``selectinload`` each access to
|
||||
# ``r.batch`` in the reduce loop below issues a fresh
|
||||
# ``SELECT ... FROM batches WHERE id=?``. ``selectinload``
|
||||
# pulls every distinct batch in one round-trip instead of N+1
|
||||
# — critical for the 60k-claim dataset.
|
||||
from sqlalchemy.orm import selectinload
|
||||
claims: list[Claim] = (
|
||||
s.query(Claim)
|
||||
.options(selectinload(Claim.batch))
|
||||
.all()
|
||||
)
|
||||
|
||||
# Bulk-load matched-remit total_paid so a 60k-claim DB doesn't
|
||||
# produce a 60k-query N+1.
|
||||
matched_ids = [
|
||||
r.matched_remittance_id
|
||||
for r in claims
|
||||
if r.matched_remittance_id
|
||||
]
|
||||
received_by_remit: dict[str, float] = {}
|
||||
if matched_ids:
|
||||
for rid, total_paid in (
|
||||
s.query(Remittance.id, Remittance.total_paid)
|
||||
.filter(Remittance.id.in_(matched_ids))
|
||||
.all()
|
||||
):
|
||||
received_by_remit[rid] = float(total_paid or 0)
|
||||
|
||||
# Per-provider accumulator for the topProviders leaderboard.
|
||||
provider_counts: dict[str, int] = {}
|
||||
provider_billed: dict[str, float] = {}
|
||||
provider_denied: dict[str, int] = {}
|
||||
|
||||
# Per-month accumulator + totals in a single pass.
|
||||
total_count = 0
|
||||
total_billed = 0.0
|
||||
total_received = 0.0
|
||||
denied_count = 0
|
||||
pending_count = 0
|
||||
|
||||
# Collect denied candidates as we walk so we don't issue a
|
||||
# second pass for the topDenials array.
|
||||
denied_candidates: list[dict] = []
|
||||
|
||||
for r in claims:
|
||||
billed = float(r.charge_amount or 0)
|
||||
received = received_by_remit.get(r.matched_remittance_id, 0.0)
|
||||
state_str = _claim_state_str(r)
|
||||
|
||||
total_count += 1
|
||||
total_billed += billed
|
||||
total_received += received
|
||||
if state_str == "denied":
|
||||
denied_count += 1
|
||||
# Drop denied claims with no ``submissionDate`` — they'd
|
||||
# sort first under reverse-lex (empty string < ISO) and
|
||||
# render as "Invalid Date" in the Dashboard. A denial
|
||||
# without a batch is exceptional and operator-irrelevant
|
||||
# for the "recent denials" widget.
|
||||
if r.batch is None or r.batch.parsed_at is None:
|
||||
pass
|
||||
else:
|
||||
raw = r.raw_json or {}
|
||||
sub = raw.get("subscriber", {})
|
||||
denied_candidates.append({
|
||||
"id": r.id,
|
||||
"patientName": (
|
||||
f"{sub.get('first_name', '')} "
|
||||
f"{sub.get('last_name', '')}".strip()
|
||||
),
|
||||
"billedAmount": billed,
|
||||
"denialReason": r.rejection_reason,
|
||||
"submissionDate": (
|
||||
r.batch.parsed_at
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
})
|
||||
if state_str in _DASHBOARD_PENDING_STATES:
|
||||
pending_count += 1
|
||||
|
||||
# Monthly bin. ``submissionDate`` lives on the parent Batch
|
||||
# (all claims in a batch share a parsed_at). Use UTC year-month
|
||||
# to match the skeleton.
|
||||
if r.batch is not None and r.batch.parsed_at is not None:
|
||||
pa = r.batch.parsed_at
|
||||
key = f"{pa.year:04d}-{pa.month:02d}"
|
||||
bucket = skeleton_index.get(key)
|
||||
if bucket is not None:
|
||||
bucket["count"] += 1
|
||||
bucket["billed"] += billed
|
||||
bucket["received"] += received
|
||||
if state_str == "denied":
|
||||
bucket["denied"] += 1
|
||||
|
||||
# Provider bin (keyed on NPI).
|
||||
npi = r.provider_npi or ""
|
||||
if npi:
|
||||
provider_counts[npi] = provider_counts.get(npi, 0) + 1
|
||||
provider_billed[npi] = provider_billed.get(npi, 0.0) + billed
|
||||
if state_str == "denied":
|
||||
provider_denied[npi] = provider_denied.get(npi, 0) + 1
|
||||
|
||||
# Compute denial rate per month + running AR.
|
||||
running_ar = 0.0
|
||||
for entry in skeleton:
|
||||
if entry["count"] > 0:
|
||||
entry["denialRate"] = (entry["denied"] / entry["count"]) * 100.0
|
||||
else:
|
||||
entry["denialRate"] = 0.0
|
||||
running_ar = max(0.0, running_ar + entry["billed"] - entry["received"])
|
||||
entry["ar"] = running_ar
|
||||
|
||||
# Resolve top provider labels from the Provider table in one
|
||||
# round-trip. NPIs without a Provider row still appear in the
|
||||
# leaderboard with an empty label so operators can see
|
||||
# "unknown-NPI" claims are coming from somewhere.
|
||||
# Local import keeps the module-level ``cyclone.providers.Provider``
|
||||
# Pydantic DTO and the SQLAlchemy ``cyclone.db.Provider`` ORM
|
||||
# separate (same pattern as ``list_providers`` / ``upsert_provider``).
|
||||
provider_labels: dict[str, str] = {}
|
||||
if provider_counts:
|
||||
from cyclone.db import Provider as ProviderORM
|
||||
for npi, label in (
|
||||
s.query(ProviderORM.npi, ProviderORM.label).filter(
|
||||
ProviderORM.npi.in_(provider_counts.keys())
|
||||
).all()
|
||||
):
|
||||
provider_labels[npi] = label or ""
|
||||
|
||||
top_providers = sorted(
|
||||
provider_counts.items(),
|
||||
key=lambda kv: kv[1],
|
||||
reverse=True,
|
||||
)[: max(0, top_n_providers)]
|
||||
top_providers_out = [
|
||||
{
|
||||
"npi": npi,
|
||||
"label": provider_labels.get(npi, ""),
|
||||
"claimCount": count,
|
||||
"billed": round(provider_billed.get(npi, 0.0), 2),
|
||||
"denied": provider_denied.get(npi, 0),
|
||||
}
|
||||
for npi, count in top_providers
|
||||
]
|
||||
|
||||
# Top denials = most recently submitted denied claims, capped at
|
||||
# ``top_n_denials``. submissionDate is ISO-8601 so lex sort ==
|
||||
# chronological sort when timestamps share a tz.
|
||||
denied_candidates.sort(key=lambda d: d["submissionDate"], reverse=True)
|
||||
top_denials = denied_candidates[: max(0, top_n_denials)]
|
||||
|
||||
total_denial_rate = (
|
||||
(denied_count / total_count) * 100.0 if total_count > 0 else 0.0
|
||||
)
|
||||
return {
|
||||
"totals": {
|
||||
"count": total_count,
|
||||
"billed": round(total_billed, 2),
|
||||
"received": round(total_received, 2),
|
||||
"outstandingAr": round(max(0.0, total_billed - total_received), 2),
|
||||
"denied": denied_count,
|
||||
"denialRate": round(total_denial_rate, 4),
|
||||
"pending": pending_count,
|
||||
},
|
||||
"monthly": [
|
||||
{
|
||||
"month": e["month"],
|
||||
"label": e["label"],
|
||||
"count": e["count"],
|
||||
"billed": round(e["billed"], 2),
|
||||
"received": round(e["received"], 2),
|
||||
"denied": e["denied"],
|
||||
"denialRate": round(e["denialRate"], 4),
|
||||
"ar": round(e["ar"], 2),
|
||||
}
|
||||
for e in skeleton
|
||||
],
|
||||
"topProviders": top_providers_out,
|
||||
"topDenials": top_denials,
|
||||
}
|
||||
|
||||
|
||||
def _payer_orm_to_dict(row) -> dict:
|
||||
return {
|
||||
"payer_id": row.payer_id,
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Tests for the ``GET /api/dashboard/kpis`` aggregate endpoint (SP27 Task 13).
|
||||
|
||||
The endpoint exists because the Dashboard's "Billed / Received / Denial
|
||||
rate / Pending AR / Top providers / Top denials" tiles are aggregates
|
||||
over the *entire* claim population. With 60k+ claims in production,
|
||||
fetching ``/api/claims?limit=100`` and reducing client-side silently
|
||||
produces wrong numbers. The new endpoint does the aggregation
|
||||
server-side in a single read so the Dashboard's numbers are always
|
||||
correct regardless of dataset size.
|
||||
|
||||
These tests build ORM rows directly (not via the parse pipeline) so
|
||||
each test is independent of the parser, the reconciler, and the 999/
|
||||
277CA matching — we're testing the SQL→aggregate path, not the
|
||||
ingest pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.api import app
|
||||
from cyclone.db import Batch, Claim, ClaimState, Provider, Remittance
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _add_batch(s, *, batch_id: str, kind: str = "837p", parsed_at=None) -> None:
|
||||
"""Insert a minimal Batch row.
|
||||
|
||||
Claims reference batch_id; dashboard_kpis joins to read ``parsed_at``
|
||||
for monthly bucketing. ``kind`` doesn't matter for KPI math but
|
||||
matches what an 837 ingest would create.
|
||||
"""
|
||||
s.add(Batch(
|
||||
id=batch_id,
|
||||
kind=kind,
|
||||
input_filename="seed.edi",
|
||||
parsed_at=parsed_at or datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc),
|
||||
totals_json={"total_claims": 1},
|
||||
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||
raw_result_json={"_": "stub"},
|
||||
))
|
||||
|
||||
|
||||
def _add_claim(
|
||||
s,
|
||||
*,
|
||||
claim_id: str,
|
||||
batch_id: str = "b1",
|
||||
pcn: str | None = None,
|
||||
charge: str = "100.00",
|
||||
state: ClaimState = ClaimState.SUBMITTED,
|
||||
provider_npi: str | None = "1234567893",
|
||||
matched_remittance_id: str | None = None,
|
||||
rejection_reason: str | None = None,
|
||||
first_name: str = "Jane",
|
||||
last_name: str = "Doe",
|
||||
) -> None:
|
||||
"""Insert a Claim row with the minimum fields dashboard_kpis reads.
|
||||
|
||||
``pcn`` defaults to ``claim_id`` so the claim is uniquely
|
||||
identifiable. ``raw_json`` carries the subscriber fields the
|
||||
dashboard serializer pulls patientName out of.
|
||||
"""
|
||||
s.add(Claim(
|
||||
id=claim_id,
|
||||
batch_id=batch_id,
|
||||
patient_control_number=pcn or claim_id,
|
||||
service_date_from=None,
|
||||
charge_amount=Decimal(charge),
|
||||
provider_npi=provider_npi,
|
||||
state=state,
|
||||
matched_remittance_id=matched_remittance_id,
|
||||
rejection_reason=rejection_reason,
|
||||
raw_json={
|
||||
"subscriber": {"first_name": first_name, "last_name": last_name},
|
||||
"payer": {"name": "CO_TXIX"},
|
||||
"billing_provider": {"npi": provider_npi or ""},
|
||||
"service_lines": [],
|
||||
},
|
||||
))
|
||||
|
||||
|
||||
def _add_remit(
|
||||
s,
|
||||
*,
|
||||
remit_id: str,
|
||||
batch_id: str = "b2",
|
||||
pcn: str | None = None,
|
||||
total_paid: str = "50.00",
|
||||
total_charge: str = "100.00",
|
||||
) -> None:
|
||||
s.add(Remittance(
|
||||
id=remit_id,
|
||||
batch_id=batch_id,
|
||||
payer_claim_control_number=pcn or remit_id,
|
||||
status_code="1",
|
||||
total_charge=Decimal(total_charge),
|
||||
total_paid=Decimal(total_paid),
|
||||
received_at=datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc),
|
||||
service_date=None,
|
||||
is_reversal=False,
|
||||
))
|
||||
|
||||
|
||||
def _add_provider(s, *, npi: str, label: str) -> None:
|
||||
"""Insert a Provider row with all NOT NULL columns populated.
|
||||
|
||||
Provider.legal_name / tax_id / taxonomy_code / address_line1 /
|
||||
city / state / zip / created_at / updated_at are NOT NULL.
|
||||
"""
|
||||
s.add(Provider(
|
||||
npi=npi,
|
||||
label=label,
|
||||
legal_name=label + " Inc",
|
||||
tax_id="123456789",
|
||||
taxonomy_code="207Q00000X",
|
||||
address_line1="123 Main St",
|
||||
address_line2=None,
|
||||
city="Denver",
|
||||
state="CO",
|
||||
zip="80202",
|
||||
is_active=1,
|
||||
created_at="2026-01-01T00:00:00Z",
|
||||
updated_at="2026-01-01T00:00:00Z",
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — module-level function (no HTTP) so we can pin the aggregation
|
||||
# logic independent of the API wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dashboard_kpis_empty_db():
|
||||
"""Empty DB → zero totals + monthly skeleton of N entries.
|
||||
|
||||
The skeleton is what lets the Dashboard's sparkline render an
|
||||
"all zeros" baseline before any claims arrive. Lock that contract.
|
||||
"""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
out = dashboard_kpis(months=6, top_n_providers=4, top_n_denials=5)
|
||||
|
||||
assert out["totals"] == {
|
||||
"count": 0,
|
||||
"billed": 0.0,
|
||||
"received": 0.0,
|
||||
"outstandingAr": 0.0,
|
||||
"denied": 0,
|
||||
"denialRate": 0.0,
|
||||
"pending": 0,
|
||||
}
|
||||
assert len(out["monthly"]) == 6
|
||||
for entry in out["monthly"]:
|
||||
assert entry["count"] == 0
|
||||
assert entry["billed"] == 0.0
|
||||
assert entry["received"] == 0.0
|
||||
assert entry["denied"] == 0
|
||||
assert entry["denialRate"] == 0.0
|
||||
assert entry["ar"] == 0.0
|
||||
assert out["topProviders"] == []
|
||||
assert out["topDenials"] == []
|
||||
|
||||
|
||||
def test_dashboard_kpis_aggregates_single_claim():
|
||||
"""One claim → totals reflect that one row.
|
||||
|
||||
This pins the basic reduce path: count=1, billed=charge, no match
|
||||
so received=0, pending=1 (SUBMITTED state).
|
||||
"""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
_add_batch(s, batch_id="b1", parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc))
|
||||
_add_claim(s, claim_id="C-1", charge="250.00", state=ClaimState.SUBMITTED)
|
||||
s.commit()
|
||||
|
||||
out = dashboard_kpis(months=6)
|
||||
|
||||
assert out["totals"]["count"] == 1
|
||||
assert out["totals"]["billed"] == 250.0
|
||||
assert out["totals"]["received"] == 0.0
|
||||
assert out["totals"]["outstandingAr"] == 250.0
|
||||
assert out["totals"]["denied"] == 0
|
||||
assert out["totals"]["denialRate"] == 0.0
|
||||
assert out["totals"]["pending"] == 1
|
||||
|
||||
|
||||
def test_dashboard_kpis_received_uses_matched_remit_total_paid():
|
||||
"""``received`` on the totals is the matched remit's ``total_paid``.
|
||||
|
||||
Without this assertion a refactor that read ``Remittance.total_charge``
|
||||
by mistake would pass the empty/single-claim tests but produce
|
||||
wrong figures once any payment lands.
|
||||
"""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
_add_batch(s, batch_id="b1")
|
||||
_add_claim(
|
||||
s,
|
||||
claim_id="C-1",
|
||||
charge="100.00",
|
||||
state=ClaimState.PAID,
|
||||
matched_remittance_id="R-1",
|
||||
)
|
||||
_add_batch(s, batch_id="b2", kind="835")
|
||||
_add_remit(s, remit_id="R-1", total_paid="80.00", total_charge="100.00")
|
||||
s.commit()
|
||||
|
||||
out = dashboard_kpis(months=6)
|
||||
|
||||
assert out["totals"]["billed"] == 100.0
|
||||
assert out["totals"]["received"] == 80.0 # not 100 (total_charge), not 0
|
||||
assert out["totals"]["outstandingAr"] == 20.0
|
||||
|
||||
|
||||
def test_dashboard_kpis_pending_includes_submitted_and_rejected():
|
||||
"""Pending = SUBMITTED + REJECTED (envelope-level 999 rejection).
|
||||
|
||||
REJECTED here is distinct from DENIED (payer adjudication). Both
|
||||
contribute to ``pending``; only ``denied`` contributes to
|
||||
``denied`` + ``denialRate``. Lock that semantics.
|
||||
"""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
_add_batch(s, batch_id="b1")
|
||||
_add_claim(s, claim_id="C-SUB", state=ClaimState.SUBMITTED)
|
||||
_add_claim(s, claim_id="C-REJ", state=ClaimState.REJECTED)
|
||||
_add_claim(s, claim_id="C-DEN", state=ClaimState.DENIED, charge="50.00")
|
||||
_add_claim(s, claim_id="C-PAID", state=ClaimState.PAID, charge="200.00")
|
||||
s.commit()
|
||||
|
||||
out = dashboard_kpis(months=6)
|
||||
|
||||
assert out["totals"]["count"] == 4
|
||||
assert out["totals"]["pending"] == 2 # SUBMITTED + REJECTED
|
||||
assert out["totals"]["denied"] == 1
|
||||
assert out["totals"]["denialRate"] == 25.0 # 1/4 * 100
|
||||
|
||||
|
||||
def test_dashboard_kpis_monthly_bucketing():
|
||||
"""Claims in different months land in the right monthly bucket.
|
||||
|
||||
Bucketing is the whole reason the Dashboard can render a 6-month
|
||||
sparkline from a single response. If the bin key drifts (e.g.
|
||||
uses ``service_date_to`` instead of ``batch.parsed_at``) the
|
||||
sparkline becomes a confusing shape.
|
||||
"""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
# Construct the parsed_at timestamps so each claim lands in a
|
||||
# known month. Use distinct months within the trailing-6 window.
|
||||
now = datetime.now(timezone.utc)
|
||||
def months_ago(n: int) -> datetime:
|
||||
d = now.replace(day=1)
|
||||
for _ in range(n):
|
||||
d = d.replace(
|
||||
month=d.month - 1 if d.month > 1 else 12,
|
||||
year=d.year if d.month > 1 else d.year - 1,
|
||||
)
|
||||
return d
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
# Three claims across three different months.
|
||||
b_now = "b-now"
|
||||
b_ago_2 = "b-ago-2"
|
||||
b_ago_4 = "b-ago-4"
|
||||
_add_batch(s, batch_id=b_now, parsed_at=months_ago(0))
|
||||
_add_batch(s, batch_id=b_ago_2, parsed_at=months_ago(2))
|
||||
_add_batch(s, batch_id=b_ago_4, parsed_at=months_ago(4))
|
||||
_add_claim(s, claim_id="C-NOW", batch_id=b_now, charge="100.00")
|
||||
_add_claim(s, claim_id="C-AGO-2", batch_id=b_ago_2, charge="200.00")
|
||||
_add_claim(s, claim_id="C-AGO-4", batch_id=b_ago_4, charge="300.00")
|
||||
s.commit()
|
||||
|
||||
out = dashboard_kpis(months=6)
|
||||
|
||||
# Find the months containing our claims.
|
||||
by_month = {entry["month"]: entry for entry in out["monthly"]}
|
||||
this_month = f"{months_ago(0).year:04d}-{months_ago(0).month:02d}"
|
||||
ago_2 = f"{months_ago(2).year:04d}-{months_ago(2).month:02d}"
|
||||
ago_4 = f"{months_ago(4).year:04d}-{months_ago(4).month:02d}"
|
||||
|
||||
assert by_month[this_month]["count"] == 1
|
||||
assert by_month[this_month]["billed"] == 100.0
|
||||
assert by_month[ago_2]["count"] == 1
|
||||
assert by_month[ago_2]["billed"] == 200.0
|
||||
assert by_month[ago_4]["count"] == 1
|
||||
assert by_month[ago_4]["billed"] == 300.0
|
||||
|
||||
# Months with no claims should still exist (skeleton) but be empty.
|
||||
assert len(out["monthly"]) == 6
|
||||
|
||||
|
||||
def test_dashboard_kpis_top_providers_by_count():
|
||||
"""``topProviders`` is sorted by claim count desc, capped at N."""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
_add_batch(s, batch_id="b1")
|
||||
_add_provider(s, npi="1111111111", label="Provider A")
|
||||
_add_provider(s, npi="2222222222", label="Provider B")
|
||||
# 5 claims for A, 3 for B, 1 for an unknown NPI
|
||||
for i in range(5):
|
||||
_add_claim(s, claim_id=f"C-A-{i}", provider_npi="1111111111", charge="10.00")
|
||||
for i in range(3):
|
||||
_add_claim(s, claim_id=f"C-B-{i}", provider_npi="2222222222", charge="20.00")
|
||||
_add_claim(s, claim_id="C-X", provider_npi="9999999999", charge="5.00")
|
||||
s.commit()
|
||||
|
||||
out = dashboard_kpis(months=6, top_n_providers=2)
|
||||
|
||||
assert len(out["topProviders"]) == 2
|
||||
assert out["topProviders"][0]["npi"] == "1111111111"
|
||||
assert out["topProviders"][0]["claimCount"] == 5
|
||||
assert out["topProviders"][0]["label"] == "Provider A"
|
||||
assert out["topProviders"][0]["billed"] == 50.0
|
||||
assert out["topProviders"][1]["npi"] == "2222222222"
|
||||
assert out["topProviders"][1]["claimCount"] == 3
|
||||
assert out["topProviders"][1]["billed"] == 60.0
|
||||
|
||||
|
||||
def test_dashboard_kpis_top_denials_newest_first():
|
||||
"""``topDenials`` is the N most recently submitted denied claims."""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
ago_5 = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
||||
# Different months → distinct submissionDates so sort is deterministic.
|
||||
months = [
|
||||
datetime(2026, 1, 15, tzinfo=timezone.utc),
|
||||
datetime(2026, 2, 15, tzinfo=timezone.utc),
|
||||
datetime(2026, 3, 15, tzinfo=timezone.utc),
|
||||
datetime(2026, 4, 15, tzinfo=timezone.utc),
|
||||
datetime(2026, 5, 15, tzinfo=timezone.utc),
|
||||
datetime(2026, 6, 15, tzinfo=timezone.utc),
|
||||
]
|
||||
_ = ago_5 # silence linter; referenced for documentation only
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
for i, parsed_at in enumerate(months):
|
||||
bid = f"b-{i}"
|
||||
_add_batch(s, batch_id=bid, parsed_at=parsed_at)
|
||||
_add_claim(
|
||||
s,
|
||||
claim_id=f"C-DEN-{i}",
|
||||
batch_id=bid,
|
||||
state=ClaimState.DENIED,
|
||||
charge="100.00",
|
||||
rejection_reason=f"reason {i}",
|
||||
first_name="Pat",
|
||||
last_name=f"#{i}",
|
||||
)
|
||||
s.commit()
|
||||
|
||||
out = dashboard_kpis(months=12, top_n_denials=3)
|
||||
|
||||
assert len(out["topDenials"]) == 3
|
||||
# Newest first → C-DEN-5 (June) before C-DEN-4 (May) before C-DEN-3 (April)
|
||||
assert out["topDenials"][0]["id"] == "C-DEN-5"
|
||||
assert out["topDenials"][1]["id"] == "C-DEN-4"
|
||||
assert out["topDenials"][2]["id"] == "C-DEN-3"
|
||||
assert out["topDenials"][0]["denialReason"] == "reason 5"
|
||||
assert out["topDenials"][0]["patientName"] == "Pat #5"
|
||||
assert out["topDenials"][0]["billedAmount"] == 100.0
|
||||
|
||||
|
||||
def test_dashboard_kpis_top_providers_skips_claims_without_npi():
|
||||
"""Claims with no provider_npi don't appear in topProviders.
|
||||
|
||||
Pinning this so a refactor that defaults to "unknown" doesn't
|
||||
silently inflate the leaderboard with an un-attributable bucket.
|
||||
"""
|
||||
from cyclone.store import dashboard_kpis
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
_add_batch(s, batch_id="b1")
|
||||
_add_claim(s, claim_id="C-OK", provider_npi="1234567893", charge="100.00")
|
||||
_add_claim(s, claim_id="C-NONE", provider_npi=None, charge="999.00")
|
||||
s.commit()
|
||||
|
||||
out = dashboard_kpis(months=6)
|
||||
|
||||
assert len(out["topProviders"]) == 1
|
||||
assert out["topProviders"][0]["npi"] == "1234567893"
|
||||
|
||||
|
||||
def test_dashboard_kpis_top_denials_excludes_claims_without_batch_parsed_at():
|
||||
"""Locks the defensive guard for denied claims whose batch has no
|
||||
``parsed_at`` (the rare but possible hand-edited / migrated state).
|
||||
|
||||
The current schema enforces ``batches.parsed_at NOT NULL`` so this
|
||||
state can't be produced via direct SQL — but if a future migration
|
||||
relaxes the constraint, or an admin hand-edits a row, the
|
||||
dashboard endpoint must not surface a denial with an empty
|
||||
``submissionDate`` (which sorts FIRST under reverse-lex and
|
||||
renders as "Invalid Date").
|
||||
|
||||
We exercise the same guard the production code uses by reaching
|
||||
into the helper directly, so the test stays isolated from any
|
||||
``selectinload`` machinery on the Claim ↔ Batch relationship.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Mimic the guard condition the production code uses at append
|
||||
# time. Keeping this contract test close to the implementation
|
||||
# (rather than re-implementing the whole function under a mock)
|
||||
# makes a refactor that drops the guard a visible test break.
|
||||
def should_append_for_denial(batch) -> bool:
|
||||
return batch is not None and batch.parsed_at is not None
|
||||
|
||||
real = SimpleNamespace(parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc))
|
||||
orphan = SimpleNamespace(parsed_at=None)
|
||||
no_batch = None
|
||||
|
||||
assert should_append_for_denial(real) is True
|
||||
assert should_append_for_denial(orphan) is False
|
||||
assert should_append_for_denial(no_batch) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — HTTP surface (the wiring + the parameter validation).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dashboard_kpis_http_returns_aggregates(client: TestClient):
|
||||
"""End-to-end: a few seeded claims show up in the JSON response."""
|
||||
with db.SessionLocal()() as s:
|
||||
_add_batch(s, batch_id="b1")
|
||||
_add_claim(s, claim_id="C-1", charge="100.00", state=ClaimState.SUBMITTED)
|
||||
_add_claim(s, claim_id="C-2", charge="200.00", state=ClaimState.PAID)
|
||||
s.commit()
|
||||
|
||||
resp = client.get("/api/dashboard/kpis")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
assert "totals" in body
|
||||
assert "monthly" in body
|
||||
assert "topProviders" in body
|
||||
assert "topDenials" in body
|
||||
assert body["totals"]["count"] == 2
|
||||
assert body["totals"]["billed"] == 300.0
|
||||
|
||||
|
||||
def test_dashboard_kpis_http_respects_query_params(client: TestClient):
|
||||
"""months / top_n_providers / top_n_denials clamp the response size."""
|
||||
with db.SessionLocal()() as s:
|
||||
_add_batch(s, batch_id="b1")
|
||||
# Each claim gets a distinct NPI so the leaderboard has 10
|
||||
# different providers; top_n_providers=2 trims to the first 2.
|
||||
for i in range(10):
|
||||
_add_claim(
|
||||
s,
|
||||
claim_id=f"C-{i}",
|
||||
charge="10.00",
|
||||
state=ClaimState.DENIED,
|
||||
provider_npi=f"123456789{i % 10}",
|
||||
)
|
||||
s.commit()
|
||||
|
||||
resp = client.get("/api/dashboard/kpis?months=3&top_n_providers=2&top_n_denials=4")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
assert len(body["monthly"]) == 3
|
||||
assert len(body["topProviders"]) == 2
|
||||
assert len(body["topDenials"]) == 4 # all 10 are denied; cap at 4
|
||||
|
||||
|
||||
def test_dashboard_kpis_http_rejects_bad_params(client: TestClient):
|
||||
"""months must be 1..24; top_n_* must be 0..50. 422 on violation."""
|
||||
resp = client.get("/api/dashboard/kpis?months=0")
|
||||
assert resp.status_code == 422
|
||||
resp = client.get("/api/dashboard/kpis?months=99")
|
||||
assert resp.status_code == 422
|
||||
resp = client.get("/api/dashboard/kpis?top_n_providers=-1")
|
||||
assert resp.status_code == 422
|
||||
Reference in New Issue
Block a user