3 Commits

Author SHA1 Message Date
Nora add6e982a4 Populate receivedAmount from matched Remittance.total_paid
Before this change, every claim — matched or not — came back from
`/api/claims` with `receivedAmount: 0.0`. `to_ui_claim_from_orm`
hardcoded the value, so the Dashboard's 'Received' KPI and the
claim detail drawer's paid amount always read $0 regardless of
whether the claim had been paired with a paying remittance.

The fix threads the real value through the read path:

- `to_ui_claim_from_orm` accepts a new `received_total: float`
  kwarg (default 0.0 for callers that don't have a remittance in
  scope).
- `iter_claims` bulk-loads `Remittance.total_paid` for every
  matched claim id in the result set (single SQL roundtrip via
  `IN` filter — no N+1) and stamps the sum onto each claim dict.
- `manual_match` passes `float(remit.total_paid)` from the remit
  it's already holding.
- `manual_unmatch` reads `paired_remit.total_paid` before clearing
  the FK so the response still reports what was paid pre-unpair.
- `add` (write path) and `list_unmatched` (filters matched-remit
  is NULL) pass `0.0` explicitly for readability.

The dev seed CLI now also generates matched Remittance rows for
every PAID/PARTIAL claim — `matched_remittance_id` is set on the
claim, and the remittance's `total_paid` is derived from the
billed amount using the same ratios the frontend's old sample
fixtures used (60–100% for PAID, 20–50% for PARTIAL). That gives
the Dashboard a non-zero 'Received' KPI on a fresh dev DB.

New tests:
- tests/test_iter_claims_received.py — 4 cases covering matched,
  unmatched, orphan FK, and bulk-load paths. Catches regressions
  if anyone re-introduces the hardcoded 0.0 or breaks the bulk
  query.
2026-06-22 17:46:47 -06:00
Nora 414d2eb722 Add cyclone seed CLI subcommand for dev DB population
Now that Dashboard reads live API hooks instead of the in-memory
sample store, a fresh dev DB renders as $0 KPIs and 'No activity
yet.' for a long time. `python -m cyclone seed` inserts a
deterministic batch of 96 claims + 28 activity events so the
Dashboard / Claims / Activity Log pages have something to render
without going through the Inbox / EDI parser pipeline.

  python -m cyclone seed           # insert (no-op if seeded)
  python -m cyclone seed --reset   # wipe + re-insert
  python -m cyclone seed --count 10
  python -m cyclone seed --status  # counts only

The seed mirrors the frontend `sampleData.ts` fixtures (3 TX
providers, 7 payers, 8 CPTs, 96 claims spread across the last 200
days) so dev dashboards look identical to the previous in-memory
fixture mode. `raw_json` and `payload_json` are populated in the
shape that `store.to_ui_claim_from_orm` / `recent_activity` parse
back into the UI wire format — wire parity, not shortcuts.

`--reset` cleans by id prefix (`SEED-` / `CLM-S`) rather than
relying on SQLite's `ON DELETE CASCADE`, which the dev session
doesn't enforce (no `PRAGMA foreign_keys = ON` at the SQLAlchemy
session layer). Safer than silent orphans.

