534130ee2b
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.
148 lines
5.3 KiB
Python
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
|