feat(backend): add cyclone.store with BatchRecord and InMemoryStore
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
"""In-memory batch store for parsed X12 files.
|
||||||
|
|
||||||
|
Lives at module scope so route handlers and tests can import a single
|
||||||
|
singleton. Single-process only — no cross-process coordination. Threading:
|
||||||
|
a re-entrant lock guards every public method so FastAPI's threadpool
|
||||||
|
request handlers can call them concurrently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Iterable, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from cyclone.parsers.models import ParseResult
|
||||||
|
from cyclone.parsers.models_835 import ParseResult835
|
||||||
|
|
||||||
|
|
||||||
|
BatchKind = Literal["837p", "835"]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchRecord(BaseModel):
|
||||||
|
"""One parsed file, with a stable uuid4 id and the full ParseResult."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
id: str
|
||||||
|
kind: BatchKind
|
||||||
|
input_filename: str
|
||||||
|
parsed_at: str # ISO 8601 UTC, e.g. "2026-06-19T12:00:00Z"
|
||||||
|
result: ParseResult | ParseResult835 = Field(discriminator=None)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
store = InMemoryStore()
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow_iso() -> str:
|
||||||
|
"""ISO 8601 UTC timestamp, second precision, suffixed with 'Z'."""
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Tests for the in-memory batch store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from cyclone.parsers.models import (
|
||||||
|
BatchSummary,
|
||||||
|
BillingProvider,
|
||||||
|
ClaimHeader,
|
||||||
|
ClaimOutput,
|
||||||
|
Envelope,
|
||||||
|
Payer,
|
||||||
|
ParseResult,
|
||||||
|
Subscriber,
|
||||||
|
ValidationIssue,
|
||||||
|
ValidationReport,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models_835 import ClaimPayment
|
||||||
|
from cyclone.store import BatchRecord, InMemoryStore, store
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stubs (match real model field names)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_claim(claim_id: str = "CLM-1") -> ClaimOutput:
|
||||||
|
"""Minimal ClaimOutput stub. Real parser not needed here."""
|
||||||
|
return ClaimOutput(
|
||||||
|
claim_id=claim_id,
|
||||||
|
control_number="0001",
|
||||||
|
transaction_date="2026-06-19",
|
||||||
|
billing_provider=BillingProvider(
|
||||||
|
name="Test",
|
||||||
|
npi="1234567890",
|
||||||
|
tax_id=None,
|
||||||
|
address=None,
|
||||||
|
),
|
||||||
|
subscriber=Subscriber(
|
||||||
|
first_name="Jane",
|
||||||
|
last_name="Doe",
|
||||||
|
member_id="M1",
|
||||||
|
dob=None,
|
||||||
|
gender=None,
|
||||||
|
address=None,
|
||||||
|
),
|
||||||
|
payer=Payer(name="Test Payer", id="P1"),
|
||||||
|
claim=ClaimHeader(
|
||||||
|
claim_id=claim_id,
|
||||||
|
total_charge=Decimal("0.00"),
|
||||||
|
place_of_service="11",
|
||||||
|
facility_code_qualifier=None,
|
||||||
|
frequency_code="1",
|
||||||
|
provider_signature=None,
|
||||||
|
assignment=None,
|
||||||
|
release_of_info=None,
|
||||||
|
prior_auth=None,
|
||||||
|
),
|
||||||
|
diagnoses=[],
|
||||||
|
service_lines=[],
|
||||||
|
validation=ValidationReport(passed=True, errors=[], warnings=[]),
|
||||||
|
raw_segments=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_result() -> ParseResult:
|
||||||
|
return ParseResult(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="SENDER",
|
||||||
|
receiver_id="RECEIVER",
|
||||||
|
control_number="0001",
|
||||||
|
transaction_date="2026-06-19",
|
||||||
|
transaction_time=None,
|
||||||
|
implementation_guide=None,
|
||||||
|
),
|
||||||
|
claims=[_make_claim("CLM-1")],
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 1: BatchRecord + InMemoryStore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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="2026-06-19T12:00:00Z",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
s.add(rec)
|
||||||
|
assert s.get("abc123") is rec
|
||||||
|
assert s.list() == [rec]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_returns_none():
|
||||||
|
s = InMemoryStore()
|
||||||
|
assert s.get("does-not-exist") is None
|
||||||
Reference in New Issue
Block a user