feat(sp32): _content_keys_match prefers typed NPI columns over raw_json fallback
This commit is contained in:
@@ -151,13 +151,16 @@ def _content_keys_match(remit, claim) -> set[str]:
|
|||||||
- shim / dataclass test objects (planned names: total_charge_amount,
|
- shim / dataclass test objects (planned names: total_charge_amount,
|
||||||
total_charge, rendering_provider_npi), and
|
total_charge, rendering_provider_npi), and
|
||||||
- real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge,
|
- 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
|
Production note (SP32): the typed ``rendering_provider_npi`` column is
|
||||||
``total_charge_amount`` on ``Remittance.raw_json`` (the ORM has no
|
the primary read for both sides (Claim = NM1*82, Remit = NM1*1P). Legacy
|
||||||
dedicated column), so the read order below is:
|
rows pre-0019 still carry the value in ``raw_json``; the read order is:
|
||||||
- NPI: transient attribute → raw_json
|
- NPI: typed column → raw_json (``service_provider_npi`` for remit,
|
||||||
- Charge: planned attribute name → real ORM column → raw_json
|
``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
|
The ``raw_json`` fallback is what makes this helper safe against a
|
||||||
raw ``Remittance`` ORM instance from ``reconcile.run()``.
|
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:
|
abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE:
|
||||||
matched.add("charge")
|
matched.add("charge")
|
||||||
|
|
||||||
# NPI: prefer attribute, fall back to raw_json (where the 835 parser
|
# NPI: typed-column primary path (SP32), raw_json fallback (legacy rows).
|
||||||
# stores rendering_provider_npi). The Remittance ORM has no NPI column,
|
# Remit reads rendering_provider_npi (single value, D4); claim reads
|
||||||
# so the raw_json path is the production read for raw ORM instances —
|
# rendering_provider_npi first, then falls back to provider_npi (billing)
|
||||||
# it must NOT be removed.
|
# for legacy rows where the 837p parser hadn't yet extracted NM1*82.
|
||||||
remit_npi = (
|
remit_npi = (
|
||||||
(getattr(remit, "rendering_provider_npi", "") or "")
|
(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 "")
|
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
|
||||||
).strip()
|
).strip()
|
||||||
claim_npi = (
|
claim_npi = (
|
||||||
|
|||||||
@@ -146,9 +146,8 @@ def make_claim(db_session):
|
|||||||
claim_id=None
|
claim_id=None
|
||||||
|
|
||||||
`state=None` defaults to ``ClaimState.SUBMITTED`` so existing tests
|
`state=None` defaults to ``ClaimState.SUBMITTED`` so existing tests
|
||||||
that omit it keep behaving. `rendering_provider_npi` is stored as
|
that omit it keep behaving. `rendering_provider_npi` is wired through
|
||||||
a transient attribute (no ORM column) for ``_content_keys_match``
|
the real ``Claim.rendering_provider_npi`` column (SP32 migration 0019).
|
||||||
to read via ``getattr``.
|
|
||||||
"""
|
"""
|
||||||
def _make(
|
def _make(
|
||||||
patient_control_number: str,
|
patient_control_number: str,
|
||||||
@@ -171,10 +170,8 @@ def make_claim(db_session):
|
|||||||
charge_amount=total_charge,
|
charge_amount=total_charge,
|
||||||
state=state if state is not None else _CS.SUBMITTED,
|
state=state if state is not None else _CS.SUBMITTED,
|
||||||
matched_remittance_id=matched_remittance_id,
|
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.add(c)
|
||||||
db_session.flush()
|
db_session.flush()
|
||||||
return c
|
return c
|
||||||
@@ -191,9 +188,8 @@ def make_remit(db_session):
|
|||||||
service_date, remit_id=None
|
service_date, remit_id=None
|
||||||
|
|
||||||
`total_charge_amount` is mapped to ``Remittance.total_charge`` (real ORM
|
`total_charge_amount` is mapped to ``Remittance.total_charge`` (real ORM
|
||||||
field). `rendering_provider_npi` is stored as a transient attribute for
|
field). `rendering_provider_npi` is wired through the real
|
||||||
``_content_keys_match`` to read via ``getattr`` (Remittance has no NPI
|
``Remittance.rendering_provider_npi`` column (SP32 migration 0019).
|
||||||
column; the parser reads it from raw_json in production).
|
|
||||||
"""
|
"""
|
||||||
def _make(
|
def _make(
|
||||||
payer_claim_control_number: str,
|
payer_claim_control_number: str,
|
||||||
@@ -216,11 +212,8 @@ def make_remit(db_session):
|
|||||||
received_at=datetime.now(timezone.utc),
|
received_at=datetime.now(timezone.utc),
|
||||||
service_date=service_date,
|
service_date=service_date,
|
||||||
is_reversal=False,
|
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.add(r)
|
||||||
db_session.flush()
|
db_session.flush()
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -846,3 +846,50 @@ def test_remittance_835_row_includes_service_provider_npi():
|
|||||||
assert parsed.claims[0].service_provider_npi == "2222222222"
|
assert parsed.claims[0].service_provider_npi == "2222222222"
|
||||||
row = _remittance_835_row(parsed.claims[0], batch_id="test-batch")
|
row = _remittance_835_row(parsed.claims[0], batch_id="test-batch")
|
||||||
assert row.rendering_provider_npi == "2222222222"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user