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:
Tyler
2026-06-19 19:22:44 -06:00
parent d0411b8186
commit ca645db518
2 changed files with 238 additions and 46 deletions
+128 -46
View File
@@ -10,9 +10,9 @@ from __future__ import annotations
import threading
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_835 import ClaimPayment, ParseResult835
@@ -23,7 +23,18 @@ BatchKind = Literal["837p", "835"]
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")
@@ -31,7 +42,44 @@ class BatchRecord(BaseModel):
kind: BatchKind
input_filename: str
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:
@@ -66,36 +114,45 @@ class InMemoryStore:
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,
) -> Iterable[dict]:
) -> list[dict]:
with self._lock:
all_claims: list[dict] = []
for b in self._batches:
if b.kind != "837p":
if not isinstance(b, BatchRecord837):
continue
if batch_id is not None and b.id != batch_id:
continue
# ParseResult / ParseResult835 union: 837p kinds are ParseResult
result = b.result # type: ignore[assignment]
for c in result.claims:
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 sort is not None:
all_claims.sort(
key=lambda c: c.get(sort, 0) or 0,
reverse=(order == "desc"),
)
yield from all_claims[offset:offset + limit]
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,
@@ -109,43 +166,44 @@ class InMemoryStore:
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> Iterable[dict]:
) -> list[dict]:
with self._lock:
all_remits: list[dict] = []
for b in self._batches:
if b.kind != "835":
if not isinstance(b, BatchRecord835):
continue
if batch_id is not None and b.id != batch_id:
continue
# 835 kinds are ParseResult835; its claims list holds ClaimPayment
result = b.result # type: ignore[assignment]
for cp in result.claims:
for cp in b.result.claims:
all_remits.append(to_ui_remittance(
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:
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]
if sort is not None:
all_remits.sort(
key=lambda r: r.get(sort, 0) or 0,
reverse=(order == "desc"),
)
yield from all_remits[offset:offset + limit]
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 b.kind != "837p":
if not isinstance(b, BatchRecord837):
continue
result = b.result # type: ignore[assignment]
if not result.claims:
if not b.result.claims:
continue
# 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
if npi not in by_npi:
by_npi[npi] = to_ui_provider(
@@ -155,16 +213,15 @@ class InMemoryStore:
claim_count=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())
def recent_activity(self, *, limit: int = 200) -> list[dict]:
with self._lock:
events: list[dict] = []
for b in reversed(self._batches):
if b.kind == "837p":
result = b.result # type: ignore[assignment]
for c in result.claims:
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",
@@ -173,9 +230,8 @@ class InMemoryStore:
npi=c.billing_provider.npi,
amount=float(c.claim.total_charge or 0.0),
))
elif b.kind == "835":
result = b.result # type: ignore[assignment]
for cp in result.claims:
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",
@@ -194,6 +250,31 @@ def utcnow() -> datetime:
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.
# 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 "",
"payerName": payer_name,
"paidAmount": float(cp.total_paid or 0.0),
# TODO: aggregate from CAS segments when reconciliation lands (sub-project 2)
"adjustmentAmount": 0.0,
"status": status,
"denialReason": denial_reason,