feat(sp27): pin Claim↔Remit matched-pair invariant + startup drift audit
Add check_matched_pair_drift() at module level in store.py — read-only audit of the Claim.matched_remittance_id ↔ Remittance.claim_id FK pair. Logs WARNING on drift with up to 5 examples of each of two cases (claim-side and remit-side), returns the count of drifted rows. Wired into api.py::lifespan right after db.init_db() and ensure_clearhouse_seeded(), wrapped in try/except so a query failure logs but doesn't crash boot. The symmetric-write + symmetric-clear invariants on manual_match / manual_unmatch were already correct; this commit pins them with focused regression tests in test_store_match_invariant.py (8 tests covering both directions, both happy-path, rollback pin, and drift-check unit behavior). PCN asymmetry in the fixture prevents the auto-match inside CycloneStore.add (Task 10) from pre-pairing, so manual_match is the only writer. Migration 0016 adds the missing ix_claims_matched_remittance_id index — the drift check scans WHERE matched_remittance_id IS NOT NULL on every boot and would become a full-table scan past ~10k claims. Symmetric with ix_remittances_claim_id (added in 0007). Migration tests bumped from head=15 to head=16.
This commit is contained in:
@@ -146,6 +146,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.exception("SP9 seed failed: %s", exc)
|
log.exception("SP9 seed failed: %s", exc)
|
||||||
|
|
||||||
|
# SP27 Task 11: startup audit for the Claim.matched_remittance_id ↔
|
||||||
|
# Remittance.claim_id denormalized FK pair. Non-blocking — mismatches
|
||||||
|
# are logged at WARNING with up to 5 examples of each case so the
|
||||||
|
# operator can investigate without the system failing to boot.
|
||||||
|
try:
|
||||||
|
from cyclone.store import check_matched_pair_drift
|
||||||
|
check_matched_pair_drift()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("matched-pair drift check failed: %s", exc)
|
||||||
|
|
||||||
# SP16: configure the inbound MFT polling scheduler. The
|
# SP16: configure the inbound MFT polling scheduler. The
|
||||||
# dzinesco clearhouse singleton (seeded by SP9) carries the SFTP
|
# dzinesco clearhouse singleton (seeded by SP9) carries the SFTP
|
||||||
# block — multi-provider polling is out of scope for v1.
|
# block — multi-provider polling is out of scope for v1.
|
||||||
|
|||||||
@@ -317,6 +317,13 @@ class Claim(Base):
|
|||||||
Index("ix_claims_state", "state"),
|
Index("ix_claims_state", "state"),
|
||||||
Index("ix_claims_patient_control_number", "patient_control_number"),
|
Index("ix_claims_patient_control_number", "patient_control_number"),
|
||||||
Index("ix_claims_service_date_from", "service_date_from"),
|
Index("ix_claims_service_date_from", "service_date_from"),
|
||||||
|
# SP27 Task 11: matched-pair drift check (run at startup)
|
||||||
|
# scans ``WHERE matched_remittance_id IS NOT NULL``. Without
|
||||||
|
# this index it's a full claim scan. The reverse side
|
||||||
|
# (``ix_remittances_claim_id``) is added in 0007. Pure index
|
||||||
|
# (non-unique) — a claim without a match is fine, reversals
|
||||||
|
# leave the previous claim/claim match intact.
|
||||||
|
Index("ix_claims_matched_remittance_id", "matched_remittance_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- version: 16
|
||||||
|
-- Add the missing index on claims.matched_remittance_id.
|
||||||
|
--
|
||||||
|
-- SP27 Task 11 added ``check_matched_pair_drift`` at startup, which
|
||||||
|
-- scans ``WHERE Claim.matched_remittance_id IS NOT NULL``. Without an
|
||||||
|
-- index this is a full-table scan; becomes a noticeable boot
|
||||||
|
-- latency cost past ~10k claims. The companion index on the
|
||||||
|
-- reverse side (``remittances.claim_id``) was added in 0007.
|
||||||
|
--
|
||||||
|
-- A plain index is enough — neither side is unique (reversals
|
||||||
|
-- re-reference the original PCN, and a claim without a match is
|
||||||
|
-- fine).
|
||||||
|
|
||||||
|
CREATE INDEX ix_claims_matched_remittance_id
|
||||||
|
ON claims(matched_remittance_id);
|
||||||
@@ -878,6 +878,110 @@ class _BatchesShim:
|
|||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def check_matched_pair_drift() -> int:
|
||||||
|
"""Audit the ``Claim.matched_remittance_id`` ↔ ``Remittance.claim_id``
|
||||||
|
FK pair at startup. Non-blocking (SP27 Task 11).
|
||||||
|
|
||||||
|
The matched pair is a denormalized FK pair maintained transactionally
|
||||||
|
by ``manual_match``, ``manual_unmatch``, and ``reconcile.run``. A
|
||||||
|
pre-existing mismatch (e.g. a row written before a state migration
|
||||||
|
that added one column but not the other) would otherwise stay
|
||||||
|
invisible until the next operator pair attempt fails confusingly.
|
||||||
|
This check logs the count + up to N examples so operators can
|
||||||
|
investigate without booting the system.
|
||||||
|
|
||||||
|
Returns the number of drifted rows (0 means clean). Does not
|
||||||
|
raise; bootstrap continues even if drift is detected.
|
||||||
|
|
||||||
|
Count semantics: this returns *drifted rows*, not *drifted pairs*.
|
||||||
|
A single broken pair (``Claim.matched_remittance_id = X`` AND
|
||||||
|
``Remittance.claim_id = Y != nil`` with neither pointing back)
|
||||||
|
can produce TWO drifted rows — one in case A (the claim) and
|
||||||
|
one in case B (the remit). In practice drift is almost always
|
||||||
|
asymmetric (one side NULL), so count == count(pairs); for the
|
||||||
|
fully-symmetric minority, divide by ~2 when alerting. Real drift
|
||||||
|
should be fixed by repairing the writer path, not by counting.
|
||||||
|
|
||||||
|
Cases:
|
||||||
|
A. Claim ``matched_remittance_id = X`` but the paired remit X's
|
||||||
|
``claim_id`` is either NULL or doesn't point back to the claim.
|
||||||
|
B. Remit ``claim_id = A`` but the paired claim A's
|
||||||
|
``matched_remittance_id`` is either NULL or doesn't point back.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# Case A: claim says X is paired, but X.claim_id doesn't point back.
|
||||||
|
case_a = list(
|
||||||
|
s.execute(
|
||||||
|
select(
|
||||||
|
Claim.id.label("claim_id"),
|
||||||
|
Claim.matched_remittance_id.label("claimed_remit_id"),
|
||||||
|
Remittance.claim_id.label("remit_points_to"),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Remittance,
|
||||||
|
Remittance.id == Claim.matched_remittance_id,
|
||||||
|
)
|
||||||
|
.where(Claim.matched_remittance_id.is_not(None))
|
||||||
|
.where(
|
||||||
|
(Remittance.claim_id.is_(None))
|
||||||
|
| (Remittance.claim_id != Claim.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
# Case B: remit says A is paired, but A.matched_remittance_id
|
||||||
|
# doesn't point back.
|
||||||
|
case_b = list(
|
||||||
|
s.execute(
|
||||||
|
select(
|
||||||
|
Remittance.id.label("remit_id"),
|
||||||
|
Remittance.claim_id.label("claimed_claim_id"),
|
||||||
|
Claim.matched_remittance_id.label("claim_points_to"),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Claim,
|
||||||
|
Claim.id == Remittance.claim_id,
|
||||||
|
)
|
||||||
|
.where(Remittance.claim_id.is_not(None))
|
||||||
|
.where(
|
||||||
|
(Claim.matched_remittance_id.is_(None))
|
||||||
|
| (Claim.matched_remittance_id != Remittance.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(case_a) + len(case_b)
|
||||||
|
if total == 0:
|
||||||
|
log.info("matched-pair drift check: 0 mismatches (clean)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
log.warning(
|
||||||
|
"matched-pair drift check: %d mismatched pair(s) (showing up to 5 "
|
||||||
|
"of each). Investigate via SELECT against claim / remittance; "
|
||||||
|
"manual re-pair via /api/claims/{id}/manual-match will repair.",
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
for r in case_a[:5]:
|
||||||
|
log.warning(
|
||||||
|
" case A: claim %s -> remit %s, but remit.claim_id=%r",
|
||||||
|
r.claim_id,
|
||||||
|
r.claimed_remit_id,
|
||||||
|
r.remit_points_to,
|
||||||
|
)
|
||||||
|
for r in case_b[:5]:
|
||||||
|
log.warning(
|
||||||
|
" case B: remit %s -> claim %s, but claim.matched_remittance_id=%r",
|
||||||
|
r.remit_id,
|
||||||
|
r.claimed_claim_id,
|
||||||
|
r.claim_points_to,
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CycloneStore: the SQLAlchemy-backed facade.
|
# CycloneStore: the SQLAlchemy-backed facade.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,21 +51,22 @@ def test_migration_0002_creates_acks_table():
|
|||||||
|
|
||||||
def test_migration_latest_idempotent_on_fresh_db():
|
def test_migration_latest_idempotent_on_fresh_db():
|
||||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||||
user_version already at the latest version — currently 15 after
|
user_version already at the latest version — currently 16 after
|
||||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
||||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
||||||
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups,
|
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups,
|
||||||
SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id,
|
SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id,
|
||||||
SP22's 0015 drop_claims_unique_constraint)."""
|
SP22's 0015 drop_claims_unique_constraint, SP27-Task 11's 0016
|
||||||
|
claims.matched_remittance_id index)."""
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v1 == 15
|
assert v1 == 16
|
||||||
# A second run should not raise and should not bump the version.
|
# A second run should not raise and should not bump the version.
|
||||||
db_migrate.run(db.engine())
|
db_migrate.run(db.engine())
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v2 == 15
|
assert v2 == 16
|
||||||
|
|
||||||
|
|
||||||
def test_add_ack_persists_row():
|
def test_add_ack_persists_row():
|
||||||
|
|||||||
@@ -119,14 +119,16 @@ def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
|
|||||||
"""All migrations up to the current head run cleanly on a fresh DB,
|
"""All migrations up to the current head run cleanly on a fresh DB,
|
||||||
and a second run is a no-op (no version bump). SP22 bumped the
|
and a second run is a no-op (no version bump). SP22 bumped the
|
||||||
expected head from 14 to 15 with the new UNIQUE-drop migration.
|
expected head from 14 to 15 with the new UNIQUE-drop migration.
|
||||||
|
SP27 Task 11 bumped it to 16 with the claims.matched_remittance_id
|
||||||
|
index (drift-check perf).
|
||||||
"""
|
"""
|
||||||
engine = _fresh_engine(tmp_path)
|
engine = _fresh_engine(tmp_path)
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
v_after_first = _user_version(engine)
|
v_after_first = _user_version(engine)
|
||||||
assert v_after_first == 15, f"expected head=15, got {v_after_first}"
|
assert v_after_first == 16, f"expected head=16, got {v_after_first}"
|
||||||
|
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
assert _user_version(engine) == 15, "second run should not bump version"
|
assert _user_version(engine) == 16, "second run should not bump version"
|
||||||
|
|
||||||
|
|
||||||
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -152,7 +154,7 @@ def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: py
|
|||||||
engine = _fresh_engine(tmp_path)
|
engine = _fresh_engine(tmp_path)
|
||||||
|
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
assert _user_version(engine) == 15, f"expected head=15, got {_user_version(engine)}"
|
assert _user_version(engine) == 16, f"expected head=16, got {_user_version(engine)}"
|
||||||
|
|
||||||
# Two claims in one batch with the same patient_control_number
|
# Two claims in one batch with the same patient_control_number
|
||||||
# must be insertable. If 0015's table recreation re-introduced a
|
# must be insertable. If 0015's table recreation re-introduced a
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
"""SP27 Task 11: matched-pair invariant.
|
||||||
|
|
||||||
|
The matched pair is a denormalized FK pair — ``Claim.matched_remittance_id``
|
||||||
|
and ``Remittance.claim_id`` — that MUST agree in both directions at every
|
||||||
|
moment. Three writers can mutate it:
|
||||||
|
|
||||||
|
1. ``CycloneStore.manual_match`` — operator override. SP27 Task 11 pins
|
||||||
|
it writes BOTH sides in one transaction.
|
||||||
|
2. ``CycloneStore.manual_unmatch`` — operator reversal. SP27 Task 11
|
||||||
|
pins it clears BOTH sides in one transaction.
|
||||||
|
3. ``reconcile.run`` (inside ``CycloneStore.add`` post Task 10) — auto-
|
||||||
|
match. Already pinned by the existing reconcile tests.
|
||||||
|
|
||||||
|
The drift check (``check_matched_pair_drift``) catches pre-existing
|
||||||
|
mismatches at startup and logs them at WARNING. Non-blocking.
|
||||||
|
|
||||||
|
These tests pin the invariants directly. They are explicitly redundant
|
||||||
|
with the integration tests in ``test_store_reconcile.py`` — the goal
|
||||||
|
here is a focused regression pin so a future refactor that touches
|
||||||
|
``manual_match`` / ``manual_unmatch`` (e.g. to defer the symmetric write
|
||||||
|
to a post-commit pass) doesn't silently break the invariant.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.parsers.models import (
|
||||||
|
BatchSummary, BillingProvider, ClaimHeader, ClaimOutput,
|
||||||
|
Envelope, Payer, ParseResult, Subscriber, ValidationReport,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models_835 import (
|
||||||
|
BatchSummary as BatchSummary835, ClaimAdjustment, ClaimPayment,
|
||||||
|
Envelope as Envelope835, FinancialInfo, ParseResult835, Payer835,
|
||||||
|
Payee835, ReassociationTrace, ServicePayment,
|
||||||
|
)
|
||||||
|
from cyclone.store import (
|
||||||
|
CycloneStore,
|
||||||
|
check_matched_pair_drift,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
yield
|
||||||
|
db._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_claim(claim_id, pcn):
|
||||||
|
"""Build a single 837 claim + persist it through CycloneStore.add."""
|
||||||
|
from cyclone.store import BatchRecord837
|
||||||
|
|
||||||
|
co = ClaimOutput(
|
||||||
|
claim_id=claim_id,
|
||||||
|
control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
billing_provider=BillingProvider(name="Test", npi="1234567890"),
|
||||||
|
subscriber=Subscriber(
|
||||||
|
first_name="Jane", last_name="Doe", member_id=pcn,
|
||||||
|
),
|
||||||
|
payer=Payer(name="Test Payer", id="P1"),
|
||||||
|
claim=ClaimHeader(
|
||||||
|
claim_id=claim_id, total_charge=Decimal("100"),
|
||||||
|
frequency_code="1", place_of_service="11",
|
||||||
|
),
|
||||||
|
diagnoses=[],
|
||||||
|
service_lines=[],
|
||||||
|
validation=ValidationReport(passed=True, errors=[], warnings=[]),
|
||||||
|
raw_segments=[],
|
||||||
|
)
|
||||||
|
pr837 = ParseResult(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="S", receiver_id="R", control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
),
|
||||||
|
claims=[co],
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="c.txt", control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
total_claims=1, passed=1, failed=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CycloneStore().add(BatchRecord837(
|
||||||
|
id="b-837", kind="837p", input_filename="c.txt",
|
||||||
|
parsed_at=date(2026, 6, 19).isoformat() + "T12:00:00+00:00",
|
||||||
|
result=pr837,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_remit(remit_id, pcn):
|
||||||
|
"""Build a single 835 remit + persist it through CycloneStore.add."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from cyclone.store import BatchRecord835
|
||||||
|
|
||||||
|
cp = ClaimPayment(
|
||||||
|
payer_claim_control_number=pcn,
|
||||||
|
status_code="1",
|
||||||
|
status_label="Primary",
|
||||||
|
total_charge=Decimal("124.00"),
|
||||||
|
total_paid=Decimal("100.00"),
|
||||||
|
service_payments=[
|
||||||
|
ServicePayment(
|
||||||
|
line_number=1, procedure_qualifier="HC", procedure_code="99213",
|
||||||
|
charge=Decimal("124.00"), payment=Decimal("100.00"),
|
||||||
|
adjustments=[ClaimAdjustment(
|
||||||
|
group_code="CO", reason_code="45",
|
||||||
|
amount=Decimal("24.00"),
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
pr835 = ParseResult835(
|
||||||
|
envelope=Envelope835(
|
||||||
|
sender_id="S", receiver_id="R", control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
),
|
||||||
|
financial_info=FinancialInfo(
|
||||||
|
handling_code="C", paid_amount=Decimal("0"),
|
||||||
|
credit_debit_flag="C", payment_method=None,
|
||||||
|
),
|
||||||
|
trace=ReassociationTrace(
|
||||||
|
trace_type_code="1", trace_number="0001",
|
||||||
|
originating_company_id="S",
|
||||||
|
),
|
||||||
|
payer=Payer835(name="X", id="SKCO0"),
|
||||||
|
payee=Payee835(name="Y", npi="1234567890"),
|
||||||
|
claims=[cp],
|
||||||
|
summary=BatchSummary835(
|
||||||
|
input_file="era.txt", control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
total_claims=1, passed=1, failed=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CycloneStore().add(BatchRecord835(
|
||||||
|
id="b-835", kind="835", input_filename="era.txt",
|
||||||
|
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
|
||||||
|
result=pr835,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# manual_match — writes BOTH sides in one transaction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_match_writes_both_sides_in_one_transaction():
|
||||||
|
"""After ``manual_match`` returns, ``Claim.matched_remittance_id``
|
||||||
|
AND ``Remittance.claim_id`` both point at their pair.
|
||||||
|
|
||||||
|
Without the symmetric write, a later ``list_unmatched`` filter
|
||||||
|
(which keys off ``Remittance.claim_id IS NULL``) would surface
|
||||||
|
an already-matched remit as "orphan". Pre-T11 the symmetric
|
||||||
|
write was correct but only implicitly tested; this pins it
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
Note: claim PCN and remit PCN are DELIBERATELY DIFFERENT so the
|
||||||
|
auto-match inside ``CycloneStore.add`` (Task 10) doesn't pre-pair
|
||||||
|
them and ``manual_match`` becomes a no-op no-throw.
|
||||||
|
"""
|
||||||
|
from cyclone.db import Claim, Remittance
|
||||||
|
|
||||||
|
_build_claim("CLM-1", pcn="CLM-PATIENT-PCN")
|
||||||
|
_build_remit("CLP-1", pcn="CLP-1")
|
||||||
|
|
||||||
|
CycloneStore().manual_match("CLM-1", "CLP-1")
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
claim = s.get(Claim, "CLM-1")
|
||||||
|
# Remit PK is payer_claim_control_number (see Remittance ORM).
|
||||||
|
from sqlalchemy import select
|
||||||
|
remit = s.execute(
|
||||||
|
select(Remittance).where(
|
||||||
|
Remittance.payer_claim_control_number == "CLP-1"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
assert claim.matched_remittance_id == remit.id, (
|
||||||
|
f"claim.matched_remittance_id={claim.matched_remittance_id!r} "
|
||||||
|
f"!= remit.id={remit.id!r}"
|
||||||
|
)
|
||||||
|
assert remit.claim_id == claim.id, (
|
||||||
|
f"remit.claim_id={remit.claim_id!r} != claim.id={claim.id!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# manual_unmatch — clears BOTH sides in one transaction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_unmatch_clears_both_sides():
|
||||||
|
"""After ``manual_unmatch`` returns, BOTH ``Claim.matched_remittance_id``
|
||||||
|
AND ``Remittance.claim_id`` return to NULL.
|
||||||
|
|
||||||
|
Without the symmetric clear, an operator-initiated unmatch would
|
||||||
|
leave the orphaned remit pointing at the claim while the claim
|
||||||
|
is correctly cleared — ``list_unmatched`` would then NOT surface
|
||||||
|
the pair (remit side is non-NULL), even though the operator
|
||||||
|
intended to put it back in the unmatched bucket.
|
||||||
|
|
||||||
|
PCN asymmetry prevents auto-match from pre-pairing — see
|
||||||
|
``test_manual_match_writes_both_sides_in_one_transaction``.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Claim, Remittance
|
||||||
|
|
||||||
|
_build_claim("CLM-1", pcn="CLM-PATIENT-PCN")
|
||||||
|
_build_remit("CLP-1", pcn="CLP-1")
|
||||||
|
|
||||||
|
store = CycloneStore()
|
||||||
|
store.manual_match("CLM-1", "CLP-1")
|
||||||
|
store.manual_unmatch("CLM-1")
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
claim = s.get(Claim, "CLM-1")
|
||||||
|
remit = s.execute(
|
||||||
|
select(Remittance).where(
|
||||||
|
Remittance.payer_claim_control_number == "CLP-1"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
assert claim.matched_remittance_id is None
|
||||||
|
assert remit.claim_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_match_rollback_clears_symmetric_writes(monkeypatch):
|
||||||
|
"""If anything between the two writes raises, the session's
|
||||||
|
rollback MUST clear BOTH writes — neither side set.
|
||||||
|
|
||||||
|
Pins the SP27 Task 11 invariant "writes BOTH sides in one
|
||||||
|
transaction" at the rollback level. Without this pin, a future
|
||||||
|
refactor that pushes the symmetric write to a post-commit
|
||||||
|
handler (or splits ``manual_match`` across two sessions) would
|
||||||
|
still pass the happy-path test but leave one side set on a
|
||||||
|
failed match — the very class of drift Task 11 was added to
|
||||||
|
surface.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Claim, Remittance
|
||||||
|
from cyclone import reconcile
|
||||||
|
|
||||||
|
_build_claim("CLM-ROLL", pcn="CLM-PATIENT-PCN")
|
||||||
|
_build_remit("CLP-ROLL", pcn="CLP-ROLL")
|
||||||
|
|
||||||
|
# Force the second-loop line-reconcile pass to raise. By the time
|
||||||
|
# it runs, the claim + remit writes are staged in the session
|
||||||
|
# but not committed — the raise must trigger a rollback.
|
||||||
|
def boom(session, claim, remittance):
|
||||||
|
raise RuntimeError("simulated post-write fault")
|
||||||
|
|
||||||
|
monkeypatch.setattr(reconcile, "_reconcile_pair", boom)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="simulated post-write fault"):
|
||||||
|
CycloneStore().manual_match("CLM-ROLL", "CLP-ROLL")
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
claim = s.get(Claim, "CLM-ROLL")
|
||||||
|
remit = s.execute(
|
||||||
|
select(Remittance).where(
|
||||||
|
Remittance.payer_claim_control_number == "CLP-ROLL"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
# Neither side set — the session rolled back the partial
|
||||||
|
# state machine. claim.matched_remittance_id is the seeded
|
||||||
|
# NULL (no manual match); remit.claim_id is seeded NULL.
|
||||||
|
assert claim.matched_remittance_id is None
|
||||||
|
assert remit.claim_id is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Drift check — startup audit, non-blocking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_check_returns_zero_for_clean_db():
|
||||||
|
"""A clean DB (no manual_match calls, no remits with claim_id)
|
||||||
|
reports zero drift and does not log any warning."""
|
||||||
|
_build_claim("CLM-1", pcn="PCN-A")
|
||||||
|
|
||||||
|
# No remits paired — no drift expected.
|
||||||
|
count = check_matched_pair_drift()
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_check_logs_warning_for_mismatched_pair(caplog):
|
||||||
|
"""Inject drift directly via SQL (bypassing store so we can build
|
||||||
|
a state the writers can never produce), call the check, assert
|
||||||
|
a WARNING was logged with the offending ids.
|
||||||
|
|
||||||
|
The drift check is read-only and non-blocking — operators can
|
||||||
|
clean up manually. Logging at WARNING makes it visible in the
|
||||||
|
standard boot log without paging anyone.
|
||||||
|
|
||||||
|
This case is the inverse direction: a REMIT points at a claim
|
||||||
|
whose ``matched_remittance_id`` doesn't point back. Pairs with
|
||||||
|
``test_drift_check_logs_warning_for_case_a_mismatch`` to cover
|
||||||
|
both SQL paths through the check.
|
||||||
|
"""
|
||||||
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
# Build the inverse state: a remit whose claim_id points at a
|
||||||
|
# claim that does NOT have matched_remittance_id back. Only Case
|
||||||
|
# B fires; Case A (claim → remit) is clean by construction.
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
s.add(Remittance(
|
||||||
|
id="orphan-remit", batch_id="b1",
|
||||||
|
payer_claim_control_number="PCN-B",
|
||||||
|
status_code="1", total_charge=Decimal("100"),
|
||||||
|
total_paid=Decimal("100"),
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
service_date=None, is_reversal=False,
|
||||||
|
claim_id="orphan-claim", # ← only this side set
|
||||||
|
))
|
||||||
|
s.add(Claim(
|
||||||
|
id="orphan-claim", batch_id="b1",
|
||||||
|
patient_control_number="PCN-B-CLAIM",
|
||||||
|
service_date_from=None,
|
||||||
|
charge_amount=Decimal("100"),
|
||||||
|
state=ClaimState.SUBMITTED,
|
||||||
|
matched_remittance_id=None, # ← back-pointer missing
|
||||||
|
))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING", logger="cyclone.store"):
|
||||||
|
count = check_matched_pair_drift()
|
||||||
|
|
||||||
|
assert count == 1, "exactly one drift was injected"
|
||||||
|
# The warning log should mention both ids so the operator can grep.
|
||||||
|
assert any(
|
||||||
|
"orphan-claim" in record.message
|
||||||
|
and "orphan-remit" in record.message
|
||||||
|
for record in caplog.records
|
||||||
|
), "drift warning should mention the offending claim + remit ids"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_check_logs_warning_for_case_a_mismatch(caplog):
|
||||||
|
"""Pins the OTHER direction: claim → remit drift where the
|
||||||
|
claim's matched_remittance_id points at a remit whose claim_id
|
||||||
|
doesn't point back.
|
||||||
|
|
||||||
|
Without this test (paired with the inverse test above) a SQL
|
||||||
|
typo in Case A or Case B would silently pass the suite.
|
||||||
|
"""
|
||||||
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# Remit exists but its claim_id is NULL (claim side is the
|
||||||
|
# only one pointing).
|
||||||
|
s.add(Remittance(
|
||||||
|
id="dangle-remit", batch_id="b1",
|
||||||
|
payer_claim_control_number="PCN-A",
|
||||||
|
status_code="1", total_charge=Decimal("100"),
|
||||||
|
total_paid=Decimal("100"),
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
service_date=None, is_reversal=False,
|
||||||
|
claim_id=None,
|
||||||
|
))
|
||||||
|
s.add(Claim(
|
||||||
|
id="dangle-claim", batch_id="b1",
|
||||||
|
patient_control_number="PCN-A",
|
||||||
|
service_date_from=None,
|
||||||
|
charge_amount=Decimal("100"),
|
||||||
|
state=ClaimState.SUBMITTED,
|
||||||
|
matched_remittance_id="dangle-remit",
|
||||||
|
))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING", logger="cyclone.store"):
|
||||||
|
count = check_matched_pair_drift()
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert any(
|
||||||
|
"dangle-claim" in record.message and "dangle-remit" in record.message
|
||||||
|
for record in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_check_returns_count_of_mismatched_pairs():
|
||||||
|
"""The return value is the number of mismatched pairs so operators
|
||||||
|
can alert on it (e.g. fail boot if drift > 0 in a future iteration)."""
|
||||||
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# Two drifted pairs: case A and case B in a single DB.
|
||||||
|
for i, (claim_id, remit_id) in enumerate(
|
||||||
|
[("drift-A-claim", "drift-A-remit"), ("drift-B-claim", "drift-B-remit")]
|
||||||
|
):
|
||||||
|
s.add(Remittance(
|
||||||
|
id=remit_id, batch_id="b1",
|
||||||
|
payer_claim_control_number=f"PCN-{i}",
|
||||||
|
status_code="1", total_charge=Decimal("100"),
|
||||||
|
total_paid=Decimal("100"),
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
service_date=None, is_reversal=False,
|
||||||
|
))
|
||||||
|
s.add(Claim(
|
||||||
|
id=claim_id, batch_id="b1",
|
||||||
|
patient_control_number=f"PCN-{i}",
|
||||||
|
service_date_from=None,
|
||||||
|
charge_amount=Decimal("100"),
|
||||||
|
state=ClaimState.SUBMITTED,
|
||||||
|
matched_remittance_id=remit_id,
|
||||||
|
))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
count = check_matched_pair_drift()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_check_does_not_raise_on_query_error(monkeypatch):
|
||||||
|
"""The drift check is non-blocking at boot — if a DB query fails
|
||||||
|
we want a logged exception, NOT a crashed backend. Wrapped at the
|
||||||
|
api.py call site (Task 11 wiring); this pins that the function
|
||||||
|
itself doesn't try/catch (the call site does).
|
||||||
|
"""
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
|
# Force the SessionLocal to raise on use.
|
||||||
|
def boom():
|
||||||
|
raise OperationalError("SELECT 1", {}, Exception("synthetic"))
|
||||||
|
|
||||||
|
monkeypatch.setattr(db, "SessionLocal", lambda: boom)
|
||||||
|
with pytest.raises(OperationalError):
|
||||||
|
check_matched_pair_drift()
|
||||||
|
# The exception propagates — api.py::lifespan wraps it with a
|
||||||
|
# try/except so the boot continues. The pin here is that the
|
||||||
|
# function doesn't swallow by accident.
|
||||||
Reference in New Issue
Block a user