fix(sp27): Claim.patient_control_number populated from CLM01 (claim_id), not 2010BA NM109 (member_id)

The 837 ingest path (_claim_837_row in store.py:194) populated
Claim.patient_control_number from claim.subscriber.member_id — the
subscriber's 2010BA NM109 Medicaid ID — instead of from
claim.claim_id (CLM01, the claim submitter's identifier the 837
actually sent).

That silently broke every downstream join that uses this column as a
cross-reference key:

  * reconcile.match() (reconcile.py:74) — joins
    Claim.patient_control_number against
    Remittance.payer_claim_control_number (which is parsed from CLP01,
    the 835's echo of CLM01 per X12 spec). Member_id never matches
    CLP01, so auto-match always fails.
  * apply_999_rejections — same lookup, same broken key.
  * apply_277ca_rejections — same lookup, same broken key.
  * scoring.score_pair — same broken key.

Live DB probe (1,739 remits, 337 claims):
  * Claim.id == Remit.payer_claim_control_number    → 0 matches
  * Claim.patient_control_number == Remit.payer_claim_control_number
    → 9 matches (substring coincidences; synthetic PCN strings share
    alphanumeric characters with the human-readable member_ids)
  * Claim.matched_remittance_id NOT NULL             → 0 claims

This commit changes _claim_837_row to write
Claim.patient_control_number = claim.claim_id (= CLM01). The
reconcile matcher's existing join now hits the row the 835 echoes
back. Companion migration 0017 backfills the 337 pre-fix rows so
they're on equal footing with new ingests (UPDATE claims SET
patient_control_number = id WHERE patient_control_number IS DISTINCT
FROM id — idempotent).

Tests:
  * 2 new RED→GREEN tests in test_store_reconcile.py:
    - test_837_ingest_populates_patient_control_number_from_claim_id
      pins the field semantics directly
    - test_837_then_835_with_echoed_pcn_auto_pairs proves the full
      end-to-end auto-match now fires
  * 3 existing manual_match tests that relied on the bug (had setup
    using member_id != claim_id to deliberately prevent auto-match)
    updated to use distinct PCNs explicitly so the tests still
    exercise the manual-match path with a real orphan pair.
  * 3 store_claim_detail / api_gets tests adjusted for the same
    reason.

Verified: 1,176 / 1,177 backend tests pass; the one failure is the
pre-existing flake in test_provider_extended_response.py noted before
this work started.