Verified end-to-end: 986 backend tests pass, dashboard renders
$75,833 / 96 claims / 11.5% denial rate / 10 activity events /
top-3 providers ranked by claim count.
2026-06-22 17:30:39 -06:00
Nora 7e4bb4d2c8 Wire Dashboard to live API hooks
Dashboard.tsx was reading claims/providers/activity directly from the
zustand `useAppStore`, which returns the hardcoded sample fixtures
(sampleClaims / sampleProviders / sampleActivity) regardless of whether
a backend session is active. The hooks useClaims / useProviders /
useActivity are wired correctly to /api/* when api.isConfigured, so
the fix is to swap the three direct store reads for the hooks.

buildMonthly's parameter is now typed as Claim[] (the live Claim
shape) instead of the store's heterogeneous slice, and the
`useAppStore` import is gone.

Verified end-to-end: after logging in as admin via the live server,
the Dashboard now shows $0 KPIs, "0 events / No activity yet.",
and the live operator name in the greeting — proving the hooks are
hitting /api/* and not falling back to the sample store.
2026-06-22 17:20:48 -06:00
5 changed files with 636 additions and 12 deletions
+6
View File
@@ -79,6 +79,12 @@ def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> N
from cyclone.auth.cli import users_cli # noqa: E402
main.add_command(users_cli)
# Register the dev seed subcommand. Imported here for the same lazy-load
# reason as users_cli — keeps passlib/bcrypt + SQLAlchemy out of the
# parse-only path so ``python -m cyclone --help`` stays snappy.
from cyclone.seed_cli import seed_cli # noqa: E402
main.add_command(seed_cli)
@main.command("parse-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
+432
View File
@@ -0,0 +1,432 @@
"""CLI subcommand: ``python -m cyclone seed``.
Populates the local DB with a deterministic batch of sample claims
plus matching activity events, so the Dashboard / Claims / Activity
Log pages have something to render in a fresh dev environment.
This is a dev-only convenience — the production DB never sees this
command (no ``[env: dev]`` gate, but it's never wired into any
container image). It writes rows with a recognizable batch id prefix
(``SEED-``) so ``--reset`` can clean them up without touching real
ingested data.
Why the data shape is hand-rolled here
--------------------------------------
The ``Claim`` ORM table stores its billing_provider / payer /
subscriber / service_lines payloads in a ``raw_json`` blob that the
read path (``store.to_ui_claim_from_orm``) parses back into the UI
shape. Mirroring the 837 parser's structure keeps the UI rendering
identical to a real ingestion — wire format parity, not shortcuts.
Activity rows are similar: ``payload_json`` carries the message
string the Dashboard's activity card shows.
Subcommands
-----------
``python -m cyclone seed`` — insert the default batch.
``python -m cyclone seed --count N`` — insert N claims (default 96).
``python -m cyclone seed --reset`` — wipe previously-seeded rows
first, then insert a fresh batch.
``python -m cyclone seed --status`` — print row counts without
inserting anything.
Exit codes
----------
* 0 — success (or already seeded and not asked to reset).
* 1 — DB error during insert.
* 2 — usage error (bad flag value).
The command is idempotent: re-running without ``--reset`` is a no-op
once a seed batch exists. This keeps ``cyclone``-driven boot scripts
safe to re-run.
"""
from __future__ import annotations
import json
import random
import sys
from datetime import datetime, timedelta, timezone
import click
from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance, SessionLocal
SEED_BATCH_PREFIX = "SEED-"
SEED_CLAIM_PREFIX = "CLM-S"
SEED_REMIT_PREFIX = "REM-S"
SEED_DEFAULT_COUNT = 96
SEED_ACTIVITY_LIMIT = 28
# Mirror src/data/sampleData.ts on the frontend so the Dashboard looks
# the same in dev mode as it did with the in-memory fixtures.
SAMPLE_PROVIDERS = [
{
"npi": "1730187395",
"name": "Cedar Park Family Medicine",
"tax_id": "47-3829104",
"address": "1401 Medical Pkwy",
"city": "Cedar Park",
"state": "TX",
"zip": "78613",
"phone": "(512) 555-0142",
},
{
"npi": "1528471902",
"name": "Lakeside Orthopedics",
"tax_id": "83-1172654",
"address": "900 W Lake Dr",
"city": "Austin",
"state": "TX",
"zip": "78746",
"phone": "(512) 555-0188",
},
{
"npi": "1982036471",
"name": "Hill Country Pediatrics",
"tax_id": "74-5520183",
"address": "205 State Hwy 27",
"city": "Marble Falls",
"state": "TX",
"zip": "78654",
"phone": "(830) 555-0117",
},
]
SAMPLE_PAYERS = [
"Blue Cross Blue Shield",
"United Healthcare",
"Aetna",
"Cigna",
"Humana",
"Medicare",
"Medicaid TX",
]
SAMPLE_CPTS = ["99213", "99214", "99203", "93000", "85025", "80053", "73721", "20610"]
SAMPLE_FIRST_NAMES = [
"Avery", "Jordan", "Riley", "Casey", "Morgan",
"Quinn", "Reese", "Sasha", "Drew", "Hayden",
]
SAMPLE_LAST_NAMES = [
"Nguyen", "Patel", "Garcia", "Cohen", "Okafor",
"Martinez", "Hwang", "Brooks", "Singh", "Tanaka",
]
# Frontend uses "accepted"/"pending" which the backend ClaimState
# enum doesn't carry. Map them to the closest real states so the
# Dashboard's filters (e.g. "status === 'submitted' || 'pending'")
# still find a match for in-flight work.
STATUS_WEIGHTS: list[tuple[ClaimState, int]] = [
(ClaimState.SUBMITTED, 1),
(ClaimState.RECEIVED, 1),
(ClaimState.DENIED, 1),
(ClaimState.PAID, 3),
(ClaimState.PAID, 3),
(ClaimState.PARTIAL, 1),
]
DENIAL_REASONS = [
"CO-97: Service included in another service",
"CO-16: Claim lacks information",
"CO-50: Non-covered service",
"PR-1: Deductible amount",
]
def _now_utc() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _pick(rng: random.Random, items):
return items[rng.randint(0, len(items) - 1)]
def _build_seed_rows(
count: int, *, seed: int = 42,
) -> tuple[Batch, list[Claim], list[ActivityEvent], list[Remittance]]:
"""Build a deterministic Batch + N Claims + matching activity events.
PAID and PARTIAL claims get a paired ``Remittance`` row (with a
realistic ``total_paid`` derived from the billed amount) so the
Dashboard's "Received" KPI lights up; the wire shape mirrors what
``CycloneStore.iter_claims`` expects to find via
``Claim.matched_remittance_id`` → ``Remittance.total_paid``.
The seed is fixed so re-running produces identical data — keeps
screenshots and dev environments stable.
"""
rng = random.Random(seed)
now = _now_utc()
parsed_at = now - timedelta(minutes=5)
batch = Batch(
id=f"{SEED_BATCH_PREFIX}{now.strftime('%Y%m%d-%H%M%S')}",
kind="837",
input_filename="seed/sample-837.edi",
parsed_at=parsed_at,
totals_json={"claim_count": count},
validation_json={"errors": [], "warnings": []},
raw_result_json={},
)
claims: list[Claim] = []
activity: list[ActivityEvent] = []
remittances: list[Remittance] = []
seq = 10428 # Match the frontend fixture's id range
for i in range(count):
provider = _pick(rng, SAMPLE_PROVIDERS)
payer = _pick(rng, SAMPLE_PAYERS)
cpt = _pick(rng, SAMPLE_CPTS)
first = _pick(rng, SAMPLE_FIRST_NAMES)
last = _pick(rng, SAMPLE_LAST_NAMES)
state, _ = _pick(rng, STATUS_WEIGHTS)
days_back = rng.randint(0, 200)
submitted = now - timedelta(days=days_back, hours=rng.randint(0, 23))
billed = 80 + rng.randint(0, 1400)
if state == ClaimState.PAID:
received = int(billed * (0.6 + rng.random() * 0.4))
elif state == ClaimState.PARTIAL:
received = int(billed * (0.2 + rng.random() * 0.3))
elif state == ClaimState.DENIED:
received = 0
else:
received = 0
claim_id = f"{SEED_CLAIM_PREFIX}{(seq + i):05d}"
denial_reason = _pick(rng, DENIAL_REASONS) if state == ClaimState.DENIED else None
# Build a paired Remittance for any claim with received > 0. Status
# code 1 = "Primary payer forward" — the 835 CAS code that the
# remittance mapper turns into "received". Mirror the 835 parser's
# raw_json shape (a stripped provider/payer/service_lines block)
# so downstream debug views still render something useful.
matched_remit_id: str | None = None
if received > 0:
matched_remit_id = f"{SEED_REMIT_PREFIX}{(seq + i):05d}"
remittances.append(Remittance(
id=matched_remit_id,
batch_id=batch.id,
payer_claim_control_number=f"PCN-{(seq + i):05d}",
claim_id=claim_id,
status_code="1",
status_label="Primary payer forward",
total_charge=float(billed),
total_paid=float(received),
adjustment_amount=float(billed - received),
received_at=submitted + timedelta(days=rng.randint(2, 14)),
raw_json={
"payer": {"name": payer},
"provider": {
"npi": provider["npi"],
"name": provider["name"],
},
"service_lines": [
{
"procedure": {"code": cpt},
"charge_amount": float(billed),
"paid_amount": float(received),
}
],
},
))
raw_json = {
"billing_provider": {
"npi": provider["npi"],
"name": provider["name"],
"tax_id": provider["tax_id"],
"address": provider["address"],
"city": provider["city"],
"state": provider["state"],
"zip": provider["zip"],
"phone": provider["phone"],
},
"payer": {"name": payer},
"subscriber": {"first_name": first, "last_name": last},
"service_lines": [
{
"procedure": {"code": cpt},
"charge_amount": float(billed),
"service_date": submitted.date().isoformat(),
}
],
}
claims.append(Claim(
id=claim_id,
batch_id=batch.id,
patient_control_number=claim_id.replace(SEED_CLAIM_PREFIX, "PCN-"),
service_date_from=submitted.date(),
service_date_to=submitted.date(),
charge_amount=billed,
provider_npi=provider["npi"],
payer_id=None,
state=state,
state_changed_at=submitted,
rejection_reason=denial_reason,
resubmit_count=0,
matched_remittance_id=matched_remit_id,
raw_json=raw_json,
))
# First SEED_ACTIVITY_LIMIT claims get an activity event. Mirrors
# the frontend buildActivity() shape (claim_paid / claim_denied /
# claim_accepted / claim_submitted) but maps frontend statuses
# onto the backend's wire enum.
if i < SEED_ACTIVITY_LIMIT:
if state == ClaimState.PAID:
kind = "claim_paid"
verb = "Paid"
elif state == ClaimState.DENIED:
kind = "claim_denied"
verb = "Denied"
elif state in (ClaimState.RECEIVED, ClaimState.PARTIAL):
kind = "claim_accepted"
verb = "Accepted"
else:
kind = "claim_submitted"
verb = "Submitted"
payload = {
"message": f"{verb} {claim_id} · {first} {last}",
"npi": provider["npi"],
"amount": float(billed),
}
activity.append(ActivityEvent(
ts=submitted,
kind=kind,
batch_id=batch.id,
claim_id=claim_id,
remittance_id=None,
payload_json=payload,
))
return batch, claims, activity, remittances
def _existing_seed_batch_ids(s) -> list[str]:
rows = s.query(Batch.id).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).all()
return [r[0] for r in rows]
def _delete_seed_rows(s) -> int:
"""Delete every row that was inserted by the seed.
The seed writes rows whose ids begin with ``SEED-`` (batches),
``CLM-S`` (claims), or ``REM-S`` (remittances). Activity events
are joined to seeded batches when the batch is still around, but
we also clean up orphan activity rows (no FK from
``activity_events.claim_id`` to ``claims.id``) by ``claim_id``
prefix. Returns the number of batch rows deleted.
"""
# Activity first — its rows reference both claims and batches, so
# delete the events tied to seed claims first, then any that were
# only tied to a now-orphaned seed batch.
activity = s.query(ActivityEvent).filter(
ActivityEvent.claim_id.like(f"{SEED_CLAIM_PREFIX}%")
).delete(synchronize_session=False)
activity2 = s.query(ActivityEvent).filter(
ActivityEvent.batch_id.like(f"{SEED_BATCH_PREFIX}%")
).delete(synchronize_session=False)
# Remittances (FK to claims; cascade may not fire without FK pragma,
# so we delete them explicitly to clear the symmetric FK first).
remits = s.query(Remittance).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).delete(synchronize_session=False)
claims = s.query(Claim).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).delete(synchronize_session=False)
batches = s.query(Batch).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).delete(synchronize_session=False)
s.commit()
click.echo(f" Removed {batches} seeded batch(es), {claims} claim(s), "
f"{remits} remittance(s), {activity + activity2} activity event(s).")
return batches
def _insert(
batch: Batch,
claims: list[Claim],
activity: list[ActivityEvent],
remittances: list[Remittance],
) -> None:
with SessionLocal()() as s:
s.add(batch)
s.add_all(claims)
s.add_all(activity)
s.add_all(remittances)
s.commit()
def _print_status(s) -> None:
from sqlalchemy import func
seed_batches = s.query(func.count(Batch.id)).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).scalar() or 0
seed_claims = s.query(func.count(Claim.id)).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).scalar() or 0
seed_remits = s.query(func.count(Remittance.id)).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).scalar() or 0
total_claims = s.query(func.count(Claim.id)).scalar() or 0
total_remits = s.query(func.count(Remittance.id)).scalar() or 0
total_activity = s.query(func.count(ActivityEvent.id)).scalar() or 0
click.echo(f" Seed batches: {seed_batches}")
click.echo(f" Seed claims: {seed_claims}")
click.echo(f" Seed remits: {seed_remits}")
click.echo(f" Total claims: {total_claims}")
click.echo(f" Total remits: {total_remits}")
click.echo(f" Total activity: {total_activity}")
@click.command("seed")
@click.option(
"--count",
default=SEED_DEFAULT_COUNT,
show_default=True,
type=click.IntRange(min=1, max=10_000),
help="Number of claims to insert in the new batch.",
)
@click.option(
"--reset",
is_flag=True,
help="Delete any existing seeded batch (and its claims/activity) before inserting.",
)
@click.option(
"--status",
"show_status",
is_flag=True,
help="Print current seeded row counts and exit.",
)
def seed_cli(count: int, reset: bool, show_status: bool) -> None:
"""Populate the local DB with a deterministic batch of sample claims."""
with SessionLocal()() as s:
if show_status:
_print_status(s)
return
existing = _existing_seed_batch_ids(s)
if existing and not reset:
click.echo(
f"Seed already present (batch ids: {', '.join(existing)}). "
f"Re-run with --reset to replace, or --status to inspect.",
err=True,
)
sys.exit(0)
if reset and existing:
deleted = _delete_seed_rows(s)
click.echo(f" Removed {deleted} seeded batch(es).")
try:
batch, claims, activity, remittances = _build_seed_rows(count)
except Exception as exc:
click.echo(f"Failed to build seed rows: {exc}", err=True)
sys.exit(1)
try:
_insert(batch, claims, activity, remittances)
except Exception as exc:
click.echo(f"Failed to insert seed rows: {exc}", err=True)
sys.exit(1)
click.echo(f" Inserted batch {batch.id} with {len(claims)} claims, "
f"{len(remittances)} remittances, {len(activity)} activity events.")
_print_status(s)
+49 -7
View File
@@ -431,6 +431,7 @@ def to_ui_claim_from_orm(
*,
batch_id: str,
parsed_at: datetime,
received_total: float = 0.0,
) -> dict:
"""Map an ORM ``Claim`` row to the UI's claim shape.
@@ -476,7 +477,10 @@ def to_ui_claim_from_orm(
"batchId": batch_id,
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
# but expects these on freshly-loaded rows from /api/claims too.
"receivedAmount": 0.0,
# ``received_total`` comes from the matched Remittance row when one
# exists; callers that don't pre-compute it (write path, unmatched
# list) get the default of 0.0 — which matches the unmapped state.
"receivedAmount": float(received_total),
"denialReason": None,
}
@@ -1068,6 +1072,9 @@ class CycloneStore:
ui = to_ui_claim_from_orm(
row, batch_id=row.batch_id or record.id,
parsed_at=record.parsed_at,
# Fresh ingest — no remittance has been paired yet,
# so ``Received`` is necessarily 0.
received_total=0.0,
)
self._sync_publish(event_bus, "claim_written", ui)
for rid in remit_ids:
@@ -1488,6 +1495,24 @@ class CycloneStore:
q = q.filter(Claim.provider_npi == provider_npi)
rows = q.all()
# Bulk-load matched-remittance totals so the UI's "Received"
# KPI + per-claim received_amount reflect real paid amounts
# rather than always-0. One SQL roundtrip for the whole page
# rather than per-claim lookups.
matched_ids = [
r.matched_remittance_id
for r in rows
if r.matched_remittance_id
]
received_by_remit: dict[str, float] = {}
if matched_ids:
for rid, total_paid in (
s.query(Remittance.id, Remittance.total_paid)
.filter(Remittance.id.in_(matched_ids))
.all()
):
received_by_remit[rid] = float(total_paid or 0)
out: list[dict] = []
for r in rows:
raw = r.raw_json or {}
@@ -1516,7 +1541,9 @@ class CycloneStore:
"payerName": payer_obj.get("name") or "",
"cptCode": cpt,
"billedAmount": float(r.charge_amount or 0),
"receivedAmount": 0.0,
"receivedAmount": received_by_remit.get(
r.matched_remittance_id, 0.0
),
"status": r.state.value if hasattr(r.state, "value") else str(r.state),
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
"denialReason": None,
@@ -1936,6 +1963,9 @@ class CycloneStore:
result["claims"].append(
to_ui_claim_from_orm(
r, batch_id=r.batch_id, parsed_at=parsed_at,
# list_unmatched filters matched_remittance_id IS NULL,
# so every row has no remittance yet.
received_total=0.0,
)
)
@@ -2063,6 +2093,7 @@ class CycloneStore:
)
claim_dict = to_ui_claim_from_orm(
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
received_total=float(remit.total_paid or 0),
)
matched_at_iso = now.isoformat().replace("+00:00", "Z")
return {
@@ -2119,9 +2150,11 @@ class CycloneStore:
# rows exist. Shouldn't happen, but if it does, fall back
# to clearing the FK and starting fresh.
latest = None
paired_remit = None
restored_state = ClaimState.SUBMITTED
else:
latest = matches[0]
paired_remit = s.get(Remittance, latest.remittance_id)
restored_state = (
latest.prior_claim_state
if latest.prior_claim_state is not None
@@ -2137,11 +2170,9 @@ class CycloneStore:
# Clear the symmetric FK on the remittance so list_unmatched
# surfaces the pair again. The remittance may have been
# deleted between the match and this call — guard with a
# get() so we don't blow up on a stale FK.
if latest is not None:
paired_remit = s.get(Remittance, latest.remittance_id)
if paired_remit is not None:
paired_remit.claim_id = None
# None check so we don't blow up on a stale FK.
if paired_remit is not None:
paired_remit.claim_id = None
now = utcnow()
s.add(ActivityEvent(
@@ -2161,8 +2192,19 @@ class CycloneStore:
if claim.batch is not None
else now
)
# ``paired_remit`` is the matched remittance we cleared in
# the unmatch; use its ``total_paid`` for the response shape
# so the UI sees what was paid before the unpair. May be
# ``None`` if the remittance was deleted since the match —
# default to 0.0 in that case.
received_total = (
float(paired_remit.total_paid or 0)
if paired_remit is not None
else 0.0
)
claim_dict = to_ui_claim_from_orm(
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
received_total=received_total,
)
return {
"claim": claim_dict,
+134
View File
@@ -0,0 +1,134 @@
"""Tests that ``CycloneStore.iter_claims`` populates ``receivedAmount``
from the matched ``Remittance.total_paid``.
Before SP_Auth follow-up: every claim came back with
``receivedAmount: 0.0`` regardless of whether it had been paired with
a paid remittance, so the Dashboard's "Received" KPI was always $0
even when claims had been paid and reconciled.
The fix: ``iter_claims`` bulk-loads ``Remittance.total_paid`` for every
matched claim id in the result set (single SQL, no N+1) and stamps
the sum onto each claim dict.
"""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import db
from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance
from cyclone.store import store as global_store
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
def _make_batch(s, batch_id: str) -> None:
s.add(Batch(
id=batch_id,
kind="837p",
input_filename="seed.edi",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
totals_json={"total_claims": 3},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"_": "stub"},
))
def _make_claim(s, claim_id: str, batch_id: str, *, matched_remit_id: str | None = None) -> None:
s.add(Claim(
id=claim_id,
batch_id=batch_id,
patient_control_number=claim_id,
service_date_from=date(2026, 6, 1),
service_date_to=date(2026, 6, 1),
charge_amount=Decimal("200.00"),
provider_npi="1234567890",
payer_id="SKCO0",
state=ClaimState.PAID,
matched_remittance_id=matched_remit_id,
))
def _make_remit(s, remit_id: str, batch_id: str, *, total_paid: Decimal) -> None:
s.add(Remittance(
id=remit_id,
batch_id=batch_id,
payer_claim_control_number=remit_id,
claim_id=None,
status_code="1",
total_charge=Decimal("200.00"),
total_paid=total_paid,
adjustment_amount=Decimal("0"),
received_at=datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc),
))
def test_iter_claims_populates_received_amount_from_matched_remittance():
"""A matched claim should reflect its remittance's ``total_paid``."""
with db.SessionLocal()() as s:
_make_batch(s, "b1")
_make_remit(s, "r1", "b1", total_paid=Decimal("180.00"))
_make_claim(s, "CLM-A", "b1", matched_remit_id="r1")
s.commit()
items = global_store.iter_claims(limit=10)
by_id = {c["id"]: c for c in items}
assert by_id["CLM-A"]["receivedAmount"] == pytest.approx(180.00)
def test_iter_claims_unmatched_claim_has_zero_received():
"""Claims with no matched remittance still get 0.0, not stale data."""
with db.SessionLocal()() as s:
_make_batch(s, "b1")
_make_claim(s, "CLM-U", "b1", matched_remit_id=None)
s.commit()
items = global_store.iter_claims(limit=10)
by_id = {c["id"]: c for c in items}
assert by_id["CLM-U"]["receivedAmount"] == 0.0
def test_iter_claims_handles_orphan_match_fk():
"""A claim with a stale ``matched_remittance_id`` whose remittance row
was deleted should default to 0.0 rather than blow up."""
with db.SessionLocal()() as s:
_make_batch(s, "b1")
_make_claim(s, "CLM-ORPHAN", "b1", matched_remit_id="r-deleted")
s.commit()
# No remittance row exists — the FK is dangling, which can happen
# if the remittance was deleted between match and now.
items = global_store.iter_claims(limit=10)
by_id = {c["id"]: c for c in items}
assert by_id["CLM-ORPHAN"]["receivedAmount"] == 0.0
def test_iter_claims_bulk_loads_multiple_matches_in_one_pass():
"""All matched claims in a page reflect their distinct remittance
totals — the bulk load must aggregate per remittance id."""
with db.SessionLocal()() as s:
_make_batch(s, "b1")
_make_remit(s, "r1", "b1", total_paid=Decimal("120.00"))
_make_remit(s, "r2", "b1", total_paid=Decimal("175.50"))
_make_remit(s, "r3", "b1", total_paid=Decimal("0"))
_make_claim(s, "CLM-1", "b1", matched_remit_id="r1")
_make_claim(s, "CLM-2", "b1", matched_remit_id="r2")
_make_claim(s, "CLM-3", "b1", matched_remit_id="r3")
_make_claim(s, "CLM-4", "b1", matched_remit_id=None)
s.commit()
items = global_store.iter_claims(limit=10)
by_id = {c["id"]: c for c in items}
assert by_id["CLM-1"]["receivedAmount"] == pytest.approx(120.00)
assert by_id["CLM-2"]["receivedAmount"] == pytest.approx(175.50)
assert by_id["CLM-3"]["receivedAmount"] == pytest.approx(0.0)
assert by_id["CLM-4"]["receivedAmount"] == 0.0
+15 -5
View File
@@ -17,13 +17,16 @@ import { AnimatedNumber } from "@/components/AnimatedNumber";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { fmt } from "@/lib/format";
import { eventKindToUrl } from "@/lib/event-routing";
import { useAppStore } from "@/store";
import { useAuth } from "@/auth/useAuth";
import { useClaims } from "@/hooks/useClaims";
import { useProviders } from "@/hooks/useProviders";
import { useActivity } from "@/hooks/useActivity";
import type { Claim } from "@/types";
import { toast } from "sonner";
const MONTHS_BACK = 6;
function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"]) {
function buildMonthly(claims: Claim[]) {
const now = new Date();
const months: {
key: string;
@@ -71,9 +74,16 @@ function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"])
}
export function Dashboard() {
const claims = useAppStore((s) => s.claims);
const providers = useAppStore((s) => s.providers);
const activity = useAppStore((s) => s.activity);
// Live data: hooks fetch from /api/* when api.isConfigured; otherwise
// they fall back to the in-memory store. Pulling from the hooks (not
// the store directly) is what wires the Dashboard to the backend.
const claimsQuery = useClaims({ limit: 100 });
const providersQuery = useProviders();
const activityQuery = useActivity({ limit: 10 });
const claims = claimsQuery.data?.items ?? [];
const providers = providersQuery.data?.items ?? [];
const activity = activityQuery.data?.items ?? [];
const navigate = useNavigate();
// Time-of-day greeting + live operator name from the auth context.