feat(backend): add Batch + Claim ORM models + ClaimState enum
This commit is contained in:
+129
-2
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user