feat(api): GET /api/payers/{payer_id}/summary with 60s cache + pubsub invalidation
This commit is contained in:
@@ -22,6 +22,7 @@ import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import asynccontextmanager
|
||||
from time import monotonic
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||
@@ -2958,6 +2959,126 @@ def list_payer_configs(payer_id: str):
|
||||
return configs
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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")
|
||||
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
|
||||
|
||||
|
||||
@app.post("/api/admin/reload-config")
|
||||
def reload_config():
|
||||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||||
|
||||
Reference in New Issue
Block a user