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
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -28,6 +29,7 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
||||||
from sqlalchemy.types import TypeDecorator
|
from sqlalchemy.types import TypeDecorator
|
||||||
@@ -141,15 +143,11 @@ class JSONText(TypeDecorator):
|
|||||||
def process_bind_param(self, value, dialect): # type: ignore[no-untyped-def]
|
def process_bind_param(self, value, dialect): # type: ignore[no-untyped-def]
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
import json
|
|
||||||
|
|
||||||
return json.dumps(value)
|
return json.dumps(value)
|
||||||
|
|
||||||
def process_result_value(self, value, dialect): # type: ignore[no-untyped-def]
|
def process_result_value(self, value, dialect): # type: ignore[no-untyped-def]
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
import json
|
|
||||||
|
|
||||||
return json.loads(value)
|
return json.loads(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -172,8 +170,13 @@ class Batch(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__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):
|
class Claim(Base):
|
||||||
@@ -196,7 +199,17 @@ class Claim(Base):
|
|||||||
Enum(ClaimState, native_enum=False), nullable=True
|
Enum(ClaimState, native_enum=False), nullable=True
|
||||||
)
|
)
|
||||||
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
|
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)
|
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.state == ClaimState.SUBMITTED
|
||||||
assert loaded.charge_amount == Decimal("124.00")
|
assert loaded.charge_amount == Decimal("124.00")
|
||||||
assert loaded.service_date_from == date(2026, 6, 1)
|
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