Files
cyclone/backend/tests/test_inbox_endpoints_sp7.py
T
Tyler 534130ee2b feat(db): migration 0014 relaxes claims/remittances PK to (batch_id, id)
Migration 0014 changes the PRIMARY KEYs of claims and remittances from
single-column id to composite (batch_id, id). Enables the spec'd
cross-batch CLM01 / CLP01 collision workflow: same CLM01 in multiple
batches is now representable (resubmits), pre-flight dedup 409 path is
genuinely exercisable, force-insert can skip pre-existing duplicates.

Strategy: PRAGMA defer_foreign_keys = ON + table recreation for every
table whose FKs pointed at the old single-column PK. Tables recreated:
remittances, claims, matches, cas_adjustments, service_line_payments,
line_reconciliations. Each child table gains a batch_id (or
remittance_batch_id) column for the composite FK side; INSERT INTO new
SELECT FROM old JOINs populate it from the already-recreated parent.

Cross-table FKs (remittances.claim_id, claims.matched_remittance_id)
cannot be SQL-enforced with composite PKs (SQLite has no ALTER
CONSTRAINT). Dropped at SQL level; enforced via application-layer
invariants in store.manual_match / manual_unmatch / reconcile.run and
the dedup.preflight_* helpers.

ORM updates (db.py):
- Claim / Remittance: composite PK via explicit PrimaryKeyConstraint in
  __table_args__ (column order matches SQL: batch_id, id).
- New Claim.matched_remittance_batch_id column.
- Match / CasAdjustment / ServiceLinePayment / LineReconciliation:
  added the batch side of their composite FK to the parent table.
- SQLAlchemy before_insert events auto-populate the batch side of the
  composite FK from session.new, then identity_map, then SQL fallback.

Production code updates:
- store.manual_match / manual_unmatch: also write
  matched_remittance_batch_id on the claim (was missing).
- api.py manual-match endpoint: same fix.
- reconcile.run: same fix for auto-matched pairs.

Test updates: replaced s.get(Claim, X) with the composite key
(batch_id, id) where batch_id is known, or s.query().filter().first()
where the test only knows the id. Tests that previously inserted a Match
row pointing at a non-existent Remittance now seed the parent Remittance
so the new NOT NULL composite FK is satisfied.
2026-06-21 18:56:18 -06:00

148 lines
5.3 KiB
Python

"""SP7 — inbox lanes expose matched_remittance line counts.
For each claim row in the rejected / done_today lanes that has a
matched remittance, the row must include a ``matched_remittance`` block
with ``id``, ``matched_lines``, and ``total_lines``. These power the
``MatchedRemitCard`` badge in T17.
Architecture note: 837 service lines live in ``Claim.raw_json``, so
``total_lines`` is read from the JSON blob.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
import uuid
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.parsers.models_835 import ClaimPayment, ServicePayment
from cyclone.reconcile import run as reconcile_run
from cyclone.store import _persist_835_remit, _remittance_835_row
@pytest.fixture(autouse=True)
def _clear_app_state():
yield
if hasattr(app.state, "dismissed_pairs"):
app.state.dismissed_pairs = set()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _seed_pair_with_two_lines():
"""One 837 claim (2 service lines) + one 835 remit (1 svc composite
matching line 1). After reconcile: 1 matched, 1 unmatched_837_only.
Returns the claim_id (and the remittance_id via matched_remittance).
"""
bid_837 = str(uuid.uuid4())
bid_835 = str(uuid.uuid4())
claim_id = "CLM-INBOX-1"
svc_lines_json = [
{
"line_number": 1,
"procedure": {"qualifier": "HC", "code": "99213", "modifiers": []},
"charge": "100.00",
"unit_type": "UN",
"units": "1",
"place_of_service": "11",
"service_date": "2026-06-01",
"provider_reference": None,
},
{
"line_number": 2,
"procedure": {"qualifier": "HC", "code": "99214", "modifiers": []},
"charge": "100.00",
"unit_type": "UN",
"units": "1",
"place_of_service": "11",
"service_date": "2026-06-01",
"provider_reference": None,
},
]
with db.SessionLocal()() as s:
# 837 batch + claim
s.add(db.Batch(
id=bid_837, kind="837p", input_filename="x.837",
parsed_at=datetime.now(timezone.utc),
totals_json={}, validation_json={}, raw_result_json={},
))
s.add(db.Claim(
id=claim_id,
patient_control_number="PCN-INBOX",
charge_amount=Decimal("200.00"),
provider_npi="1234567890",
payer_id="SKCO0",
service_date_from=date(2026, 6, 1),
service_date_to=date(2026, 6, 1),
state=db.ClaimState.SUBMITTED,
batch_id=bid_837,
raw_json={"service_lines": svc_lines_json},
))
# 835 batch + remit
s.add(db.Batch(
id=bid_835, kind="835", input_filename="x.835",
parsed_at=datetime.now(timezone.utc),
totals_json={}, validation_json={}, raw_result_json={},
))
cp = ClaimPayment(
payer_claim_control_number="PCN-INBOX",
status_code="1",
total_charge=Decimal("100.00"),
total_paid=Decimal("80.00"),
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC",
procedure_code="99213",
charge=Decimal("100.00"),
payment=Decimal("80.00"),
units=Decimal("1"),
service_date=date(2026, 6, 1),
),
],
)
remit = _remittance_835_row(cp, bid_835)
s.add(remit)
s.flush()
_persist_835_remit(s, cp, remit.id)
# Force the claim to a terminal state so it lands in done_today.
# Run reconcile which auto-matches by PCN + sets state to PAID.
reconcile_run(s, bid_835)
s.commit()
return claim_id, bid_837
def test_done_today_lane_includes_matched_remittance_line_counts(client):
claim_id, bid_837 = _seed_pair_with_two_lines()
# Backfill state_changed_at so the claim lands in the done_today lane
# (reconcile.run() doesn't write it; the API surface is exercised by
# the existing manual-match flow which sets it).
from sqlalchemy.orm import Session
with db.SessionLocal()() as s:
# Migration 0014: composite PK (batch_id, id). Use filter-by-id
# since we don't have a single canonical batch_id (the test seeds
# both 837 and 835 batches under fresh UUIDs).
cl = s.query(db.Claim).filter(db.Claim.id == claim_id).first()
cl.state_changed_at = datetime.now(timezone.utc)
s.commit()
r = client.get("/api/inbox/lanes")
assert r.status_code == 200
body = r.json()
# The seeded claim should be in done_today (auto-reconciled by run()).
ids = [row["id"] for row in body["done_today"]]
assert claim_id in ids, f"expected claim in done_today, got ids={ids}"
row = next(r for r in body["done_today"] if r["id"] == claim_id)
mr = row.get("matched_remittance")
assert mr is not None, "expected matched_remittance block on done_today claim row"
assert "id" in mr
assert mr["total_lines"] == 2 # two 837 service lines
assert mr["matched_lines"] == 1 # only line 1 has a matching SVC composite