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
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user