feat(sp30): Dashboard Recent batches widget with billing outcome

- backend: GET /api/batches now returns acceptedCount/rejectedCount/
  pendingCount/billedTotal/topRejectionReason/hasProblem per item
  (one GROUP BY query + one ordered scan, no N+1)
- backend: 2 tests pin the 837p full-bucket case + the 835 zero case
- frontend: BatchSummary extended with 6 optional fields
  (backwards compat preserved)
- frontend: new RecentBatchesWidget renders one row per batch with
  status icon + billed total + accepted count + top rejection reason
- frontend: 837p rows show $ total + 'N/M accepted'; 835 rows show
  payment count (Remittance has no batch-level total_charge)
- frontend: full-width row between KPI tiles and Activity on the
  Dashboard; click navigates to /batches?batch=ID
- frontend: 3 component tests cover empty/clean/problem/835 branches
This commit is contained in:
Nora
2026-07-02 14:18:55 -06:00
parent bde3060e9e
commit 97512ec4a7
7 changed files with 661 additions and 0 deletions
+163
View File
@@ -1766,6 +1766,153 @@ def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
]
# SP30: state buckets the Dashboard widget (and any future "how the
# last batch billed" surface) reads at a glance. Keep these in sync
# with ClaimState — adding a new state here is a deliberate decision
# the operator needs to see, not a coincidence.
_BATCH_SUMMARY_ACCEPTED_STATES: tuple[ClaimState, ...] = (
ClaimState.PAID,
ClaimState.RECEIVED,
ClaimState.RECONCILED,
ClaimState.PARTIAL,
)
_BATCH_SUMMARY_REJECTED_STATES: tuple[ClaimState, ...] = (
ClaimState.REJECTED,
ClaimState.DENIED,
ClaimState.REVERSED,
)
_BATCH_SUMMARY_PENDING_STATES: tuple[ClaimState, ...] = (
ClaimState.SUBMITTED,
)
# 277CA STC category A4/A6/A7 — payer-side rejections that may not
# yet have flipped Claim.state (the operator hasn't acknowledged).
# The Dashboard widget treats these as problems too, mirroring the
# Inbox `rejected + payer_rejected` aggregation.
_BATCH_SUMMARY_PAYER_REJECT_CODES: tuple[str, ...] = ("A4", "A6", "A7")
def _batch_summary_billing_outcomes(
records: list[BatchRecord],
) -> dict[str, dict]:
"""Compute per-batch billing outcome for the Dashboard widget.
Returns ``{batch_id: {accepted, rejected, pending, billed,
top_rejection_reason, has_problem}}`` for every batch in
``records``. Empty input → empty dict.
Two SQL queries, both bounded by the supplied batch ids:
1. One GROUP BY ``(batch_id, state)`` aggregate that produces
the accepted/rejected/pending counts and the sum of
``charge_amount`` (the billed total). Single pass — no N+1.
2. One ordered scan over the rejected + payer-rejected subset
to pick the most recent rejection reason (truncated to 60
chars). Skipped when the first query found no rejections
and no payer-rejects, so the happy path stays at one query.
835 batches have no Claim rows — the GROUP BY returns no
rows for them, so the dict entry for an 835 batch is
``{accepted:0, rejected:0, pending:0, billed:0.0,
top_rejection_reason:None, has_problem:False}`` (filled by
the caller's ``.get(id, defaults)`` pattern).
"""
if not records:
return {}
from sqlalchemy import func # local import to keep top-of-file light
batch_ids = [r.id for r in records]
outcome: dict[str, dict] = {
bid: {
"accepted": 0,
"rejected": 0,
"pending": 0,
"billed": 0.0,
"top_rejection_reason": None,
"has_problem": False,
}
for bid in batch_ids
}
with db.SessionLocal()() as s:
# ---- 1. GROUP BY (batch_id, state) for counts + billed total ----
rows = (
s.query(
Claim.batch_id,
Claim.state,
func.count(Claim.id),
func.coalesce(func.sum(Claim.charge_amount), 0),
)
.filter(Claim.batch_id.in_(batch_ids))
.group_by(Claim.batch_id, Claim.state)
.all()
)
any_rejection_or_payer = False
for batch_id, state, count, billed in rows:
slot = outcome.get(batch_id)
if slot is None:
continue # batch has no row in our pre-allocated dict
count = int(count or 0)
billed_f = float(billed or 0)
slot["billed"] += billed_f
if state in _BATCH_SUMMARY_ACCEPTED_STATES:
slot["accepted"] += count
elif state in _BATCH_SUMMARY_REJECTED_STATES:
slot["rejected"] += count
any_rejection_or_payer = True
elif state in _BATCH_SUMMARY_PENDING_STATES:
slot["pending"] += count
# everything else (DRAFT, etc.) is excluded from the widget.
# ---- 2. Most-recent rejection reason + payer-reject probe ----
# Only run when we know there IS at least one rejection OR a
# payer-reject claim somewhere in the batch set; otherwise
# the first query alone is enough.
if any_rejection_or_payer:
rej_rows = (
s.query(
Claim.batch_id,
Claim.rejection_reason,
Claim.payer_rejected_status_code,
)
.filter(
Claim.batch_id.in_(batch_ids),
Claim.state.in_(_BATCH_SUMMARY_REJECTED_STATES)
| Claim.payer_rejected_status_code.in_(
_BATCH_SUMMARY_PAYER_REJECT_CODES
),
)
.order_by(Claim.rejected_at.desc().nullslast())
.all()
)
seen_reason: set[str] = set()
for batch_id, reason, payer_code in rej_rows:
slot = outcome.get(batch_id)
if slot is None:
continue
if payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES:
slot["has_problem"] = True
# Capture the first non-null reason for this batch
# (rej_rows is ordered newest-first, so the first
# non-null wins). Truncate to 60 chars + ellipsis.
if (
slot["top_rejection_reason"] is None
and reason
and batch_id not in seen_reason
):
r = reason.strip()
if len(r) > 60:
r = r[:60] + ""
slot["top_rejection_reason"] = r
seen_reason.add(batch_id)
if (
slot["rejected"] > 0
or payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES
):
slot["has_problem"] = True
return outcome
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
def list_batches(
request: Request,
@@ -1778,8 +1925,16 @@ def list_batches(
row without an extra round-trip to ``/api/batches/{id}``. The
list is still capped at ``limit`` claims; see the full result
via the by-id endpoint when more is needed.
SP30: also returns billing-outcome fields
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so
the Dashboard "Recent batches" widget can render one row per
batch without an N+1 fetch. See
:func:`_batch_summary_billing_outcomes`.
"""
records = store.list(limit=limit)
outcomes = _batch_summary_billing_outcomes(records)
items = [
{
"id": r.id,
@@ -1788,6 +1943,14 @@ def list_batches(
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
"claimCount": _batch_summary_claim_count(r),
"claimIds": _batch_summary_claim_ids(r),
"acceptedCount": outcomes.get(r.id, {}).get("accepted", 0),
"rejectedCount": outcomes.get(r.id, {}).get("rejected", 0),
"pendingCount": outcomes.get(r.id, {}).get("pending", 0),
"billedTotal": round(outcomes.get(r.id, {}).get("billed", 0.0), 2),
"topRejectionReason": outcomes.get(r.id, {}).get(
"top_rejection_reason"
),
"hasProblem": outcomes.get(r.id, {}).get("has_problem", False),
}
for r in records
]
+116
View File
@@ -101,6 +101,122 @@ def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
assert item["claimIds"] == []
# --------------------------------------------------------------------------- #
# SP30: /api/batches billing-outcome fields
# --------------------------------------------------------------------------- #
def _sp30_seed_billing_outcome_batch() -> None:
"""Insert one batch with 3 claims (paid / rejected / submitted)
via the ORM directly. Mirrors the helper pattern in
``test_dashboard_kpis.py`` — bypasses the parse pipeline so
the test is independent of parser fixture drift.
"""
from datetime import datetime, timezone
from decimal import Decimal
from cyclone.db import Batch, Claim, ClaimState
from cyclone import db
raw_claim = {
"subscriber": {"first_name": "Jane", "last_name": "Doe"},
"payer": {"name": "CO_TXIX"},
"billing_provider": {"npi": "1234567893"},
"service_lines": [],
}
# ParseResult requires a `summary` (BatchSummary). The list endpoint
# rehydrates Batch.raw_result_json through ParseResult.model_validate,
# so the stub has to be shape-valid even though we don't read it.
valid_raw_result = {
"claims": [],
"summary": {
"input_file": "out.edi",
"total_claims": 3,
"passed": 3,
"failed": 0,
},
}
with db.SessionLocal()() as s:
s.add(Batch(
id="b-sp30-out",
kind="837p",
input_filename="out.edi",
parsed_at=datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc),
totals_json={"total_claims": 3},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json=valid_raw_result,
))
s.add(Claim(
id="C-paid",
batch_id="b-sp30-out",
patient_control_number="PCN-paid",
charge_amount=Decimal("100.00"),
state=ClaimState.PAID,
raw_json=raw_claim,
))
s.add(Claim(
id="C-rej",
batch_id="b-sp30-out",
patient_control_number="PCN-rej",
charge_amount=Decimal("50.00"),
state=ClaimState.REJECTED,
rejection_reason="999 AK5 R",
raw_json=raw_claim,
))
s.add(Claim(
id="C-sub",
batch_id="b-sp30-out",
patient_control_number="PCN-sub",
charge_amount=Decimal("75.00"),
state=ClaimState.SUBMITTED,
raw_json=raw_claim,
))
s.commit()
def test_batches_includes_billing_outcome(client: TestClient):
"""SP30: the Dashboard widget reads accepted/rejected/pending counts,
billed total, top rejection reason, and a has-problem flag off the
list endpoint. One batch, three claims covering all three buckets."""
_sp30_seed_billing_outcome_batch()
resp = client.get("/api/batches", headers=JSON)
assert resp.status_code == 200
body = resp.json()
# We may have other batches from earlier tests (the `seeded_store`
# autouse isn't applied here); find the SP30 batch by id.
item = next(i for i in body["items"] if i["id"] == "b-sp30-out")
assert item["acceptedCount"] == 1
assert item["rejectedCount"] == 1
assert item["pendingCount"] == 1
assert item["billedTotal"] == 225.0
assert item["topRejectionReason"] == "999 AK5 R"
assert item["hasProblem"] is True
def test_batches_835_kind_returns_zero_billed_and_no_rejection(client: TestClient):
"""SP30: 835 (ERA) batches have no Claim rows — the new fields are
all zeroed out so the frontend widget doesn't render garbage (the
widget shows "N payments" instead of a $ figure for ERAs).
"""
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
r = client.post(
"/api/parse-835",
params={"payer": "co_medicaid_835"},
files=files,
headers=JSON,
)
assert r.status_code == 200, r.text
body = client.get("/api/batches", headers=JSON).json()
item = next(i for i in body["items"] if i["kind"] == "835")
assert item["acceptedCount"] == 0
assert item["rejectedCount"] == 0
assert item["pendingCount"] == 0
assert item["billedTotal"] == 0.0
assert item["topRejectionReason"] is None
assert item["hasProblem"] is False
# --------------------------------------------------------------------------- #
# /api/batches/{id}
# --------------------------------------------------------------------------- #