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
+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}
# --------------------------------------------------------------------------- #