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
+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"),