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
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import asynccontextmanager
|
||||
from time import monotonic
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
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
|
||||
@@ -3316,5 +3193,15 @@ from cyclone.auth.admin import router as admin_users_router
|
||||
app.include_router(auth_router)
|
||||
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"]
|
||||
|
||||
@@ -17,6 +17,7 @@ from cyclone.api_routers import (
|
||||
dashboard,
|
||||
eligibility,
|
||||
health,
|
||||
payers,
|
||||
ta1_acks,
|
||||
)
|
||||
|
||||
@@ -27,6 +28,7 @@ routers: list[APIRouter] = [
|
||||
dashboard.router, # gated
|
||||
eligibility.router, # gated
|
||||
health.router, # public — health probes must work pre-auth
|
||||
payers.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