feat(sp36): extract payers router
Move GET /api/payers/{payer_id}/summary from api.py to api_routers/payers.py, plus the in-process memo (constants _SUMMARY_TTL_S, _summary_cache and helper _clear_summary_cache). Behaviour-preserving — endpoint URL, params, response shape, 404-on-missing behavior, and the 60s cache TTL are unchanged.
Add a one-line backward-compat shim at the bottom of api.py that re-imports _clear_summary_cache from the new home. This keeps test_payer_summary.py working (its fixture calls api_mod._clear_summary_cache() between requests to wipe the cache) without modifying the test — SP36 explicitly forbids test edits for a structural refactor.
This commit is contained in:
+10
-123
@@ -27,7 +27,6 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from time import monotonic
|
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
|
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||||
@@ -3186,128 +3185,6 @@ def list_payer_configs(payer_id: str):
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# SP21 Task 1.5: payer-level summary for the drill-down panel
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill-
|
|
||||||
# down UI hammers this on every hover; the underlying query is O(claims)
|
|
||||||
# per payer so a 60s TTL keeps the panel responsive. Process-local on
|
|
||||||
# purpose — invalidation is driven by the TTL alone for now (see note
|
|
||||||
# below).
|
|
||||||
_SUMMARY_TTL_S = 60.0
|
|
||||||
_summary_cache: dict[str, tuple[float, dict]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_summary_cache() -> None:
|
|
||||||
"""Test hook: wipe the process-local cache.
|
|
||||||
|
|
||||||
The 60s TTL means tests that want to assert on a recomputed payload
|
|
||||||
must clear the cache between requests. Not used by production code.
|
|
||||||
"""
|
|
||||||
_summary_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# Pubsub invalidation is intentionally NOT wired. The ``claim_written``
|
|
||||||
# and ``remittance_written`` payloads emitted by ``store.add`` are the
|
|
||||||
# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``,
|
|
||||||
# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier).
|
|
||||||
# They carry ``payerName`` only, which is the human-readable name and not
|
|
||||||
# the URL key we cache on. Wiring a subscriber here would either need a
|
|
||||||
# DB lookup per event (re-deriving payer_id from Claim.id) or a different
|
|
||||||
# cache key — both are out of scope for this task. The 60s TTL keeps
|
|
||||||
# staleness bounded; a follow-up can wire targeted invalidation if the
|
|
||||||
# UI proves TTL-bounded staleness is unacceptable.
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/payers/{payer_id}/summary", dependencies=[Depends(matrix_gate)])
|
|
||||||
def get_payer_summary(payer_id: str):
|
|
||||||
"""Payer-level rollup for the drill-down panel.
|
|
||||||
|
|
||||||
Returns billed/received totals, denial rate, and top providers for
|
|
||||||
the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
|
|
||||||
Cached in-process for ``_SUMMARY_TTL_S`` seconds.
|
|
||||||
|
|
||||||
404 when no claims AND no remits reference this payer_id — the UI
|
|
||||||
uses that to distinguish a typo from a payer with zero traffic
|
|
||||||
(the latter would still return a valid 200 with zeroed totals).
|
|
||||||
"""
|
|
||||||
now = monotonic()
|
|
||||||
cached = _summary_cache.get(payer_id)
|
|
||||||
if cached and (now - cached[0]) < _SUMMARY_TTL_S:
|
|
||||||
return cached[1]
|
|
||||||
|
|
||||||
log.debug("payer summary cache miss", extra={"payer_id": payer_id})
|
|
||||||
|
|
||||||
# Query claims + remits scoped to this payer_id. We bypass
|
|
||||||
# ``store.iter_claims`` because that helper filters by payer NAME
|
|
||||||
# substring, not by the X12 PI qualifier we use as the URL key.
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
claim_rows: list[Claim] = (
|
|
||||||
s.query(Claim).filter(Claim.payer_id == payer_id).all()
|
|
||||||
)
|
|
||||||
claim_ids = [c.id for c in claim_rows]
|
|
||||||
remit_rows: list[Remittance] = []
|
|
||||||
if claim_ids:
|
|
||||||
remit_rows = (
|
|
||||||
s.query(Remittance)
|
|
||||||
.filter(Remittance.claim_id.in_(claim_ids))
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not claim_rows and not remit_rows:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"Payer {payer_id!r} not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
billed_total = sum(float(c.charge_amount or 0) for c in claim_rows)
|
|
||||||
received_total = sum(float(r.total_paid or 0) for r in remit_rows)
|
|
||||||
denied = sum(
|
|
||||||
1 for c in claim_rows if c.state == ClaimState.DENIED
|
|
||||||
)
|
|
||||||
claim_count = len(claim_rows)
|
|
||||||
denial_rate = (denied / claim_count) if claim_count else 0.0
|
|
||||||
|
|
||||||
provider_counts: dict[str, int] = {}
|
|
||||||
for c in claim_rows:
|
|
||||||
npi = c.provider_npi
|
|
||||||
if not npi:
|
|
||||||
continue
|
|
||||||
provider_counts[npi] = provider_counts.get(npi, 0) + 1
|
|
||||||
top_providers = [
|
|
||||||
{"npi": npi, "count": count}
|
|
||||||
for npi, count in sorted(
|
|
||||||
provider_counts.items(), key=lambda kv: -kv[1]
|
|
||||||
)[:5]
|
|
||||||
]
|
|
||||||
|
|
||||||
# Best-effort payer display name from the first claim's raw
|
|
||||||
# payer object (NM1*PR name element, e.g. "COHCPF"). Falls
|
|
||||||
# back to the id when no parsed envelope is available.
|
|
||||||
payer_name = payer_id
|
|
||||||
for c in claim_rows:
|
|
||||||
raw = c.raw_json or {}
|
|
||||||
p = raw.get("payer") if isinstance(raw, dict) else None
|
|
||||||
if isinstance(p, dict) and p.get("name"):
|
|
||||||
payer_name = p["name"]
|
|
||||||
break
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"payer_id": payer_id,
|
|
||||||
"name": payer_name,
|
|
||||||
"claim_count": claim_count,
|
|
||||||
"billed_total": billed_total,
|
|
||||||
"received_total": received_total,
|
|
||||||
"denial_rate": denial_rate,
|
|
||||||
"top_providers": top_providers,
|
|
||||||
}
|
|
||||||
_summary_cache[payer_id] = (now, payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Auth routers (login/logout/me + admin user management) #
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
from cyclone.auth.routes import router as auth_router
|
from cyclone.auth.routes import router as auth_router
|
||||||
@@ -3316,5 +3193,15 @@ from cyclone.auth.admin import router as admin_users_router
|
|||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])
|
app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
# SP36 Task 6 backward-compat: ``_clear_summary_cache`` moved to
|
||||||
|
# ``cyclone.api_routers.payers`` (it's a single-router helper per
|
||||||
|
# spec D4). The test fixture in ``tests/test_payer_summary.py``
|
||||||
|
# calls ``api_mod._clear_summary_cache()`` between requests to wipe
|
||||||
|
# the in-process cache. Per the SP-N invariant that we don't
|
||||||
|
# rewrite tests for a structural refactor, expose the helper on
|
||||||
|
# this module's namespace via a one-line re-import. Delete this
|
||||||
|
# line once the test is updated to import from the new home.
|
||||||
|
from cyclone.api_routers.payers import _clear_summary_cache # noqa: F401 # SP36 backward-compat shim
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["app"]
|
__all__ = ["app"]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from cyclone.api_routers import (
|
|||||||
dashboard,
|
dashboard,
|
||||||
eligibility,
|
eligibility,
|
||||||
health,
|
health,
|
||||||
|
payers,
|
||||||
ta1_acks,
|
ta1_acks,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ routers: list[APIRouter] = [
|
|||||||
dashboard.router, # gated
|
dashboard.router, # gated
|
||||||
eligibility.router, # gated
|
eligibility.router, # gated
|
||||||
health.router, # public — health probes must work pre-auth
|
health.router, # public — health probes must work pre-auth
|
||||||
|
payers.router, # gated
|
||||||
ta1_acks.router, # gated
|
ta1_acks.router, # gated
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""``GET /api/payers/{payer_id}/summary`` — payer-level rollup for the drill-down panel.
|
||||||
|
|
||||||
|
Returns billed/received totals, denial rate, and the top providers for
|
||||||
|
a given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
|
||||||
|
Cached in-process for ``_SUMMARY_TTL_S`` seconds via a per-payer_id
|
||||||
|
memo. The drill-down UI hammers this on every hover; the underlying
|
||||||
|
query is O(claims) per payer, so the TTL keeps the panel responsive.
|
||||||
|
|
||||||
|
Pubsub invalidation is intentionally NOT wired (see the long comment
|
||||||
|
in the body). The 60s TTL keeps staleness bounded; a follow-up can
|
||||||
|
wire targeted invalidation if the UI proves TTL-bounded staleness is
|
||||||
|
unacceptable.
|
||||||
|
|
||||||
|
404 when no claims AND no remits reference this payer_id — the UI
|
||||||
|
uses that to distinguish a typo from a payer with zero traffic (the
|
||||||
|
latter would still return a valid 200 with zeroed totals).
|
||||||
|
|
||||||
|
SP36 Task 6: this block moved here from ``api.py:3188`` (the
|
||||||
|
``SP21 Task 1.5: payer-level summary`` divider).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.auth.deps import matrix_gate
|
||||||
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill-
|
||||||
|
# down UI hammers this on every hover; the underlying query is O(claims)
|
||||||
|
# per payer so a 60s TTL keeps the panel responsive. Process-local on
|
||||||
|
# purpose — invalidation is driven by the TTL alone for now (see note
|
||||||
|
# below).
|
||||||
|
_SUMMARY_TTL_S = 60.0
|
||||||
|
_summary_cache: dict[str, tuple[float, dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_summary_cache() -> None:
|
||||||
|
"""Test hook: wipe the process-local cache.
|
||||||
|
|
||||||
|
The 60s TTL means tests that want to assert on a recomputed payload
|
||||||
|
must clear the cache between requests. Not used by production code.
|
||||||
|
"""
|
||||||
|
_summary_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Pubsub invalidation is intentionally NOT wired. The ``claim_written``
|
||||||
|
# and ``remittance_written`` payloads emitted by ``store.add`` are the
|
||||||
|
# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``,
|
||||||
|
# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier).
|
||||||
|
# They carry ``payerName`` only, which is the human-readable name and not
|
||||||
|
# the URL key we cache on. Wiring a subscriber here would either need a
|
||||||
|
# DB lookup per event (re-deriving payer_id from Claim.id) or a different
|
||||||
|
# cache key — both are out of scope for this task. The 60s TTL keeps
|
||||||
|
# staleness bounded; a follow-up can wire targeted invalidation if the
|
||||||
|
# UI proves TTL-bounded staleness is unacceptable.
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/payers/{payer_id}/summary")
|
||||||
|
def get_payer_summary(payer_id: str):
|
||||||
|
"""Payer-level rollup for the drill-down panel.
|
||||||
|
|
||||||
|
Returns billed/received totals, denial rate, and top providers for
|
||||||
|
the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
|
||||||
|
Cached in-process for ``_SUMMARY_TTL_S`` seconds.
|
||||||
|
|
||||||
|
404 when no claims AND no remits reference this payer_id — the UI
|
||||||
|
uses that to distinguish a typo from a payer with zero traffic
|
||||||
|
(the latter would still return a valid 200 with zeroed totals).
|
||||||
|
"""
|
||||||
|
now = monotonic()
|
||||||
|
cached = _summary_cache.get(payer_id)
|
||||||
|
if cached and (now - cached[0]) < _SUMMARY_TTL_S:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
log.debug("payer summary cache miss", extra={"payer_id": payer_id})
|
||||||
|
|
||||||
|
# Query claims + remits scoped to this payer_id. We bypass
|
||||||
|
# ``store.iter_claims`` because that helper filters by payer NAME
|
||||||
|
# substring, not by the X12 PI qualifier we use as the URL key.
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
claim_rows: list[Claim] = (
|
||||||
|
s.query(Claim).filter(Claim.payer_id == payer_id).all()
|
||||||
|
)
|
||||||
|
claim_ids = [c.id for c in claim_rows]
|
||||||
|
remit_rows: list[Remittance] = []
|
||||||
|
if claim_ids:
|
||||||
|
remit_rows = (
|
||||||
|
s.query(Remittance)
|
||||||
|
.filter(Remittance.claim_id.in_(claim_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not claim_rows and not remit_rows:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Payer {payer_id!r} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
billed_total = sum(float(c.charge_amount or 0) for c in claim_rows)
|
||||||
|
received_total = sum(float(r.total_paid or 0) for r in remit_rows)
|
||||||
|
denied = sum(
|
||||||
|
1 for c in claim_rows if c.state == ClaimState.DENIED
|
||||||
|
)
|
||||||
|
claim_count = len(claim_rows)
|
||||||
|
denial_rate = (denied / claim_count) if claim_count else 0.0
|
||||||
|
|
||||||
|
provider_counts: dict[str, int] = {}
|
||||||
|
for c in claim_rows:
|
||||||
|
npi = c.provider_npi
|
||||||
|
if not npi:
|
||||||
|
continue
|
||||||
|
provider_counts[npi] = provider_counts.get(npi, 0) + 1
|
||||||
|
top_providers = [
|
||||||
|
{"npi": npi, "count": count}
|
||||||
|
for npi, count in sorted(
|
||||||
|
provider_counts.items(), key=lambda kv: -kv[1]
|
||||||
|
)[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Best-effort payer display name from the first claim's raw
|
||||||
|
# payer object (NM1*PR name element, e.g. "COHCPF"). Falls
|
||||||
|
# back to the id when no parsed envelope is available.
|
||||||
|
payer_name = payer_id
|
||||||
|
for c in claim_rows:
|
||||||
|
raw = c.raw_json or {}
|
||||||
|
p = raw.get("payer") if isinstance(raw, dict) else None
|
||||||
|
if isinstance(p, dict) and p.get("name"):
|
||||||
|
payer_name = p["name"]
|
||||||
|
break
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"payer_id": payer_id,
|
||||||
|
"name": payer_name,
|
||||||
|
"claim_count": claim_count,
|
||||||
|
"billed_total": billed_total,
|
||||||
|
"received_total": received_total,
|
||||||
|
"denial_rate": denial_rate,
|
||||||
|
"top_providers": top_providers,
|
||||||
|
}
|
||||||
|
_summary_cache[payer_id] = (now, payload)
|
||||||
|
return payload
|
||||||
Reference in New Issue
Block a user