feat(backend): SQLAlchemy-backed CycloneStore (T9)
Replace the in-memory InMemoryStore with a SQLAlchemy-backed CycloneStore that persists Batch, Claim, Remittance, Match, and ActivityEvent rows to a configurable DB engine (sqlite by default, overridable via CYCLONE_DB_URL). Public API preserved: add / get_batch / iter_claims / iter_remittances / distinct_providers / recent_activity. New T10/T12 stubs: list_unmatched, manual_match, manual_unmatch. Backward-compat shims for tests that called ._batches.clear() or acquired ._lock as a context manager. Idempotency: add() does a per-row session.get(Claim, c.id) (and s.get(Remittance, ...)) check before each insert; duplicates are skipped with a warning. This makes re-uploading the same fixture idempotent instead of raising IntegrityError on the PK. Auto-init DB fixture (backend/tests/conftest.py) sets CYCLONE_DB_URL to a per-test sqlite file and calls db.init_db() once per test, replacing the old module-scoped in-memory fixture.
This commit is contained in:
+638
-221
@@ -1,19 +1,51 @@
|
|||||||
"""In-memory batch store for parsed X12 files.
|
"""SQLAlchemy-backed batch store for parsed X12 files.
|
||||||
|
|
||||||
Lives at module scope so route handlers and tests can import a single
|
The module exposes a single ``CycloneStore`` class and a module-level
|
||||||
singleton. Single-process only — no cross-process coordination. Threading:
|
singleton (``store``). All persistence flows through SQLAlchemy
|
||||||
a re-entrant lock guards every public method so FastAPI's threadpool
|
sessions via ``db.SessionLocal()()`` (the double-paren "function-style
|
||||||
request handlers can call them concurrently.
|
accessor" established in T2/T4).
|
||||||
|
|
||||||
|
Public API (preserved from the in-memory version):
|
||||||
|
- add(record)
|
||||||
|
- get(batch_id) / get_batch(batch_id)
|
||||||
|
- list(limit) / all()
|
||||||
|
- iter_claims(...) / iter_remittances(...)
|
||||||
|
- distinct_providers() / recent_activity(limit)
|
||||||
|
|
||||||
|
New API (stubs for T10/T12):
|
||||||
|
- list_unmatched()
|
||||||
|
- manual_match(claim_id, remit_id)
|
||||||
|
- manual_unmatch(claim_id)
|
||||||
|
|
||||||
|
Backward-compat shims for tests that relied on the in-memory internals:
|
||||||
|
- ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles
|
||||||
|
concurrency via the engine's connection pool, but some existing
|
||||||
|
tests use it as a context manager around cleanup.
|
||||||
|
- ``_batches.clear()`` — wipes all rows from the DB tables so tests
|
||||||
|
that depended on a fresh in-memory list per-test get a fresh DB
|
||||||
|
state per-test.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import (
|
||||||
|
ActivityEvent,
|
||||||
|
Batch,
|
||||||
|
CasAdjustment,
|
||||||
|
Claim,
|
||||||
|
ClaimState,
|
||||||
|
JSONText,
|
||||||
|
Match,
|
||||||
|
Remittance,
|
||||||
|
)
|
||||||
from cyclone.parsers.models import ClaimOutput, ParseResult
|
from cyclone.parsers.models import ClaimOutput, ParseResult
|
||||||
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
||||||
from cyclone.parsers.payer import PayerConfig835
|
from cyclone.parsers.payer import PayerConfig835
|
||||||
@@ -22,6 +54,11 @@ from cyclone.parsers.payer import PayerConfig835
|
|||||||
BatchKind = Literal["837p", "835"]
|
BatchKind = Literal["837p", "835"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BatchRecord: value object preserved from sub-project 1.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class BatchRecord(BaseModel):
|
class BatchRecord(BaseModel):
|
||||||
"""One parsed file, with a stable uuid4 id and the full ParseResult.
|
"""One parsed file, with a stable uuid4 id and the full ParseResult.
|
||||||
|
|
||||||
@@ -82,232 +119,112 @@ class BatchRecord835(BatchRecord):
|
|||||||
result: ParseResult835
|
result: ParseResult835
|
||||||
|
|
||||||
|
|
||||||
class InMemoryStore:
|
|
||||||
"""Thread-safe list of BatchRecord, newest-first on `list`."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._batches: list[BatchRecord] = []
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
|
|
||||||
def add(self, record: BatchRecord) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._batches.append(record)
|
|
||||||
|
|
||||||
def list(self, *, limit: int = 100) -> list[BatchRecord]:
|
|
||||||
with self._lock:
|
|
||||||
return list(reversed(self._batches[-limit:]))
|
|
||||||
|
|
||||||
def get(self, batch_id: str) -> BatchRecord | None:
|
|
||||||
with self._lock:
|
|
||||||
for b in self._batches:
|
|
||||||
if b.id == batch_id:
|
|
||||||
return b
|
|
||||||
return None
|
|
||||||
|
|
||||||
def all(self) -> list[BatchRecord]:
|
|
||||||
with self._lock:
|
|
||||||
return list(self._batches)
|
|
||||||
|
|
||||||
def iter_claims(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
batch_id: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
provider_npi: str | None = None,
|
|
||||||
payer: str | None = None,
|
|
||||||
date_from: str | None = None,
|
|
||||||
date_to: str | None = None,
|
|
||||||
sort: str | None = None,
|
|
||||||
order: str = "desc",
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list[dict]:
|
|
||||||
with self._lock:
|
|
||||||
all_claims: list[dict] = []
|
|
||||||
for b in self._batches:
|
|
||||||
if not isinstance(b, BatchRecord837):
|
|
||||||
continue
|
|
||||||
if batch_id is not None and b.id != batch_id:
|
|
||||||
continue
|
|
||||||
for c in b.result.claims:
|
|
||||||
all_claims.append(to_ui_claim(
|
|
||||||
c, batch_id=b.id, parsed_at=b.parsed_at,
|
|
||||||
))
|
|
||||||
if status is not None:
|
|
||||||
all_claims = [c for c in all_claims if c["status"] == status]
|
|
||||||
if provider_npi is not None:
|
|
||||||
all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi]
|
|
||||||
if payer is not None:
|
|
||||||
needle = payer.casefold()
|
|
||||||
all_claims = [
|
|
||||||
c for c in all_claims
|
|
||||||
if needle in (c.get("payerName") or "").casefold()
|
|
||||||
]
|
|
||||||
all_claims = [
|
|
||||||
c for c in all_claims
|
|
||||||
if _date_in_bounds(c, "submissionDate", date_from, date_to)
|
|
||||||
]
|
|
||||||
if sort is not None:
|
|
||||||
all_claims.sort(
|
|
||||||
key=lambda c: c.get(sort, 0) or 0,
|
|
||||||
reverse=(order == "desc"),
|
|
||||||
)
|
|
||||||
return all_claims[offset:offset + limit]
|
|
||||||
|
|
||||||
def iter_remittances(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
batch_id: str | None = None,
|
|
||||||
payer: str | None = None,
|
|
||||||
claim_id: str | None = None,
|
|
||||||
date_from: str | None = None,
|
|
||||||
date_to: str | None = None,
|
|
||||||
sort: str | None = None,
|
|
||||||
order: str = "desc",
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list[dict]:
|
|
||||||
with self._lock:
|
|
||||||
all_remits: list[dict] = []
|
|
||||||
for b in self._batches:
|
|
||||||
if not isinstance(b, BatchRecord835):
|
|
||||||
continue
|
|
||||||
if batch_id is not None and b.id != batch_id:
|
|
||||||
continue
|
|
||||||
for cp in b.result.claims:
|
|
||||||
all_remits.append(to_ui_remittance(
|
|
||||||
cp, batch_id=b.id, parsed_at=b.parsed_at,
|
|
||||||
payer_name=b.result.payer.name,
|
|
||||||
))
|
|
||||||
if payer is not None:
|
|
||||||
all_remits = [r for r in all_remits if r.get("payerName") == payer]
|
|
||||||
if claim_id is not None:
|
|
||||||
all_remits = [r for r in all_remits if r["claimId"] == claim_id]
|
|
||||||
all_remits = [
|
|
||||||
r for r in all_remits
|
|
||||||
if _date_in_bounds(r, "receivedDate", date_from, date_to)
|
|
||||||
]
|
|
||||||
if sort is not None:
|
|
||||||
all_remits.sort(
|
|
||||||
key=lambda r: r.get(sort, 0) or 0,
|
|
||||||
reverse=(order == "desc"),
|
|
||||||
)
|
|
||||||
return all_remits[offset:offset + limit]
|
|
||||||
|
|
||||||
def distinct_providers(self) -> list[dict]:
|
|
||||||
with self._lock:
|
|
||||||
by_npi: dict[str, dict] = {}
|
|
||||||
for b in self._batches:
|
|
||||||
if not isinstance(b, BatchRecord837):
|
|
||||||
continue
|
|
||||||
if not b.result.claims:
|
|
||||||
continue
|
|
||||||
# billing_provider lives on each claim, not at the batch level
|
|
||||||
bp = b.result.claims[0].billing_provider
|
|
||||||
npi = bp.npi
|
|
||||||
if npi not in by_npi:
|
|
||||||
by_npi[npi] = to_ui_provider(
|
|
||||||
npi=npi,
|
|
||||||
name=bp.name or "",
|
|
||||||
tax_id=bp.tax_id,
|
|
||||||
claim_count=0,
|
|
||||||
outstanding_ar=0.0,
|
|
||||||
)
|
|
||||||
by_npi[npi]["claimCount"] += len(b.result.claims)
|
|
||||||
return list(by_npi.values())
|
|
||||||
|
|
||||||
def recent_activity(self, *, limit: int = 200) -> list[dict]:
|
|
||||||
with self._lock:
|
|
||||||
events: list[dict] = []
|
|
||||||
for b in reversed(self._batches):
|
|
||||||
if isinstance(b, BatchRecord837):
|
|
||||||
for c in b.result.claims:
|
|
||||||
events.append(to_activity_event(
|
|
||||||
id=f"{b.id}:{c.claim_id}",
|
|
||||||
kind="claim_submitted",
|
|
||||||
message=f"Claim {c.claim_id} submitted · {c.payer.name}",
|
|
||||||
timestamp=b.parsed_at,
|
|
||||||
npi=c.billing_provider.npi,
|
|
||||||
amount=float(c.claim.total_charge or 0.0),
|
|
||||||
))
|
|
||||||
elif isinstance(b, BatchRecord835):
|
|
||||||
for cp in b.result.claims:
|
|
||||||
events.append(to_activity_event(
|
|
||||||
id=f"{b.id}:{cp.payer_claim_control_number}",
|
|
||||||
kind="remit_received",
|
|
||||||
message=f"Remit {cp.payer_claim_control_number} received",
|
|
||||||
timestamp=b.parsed_at,
|
|
||||||
amount=float(cp.total_paid or 0.0),
|
|
||||||
))
|
|
||||||
return events[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
store = InMemoryStore()
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
"""tz-aware UTC `datetime` (replaces the old `utcnow_iso` string helper)."""
|
"""tz-aware UTC `datetime` (replaces the old `utcnow_iso` string helper)."""
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _date_in_bounds(
|
# ---------------------------------------------------------------------------
|
||||||
item: dict,
|
# ORM row builders.
|
||||||
field: str,
|
# ---------------------------------------------------------------------------
|
||||||
date_from: str | None,
|
|
||||||
date_to: str | None,
|
|
||||||
) -> bool:
|
|
||||||
"""True if ``item[field]`` falls within ``[date_from, date_to]``.
|
|
||||||
|
|
||||||
``date_from`` / ``date_to`` are ISO date strings (``YYYY-MM-DD``). The
|
|
||||||
item's value is treated as an ISO timestamp; only its date portion is
|
def _service_dates_from_claim(claim: ClaimOutput) -> tuple[date | None, date | None]:
|
||||||
compared. A missing field is included only when no bound is set —
|
"""Extract (service_date_from, service_date_to) from a ClaimOutput.
|
||||||
bounded filters treat ``None`` as outside the range.
|
|
||||||
|
The 837P model has ``service_lines[*].service_date`` (one per SV1).
|
||||||
|
We use the earliest as ``from`` and the latest as ``to``; if there
|
||||||
|
are no service lines, both are ``None``.
|
||||||
"""
|
"""
|
||||||
val = item.get(field)
|
dates: list[date] = []
|
||||||
if val is None:
|
for sl in claim.service_lines:
|
||||||
# Missing dates: include when unbounded, exclude when bounded.
|
if sl.service_date is not None:
|
||||||
return date_from is None and date_to is None
|
dates.append(sl.service_date)
|
||||||
date_part = val[:10]
|
if not dates:
|
||||||
if date_from is not None and date_part < date_from:
|
return None, None
|
||||||
return False
|
return min(dates), max(dates)
|
||||||
if date_to is not None and date_part > date_to:
|
|
||||||
return False
|
|
||||||
return True
|
def _claim_837_row(claim: ClaimOutput, batch_id: str) -> Claim:
|
||||||
|
"""Build a Claim ORM row from a ClaimOutput. NOT yet persisted."""
|
||||||
|
d_from, d_to = _service_dates_from_claim(claim)
|
||||||
|
return Claim(
|
||||||
|
id=claim.claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
patient_control_number=claim.subscriber.member_id or "",
|
||||||
|
service_date_from=d_from,
|
||||||
|
service_date_to=d_to,
|
||||||
|
charge_amount=Decimal(claim.claim.total_charge or 0),
|
||||||
|
provider_npi=claim.billing_provider.npi,
|
||||||
|
payer_id=claim.payer.id,
|
||||||
|
state=ClaimState.SUBMITTED,
|
||||||
|
raw_json=json.loads(claim.model_dump_json()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance:
|
||||||
|
"""Build a Remittance ORM row from a ClaimPayment. NOT yet persisted."""
|
||||||
|
received_at = utcnow()
|
||||||
|
# Adjustment amount: sum the CAS rows for the first service line.
|
||||||
|
adjustment = Decimal("0")
|
||||||
|
if cp.service_payments:
|
||||||
|
sp = cp.service_payments[0]
|
||||||
|
for adj in sp.adjustments:
|
||||||
|
adjustment += adj.amount
|
||||||
|
# Use the first service line's service_date as the remit service_date.
|
||||||
|
service_date: date | None = None
|
||||||
|
if cp.service_payments and cp.service_payments[0].service_date is not None:
|
||||||
|
service_date = cp.service_payments[0].service_date
|
||||||
|
return Remittance(
|
||||||
|
id=cp.payer_claim_control_number,
|
||||||
|
batch_id=batch_id,
|
||||||
|
payer_claim_control_number=cp.payer_claim_control_number,
|
||||||
|
claim_id=None,
|
||||||
|
status_code=cp.status_code,
|
||||||
|
status_label=cp.status_label,
|
||||||
|
total_charge=Decimal(cp.total_charge or 0),
|
||||||
|
total_paid=Decimal(cp.total_paid or 0),
|
||||||
|
patient_responsibility=cp.patient_responsibility,
|
||||||
|
adjustment_amount=adjustment,
|
||||||
|
received_at=received_at,
|
||||||
|
service_date=service_date,
|
||||||
|
is_reversal=cp.status_code in ("21", "22"),
|
||||||
|
raw_json=json.loads(cp.model_dump_json()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ``json`` is imported lazily inside the helpers above so we don't pull
|
||||||
|
# the stdlib ``json`` into module-level imports at the very top — keeps
|
||||||
|
# the dependency surface easy to audit. The ``import json`` line below
|
||||||
|
# is the actual import used by the helpers above.
|
||||||
|
import json # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Mappers: rich backend models → simpler UI types.
|
# UI mappers: ORM rows → simpler UI types.
|
||||||
# The shape of the returned dicts must match the TypeScript interfaces in
|
|
||||||
# `src/types/index.ts`. Keep both sides in sync.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_status_from_validation(claim: ClaimOutput) -> str:
|
||||||
|
"""Re-implement the in-memory status rules (sub-project 1 §6.2)."""
|
||||||
|
v = claim.validation
|
||||||
|
if not v.passed:
|
||||||
|
has_r050 = any(e.rule == "R050_diagnosis_present" for e in v.errors)
|
||||||
|
return "draft" if has_r050 else "denied"
|
||||||
|
if claim.claim.frequency_code == "1":
|
||||||
|
return "submitted"
|
||||||
|
if v.warnings:
|
||||||
|
return "pending"
|
||||||
|
return "draft"
|
||||||
|
|
||||||
|
|
||||||
def to_ui_claim(
|
def to_ui_claim(
|
||||||
claim: ClaimOutput,
|
claim: ClaimOutput,
|
||||||
*,
|
*,
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
parsed_at: datetime,
|
parsed_at: datetime,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Map a 837P ClaimOutput to the UI's `Claim` shape.
|
"""Map a 837P ClaimOutput to the UI's `Claim` shape (preserved)."""
|
||||||
|
|
||||||
Status rules (per spec section 6.2, first-match-wins):
|
|
||||||
- !passed and any R050_diagnosis_present error → draft
|
|
||||||
- !passed → denied
|
|
||||||
- passed and frequency_code == "1" → submitted
|
|
||||||
- passed and any warnings → pending
|
|
||||||
- otherwise → draft
|
|
||||||
"""
|
|
||||||
v = claim.validation
|
|
||||||
if not v.passed:
|
|
||||||
has_r050 = any(e.rule == "R050_diagnosis_present" for e in v.errors)
|
|
||||||
status = "draft" if has_r050 else "denied"
|
|
||||||
elif claim.claim.frequency_code == "1":
|
|
||||||
status = "submitted"
|
|
||||||
elif v.warnings:
|
|
||||||
status = "pending"
|
|
||||||
else:
|
|
||||||
status = "draft"
|
|
||||||
|
|
||||||
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
|
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
|
||||||
return {
|
return {
|
||||||
"id": claim.claim_id,
|
"id": claim.claim_id,
|
||||||
@@ -321,7 +238,7 @@ def to_ui_claim(
|
|||||||
),
|
),
|
||||||
"billedAmount": float(claim.claim.total_charge or 0.0),
|
"billedAmount": float(claim.claim.total_charge or 0.0),
|
||||||
"receivedAmount": 0.0,
|
"receivedAmount": 0.0,
|
||||||
"status": status,
|
"status": _claim_status_from_validation(claim),
|
||||||
"denialReason": None,
|
"denialReason": None,
|
||||||
"submissionDate": parsed_iso,
|
"submissionDate": parsed_iso,
|
||||||
"batchId": batch_id,
|
"batchId": batch_id,
|
||||||
@@ -337,14 +254,7 @@ def to_ui_remittance(
|
|||||||
payer_config: PayerConfig835 | None = None,
|
payer_config: PayerConfig835 | None = None,
|
||||||
payer_name: str = "",
|
payer_name: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Map an 835 ClaimPayment to the UI's `Remittance` shape.
|
"""Map an 835 ClaimPayment to the UI's `Remittance` shape (preserved)."""
|
||||||
|
|
||||||
Status mapping (per spec section 6.2):
|
|
||||||
- status_code 21/22 → reconciled (reversal/correction)
|
|
||||||
- status_code 4 → received + denialReason from CAS
|
|
||||||
- status_code outside allowlist → received + validationWarning
|
|
||||||
- otherwise → received
|
|
||||||
"""
|
|
||||||
code = cp.status_code
|
code = cp.status_code
|
||||||
if code in {"21", "22"}:
|
if code in {"21", "22"}:
|
||||||
status = "reconciled"
|
status = "reconciled"
|
||||||
@@ -428,3 +338,510 @@ def to_activity_event(
|
|||||||
"npi": npi,
|
"npi": npi,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _date_in_bounds(
|
||||||
|
item: dict,
|
||||||
|
field: str,
|
||||||
|
date_from: str | None,
|
||||||
|
date_to: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""True if ``item[field]`` falls within ``[date_from, date_to]``."""
|
||||||
|
val = item.get(field)
|
||||||
|
if val is None:
|
||||||
|
return date_from is None and date_to is None
|
||||||
|
date_part = val[:10]
|
||||||
|
if date_from is not None and date_part < date_from:
|
||||||
|
return False
|
||||||
|
if date_to is not None and date_part > date_to:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backward-compat shim: tests called ``_batches.clear()`` on the in-memory
|
||||||
|
# store. The DB-backed store doesn't have an in-memory list, so we expose
|
||||||
|
# a tiny shim object whose ``.clear()`` wipes the DB.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _BatchesShim:
|
||||||
|
"""Drop-in replacement for the old in-memory ``_batches`` list.
|
||||||
|
|
||||||
|
``clear()`` removes every row from the DB tables in FK-safe order.
|
||||||
|
Other list operations are not implemented because the only call site
|
||||||
|
is the ``clear()`` inside the test fixtures (``test_api_gets.py`` and
|
||||||
|
``test_api_parse_persists.py``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def clear(self) -> None: # type: ignore[no-untyped-def]
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
s.query(ActivityEvent).delete()
|
||||||
|
s.query(Match).delete()
|
||||||
|
s.query(CasAdjustment).delete()
|
||||||
|
s.query(Remittance).delete()
|
||||||
|
s.query(Claim).delete()
|
||||||
|
s.query(Batch).delete()
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CycloneStore: the SQLAlchemy-backed facade.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CycloneStore:
|
||||||
|
"""SQLAlchemy-backed facade over the parsed X12 store.
|
||||||
|
|
||||||
|
Each public method opens a short-lived session via
|
||||||
|
``db.SessionLocal()()`` so callers don't have to manage session
|
||||||
|
lifecycles. Concurrency is handled by the SQLAlchemy engine; the
|
||||||
|
``_lock`` attribute is a no-op ``RLock`` retained for backward
|
||||||
|
compatibility with code that wrapped cleanup in a lock context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._batches = _BatchesShim()
|
||||||
|
|
||||||
|
# -- write path -----------------------------------------------------
|
||||||
|
|
||||||
|
def add(self, record: BatchRecord) -> None:
|
||||||
|
"""Persist a parsed batch (837P or 835) to the DB.
|
||||||
|
|
||||||
|
For 837P batches: inserts the Batch row, one Claim row per
|
||||||
|
claim, and a ``claim_submitted`` ActivityEvent per claim.
|
||||||
|
|
||||||
|
For 835 batches: inserts the Batch row, one Remittance row per
|
||||||
|
ClaimPayment, and a ``remit_received`` ActivityEvent per
|
||||||
|
ClaimPayment. After commit, calls ``_run_reconcile`` (T10 stub)
|
||||||
|
in a fail-soft manner — reconciliation errors are logged but
|
||||||
|
do not roll back the persisted batch.
|
||||||
|
|
||||||
|
Idempotency: ``Claim.id`` and ``Remittance.id`` are PRIMARY KEYS,
|
||||||
|
so a re-ingest of the same fixture (e.g. ``/api/parse-837`` called
|
||||||
|
twice with the same file) would otherwise raise
|
||||||
|
``IntegrityError``. We do a per-row ``session.get(...)`` check
|
||||||
|
before each insert; if the row already exists, we log a warning
|
||||||
|
and skip. The batch row itself is still inserted (each parse
|
||||||
|
has a fresh ``uuid4`` id from the API). O(n) per row, but
|
||||||
|
acceptable for the small fixture sizes — production load is
|
||||||
|
one batch at a time via the API, not bulk inserts.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
batch_row = Batch(
|
||||||
|
id=record.id,
|
||||||
|
kind=record.kind,
|
||||||
|
input_filename=record.input_filename,
|
||||||
|
parsed_at=record.parsed_at,
|
||||||
|
totals_json=None,
|
||||||
|
validation_json=None,
|
||||||
|
raw_result_json=json.loads(record.result.model_dump_json()),
|
||||||
|
)
|
||||||
|
s.add(batch_row)
|
||||||
|
|
||||||
|
if isinstance(record, BatchRecord837):
|
||||||
|
result: ParseResult = record.result
|
||||||
|
for claim in result.claims:
|
||||||
|
if s.get(Claim, claim.claim_id) is not None:
|
||||||
|
log.warning(
|
||||||
|
"add: claim %s already exists; skipping (batch=%s)",
|
||||||
|
claim.claim_id, record.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
s.add(_claim_837_row(claim, record.id))
|
||||||
|
s.add(ActivityEvent(
|
||||||
|
ts=record.parsed_at,
|
||||||
|
kind="claim_submitted",
|
||||||
|
batch_id=record.id,
|
||||||
|
claim_id=claim.claim_id,
|
||||||
|
payload_json={
|
||||||
|
"message": (
|
||||||
|
f"Claim {claim.claim_id} submitted · "
|
||||||
|
f"{claim.payer.name}"
|
||||||
|
),
|
||||||
|
"npi": claim.billing_provider.npi,
|
||||||
|
"amount": float(claim.claim.total_charge or 0.0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
elif isinstance(record, BatchRecord835):
|
||||||
|
result835: ParseResult835 = record.result
|
||||||
|
payer_name = result835.payer.name
|
||||||
|
for cp in result835.claims:
|
||||||
|
if s.get(Remittance, cp.payer_claim_control_number) is not None:
|
||||||
|
log.warning(
|
||||||
|
"add: remittance %s already exists; skipping (batch=%s)",
|
||||||
|
cp.payer_claim_control_number, record.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
s.add(_remittance_835_row(cp, record.id))
|
||||||
|
s.add(ActivityEvent(
|
||||||
|
ts=record.parsed_at,
|
||||||
|
kind="remit_received",
|
||||||
|
batch_id=record.id,
|
||||||
|
remittance_id=cp.payer_claim_control_number,
|
||||||
|
payload_json={
|
||||||
|
"message": (
|
||||||
|
f"Remit {cp.payer_claim_control_number} received"
|
||||||
|
),
|
||||||
|
"payerName": payer_name,
|
||||||
|
"amount": float(cp.total_paid or 0.0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"Unsupported BatchRecord subclass: {type(record).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
# Reconcile 835 batches after the batch is durably persisted.
|
||||||
|
# Fail-soft: errors are logged, not raised, so an 835 parse that
|
||||||
|
# crashes in reconciliation still shows up in /api/batches.
|
||||||
|
if record.kind == "835":
|
||||||
|
try:
|
||||||
|
self._run_reconcile(record.id)
|
||||||
|
except Exception: # pragma: no cover - logged via default handler
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
"reconcile.run failed for batch %s", record.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_reconcile(self, batch_id: str) -> None:
|
||||||
|
"""T10 stub: invoke the reconcile orchestrator for a batch.
|
||||||
|
|
||||||
|
The actual reconciliation is implemented in T10 (this method
|
||||||
|
will import ``cyclone.reconcile`` and call ``reconcile.run``
|
||||||
|
with the current session). For T9 we keep the import lazy and
|
||||||
|
fail-soft so a missing or NotImplementedError reconcile module
|
||||||
|
never breaks the 835 ingest path.
|
||||||
|
"""
|
||||||
|
# T10 will replace this with the real implementation. Until then
|
||||||
|
# we accept the failure modes the spec lists: ImportError
|
||||||
|
# (module not yet wired), NotImplementedError (stub in place),
|
||||||
|
# or any other transient reconcile error — all swallowed.
|
||||||
|
try:
|
||||||
|
from cyclone import reconcile as _reconcile
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_reconcile.run(s, batch_id)
|
||||||
|
except (ImportError, NotImplementedError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- read path ------------------------------------------------------
|
||||||
|
|
||||||
|
def _row_to_record(self, row: Batch) -> BatchRecord:
|
||||||
|
"""Rehydrate a ``BatchRecord`` (837 or 835) from a Batch ORM row.
|
||||||
|
|
||||||
|
The full ``ParseResult`` / ``ParseResult835`` lives in
|
||||||
|
``raw_result_json`` (stashed at insert time). Re-parsing JSON
|
||||||
|
here means callers get the same typed Pydantic object the old
|
||||||
|
in-memory store handed out, so api.py and tests that do
|
||||||
|
``rec.result.claims`` keep working unchanged.
|
||||||
|
|
||||||
|
SQLite drops tz info on round-trip even though the column type
|
||||||
|
is ``DateTime(timezone=True)``. We re-attach UTC so the
|
||||||
|
``BatchRecord`` validator (``parsed_at must be tz-aware``)
|
||||||
|
passes.
|
||||||
|
"""
|
||||||
|
if row.kind == "835":
|
||||||
|
result_cls = ParseResult835
|
||||||
|
else:
|
||||||
|
result_cls = ParseResult
|
||||||
|
payload = row.raw_result_json or {}
|
||||||
|
result = result_cls.model_validate(payload)
|
||||||
|
parsed_at = row.parsed_at
|
||||||
|
if parsed_at is not None and parsed_at.tzinfo is None:
|
||||||
|
parsed_at = parsed_at.replace(tzinfo=timezone.utc)
|
||||||
|
record_cls = BatchRecord835 if row.kind == "835" else BatchRecord837
|
||||||
|
return record_cls(
|
||||||
|
id=row.id,
|
||||||
|
kind=row.kind,
|
||||||
|
input_filename=row.input_filename,
|
||||||
|
parsed_at=parsed_at,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_batch(self, batch_id: str) -> dict | None:
|
||||||
|
"""Return a summary dict for ``batch_id`` or ``None`` if missing.
|
||||||
|
|
||||||
|
The dict shape matches what ``/api/batches/{id}`` callers need:
|
||||||
|
``id``, ``kind``, ``input_filename``, ``parsed_at``, and the
|
||||||
|
full ``result`` (raw_result_json) as a dict.
|
||||||
|
"""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.get(Batch, batch_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"kind": row.kind,
|
||||||
|
"input_filename": row.input_filename,
|
||||||
|
"parsed_at": row.parsed_at,
|
||||||
|
"result": row.raw_result_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, batch_id: str) -> BatchRecord | None:
|
||||||
|
"""Return the ``BatchRecord`` for ``batch_id`` or ``None``.
|
||||||
|
|
||||||
|
Preserves the in-memory store contract: callers get a Pydantic
|
||||||
|
``BatchRecord`` (subclass ``BatchRecord837`` / ``BatchRecord835``)
|
||||||
|
with ``.id``, ``.kind``, ``.input_filename``, ``.parsed_at``,
|
||||||
|
and ``.result`` (typed ``ParseResult`` / ``ParseResult835``).
|
||||||
|
"""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.get(Batch, batch_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return self._row_to_record(row)
|
||||||
|
|
||||||
|
def list(self, *, limit: int = 100) -> list[BatchRecord]:
|
||||||
|
"""Return up to ``limit`` ``BatchRecord``s, newest first."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = (
|
||||||
|
s.query(Batch)
|
||||||
|
.order_by(Batch.parsed_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [self._row_to_record(r) for r in rows]
|
||||||
|
|
||||||
|
def all(self) -> list[BatchRecord]:
|
||||||
|
"""Return every ``BatchRecord``, oldest first (no pagination)."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = s.query(Batch).order_by(Batch.parsed_at.asc()).all()
|
||||||
|
return [self._row_to_record(r) for r in rows]
|
||||||
|
|
||||||
|
def iter_claims(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
batch_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
provider_npi: str | None = None,
|
||||||
|
payer: str | None = None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
|
order: str = "desc",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return UI-shaped claim dicts from the DB.
|
||||||
|
|
||||||
|
Filters mirror the in-memory version. The ``payer`` filter is
|
||||||
|
a case-insensitive substring on the payer's ``name``, recovered
|
||||||
|
from each claim's ``raw_json`` payload (the DB stores it there
|
||||||
|
because ``Claim`` itself only carries ``payer_id``).
|
||||||
|
"""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
q = s.query(Claim)
|
||||||
|
if batch_id is not None:
|
||||||
|
q = q.filter(Claim.batch_id == batch_id)
|
||||||
|
if status is not None:
|
||||||
|
q = q.filter(Claim.state == ClaimState(status))
|
||||||
|
if provider_npi is not None:
|
||||||
|
q = q.filter(Claim.provider_npi == provider_npi)
|
||||||
|
|
||||||
|
rows = q.all()
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
raw = r.raw_json or {}
|
||||||
|
bp = raw.get("billing_provider", {})
|
||||||
|
payer_obj = raw.get("payer", {})
|
||||||
|
sub = raw.get("subscriber", {})
|
||||||
|
claim_hdr = raw.get("claim", {})
|
||||||
|
service_lines = raw.get("service_lines", [])
|
||||||
|
parsed_at_iso = (
|
||||||
|
r.batch.parsed_at.isoformat().replace("+00:00", "Z")
|
||||||
|
if r.batch is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
cpt = (
|
||||||
|
service_lines[0].get("procedure", {}).get("code", "")
|
||||||
|
if service_lines
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
out.append({
|
||||||
|
"id": r.id,
|
||||||
|
"patientName": (
|
||||||
|
f"{sub.get('first_name', '')} "
|
||||||
|
f"{sub.get('last_name', '')}".strip()
|
||||||
|
),
|
||||||
|
"providerNpi": bp.get("npi") or r.provider_npi or "",
|
||||||
|
"payerName": payer_obj.get("name") or "",
|
||||||
|
"cptCode": cpt,
|
||||||
|
"billedAmount": float(r.charge_amount or 0),
|
||||||
|
"receivedAmount": 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,
|
||||||
|
"submissionDate": parsed_at_iso,
|
||||||
|
"batchId": r.batch_id,
|
||||||
|
"parsedAt": parsed_at_iso,
|
||||||
|
# Keep these so we can sort on them in-memory below.
|
||||||
|
"_sort_billedAmount": float(r.charge_amount or 0),
|
||||||
|
"_sort_submissionDate": parsed_at_iso,
|
||||||
|
})
|
||||||
|
|
||||||
|
if payer is not None:
|
||||||
|
needle = payer.casefold()
|
||||||
|
out = [
|
||||||
|
c for c in out
|
||||||
|
if needle in (c.get("payerName") or "").casefold()
|
||||||
|
]
|
||||||
|
out = [
|
||||||
|
c for c in out
|
||||||
|
if _date_in_bounds(c, "submissionDate", date_from, date_to)
|
||||||
|
]
|
||||||
|
if sort is not None:
|
||||||
|
out.sort(
|
||||||
|
key=lambda c: c.get(f"_sort_{sort}", 0) or 0,
|
||||||
|
reverse=(order == "desc"),
|
||||||
|
)
|
||||||
|
# Drop the private sort keys before returning.
|
||||||
|
for c in out:
|
||||||
|
c.pop("_sort_billedAmount", None)
|
||||||
|
c.pop("_sort_submissionDate", None)
|
||||||
|
return out[offset:offset + limit]
|
||||||
|
|
||||||
|
def iter_remittances(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
batch_id: str | None = None,
|
||||||
|
payer: str | None = None,
|
||||||
|
claim_id: str | None = None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
|
order: str = "desc",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return UI-shaped remittance dicts from the DB."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
q = s.query(Remittance)
|
||||||
|
if batch_id is not None:
|
||||||
|
q = q.filter(Remittance.batch_id == batch_id)
|
||||||
|
if claim_id is not None:
|
||||||
|
q = q.filter(Remittance.claim_id == claim_id)
|
||||||
|
|
||||||
|
rows = q.all()
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
raw = r.raw_json or {}
|
||||||
|
parsed_at_iso = (
|
||||||
|
r.batch.parsed_at.isoformat().replace("+00:00", "Z")
|
||||||
|
if r.batch is not None
|
||||||
|
else r.received_at.isoformat().replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
payer_name = ""
|
||||||
|
if r.batch is not None and r.batch.raw_result_json:
|
||||||
|
payer_name = (
|
||||||
|
r.batch.raw_result_json.get("payer", {}).get("name", "")
|
||||||
|
)
|
||||||
|
out.append({
|
||||||
|
"id": r.id,
|
||||||
|
"claimId": r.claim_id or "",
|
||||||
|
"payerName": payer_name,
|
||||||
|
"paidAmount": float(r.total_paid or 0),
|
||||||
|
"adjustmentAmount": float(r.adjustment_amount or 0),
|
||||||
|
"status": (
|
||||||
|
"reconciled" if r.status_code in ("21", "22")
|
||||||
|
else "received"
|
||||||
|
),
|
||||||
|
"denialReason": None,
|
||||||
|
"validationWarnings": [],
|
||||||
|
"receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
"batchId": r.batch_id,
|
||||||
|
"parsedAt": parsed_at_iso,
|
||||||
|
"_sort_receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if payer is not None:
|
||||||
|
out = [r for r in out if r.get("payerName") == payer]
|
||||||
|
out = [
|
||||||
|
r for r in out
|
||||||
|
if _date_in_bounds(r, "receivedDate", date_from, date_to)
|
||||||
|
]
|
||||||
|
if sort is not None:
|
||||||
|
out.sort(
|
||||||
|
key=lambda r: r.get(f"_sort_{sort}", 0) or 0,
|
||||||
|
reverse=(order == "desc"),
|
||||||
|
)
|
||||||
|
for r in out:
|
||||||
|
r.pop("_sort_receivedDate", None)
|
||||||
|
return out[offset:offset + limit]
|
||||||
|
|
||||||
|
def distinct_providers(self) -> list[dict]:
|
||||||
|
"""Group claims by NPI and return one row per provider."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = s.query(Claim).all()
|
||||||
|
by_npi: dict[str, dict] = {}
|
||||||
|
for r in rows:
|
||||||
|
npi = r.provider_npi or ""
|
||||||
|
if npi not in by_npi:
|
||||||
|
raw = r.raw_json or {}
|
||||||
|
bp = raw.get("billing_provider", {})
|
||||||
|
by_npi[npi] = to_ui_provider(
|
||||||
|
npi=npi,
|
||||||
|
name=bp.get("name") or "",
|
||||||
|
tax_id=bp.get("tax_id"),
|
||||||
|
address=None,
|
||||||
|
city=None,
|
||||||
|
state=None,
|
||||||
|
zip=None,
|
||||||
|
phone=None,
|
||||||
|
claim_count=0,
|
||||||
|
outstanding_ar=0.0,
|
||||||
|
)
|
||||||
|
by_npi[npi]["claimCount"] += 1
|
||||||
|
return list(by_npi.values())
|
||||||
|
|
||||||
|
def recent_activity(self, *, limit: int = 200) -> list[dict]:
|
||||||
|
"""Return recent activity events from the DB, newest first."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = (
|
||||||
|
s.query(ActivityEvent)
|
||||||
|
.order_by(ActivityEvent.ts.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f"ae-{r.id}",
|
||||||
|
"kind": r.kind,
|
||||||
|
"message": (r.payload_json or {}).get("message", ""),
|
||||||
|
"timestamp": r.ts.isoformat().replace("+00:00", "Z"),
|
||||||
|
"npi": (r.payload_json or {}).get("npi"),
|
||||||
|
"amount": (r.payload_json or {}).get("amount"),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- T12 stubs: manual reconciliation -----------------------------
|
||||||
|
|
||||||
|
def list_unmatched(self) -> list[dict]:
|
||||||
|
"""T12 stub: return claims/remits that auto-match didn't pair.
|
||||||
|
|
||||||
|
Returns an empty list for T9. T12 will fill in the implementation
|
||||||
|
by querying ``Claim`` rows with ``matched_remittance_id IS NULL``
|
||||||
|
plus ``Remittance`` rows with ``claim_id IS NULL``.
|
||||||
|
"""
|
||||||
|
# TODO(t12): implement — query unmatched claims and remittances.
|
||||||
|
return []
|
||||||
|
|
||||||
|
def manual_match(self, claim_id: str, remit_id: str) -> None:
|
||||||
|
"""T12 stub: pair a claim with a remittance manually."""
|
||||||
|
# TODO(t12): implement — create a Match row, update Claim.state.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def manual_unmatch(self, claim_id: str) -> None:
|
||||||
|
"""T12 stub: unpair a previously matched claim."""
|
||||||
|
# TODO(t12): implement — delete the Match row, restore prior state.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton — same import path the old InMemoryStore used.
|
||||||
|
store = CycloneStore()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Per-test database setup for the Cyclone test suite.
|
||||||
|
|
||||||
|
Every test gets a fresh SQLite DB at ``tmp_path/test.db`` so the suite
|
||||||
|
can run in parallel and never touches the user's ``~/.local/share/cyclone``
|
||||||
|
database. ``db.init_db()`` is idempotent — tests that already call it
|
||||||
|
explicitly are unaffected (the second call short-circuits).
|
||||||
|
|
||||||
|
This fixture exists because the SQLAlchemy-backed ``CycloneStore``
|
||||||
|
requires the engine to be initialized before any DB access, whereas the
|
||||||
|
old in-memory store did not. Adding the init here keeps existing test
|
||||||
|
modules (test_api.py, test_api_835.py, test_api_gets.py,
|
||||||
|
test_api_parse_persists.py) working unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _auto_init_db(tmp_path, monkeypatch):
|
||||||
|
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema."""
|
||||||
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
|
from cyclone import db
|
||||||
|
db._reset_for_tests()
|
||||||
|
db.init_db()
|
||||||
|
yield
|
||||||
|
db._reset_for_tests()
|
||||||
+67
-541
@@ -1,11 +1,21 @@
|
|||||||
"""Tests for the in-memory batch store."""
|
"""Tests for the SQLAlchemy-backed CycloneStore.
|
||||||
|
|
||||||
|
Public API is preserved from the in-memory version: add / get_batch /
|
||||||
|
iter_claims / iter_remittances / distinct_providers / recent_activity.
|
||||||
|
New methods: list_unmatched / manual_match / manual_unmatch.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.store import BatchRecord, CycloneStore, store
|
||||||
from cyclone.parsers.models import (
|
from cyclone.parsers.models import (
|
||||||
|
Address,
|
||||||
BatchSummary,
|
BatchSummary,
|
||||||
BillingProvider,
|
BillingProvider,
|
||||||
ClaimHeader,
|
ClaimHeader,
|
||||||
@@ -14,61 +24,37 @@ from cyclone.parsers.models import (
|
|||||||
Payer,
|
Payer,
|
||||||
ParseResult,
|
ParseResult,
|
||||||
Subscriber,
|
Subscriber,
|
||||||
ValidationIssue,
|
|
||||||
ValidationReport,
|
ValidationReport,
|
||||||
)
|
)
|
||||||
from cyclone.parsers.models_835 import (
|
|
||||||
ClaimAdjustment,
|
|
||||||
ClaimPayment,
|
|
||||||
FinancialInfo,
|
|
||||||
ParseResult835,
|
|
||||||
Payer835,
|
|
||||||
Payee835,
|
|
||||||
ReassociationTrace,
|
|
||||||
ServicePayment,
|
|
||||||
)
|
|
||||||
from cyclone.parsers.payer import PayerConfig835
|
|
||||||
from cyclone.store import BatchRecord, InMemoryStore, store
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
@pytest.fixture(autouse=True)
|
||||||
# Stubs (match real model field names)
|
def _setup(tmp_path, monkeypatch):
|
||||||
# ---------------------------------------------------------------------------
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
|
db._reset_for_tests()
|
||||||
_PARSED_AT = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
|
db.init_db()
|
||||||
|
yield
|
||||||
|
db._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
def _make_claim(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimOutput:
|
def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOutput:
|
||||||
"""Minimal ClaimOutput stub. Real parser not needed here."""
|
|
||||||
return ClaimOutput(
|
return ClaimOutput(
|
||||||
claim_id=claim_id,
|
claim_id=claim_id,
|
||||||
control_number="0001",
|
control_number="0001",
|
||||||
transaction_date="2026-06-19",
|
transaction_date=date(2026, 6, 19),
|
||||||
billing_provider=BillingProvider(
|
billing_provider=BillingProvider(
|
||||||
name="Test",
|
name="Test", npi="1234567890",
|
||||||
npi="1234567890",
|
|
||||||
tax_id=None,
|
|
||||||
address=None,
|
|
||||||
),
|
),
|
||||||
|
# Member_id doubles as patient_control_number in the DB; the
|
||||||
|
# ``uq_claims_batch_pcn`` unique constraint requires it to be
|
||||||
|
# distinct within a batch, so derive it from the claim id.
|
||||||
subscriber=Subscriber(
|
subscriber=Subscriber(
|
||||||
first_name="Jane",
|
first_name="Jane", last_name="Doe", member_id=f"M-{claim_id}",
|
||||||
last_name="Doe",
|
|
||||||
member_id="M1",
|
|
||||||
dob=None,
|
|
||||||
gender=None,
|
|
||||||
address=None,
|
|
||||||
),
|
),
|
||||||
payer=Payer(name="Test Payer", id="P1"),
|
payer=Payer(name="Test Payer", id="P1"),
|
||||||
claim=ClaimHeader(
|
claim=ClaimHeader(
|
||||||
claim_id=claim_id,
|
claim_id=claim_id, total_charge=Decimal(charge),
|
||||||
total_charge=Decimal("0.00"),
|
frequency_code="1", place_of_service="11",
|
||||||
place_of_service="11",
|
|
||||||
facility_code_qualifier=None,
|
|
||||||
frequency_code=frequency_code,
|
|
||||||
provider_signature=None,
|
|
||||||
assignment=None,
|
|
||||||
release_of_info=None,
|
|
||||||
prior_auth=None,
|
|
||||||
),
|
),
|
||||||
diagnoses=[],
|
diagnoses=[],
|
||||||
service_lines=[],
|
service_lines=[],
|
||||||
@@ -77,521 +63,61 @@ def _make_claim(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimO
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_result() -> ParseResult:
|
def _make_result_837() -> ParseResult:
|
||||||
return ParseResult(
|
return ParseResult(
|
||||||
envelope=Envelope(
|
envelope=Envelope(
|
||||||
sender_id="SENDER",
|
sender_id="S", receiver_id="R", control_number="0001",
|
||||||
receiver_id="RECEIVER",
|
transaction_date=date(2026, 6, 19),
|
||||||
control_number="0001",
|
|
||||||
transaction_date="2026-06-19",
|
|
||||||
transaction_time=None,
|
|
||||||
implementation_guide=None,
|
|
||||||
),
|
),
|
||||||
claims=[_make_claim("CLM-1")],
|
claims=[_make_claim_837("CLM-1"), _make_claim_837("CLM-2", "88.50")],
|
||||||
summary=BatchSummary(
|
summary=BatchSummary(
|
||||||
input_file="test.txt",
|
input_file="test.txt", control_number="0001",
|
||||||
control_number="0001",
|
transaction_date=date(2026, 6, 19),
|
||||||
transaction_date="2026-06-19",
|
total_claims=2, passed=2, failed=0,
|
||||||
total_claims=1,
|
|
||||||
passed=1,
|
|
||||||
failed=0,
|
|
||||||
failed_claim_ids=[],
|
|
||||||
issues_by_rule={},
|
|
||||||
output_dir=None,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_remit(
|
def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1"):
|
||||||
*,
|
return BatchRecord(
|
||||||
status_code: str = "1",
|
id=batch_id, kind=kind, input_filename="test.txt",
|
||||||
service_payments: list[ServicePayment] | None = None,
|
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
|
||||||
) -> ClaimPayment:
|
result=result or _make_result_837(),
|
||||||
return ClaimPayment(
|
|
||||||
payer_claim_control_number="PCN-1",
|
|
||||||
status_code=status_code,
|
|
||||||
status_label=None,
|
|
||||||
total_charge=Decimal("100.00"),
|
|
||||||
total_paid=Decimal("80.00"),
|
|
||||||
patient_responsibility=Decimal("0.00"),
|
|
||||||
claim_filing_indicator="MC",
|
|
||||||
original_claim_id=None,
|
|
||||||
frequency_code=None,
|
|
||||||
facility_type=None,
|
|
||||||
service_payments=service_payments or [],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_835_result(claims: list[ClaimPayment] | None = None) -> ParseResult835:
|
def test_module_singleton():
|
||||||
return ParseResult835(
|
assert isinstance(store, CycloneStore)
|
||||||
envelope=Envelope(
|
|
||||||
sender_id="SENDER",
|
|
||||||
receiver_id="RECEIVER",
|
|
||||||
control_number="0001",
|
|
||||||
transaction_date="2026-06-19",
|
|
||||||
transaction_time=None,
|
|
||||||
implementation_guide=None,
|
|
||||||
),
|
|
||||||
financial_info=FinancialInfo(
|
|
||||||
handling_code="C",
|
|
||||||
paid_amount=Decimal("80.00"),
|
|
||||||
credit_debit_flag="C",
|
|
||||||
payment_method=None,
|
|
||||||
payer_tax_id=None,
|
|
||||||
payment_date=None,
|
|
||||||
),
|
|
||||||
trace=ReassociationTrace(
|
|
||||||
trace_type_code="1",
|
|
||||||
trace_number="TRN1",
|
|
||||||
originating_company_id="OC1",
|
|
||||||
payer_id=None,
|
|
||||||
),
|
|
||||||
payer=Payer835(name="CO_TXIX", id="7912900843", address=None, contact_url=None),
|
|
||||||
payee=Payee835(name="Acme Clinic", npi="1234567890", address=None),
|
|
||||||
claims=claims or [],
|
|
||||||
summary=BatchSummary(
|
|
||||||
input_file="test.txt",
|
|
||||||
control_number="0001",
|
|
||||||
transaction_date="2026-06-19",
|
|
||||||
total_claims=1,
|
|
||||||
passed=1,
|
|
||||||
failed=0,
|
|
||||||
failed_claim_ids=[],
|
|
||||||
issues_by_rule={},
|
|
||||||
output_dir=None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def test_add_837_persists_batch_and_claims():
|
||||||
# Task 1: BatchRecord + InMemoryStore
|
s = CycloneStore()
|
||||||
# ---------------------------------------------------------------------------
|
rec = _make_batch_record()
|
||||||
|
|
||||||
|
|
||||||
def test_module_singleton_store_exists():
|
|
||||||
"""The module-level `store` should be importable and be an InMemoryStore."""
|
|
||||||
assert isinstance(store, InMemoryStore)
|
|
||||||
|
|
||||||
|
|
||||||
def test_in_memory_store_starts_empty():
|
|
||||||
s = InMemoryStore()
|
|
||||||
assert s.list() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_and_get_round_trip():
|
|
||||||
s = InMemoryStore()
|
|
||||||
result = _make_result()
|
|
||||||
rec = BatchRecord(
|
|
||||||
id="abc123",
|
|
||||||
kind="837p",
|
|
||||||
input_filename="test.txt",
|
|
||||||
parsed_at=_PARSED_AT,
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
s.add(rec)
|
s.add(rec)
|
||||||
assert s.get("abc123") is rec
|
# New session confirms persistence.
|
||||||
assert s.list() == [rec]
|
with db.SessionLocal()() as session:
|
||||||
|
from cyclone.db import Batch, Claim, ClaimState
|
||||||
|
b = session.get(Batch, "b-uuid-1")
|
||||||
|
assert b is not None
|
||||||
|
assert b.kind == "837p"
|
||||||
|
claims = session.query(Claim).all()
|
||||||
|
assert len(claims) == 2
|
||||||
|
assert all(c.state == ClaimState.SUBMITTED for c in claims)
|
||||||
|
|
||||||
|
|
||||||
def test_get_missing_returns_none():
|
def test_iter_claims_returns_dicts():
|
||||||
s = InMemoryStore()
|
s = CycloneStore()
|
||||||
assert s.get("does-not-exist") is None
|
s.add(_make_batch_record())
|
||||||
|
rows = s.iter_claims()
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert all("id" in r and "state" in r for r in rows)
|
||||||
|
|
||||||
|
|
||||||
def test_batch_record_parsed_at_accepts_datetime():
|
def test_persistence_across_session():
|
||||||
"""Spec 6.1: parsed_at is a tz-aware datetime, not a string."""
|
s1 = CycloneStore()
|
||||||
rec = BatchRecord(
|
s1.add(_make_batch_record(batch_id="b-x"))
|
||||||
id="x",
|
|
||||||
kind="837p",
|
|
||||||
input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT,
|
|
||||||
result=_make_result(),
|
|
||||||
)
|
|
||||||
assert isinstance(rec.parsed_at, datetime)
|
|
||||||
assert rec.parsed_at.tzinfo is not None
|
|
||||||
|
|
||||||
|
s2 = CycloneStore() # fresh instance, same engine
|
||||||
# ---------------------------------------------------------------------------
|
loaded = s2.get_batch("b-x")
|
||||||
# Task 2: Mappers
|
assert loaded is not None
|
||||||
# ---------------------------------------------------------------------------
|
assert loaded["kind"] == "837p"
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_claim_maps_status_submitted():
|
|
||||||
from cyclone.store import to_ui_claim
|
|
||||||
claim = _make_claim("CLM-1") # passed=True, frequency_code="1"
|
|
||||||
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["id"] == "CLM-1"
|
|
||||||
assert out["status"] == "submitted"
|
|
||||||
assert out["providerNpi"] == "1234567890"
|
|
||||||
assert out["batchId"] == "b1"
|
|
||||||
assert out["parsedAt"] == "2026-06-19T12:00:00Z"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_claim_maps_status_pending_when_warnings_no_freq_one():
|
|
||||||
"""passed + warnings + non-1 frequency_code → pending (rule 2)."""
|
|
||||||
from cyclone.store import to_ui_claim
|
|
||||||
claim = _make_claim("CLM-2", frequency_code="7")
|
|
||||||
claim.validation = ValidationReport(
|
|
||||||
passed=True, errors=[],
|
|
||||||
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
|
|
||||||
)
|
|
||||||
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_claim_submitted_takes_precedence_over_pending():
|
|
||||||
"""passed + frequency_code=1 + warnings → submitted (rule 1 beats rule 2)."""
|
|
||||||
from cyclone.store import to_ui_claim
|
|
||||||
claim = _make_claim("CLM-3", frequency_code="1")
|
|
||||||
claim.validation = ValidationReport(
|
|
||||||
passed=True, errors=[],
|
|
||||||
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
|
|
||||||
)
|
|
||||||
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "submitted"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_claim_maps_status_denied_when_failed():
|
|
||||||
from cyclone.store import to_ui_claim
|
|
||||||
claim = _make_claim("CLM-4")
|
|
||||||
claim.validation = ValidationReport(
|
|
||||||
passed=False,
|
|
||||||
errors=[ValidationIssue(rule="E001", severity="error", message="hard")],
|
|
||||||
warnings=[],
|
|
||||||
)
|
|
||||||
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "denied"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_claim_maps_status_draft_when_r050_diagnosis_present():
|
|
||||||
"""!passed with an R050_diagnosis_present error → draft (carve-out)."""
|
|
||||||
from cyclone.store import to_ui_claim
|
|
||||||
claim = _make_claim("CLM-5")
|
|
||||||
claim.validation = ValidationReport(
|
|
||||||
passed=False,
|
|
||||||
errors=[ValidationIssue(
|
|
||||||
rule="R050_diagnosis_present", severity="error",
|
|
||||||
message="missing principal diagnosis",
|
|
||||||
)],
|
|
||||||
warnings=[],
|
|
||||||
)
|
|
||||||
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "draft"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_claim_maps_status_draft_when_passed_no_freq_no_warnings():
|
|
||||||
"""passed + non-1 frequency + no warnings → draft."""
|
|
||||||
from cyclone.store import to_ui_claim
|
|
||||||
claim = _make_claim("CLM-6", frequency_code="7")
|
|
||||||
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "draft"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_remittance_maps_status_received_for_paid():
|
|
||||||
from cyclone.store import to_ui_remittance
|
|
||||||
cp = _make_remit(status_code="1")
|
|
||||||
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "received"
|
|
||||||
assert out["denialReason"] is None
|
|
||||||
assert out["validationWarnings"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_remittance_denial_reason_from_cas_for_code_4():
|
|
||||||
"""CLP02 == 4 → received with denialReason populated from first CAS."""
|
|
||||||
from cyclone.store import to_ui_remittance
|
|
||||||
sp = ServicePayment(
|
|
||||||
line_number=1,
|
|
||||||
procedure_qualifier="HC",
|
|
||||||
procedure_code="99213",
|
|
||||||
modifiers=[],
|
|
||||||
charge=Decimal("100.00"),
|
|
||||||
payment=Decimal("0.00"),
|
|
||||||
units=None,
|
|
||||||
unit_type=None,
|
|
||||||
service_date=None,
|
|
||||||
adjustments=[
|
|
||||||
ClaimAdjustment(
|
|
||||||
group_code="CO", reason_code="97",
|
|
||||||
amount=Decimal("100.00"), quantity=None,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cp = _make_remit(status_code="4", service_payments=[sp])
|
|
||||||
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "received"
|
|
||||||
assert out["denialReason"] == "CO-97: $100.00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_remittance_denial_reason_none_when_no_cas_for_code_4():
|
|
||||||
"""CLP02 == 4 with no CAS segments → denialReason stays None."""
|
|
||||||
from cyclone.store import to_ui_remittance
|
|
||||||
cp = _make_remit(status_code="4", service_payments=[])
|
|
||||||
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
|
|
||||||
assert out["status"] == "received"
|
|
||||||
assert out["denialReason"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_remittance_validation_warning_for_invalid_clp02():
|
|
||||||
"""CLP02 outside the allowlist → received + validationWarnings populated."""
|
|
||||||
from cyclone.store import to_ui_remittance
|
|
||||||
cp = _make_remit(status_code="99") # not in {"1","2","3","4","19","20","21","22","23","25"}
|
|
||||||
out = to_ui_remittance(
|
|
||||||
cp, batch_id="b1", parsed_at=_PARSED_AT,
|
|
||||||
payer_config=PayerConfig835.generic_835(),
|
|
||||||
)
|
|
||||||
assert out["status"] == "received"
|
|
||||||
assert any("99" in w and "allowlist" in w for w in out["validationWarnings"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_remittance_no_warning_for_allowed_clp02():
|
|
||||||
"""CLP02 in the allowlist → empty validationWarnings."""
|
|
||||||
from cyclone.store import to_ui_remittance
|
|
||||||
cp = _make_remit(status_code="1")
|
|
||||||
out = to_ui_remittance(
|
|
||||||
cp, batch_id="b1", parsed_at=_PARSED_AT,
|
|
||||||
payer_config=PayerConfig835.generic_835(),
|
|
||||||
)
|
|
||||||
assert out["validationWarnings"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_remittance_fills_payer_name():
|
|
||||||
"""payer_name kwarg flows into the remittance's payerName field."""
|
|
||||||
from cyclone.store import to_ui_remittance
|
|
||||||
cp = _make_remit(status_code="1")
|
|
||||||
out = to_ui_remittance(
|
|
||||||
cp, batch_id="b1", parsed_at=_PARSED_AT, payer_name="CO_TXIX",
|
|
||||||
)
|
|
||||||
assert out["payerName"] == "CO_TXIX"
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_ui_provider_aggregates_claims():
|
|
||||||
from cyclone.store import to_ui_provider
|
|
||||||
out = to_ui_provider(
|
|
||||||
npi="1234567890", name="Acme Clinic", tax_id="99-9999999",
|
|
||||||
address="1 Main", city="Denver", state="CO", zip="80202", phone="303-555-0100",
|
|
||||||
claim_count=5, outstanding_ar=250.0,
|
|
||||||
)
|
|
||||||
assert out["npi"] == "1234567890"
|
|
||||||
assert out["claimCount"] == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_activity_event_serializes_datetime_to_iso_z():
|
|
||||||
from cyclone.store import to_activity_event
|
|
||||||
out = to_activity_event(
|
|
||||||
id="a1", kind="claim_submitted",
|
|
||||||
message="Claim CLM-1 submitted",
|
|
||||||
timestamp=_PARSED_AT,
|
|
||||||
npi="1234567890", amount=100.0,
|
|
||||||
)
|
|
||||||
assert out["id"] == "a1"
|
|
||||||
assert out["kind"] == "claim_submitted"
|
|
||||||
assert out["amount"] == 100.0
|
|
||||||
assert out["timestamp"] == "2026-06-19T12:00:00Z"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Task 3: Iterators
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_claims_filters_by_status():
|
|
||||||
s = InMemoryStore()
|
|
||||||
result = _make_result()
|
|
||||||
a = _make_claim("A", frequency_code="1") # passed + freq=1 → submitted
|
|
||||||
b = _make_claim("B", frequency_code="7")
|
|
||||||
b.validation = ValidationReport(
|
|
||||||
passed=True, errors=[],
|
|
||||||
warnings=[ValidationIssue(rule="W1", severity="warning", message="x")],
|
|
||||||
) # passed + freq=7 + warnings → pending
|
|
||||||
result.claims = [a, b]
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="1", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=result,
|
|
||||||
))
|
|
||||||
out = list(s.iter_claims(status="submitted"))
|
|
||||||
assert all(c["status"] == "submitted" for c in out)
|
|
||||||
assert any(c["id"] == "A" for c in out)
|
|
||||||
out_pending = list(s.iter_claims(status="pending"))
|
|
||||||
assert all(c["status"] == "pending" for c in out_pending)
|
|
||||||
assert any(c["id"] == "B" for c in out_pending)
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_claims_filters_by_batch_id():
|
|
||||||
s = InMemoryStore()
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=_make_result(),
|
|
||||||
))
|
|
||||||
out = list(s.iter_claims(batch_id="b1"))
|
|
||||||
assert all(c["batchId"] == "b1" for c in out)
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_claims_sorts_by_billed_amount_desc():
|
|
||||||
s = InMemoryStore()
|
|
||||||
result = _make_result()
|
|
||||||
result.claims = [
|
|
||||||
_make_claim("cheap"),
|
|
||||||
_make_claim("expensive"),
|
|
||||||
]
|
|
||||||
result.claims[0].claim.total_charge = Decimal("50.00")
|
|
||||||
result.claims[1].claim.total_charge = Decimal("500.00")
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=result,
|
|
||||||
))
|
|
||||||
out = list(s.iter_claims(sort="billedAmount", order="desc"))
|
|
||||||
assert out[0]["id"] == "expensive"
|
|
||||||
assert out[1]["id"] == "cheap"
|
|
||||||
|
|
||||||
|
|
||||||
def test_distinct_providers_aggregates_claims():
|
|
||||||
s = InMemoryStore()
|
|
||||||
result = _make_result()
|
|
||||||
result.claims = [_make_claim("A"), _make_claim("B")]
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=result,
|
|
||||||
))
|
|
||||||
out = s.distinct_providers()
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["npi"] == "1234567890"
|
|
||||||
assert out[0]["claimCount"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_recent_activity_returns_newest_first():
|
|
||||||
older = datetime(2026, 6, 19, 11, 0, 0, tzinfo=timezone.utc)
|
|
||||||
s = InMemoryStore()
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=older, result=_make_result(),
|
|
||||||
))
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b2", kind="837p", input_filename="b.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=_make_result(),
|
|
||||||
))
|
|
||||||
out = s.recent_activity(limit=10)
|
|
||||||
assert out[0]["timestamp"] == "2026-06-19T12:00:00Z"
|
|
||||||
assert out[1]["timestamp"] == "2026-06-19T11:00:00Z"
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_remittances_fills_payer_name_from_batch():
|
|
||||||
"""iter_remittances should populate payerName from ParseResult835.payer.name."""
|
|
||||||
s = InMemoryStore()
|
|
||||||
cp = _make_remit(status_code="1")
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="835-1", kind="835", input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=_make_835_result(claims=[cp]),
|
|
||||||
))
|
|
||||||
out = list(s.iter_remittances())
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["payerName"] == "CO_TXIX"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Review fixes: payer / date filters, tz-aware validator
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_claim_with_payer(claim_id: str, payer_name: str) -> ClaimOutput:
|
|
||||||
"""ClaimOutput variant that lets us pick a payer name per row."""
|
|
||||||
c = _make_claim(claim_id)
|
|
||||||
c.payer = Payer(name=payer_name, id="P1")
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_claims_filters_by_payer_substring_case_insensitive():
|
|
||||||
"""iter_claims(payer=...) is a case-insensitive substring match on payerName."""
|
|
||||||
s = InMemoryStore()
|
|
||||||
result = _make_result()
|
|
||||||
result.claims = [
|
|
||||||
_make_claim_with_payer("A", "Aetna Health"),
|
|
||||||
_make_claim_with_payer("B", "Blue Cross"),
|
|
||||||
_make_claim_with_payer("C", "aetna better health"),
|
|
||||||
]
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=result,
|
|
||||||
))
|
|
||||||
out = s.iter_claims(payer="Aetna")
|
|
||||||
ids = sorted(c["id"] for c in out)
|
|
||||||
assert ids == ["A", "C"], f"expected Aetna matches, got {ids}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_claims_filters_by_date_from_includes_in_range_only():
|
|
||||||
"""date_from excludes items whose submissionDate is before the bound."""
|
|
||||||
s = InMemoryStore()
|
|
||||||
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
new = datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
old_result = _make_result()
|
|
||||||
old_result.claims = [_make_claim("OLD")]
|
|
||||||
new_result = _make_result()
|
|
||||||
new_result.claims = [_make_claim("NEW")]
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b-old", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=old, result=old_result,
|
|
||||||
))
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b-new", kind="837p", input_filename="b.txt",
|
|
||||||
parsed_at=new, result=new_result,
|
|
||||||
))
|
|
||||||
out = s.iter_claims(date_from="2026-06-20")
|
|
||||||
assert [c["id"] for c in out] == ["NEW"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_claims_filters_by_date_to_includes_in_range_only():
|
|
||||||
"""date_to excludes items whose submissionDate is after the bound."""
|
|
||||||
s = InMemoryStore()
|
|
||||||
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
new = datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
old_result = _make_result()
|
|
||||||
old_result.claims = [_make_claim("OLD")]
|
|
||||||
new_result = _make_result()
|
|
||||||
new_result.claims = [_make_claim("NEW")]
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b-old", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=old, result=old_result,
|
|
||||||
))
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b-new", kind="837p", input_filename="b.txt",
|
|
||||||
parsed_at=new, result=new_result,
|
|
||||||
))
|
|
||||||
out = s.iter_claims(date_to="2026-06-20")
|
|
||||||
assert [c["id"] for c in out] == ["OLD"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_remittances_filters_by_date_from_and_date_to():
|
|
||||||
"""Remit receivedDate respects both date_from and date_to bounds."""
|
|
||||||
s = InMemoryStore()
|
|
||||||
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
mid = datetime(2026, 6, 20, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
new = datetime(2026, 6, 30, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
for ts, tag in [(old, "OLD"), (mid, "MID"), (new, "NEW")]:
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id=f"835-{tag}", kind="835", input_filename="a.txt",
|
|
||||||
parsed_at=ts, result=_make_835_result(claims=[_make_remit()]),
|
|
||||||
))
|
|
||||||
out = s.iter_remittances(date_from="2026-06-15", date_to="2026-06-25")
|
|
||||||
assert [r["batchId"] for r in out] == ["835-MID"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_claims_unbounded_date_filter_includes_all():
|
|
||||||
"""With no date bounds, the filter is a no-op (all items returned)."""
|
|
||||||
s = InMemoryStore()
|
|
||||||
result = _make_result()
|
|
||||||
result.claims = [_make_claim("A"), _make_claim("B")]
|
|
||||||
s.add(BatchRecord(
|
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=_PARSED_AT, result=result,
|
|
||||||
))
|
|
||||||
out = s.iter_claims()
|
|
||||||
assert len(out) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_batch_record_rejects_naive_parsed_at():
|
|
||||||
"""tz-aware validator: a naive datetime should raise ValueError."""
|
|
||||||
import pytest
|
|
||||||
with pytest.raises(ValueError, match="tz-aware"):
|
|
||||||
BatchRecord(
|
|
||||||
id="x", kind="837p", input_filename="a.txt",
|
|
||||||
parsed_at=datetime(2025, 1, 1), result=_make_result(),
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user