feat(backend): add Batch + Claim ORM models + ClaimState enum

This commit is contained in:
Tyler
2026-06-19 21:36:02 -06:00
parent 934d623d3a
commit 5be6299b3a
2 changed files with 209 additions and 2 deletions
+129 -2
View File
@@ -9,11 +9,27 @@ re-uses the same engine.
from __future__ import annotations
import enum
import os
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Optional
import sqlalchemy as sa
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from sqlalchemy import (
Date,
DateTime,
Enum,
ForeignKey,
Index,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
from sqlalchemy.types import TypeDecorator
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db"
@@ -63,8 +79,11 @@ def init_db() -> None:
# Import here to avoid circular imports with models.
from cyclone import db_migrate # noqa: WPS433 (deliberate late import)
Base.metadata.create_all(_engine)
# Run migrations first so the schema is the source of truth.
# `create_all` below is a safety net for tests that may not run
# the migration runner; with the migration applied, it is a no-op.
db_migrate.run(_engine)
Base.metadata.create_all(_engine)
_SessionLocal = sessionmaker(
bind=_engine,
@@ -95,3 +114,111 @@ def SessionLocal() -> sessionmaker[sa.orm.Session]:
if _SessionLocal is None:
raise RuntimeError("db.init_db() has not been called")
return _SessionLocal
class ClaimState(str, enum.Enum):
SUBMITTED = "submitted"
RECEIVED = "received"
PAID = "paid"
PARTIAL = "partial"
DENIED = "denied"
RECONCILED = "reconciled"
REVERSED = "reversed"
class JSONText(TypeDecorator):
"""Stores a dict as a JSON string in a TEXT column.
Matches the migration's `totals_json TEXT`, `validation_json TEXT`,
`raw_result_json TEXT`, `raw_json TEXT` columns. The application
code works with dicts; the database stores JSON strings.
"""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect): # type: ignore[no-untyped-def]
if value is None:
return None
import json
return json.dumps(value)
def process_result_value(self, value, dialect): # type: ignore[no-untyped-def]
if value is None:
return None
import json
return json.loads(value)
class Batch(Base):
__tablename__ = "batches"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
kind: Mapped[str] = mapped_column(String(8), nullable=False)
input_filename: Mapped[str] = mapped_column(String(512), nullable=False)
parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
totals_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
validation_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
raw_result_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
claims: Mapped[list["Claim"]] = relationship(
back_populates="batch", cascade="all, delete-orphan"
)
remittances: Mapped[list["Remittance"]] = relationship(
back_populates="batch", cascade="all, delete-orphan"
)
__table_args__ = (
Index("ix_batches_parsed_at", "parsed_at"),
)
class Claim(Base):
__tablename__ = "claims"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
)
patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
state: Mapped[ClaimState] = mapped_column(
Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
)
state_before_reversal: Mapped[Optional[ClaimState]] = mapped_column(
Enum(ClaimState, native_enum=False), nullable=True
)
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
String(64), ForeignKey("remittances.id"), nullable=True
)
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
batch: Mapped["Batch"] = relationship(back_populates="claims")
__table_args__ = (
UniqueConstraint("batch_id", "patient_control_number", name="uq_claims_batch_pcn"),
Index("ix_claims_state", "state"),
Index("ix_claims_patient_control_number", "patient_control_number"),
Index("ix_claims_service_date_from", "service_date_from"),
)
# Minimal stub for forward relationship/FK resolution. The full Remittance
# model (with payer_claim_control_number, status_code, total_charge, etc.)
# is defined in Task 5 and will replace this stub. The stub declares only
# the columns needed for `Batch.remittances` relationship resolution and
# `Claim.matched_remittance_id` FK metadata resolution.
class Remittance(Base):
__tablename__ = "remittances"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
)
batch: Mapped["Batch"] = relationship(back_populates="remittances")
+80
View File
@@ -0,0 +1,80 @@
"""Tests for ORM model definitions and CRUD."""
from __future__ import annotations
import enum
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import db
from cyclone.db import Base, Batch, Claim, ClaimState
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
def test_claim_state_enum_values():
assert ClaimState.SUBMITTED.value == "submitted"
assert ClaimState.RECEIVED.value == "received"
assert ClaimState.PAID.value == "paid"
assert ClaimState.PARTIAL.value == "partial"
assert ClaimState.DENIED.value == "denied"
assert ClaimState.RECONCILED.value == "reconciled"
assert ClaimState.REVERSED.value == "reversed"
# 7 values total.
assert len(list(ClaimState)) == 7
def test_create_and_query_batch():
with db.SessionLocal()() as s:
b = Batch(
id="b1",
kind="837p",
input_filename="test.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
totals_json={"total_claims": 1},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"_": "stub"},
)
s.add(b)
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Batch, "b1")
assert loaded is not None
assert loaded.kind == "837p"
assert loaded.totals_json == {"total_claims": 1}
def test_create_and_query_claim_with_default_state():
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
c = Claim(
id="CLM-1",
batch_id="b1",
patient_control_number="CLM-1",
service_date_from=date(2026, 6, 1),
service_date_to=date(2026, 6, 1),
charge_amount=Decimal("124.00"),
provider_npi="1234567890",
payer_id="SKCO0",
state=ClaimState.SUBMITTED,
)
s.add(c)
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Claim, "CLM-1")
assert loaded is not None
assert loaded.state == ClaimState.SUBMITTED
assert loaded.charge_amount == Decimal("124.00")
assert loaded.service_date_from == date(2026, 6, 1)