Compare commits
3 Commits
fb913f0617
...
add6e982a4
| Author | SHA1 | Date | |
|---|---|---|---|
| add6e982a4 | |||
| 414d2eb722 | |||
| 7e4bb4d2c8 |
@@ -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
|
from cyclone.auth.cli import users_cli # noqa: E402
|
||||||
main.add_command(users_cli)
|
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")
|
@main.command("parse-837")
|
||||||
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -431,6 +431,7 @@ def to_ui_claim_from_orm(
|
|||||||
*,
|
*,
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
parsed_at: datetime,
|
parsed_at: datetime,
|
||||||
|
received_total: float = 0.0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Map an ORM ``Claim`` row to the UI's claim shape.
|
"""Map an ORM ``Claim`` row to the UI's claim shape.
|
||||||
|
|
||||||
@@ -476,7 +477,10 @@ def to_ui_claim_from_orm(
|
|||||||
"batchId": batch_id,
|
"batchId": batch_id,
|
||||||
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
|
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
|
||||||
# but expects these on freshly-loaded rows from /api/claims too.
|
# 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,
|
"denialReason": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,6 +1072,9 @@ class CycloneStore:
|
|||||||
ui = to_ui_claim_from_orm(
|
ui = to_ui_claim_from_orm(
|
||||||
row, batch_id=row.batch_id or record.id,
|
row, batch_id=row.batch_id or record.id,
|
||||||
parsed_at=record.parsed_at,
|
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)
|
self._sync_publish(event_bus, "claim_written", ui)
|
||||||
for rid in remit_ids:
|
for rid in remit_ids:
|
||||||
@@ -1488,6 +1495,24 @@ class CycloneStore:
|
|||||||
q = q.filter(Claim.provider_npi == provider_npi)
|
q = q.filter(Claim.provider_npi == provider_npi)
|
||||||
|
|
||||||
rows = q.all()
|
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] = []
|
out: list[dict] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
raw = r.raw_json or {}
|
raw = r.raw_json or {}
|
||||||
@@ -1516,7 +1541,9 @@ class CycloneStore:
|
|||||||
"payerName": payer_obj.get("name") or "",
|
"payerName": payer_obj.get("name") or "",
|
||||||
"cptCode": cpt,
|
"cptCode": cpt,
|
||||||
"billedAmount": float(r.charge_amount or 0),
|
"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),
|
"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),
|
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
|
||||||
"denialReason": None,
|
"denialReason": None,
|
||||||
@@ -1936,6 +1963,9 @@ class CycloneStore:
|
|||||||
result["claims"].append(
|
result["claims"].append(
|
||||||
to_ui_claim_from_orm(
|
to_ui_claim_from_orm(
|
||||||
r, batch_id=r.batch_id, parsed_at=parsed_at,
|
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_dict = to_ui_claim_from_orm(
|
||||||
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
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")
|
matched_at_iso = now.isoformat().replace("+00:00", "Z")
|
||||||
return {
|
return {
|
||||||
@@ -2119,9 +2150,11 @@ class CycloneStore:
|
|||||||
# rows exist. Shouldn't happen, but if it does, fall back
|
# rows exist. Shouldn't happen, but if it does, fall back
|
||||||
# to clearing the FK and starting fresh.
|
# to clearing the FK and starting fresh.
|
||||||
latest = None
|
latest = None
|
||||||
|
paired_remit = None
|
||||||
restored_state = ClaimState.SUBMITTED
|
restored_state = ClaimState.SUBMITTED
|
||||||
else:
|
else:
|
||||||
latest = matches[0]
|
latest = matches[0]
|
||||||
|
paired_remit = s.get(Remittance, latest.remittance_id)
|
||||||
restored_state = (
|
restored_state = (
|
||||||
latest.prior_claim_state
|
latest.prior_claim_state
|
||||||
if latest.prior_claim_state is not None
|
if latest.prior_claim_state is not None
|
||||||
@@ -2137,11 +2170,9 @@ class CycloneStore:
|
|||||||
# Clear the symmetric FK on the remittance so list_unmatched
|
# Clear the symmetric FK on the remittance so list_unmatched
|
||||||
# surfaces the pair again. The remittance may have been
|
# surfaces the pair again. The remittance may have been
|
||||||
# deleted between the match and this call — guard with a
|
# deleted between the match and this call — guard with a
|
||||||
# get() so we don't blow up on a stale FK.
|
# None check so we don't blow up on a stale FK.
|
||||||
if latest is not None:
|
if paired_remit is not None:
|
||||||
paired_remit = s.get(Remittance, latest.remittance_id)
|
paired_remit.claim_id = None
|
||||||
if paired_remit is not None:
|
|
||||||
paired_remit.claim_id = None
|
|
||||||
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
s.add(ActivityEvent(
|
s.add(ActivityEvent(
|
||||||
@@ -2161,8 +2192,19 @@ class CycloneStore:
|
|||||||
if claim.batch is not None
|
if claim.batch is not None
|
||||||
else now
|
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_dict = to_ui_claim_from_orm(
|
||||||
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
||||||
|
received_total=received_total,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"claim": claim_dict,
|
"claim": claim_dict,
|
||||||
|
|||||||
@@ -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
@@ -17,13 +17,16 @@ import { AnimatedNumber } from "@/components/AnimatedNumber";
|
|||||||
import { DrillableCell } from "@/components/drill/DrillableCell";
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { eventKindToUrl } from "@/lib/event-routing";
|
import { eventKindToUrl } from "@/lib/event-routing";
|
||||||
import { useAppStore } from "@/store";
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const MONTHS_BACK = 6;
|
const MONTHS_BACK = 6;
|
||||||
|
|
||||||
function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"]) {
|
function buildMonthly(claims: Claim[]) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const months: {
|
const months: {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -71,9 +74,16 @@ function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"])
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const claims = useAppStore((s) => s.claims);
|
// Live data: hooks fetch from /api/* when api.isConfigured; otherwise
|
||||||
const providers = useAppStore((s) => s.providers);
|
// they fall back to the in-memory store. Pulling from the hooks (not
|
||||||
const activity = useAppStore((s) => s.activity);
|
// 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();
|
const navigate = useNavigate();
|
||||||
// Time-of-day greeting + live operator name from the auth context.
|
// Time-of-day greeting + live operator name from the auth context.
|
||||||
|
|||||||
Reference in New Issue
Block a user