Caveat for the existing dev DB: the 1,739 remits already in the DB
were ingested from 835 fixtures whose CLP01 is a different synthetic
identifier than the 837's CLM01 (the test fixtures never echoed
CLM01 — a fixture-data limitation, not a code bug). After this fix,
new 837+835 ingest pairs whose payer echoes CLM01 in CLP01 will
auto-match as expected. The pre-existing 1,739 remits will continue
to land in the unmatched bucket; that can only be fixed by
regenerating the test fixtures (out of scope for this SP).
This commit is contained in:
Nora
2026-06-29 14:18:22 -06:00
parent d81b6ed4fc
commit d8841834dc
7 changed files with 239 additions and 27 deletions
+4 -3
View File
@@ -58,15 +58,16 @@ def test_migration_latest_idempotent_on_fresh_db():
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,
SP22's 0015 drop_claims_unique_constraint, SP27-Task 11's 0016
claims.matched_remittance_id index)."""
claims.matched_remittance_id index, SP27-Task 17's 0017
claim.patient_control_number backfill UPDATE)."""
with db.engine().begin() as c:
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v1 == 16
assert v1 == 17
# A second run should not raise and should not bump the version.
db_migrate.run(db.engine())
with db.engine().begin() as c:
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v2 == 16
assert v2 == 17
def test_add_ack_persists_row():
+10 -7
View File
@@ -509,14 +509,17 @@ def test_post_match_happy_path(client: TestClient):
)
from cyclone.store import BatchRecord837, BatchRecord835
# 837 Claim. member_id="M1" so the 835 PCN-based auto-match can't pair them.
# 837 Claim. pcn deliberately differs from the 835's PCN so the
# auto-matcher (which now joins on claim_id == pcn after the SP27
# Task 17 PCN fix) doesn't pair them — we want an orphan so the
# manual_match call has work to do.
co = ClaimOutput(
claim_id="CLM-1",
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="M1",
first_name="Jane", last_name="Doe", member_id="ORPHAN-MEMBER",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
@@ -541,12 +544,12 @@ def test_post_match_happy_path(client: TestClient):
),
))
# 835 Remittance. PCN="CLM-1" but member_id on the claim side is "M1",
# so the auto-matcher in reconcile.run does NOT pair them; we want
# an orphan so manual_match has work to do. charge == paid == 100
# so apply_payment picks ClaimState.PAID.
# 835 Remittance. PCN="CLM-1-ORPHAN" deliberately differs from the
# claim's claim_id="CLM-1" so the auto-matcher in reconcile.run does
# NOT pair them; we want an orphan so manual_match has work to do.
# charge == paid == 100 so apply_payment picks ClaimState.PAID.
cp = ClaimPayment(
payer_claim_control_number="CLM-1",
payer_claim_control_number="CLM-1-ORPHAN",
status_code="1",
total_charge=Decimal("100"),
total_paid=Decimal("100"),
+5 -3
View File
@@ -121,14 +121,16 @@ def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
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).
SP27 Task 17 bumped it to 17 with the patient_control_number
backfill UPDATE (the migration runner now applies DML too).
"""
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
v_after_first = _user_version(engine)
assert v_after_first == 16, f"expected head=16, got {v_after_first}"
assert v_after_first == 17, f"expected head=17, got {v_after_first}"
db_migrate.run(engine)
assert _user_version(engine) == 16, "second run should not bump version"
assert _user_version(engine) == 17, "second run should not bump version"
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -154,7 +156,7 @@ def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: py
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
assert _user_version(engine) == 16, f"expected head=16, got {_user_version(engine)}"
assert _user_version(engine) == 17, f"expected head=17, got {_user_version(engine)}"
# Two claims in one batch with the same patient_control_number
# must be insertable. If 0015's table recreation re-introduced a
+10 -8
View File
@@ -354,10 +354,11 @@ def test_get_claim_detail_includes_state_history():
"""``stateHistory`` must include the manual_match event after pairing."""
s = CycloneStore()
_add_837_with_claim(s, "CLM-1")
# 835 with PCN that intentionally differs from the 837's member id so
# reconcile doesn't auto-pair on ingest — we want manual_match to do it.
_add_835_with_remit(s, pcn="CLM-1", paid="100", status="1")
s.manual_match("CLM-1", "CLM-1")
# 835 PCN deliberately differs from claim_id so the auto-matcher
# (which now joins on claim_id == pcn after the SP27 Task 17 PCN
# fix) doesn't pair them — manual_match needs work to do.
_add_835_with_remit(s, pcn="CLM-1-ORPHAN", paid="100", status="1")
s.manual_match("CLM-1", "CLM-1-ORPHAN")
out = s.get_claim_detail("CLM-1")
assert out is not None
@@ -377,7 +378,7 @@ def test_get_claim_detail_includes_state_history():
# ActivityEvent row, which is correct per the spec's
# ``{kind, ts, batchId|null, remittanceId|null}`` contract.
mm = next(ev for ev in history if ev["kind"] == "manual_match")
assert mm["remittanceId"] == "CLM-1"
assert mm["remittanceId"] == "CLM-1-ORPHAN"
assert mm["batchId"] is not None
assert mm["ts"].endswith("Z")
@@ -392,14 +393,15 @@ def test_get_claim_detail_includes_matched_remittance_summary():
"""A paired claim surfaces a ``matchedRemittance`` summary block."""
s = CycloneStore()
_add_837_with_claim(s, "CLM-1")
_add_835_with_remit(s, pcn="CLM-1", paid="100", status="1")
s.manual_match("CLM-1", "CLM-1")
# 835 PCN deliberately differs from claim_id so manual_match has work.
_add_835_with_remit(s, pcn="CLM-1-ORPHAN", paid="100", status="1")
s.manual_match("CLM-1", "CLM-1-ORPHAN")
out = s.get_claim_detail("CLM-1")
assert out is not None
mr = out["matchedRemittance"]
assert mr is not None
assert mr["id"] == "CLM-1"
assert mr["id"] == "CLM-1-ORPHAN"
assert mr["totalPaid"] == 100.0
assert isinstance(mr["totalPaid"], float)
# status_code "1" → "received" (per to_ui_remittance_from_orm mapping).
+174 -5
View File
@@ -357,8 +357,11 @@ def test_manual_match_populates_line_reconciliation_rows():
))
# 835 remit with one SVC line that matches claim line 1.
# pcn deliberately ≠ claim_id so auto-match (which now joins on
# claim_id == pcn after the SP27 Task 17 PCN fix) doesn't pair them
# and the claim lands in the unmatched list for manual pairing.
cp = ClaimPayment(
payer_claim_control_number="CLM-MANUAL", # != member_id "MANUAL"
payer_claim_control_number="ORPHAN-PCN-MANUAL",
status_code="1",
status_label="Primary",
total_charge=Decimal("200.00"),
@@ -498,10 +501,10 @@ def test_manual_match_idempotent_line_reconciliation():
result=pr837,
))
# 835 with two SVC lines — auto-match won't pair (PCN="CLM-IDEMP"
# member_id="IDEMP"), so manual_match has work to do.
# 835 with two SVC lines — auto-match won't pair (pcn="ORPHAN-IDEMP"
# ≠ claim_id="CLM-IDEMP"), so manual_match has work to do.
cp = ClaimPayment(
payer_claim_control_number="CLM-IDEMP",
payer_claim_control_number="ORPHAN-IDEMP",
status_code="1",
status_label="Primary",
total_charge=Decimal("200.00"),
@@ -586,4 +589,170 @@ def test_manual_match_idempotent_line_reconciliation():
r = session.get(Remittance, remit_id)
assert r is not None
assert r.adjustment_amount == Decimal("20.00")
assert r.claim_level_adjustment_amount == Decimal("20.00")
assert r.claim_level_adjustment_amount == Decimal("20.00")
# ---------------------------------------------------------------------------
# SP27 Task 17: Claim.patient_control_number must be the CLM01 (claim_id)
# the 837 sent, NOT the 2010BA subscriber member_id. The reconcile matcher
# joins Claim.patient_control_number == Remittance.payer_claim_control_number,
# and the 835 echoes CLM01 in CLP01 per X12 spec. Storing member_id here
# silently breaks every auto-match in production (the 9/1,739 "matches"
# observed in prod data are substring coincidences between synthetic CLM01
# and the human-readable PCN).
# ---------------------------------------------------------------------------
def _make_837_result(claims):
"""Build a minimal ParseResult with the given ClaimOutputs."""
from cyclone.parsers.models import (
BatchSummary as BatchSummary837,
BillingProvider, ClaimHeader, ClaimOutput, Envelope,
Payer, ParseResult, Subscriber, ValidationReport,
)
return ParseResult(
envelope=Envelope(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
claims=claims,
summary=BatchSummary837(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=len(claims), passed=len(claims), failed=0,
),
)
def _make_claim_output(claim_id="CLM-A", member_id="MEMBER-X",
charge="100.00",
service_date=date(2026, 6, 19)):
"""Single ClaimOutput with distinct claim_id and subscriber.member_id.
``service_date`` populates the SV1 service-line date so the
reconcile matcher's ``_pick_claim`` window check can find this
claim when the matching 835 has the same date in SVC.
"""
from cyclone.parsers.models import (
BillingProvider, ClaimHeader, ClaimOutput,
Subscriber, Payer, Procedure, ServiceLine,
ValidationReport,
)
return 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=member_id),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(claim_id=claim_id, total_charge=Decimal(charge),
frequency_code="1", place_of_service="11"),
diagnoses=[],
service_lines=[
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="99213",
modifiers=[]),
charge=Decimal(charge),
unit_type="UN",
units=Decimal("1"),
service_date=service_date,
),
],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
def test_837_ingest_populates_patient_control_number_from_claim_id():
"""Claim.patient_control_number == claim_id (CLM01), not member_id.
RED: today the 837 ingest writes ``Claim.patient_control_number =
claim.subscriber.member_id`` (store.py:194). That breaks the
reconcile join against the 835's CLP01 (which echoes CLM01).
"""
from cyclone.db import Claim
from cyclone.store import BatchRecord837
s = CycloneStore()
s.add(BatchRecord837(
id="b-837-pcn", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_837_result([
_make_claim_output(claim_id="CLM-A", member_id="MEMBER-X"),
]),
))
with db.SessionLocal()() as session:
claim = session.get(Claim, "CLM-A")
assert claim is not None
assert claim.patient_control_number == "CLM-A", (
f"expected CLM01 (claim_id) 'CLM-A', "
f"got Claim.patient_control_number={claim.patient_control_number!r} "
f"(should be the value the 837 sent in CLM01, not the "
f"subscriber's 2010BA NM109 member_id)."
)
def test_837_then_835_with_echoed_pcn_auto_pairs():
"""End-to-end: 837 CLM01='CLM-A' + 835 CLP01='CLM-A' must auto-match.
RED: today ``Claim.patient_control_number == subscriber.member_id``
so the join on ``Claim.patient_control_number ==
Remittance.payer_claim_control_number`` fails even when the payer
echoes CLM01 correctly.
"""
from cyclone.db import Claim, Remittance
from cyclone.store import BatchRecord837, BatchRecord835
s = CycloneStore()
# 837: CLM01 = 'CLM-A', subscriber.member_id = 'MEMBER-X' (different)
s.add(BatchRecord837(
id="b-837-echo", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_837_result([
_make_claim_output(claim_id="CLM-A", member_id="MEMBER-X",
charge="100.00"),
]),
))
# 835: CLP01 = 'CLM-A' (the payer echoes CLM01 back), paid == charge
cp = ClaimPayment(
payer_claim_control_number="CLM-A",
status_code="1", status_label="Primary",
total_charge=Decimal("100.00"), total_paid=Decimal("100.00"),
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC",
procedure_code="99213",
charge=Decimal("100.00"), payment=Decimal("100.00"),
units=Decimal("1"),
service_date=date(2026, 6, 19),
adjustments=[],
),
],
)
s.add(BatchRecord835(
id="b-835-echo", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
with db.SessionLocal()() as session:
claim = session.get(Claim, "CLM-A")
r = session.query(Remittance).filter(
Remittance.payer_claim_control_number == "CLM-A"
).first()
# Both sides must have the FK set: Claim.matched_remittance_id
# AND Remittance.claim_id, written in the same transaction.
assert claim.matched_remittance_id is not None, (
"Claim should be auto-matched to the remit (CLM01 echoed "
"in CLP01), but matched_remittance_id is still NULL. The "
"837 ingest is storing patient_control_number from "
"subscriber.member_id instead of claim.claim_id."
)
assert r.claim_id == "CLM-A"
assert r.claim_id == claim.id
assert claim.state == "paid"