refactor(store): code quality fixes per review
- iter_claims/iter_remittances now return list[dict] (snapshot under the lock, return outside) so slow consumers no longer hold the write lock during iteration - iter_claims gains payer (case-insensitive substring) and date_from/date_to filters via the new _date_in_bounds helper - iter_remittances wires date_from/date_to and payer - BatchRecord split into BatchRecord837 / BatchRecord835 with __new__ dispatch so isinstance narrows result correctly, eliminating the four 'type: ignore[assignment]' band-aids - BatchRecord now rejects naive parsed_at via model_validator - to_ui_remittance's adjustmentAmount=0.0 is documented as a TODO for sub-project 2 reconciliation 151 passed, 1 skipped (no regressions; +6 tests for new filters)
This commit is contained in:
+128
-46
@@ -10,9 +10,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Iterable, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
|
|
||||||
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
|
||||||
@@ -23,7 +23,18 @@ BatchKind = Literal["837p", "835"]
|
|||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
``result`` is a union: ``ParseResult`` for ``kind="837p"`` and
|
||||||
|
``ParseResult835`` for ``kind="835"``. The concrete subclasses
|
||||||
|
``BatchRecord837`` and ``BatchRecord835`` narrow those fields, so
|
||||||
|
callers that want type-checked access should use them and check
|
||||||
|
``isinstance`` rather than pattern-matching on ``kind``.
|
||||||
|
|
||||||
|
Constructing ``BatchRecord(kind="837p", ...)`` dispatches to
|
||||||
|
``BatchRecord837``; ``kind="835"`` dispatches to ``BatchRecord835``.
|
||||||
|
This lets the union-member narrowing work transparently.
|
||||||
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
@@ -31,7 +42,44 @@ class BatchRecord(BaseModel):
|
|||||||
kind: BatchKind
|
kind: BatchKind
|
||||||
input_filename: str
|
input_filename: str
|
||||||
parsed_at: datetime # tz-aware UTC
|
parsed_at: datetime # tz-aware UTC
|
||||||
result: ParseResult | ParseResult835 = Field(discriminator=None)
|
result: ParseResult | ParseResult835
|
||||||
|
|
||||||
|
def __new__(
|
||||||
|
cls, *args: Any, **kwargs: Any,
|
||||||
|
) -> BatchRecord837 | BatchRecord835 | BatchRecord:
|
||||||
|
# Dispatch base-class construction to the right concrete subclass
|
||||||
|
# so isinstance checks downstream narrow `result` correctly.
|
||||||
|
if cls is BatchRecord:
|
||||||
|
kind = kwargs.get("kind")
|
||||||
|
if kind is None and args and isinstance(args[0], dict):
|
||||||
|
kind = args[0].get("kind")
|
||||||
|
if kind == "837p":
|
||||||
|
return BatchRecord837(*args, **kwargs)
|
||||||
|
if kind == "835":
|
||||||
|
return BatchRecord835(*args, **kwargs)
|
||||||
|
return super().__new__(cls)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_parsed_at_tz(self) -> BatchRecord:
|
||||||
|
if self.parsed_at.tzinfo is None:
|
||||||
|
raise ValueError(
|
||||||
|
"parsed_at must be tz-aware (use datetime.now(timezone.utc))"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BatchRecord837(BatchRecord):
|
||||||
|
"""A parsed 837P (professional claim) batch."""
|
||||||
|
|
||||||
|
kind: Literal["837p"] = "837p"
|
||||||
|
result: ParseResult
|
||||||
|
|
||||||
|
|
||||||
|
class BatchRecord835(BatchRecord):
|
||||||
|
"""A parsed 835 (remittance advice) batch."""
|
||||||
|
|
||||||
|
kind: Literal["835"] = "835"
|
||||||
|
result: ParseResult835
|
||||||
|
|
||||||
|
|
||||||
class InMemoryStore:
|
class InMemoryStore:
|
||||||
@@ -66,36 +114,45 @@ class InMemoryStore:
|
|||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
provider_npi: str | None = None,
|
provider_npi: str | None = None,
|
||||||
|
payer: str | None = None,
|
||||||
date_from: str | None = None,
|
date_from: str | None = None,
|
||||||
date_to: str | None = None,
|
date_to: str | None = None,
|
||||||
sort: str | None = None,
|
sort: str | None = None,
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> Iterable[dict]:
|
) -> list[dict]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
all_claims: list[dict] = []
|
all_claims: list[dict] = []
|
||||||
for b in self._batches:
|
for b in self._batches:
|
||||||
if b.kind != "837p":
|
if not isinstance(b, BatchRecord837):
|
||||||
continue
|
continue
|
||||||
if batch_id is not None and b.id != batch_id:
|
if batch_id is not None and b.id != batch_id:
|
||||||
continue
|
continue
|
||||||
# ParseResult / ParseResult835 union: 837p kinds are ParseResult
|
for c in b.result.claims:
|
||||||
result = b.result # type: ignore[assignment]
|
|
||||||
for c in result.claims:
|
|
||||||
all_claims.append(to_ui_claim(
|
all_claims.append(to_ui_claim(
|
||||||
c, batch_id=b.id, parsed_at=b.parsed_at,
|
c, batch_id=b.id, parsed_at=b.parsed_at,
|
||||||
))
|
))
|
||||||
if status is not None:
|
if status is not None:
|
||||||
all_claims = [c for c in all_claims if c["status"] == status]
|
all_claims = [c for c in all_claims if c["status"] == status]
|
||||||
if provider_npi is not None:
|
if provider_npi is not None:
|
||||||
all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi]
|
all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi]
|
||||||
if sort is not None:
|
if payer is not None:
|
||||||
all_claims.sort(
|
needle = payer.casefold()
|
||||||
key=lambda c: c.get(sort, 0) or 0,
|
all_claims = [
|
||||||
reverse=(order == "desc"),
|
c for c in all_claims
|
||||||
)
|
if needle in (c.get("payerName") or "").casefold()
|
||||||
yield from all_claims[offset:offset + limit]
|
]
|
||||||
|
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(
|
def iter_remittances(
|
||||||
self,
|
self,
|
||||||
@@ -109,43 +166,44 @@ class InMemoryStore:
|
|||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> Iterable[dict]:
|
) -> list[dict]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
all_remits: list[dict] = []
|
all_remits: list[dict] = []
|
||||||
for b in self._batches:
|
for b in self._batches:
|
||||||
if b.kind != "835":
|
if not isinstance(b, BatchRecord835):
|
||||||
continue
|
continue
|
||||||
if batch_id is not None and b.id != batch_id:
|
if batch_id is not None and b.id != batch_id:
|
||||||
continue
|
continue
|
||||||
# 835 kinds are ParseResult835; its claims list holds ClaimPayment
|
for cp in b.result.claims:
|
||||||
result = b.result # type: ignore[assignment]
|
|
||||||
for cp in result.claims:
|
|
||||||
all_remits.append(to_ui_remittance(
|
all_remits.append(to_ui_remittance(
|
||||||
cp, batch_id=b.id, parsed_at=b.parsed_at,
|
cp, batch_id=b.id, parsed_at=b.parsed_at,
|
||||||
payer_name=result.payer.name,
|
payer_name=b.result.payer.name,
|
||||||
))
|
))
|
||||||
if payer is not None:
|
if payer is not None:
|
||||||
all_remits = [r for r in all_remits if r.get("payerName") == payer]
|
all_remits = [r for r in all_remits if r.get("payerName") == payer]
|
||||||
if claim_id is not None:
|
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 r["claimId"] == claim_id]
|
||||||
if sort is not None:
|
all_remits = [
|
||||||
all_remits.sort(
|
r for r in all_remits
|
||||||
key=lambda r: r.get(sort, 0) or 0,
|
if _date_in_bounds(r, "receivedDate", date_from, date_to)
|
||||||
reverse=(order == "desc"),
|
]
|
||||||
)
|
if sort is not None:
|
||||||
yield from all_remits[offset:offset + limit]
|
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]:
|
def distinct_providers(self) -> list[dict]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
by_npi: dict[str, dict] = {}
|
by_npi: dict[str, dict] = {}
|
||||||
for b in self._batches:
|
for b in self._batches:
|
||||||
if b.kind != "837p":
|
if not isinstance(b, BatchRecord837):
|
||||||
continue
|
continue
|
||||||
result = b.result # type: ignore[assignment]
|
if not b.result.claims:
|
||||||
if not result.claims:
|
|
||||||
continue
|
continue
|
||||||
# billing_provider lives on each claim, not at the batch level
|
# billing_provider lives on each claim, not at the batch level
|
||||||
bp = result.claims[0].billing_provider
|
bp = b.result.claims[0].billing_provider
|
||||||
npi = bp.npi
|
npi = bp.npi
|
||||||
if npi not in by_npi:
|
if npi not in by_npi:
|
||||||
by_npi[npi] = to_ui_provider(
|
by_npi[npi] = to_ui_provider(
|
||||||
@@ -155,16 +213,15 @@ class InMemoryStore:
|
|||||||
claim_count=0,
|
claim_count=0,
|
||||||
outstanding_ar=0.0,
|
outstanding_ar=0.0,
|
||||||
)
|
)
|
||||||
by_npi[npi]["claimCount"] += len(result.claims)
|
by_npi[npi]["claimCount"] += len(b.result.claims)
|
||||||
return list(by_npi.values())
|
return list(by_npi.values())
|
||||||
|
|
||||||
def recent_activity(self, *, limit: int = 200) -> list[dict]:
|
def recent_activity(self, *, limit: int = 200) -> list[dict]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
for b in reversed(self._batches):
|
for b in reversed(self._batches):
|
||||||
if b.kind == "837p":
|
if isinstance(b, BatchRecord837):
|
||||||
result = b.result # type: ignore[assignment]
|
for c in b.result.claims:
|
||||||
for c in result.claims:
|
|
||||||
events.append(to_activity_event(
|
events.append(to_activity_event(
|
||||||
id=f"{b.id}:{c.claim_id}",
|
id=f"{b.id}:{c.claim_id}",
|
||||||
kind="claim_submitted",
|
kind="claim_submitted",
|
||||||
@@ -173,9 +230,8 @@ class InMemoryStore:
|
|||||||
npi=c.billing_provider.npi,
|
npi=c.billing_provider.npi,
|
||||||
amount=float(c.claim.total_charge or 0.0),
|
amount=float(c.claim.total_charge or 0.0),
|
||||||
))
|
))
|
||||||
elif b.kind == "835":
|
elif isinstance(b, BatchRecord835):
|
||||||
result = b.result # type: ignore[assignment]
|
for cp in b.result.claims:
|
||||||
for cp in result.claims:
|
|
||||||
events.append(to_activity_event(
|
events.append(to_activity_event(
|
||||||
id=f"{b.id}:{cp.payer_claim_control_number}",
|
id=f"{b.id}:{cp.payer_claim_control_number}",
|
||||||
kind="remit_received",
|
kind="remit_received",
|
||||||
@@ -194,6 +250,31 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
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]``.
|
||||||
|
|
||||||
|
``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
|
||||||
|
compared. A missing field is included only when no bound is set —
|
||||||
|
bounded filters treat ``None`` as outside the range.
|
||||||
|
"""
|
||||||
|
val = item.get(field)
|
||||||
|
if val is None:
|
||||||
|
# Missing dates: include when unbounded, exclude when bounded.
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Mappers: rich backend models → simpler UI types.
|
# Mappers: rich backend models → simpler UI types.
|
||||||
# The shape of the returned dicts must match the TypeScript interfaces in
|
# The shape of the returned dicts must match the TypeScript interfaces in
|
||||||
@@ -292,6 +373,7 @@ def to_ui_remittance(
|
|||||||
"claimId": cp.original_claim_id or "",
|
"claimId": cp.original_claim_id or "",
|
||||||
"payerName": payer_name,
|
"payerName": payer_name,
|
||||||
"paidAmount": float(cp.total_paid or 0.0),
|
"paidAmount": float(cp.total_paid or 0.0),
|
||||||
|
# TODO: aggregate from CAS segments when reconciliation lands (sub-project 2)
|
||||||
"adjustmentAmount": 0.0,
|
"adjustmentAmount": 0.0,
|
||||||
"status": status,
|
"status": status,
|
||||||
"denialReason": denial_reason,
|
"denialReason": denial_reason,
|
||||||
|
|||||||
@@ -485,3 +485,113 @@ def test_iter_remittances_fills_payer_name_from_batch():
|
|||||||
out = list(s.iter_remittances())
|
out = list(s.iter_remittances())
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
assert out[0]["payerName"] == "CO_TXIX"
|
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