feat(backend): fix T4 index drift, lazy import, FK policy, expand test coverage
This commit is contained in:
@@ -11,6 +11,7 @@ same engine.
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import json
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
@@ -28,6 +29,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
@@ -141,15 +143,11 @@ class JSONText(TypeDecorator):
|
||||
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)
|
||||
|
||||
|
||||
@@ -172,8 +170,13 @@ class Batch(Base):
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_batches_parsed_at", "parsed_at"),
|
||||
# Migration is the source of truth: 0001_initial.sql declares this DESC.
|
||||
# `text(...)` keeps the ORM in sync so fresh DBs built via `create_all`
|
||||
# alone (no migration runner) get the same index direction as prod.
|
||||
Index("ix_batches_parsed_at", text("parsed_at DESC")),
|
||||
)
|
||||
# TODO(T6): when ActivityEvent model is added, mirror this pattern for
|
||||
# `ix_activity_events_ts` on activity_events(ts DESC) per 0001_initial.sql:86.
|
||||
|
||||
|
||||
class Claim(Base):
|
||||
@@ -196,7 +199,17 @@ class Claim(Base):
|
||||
Enum(ClaimState, native_enum=False), nullable=True
|
||||
)
|
||||
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), ForeignKey("remittances.id"), nullable=True
|
||||
# ORM policy: SET NULL on remittance delete. The claim may outlive its
|
||||
# remittance during reversal/reimport flows; without this, deleting a
|
||||
# remittance with matched claims raises IntegrityError.
|
||||
# Note: 0001_initial.sql predates this policy (no ON DELETE clause).
|
||||
# SQLite ignores FK direction by default unless PRAGMA foreign_keys=ON,
|
||||
# so the inconsistency is benign there; the ORM is the source of truth
|
||||
# for PostgreSQL/test environments with FK enforcement. Amendment to the
|
||||
# migration is deferred to post-SQLite rollout.
|
||||
String(64),
|
||||
ForeignKey("remittances.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
||||
|
||||
|
||||
@@ -78,3 +78,80 @@ def test_create_and_query_claim_with_default_state():
|
||||
assert loaded.state == ClaimState.SUBMITTED
|
||||
assert loaded.charge_amount == Decimal("124.00")
|
||||
assert loaded.service_date_from == date(2026, 6, 1)
|
||||
|
||||
|
||||
def test_claim_uses_db_default_when_state_omitted():
|
||||
"""The `state` column has a DB-side DEFAULT of 'submitted' (per migration
|
||||
and ORM `default=`). When the caller omits `state=`, the loaded value
|
||||
must be ClaimState.SUBMITTED, not None."""
|
||||
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",
|
||||
charge_amount=Decimal("0"),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def test_state_before_reversal_round_trips():
|
||||
"""`state_before_reversal` is nullable but stores a ClaimState enum.
|
||||
Setting it to PAID must round-trip as ClaimState.PAID after commit+reload."""
|
||||
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",
|
||||
charge_amount=Decimal("50.00"),
|
||||
state_before_reversal=ClaimState.PAID,
|
||||
)
|
||||
s.add(c)
|
||||
s.commit()
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
loaded = s.get(Claim, "CLM-1")
|
||||
assert loaded is not None
|
||||
assert loaded.state_before_reversal == ClaimState.PAID
|
||||
|
||||
|
||||
def test_batch_cascade_delete_drops_claims():
|
||||
"""Deleting a Batch must cascade-delete its claims (FK is ON DELETE CASCADE
|
||||
in migration; ORM relationship uses cascade='all, delete-orphan' which makes
|
||||
SQLAlchemy emit the child DELETE at flush time even without
|
||||
PRAGMA foreign_keys=ON)."""
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Batch(
|
||||
id="b1", kind="837p", input_filename="x",
|
||||
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
||||
))
|
||||
s.add(Claim(
|
||||
id="CLM-1",
|
||||
batch_id="b1",
|
||||
patient_control_number="CLM-1",
|
||||
charge_amount=Decimal("0"),
|
||||
))
|
||||
s.commit()
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
batch = s.get(Batch, "b1")
|
||||
assert batch is not None
|
||||
s.delete(batch)
|
||||
s.commit()
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
assert s.get(Batch, "b1") is None
|
||||
assert s.get(Claim, "CLM-1") is None
|
||||
|
||||
Reference in New Issue
Block a user