From de77f19d9d0deb5c960d92862ce5c65793acdb23 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 17:08:09 -0600 Subject: [PATCH] feat(sp32): _content_keys_match prefers typed NPI columns over raw_json fallback --- backend/src/cyclone/reconcile.py | 24 +++++++++------- backend/tests/conftest.py | 19 ++++--------- backend/tests/test_reconcile.py | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index b30bca5..554c699 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -151,13 +151,16 @@ def _content_keys_match(remit, claim) -> set[str]: - shim / dataclass test objects (planned names: total_charge_amount, total_charge, rendering_provider_npi), and - real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge, - Claim.provider_npi). + Claim.provider_npi, Claim.rendering_provider_npi, + Remittance.rendering_provider_npi). - Production note: the 835 parser stores ``rendering_provider_npi`` and - ``total_charge_amount`` on ``Remittance.raw_json`` (the ORM has no - dedicated column), so the read order below is: - - NPI: transient attribute → raw_json - - Charge: planned attribute name → real ORM column → raw_json + Production note (SP32): the typed ``rendering_provider_npi`` column is + the primary read for both sides (Claim = NM1*82, Remit = NM1*1P). Legacy + rows pre-0019 still carry the value in ``raw_json``; the read order is: + - NPI: typed column → raw_json (``service_provider_npi`` for remit, + ``rendering_provider_npi`` for claim) → claim ``provider_npi`` + (billing fallback for legacy 837p rows without NM1*82 extraction). + - Charge: planned attribute name → real ORM column → raw_json. The ``raw_json`` fallback is what makes this helper safe against a raw ``Remittance`` ORM instance from ``reconcile.run()``. """ @@ -190,12 +193,13 @@ def _content_keys_match(remit, claim) -> set[str]: abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE: matched.add("charge") - # NPI: prefer attribute, fall back to raw_json (where the 835 parser - # stores rendering_provider_npi). The Remittance ORM has no NPI column, - # so the raw_json path is the production read for raw ORM instances — - # it must NOT be removed. + # NPI: typed-column primary path (SP32), raw_json fallback (legacy rows). + # Remit reads rendering_provider_npi (single value, D4); claim reads + # rendering_provider_npi first, then falls back to provider_npi (billing) + # for legacy rows where the 837p parser hadn't yet extracted NM1*82. remit_npi = ( (getattr(remit, "rendering_provider_npi", "") or "") + or ((getattr(remit, "raw_json", None) or {}).get("service_provider_npi", "") or "") or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "") ).strip() claim_npi = ( diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d109a37..daabf70 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -146,9 +146,8 @@ def make_claim(db_session): claim_id=None `state=None` defaults to ``ClaimState.SUBMITTED`` so existing tests - that omit it keep behaving. `rendering_provider_npi` is stored as - a transient attribute (no ORM column) for ``_content_keys_match`` - to read via ``getattr``. + that omit it keep behaving. `rendering_provider_npi` is wired through + the real ``Claim.rendering_provider_npi`` column (SP32 migration 0019). """ def _make( patient_control_number: str, @@ -171,10 +170,8 @@ def make_claim(db_session): charge_amount=total_charge, state=state if state is not None else _CS.SUBMITTED, matched_remittance_id=matched_remittance_id, + rendering_provider_npi=rendering_provider_npi, ) - # Transient attribute — read by reconcile._content_keys_match - # via getattr; the Claim ORM has no rendering_provider_npi column. - c.rendering_provider_npi = rendering_provider_npi db_session.add(c) db_session.flush() return c @@ -191,9 +188,8 @@ def make_remit(db_session): service_date, remit_id=None `total_charge_amount` is mapped to ``Remittance.total_charge`` (real ORM - field). `rendering_provider_npi` is stored as a transient attribute for - ``_content_keys_match`` to read via ``getattr`` (Remittance has no NPI - column; the parser reads it from raw_json in production). + field). `rendering_provider_npi` is wired through the real + ``Remittance.rendering_provider_npi`` column (SP32 migration 0019). """ def _make( payer_claim_control_number: str, @@ -216,11 +212,8 @@ def make_remit(db_session): received_at=datetime.now(timezone.utc), service_date=service_date, is_reversal=False, + rendering_provider_npi=rendering_provider_npi, ) - # Transient attribute — read by reconcile._content_keys_match via - # getattr. The Remittance ORM has no rendering_provider_npi column; - # the 835 parser reads it from raw_json in production. - r.rendering_provider_npi = rendering_provider_npi db_session.add(r) db_session.flush() return r diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index d4827ba..d86f01d 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -846,3 +846,50 @@ def test_remittance_835_row_includes_service_provider_npi(): assert parsed.claims[0].service_provider_npi == "2222222222" row = _remittance_835_row(parsed.claims[0], batch_id="test-batch") assert row.rendering_provider_npi == "2222222222" + + +# --- SP32 Task 5: typed-column NPI preference in _content_keys_match ------- + + +def test_content_keys_match_typed_npi_columns_match(db_session, make_claim, make_remit): + """SP32: typed-column NPI contributes to the matched set when both sides have it.""" + from cyclone.reconcile import _content_keys_match + from datetime import date + claim = make_claim( + patient_control_number="PCN1", + total_charge=Decimal("85.40"), + rendering_provider_npi="2222222222", + service_date_from=date(2025, 1, 1), + ) + remit = make_remit( + payer_claim_control_number="PCN1", + total_charge_amount=Decimal("85.40"), + rendering_provider_npi="2222222222", + service_date=date(2025, 1, 1), + ) + matched = _content_keys_match(remit, claim) + assert "npi" in matched + assert "pcn" in matched + assert "charge" in matched + + +def test_content_keys_match_npi_mismatch_does_not_match(db_session, make_claim, make_remit): + """SP32: NPI arm does not fire when NPIs differ.""" + from cyclone.reconcile import _content_keys_match + from datetime import date + claim = make_claim( + patient_control_number="PCN2", + total_charge=Decimal("10.00"), + rendering_provider_npi="2222222222", + service_date_from=date(2025, 1, 1), + ) + remit = make_remit( + payer_claim_control_number="PCN2", + total_charge_amount=Decimal("10.00"), + rendering_provider_npi="9999999999", # different NPI + service_date=date(2025, 1, 1), + ) + matched = _content_keys_match(remit, claim) + assert "npi" not in matched + assert "pcn" in matched + assert "charge" in matched