feat(sp33): apply_999_rejections uses batch_envelope_index
The 999 handler's rejection pass was looking up claims by patient control number, but Gainwell rejects at the SET level (ST envelope) when the whole batch fails the NM109=CO_TXIX rule. That meant a SET rejection was treated as a no-op even though every claim in the SET was actually rejected by the payer. Add a batch_envelope_index param (mirrors apply_999_acceptances from SP28) so SET-level rejections cascade to every claim in the SET. Falls back to the legacy PCN lookup when the index has no entry. Also tightens test_payer.py: PayerConfig.co_medicaid() now returns payer_id='CO_TXIX' and payer_name='CO_TXIX' per HCPF 837P Companion Guide (June 2025 - Version 2.5).
This commit is contained in:
@@ -90,7 +90,9 @@ def handle(
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
rejection_result = apply_999_rejections(
|
rejection_result = apply_999_rejections(
|
||||||
session, result, claim_lookup=_lookup,
|
session, result,
|
||||||
|
claim_lookup=_lookup,
|
||||||
|
batch_envelope_index=batch_index,
|
||||||
)
|
)
|
||||||
if rejection_result.matched:
|
if rejection_result.matched:
|
||||||
for cid in rejection_result.matched:
|
for cid in rejection_result.matched:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def apply_999_rejections(
|
|||||||
parsed_999,
|
parsed_999,
|
||||||
*,
|
*,
|
||||||
claim_lookup: Callable[[str], Claim | None],
|
claim_lookup: Callable[[str], Claim | None],
|
||||||
|
batch_envelope_index: dict[str, list[str]] | None = None,
|
||||||
) -> Apply999Result:
|
) -> Apply999Result:
|
||||||
"""For each set response with code R or E, look up the matching claim and
|
"""For each set response with code R or E, look up the matching claim and
|
||||||
move it to REJECTED. Idempotent on already-rejected claims.
|
move it to REJECTED. Idempotent on already-rejected claims.
|
||||||
@@ -41,27 +42,47 @@ def apply_999_rejections(
|
|||||||
session: SQLAlchemy session.
|
session: SQLAlchemy session.
|
||||||
parsed_999: a ParseResult999 (or any object with .set_responses).
|
parsed_999: a ParseResult999 (or any object with .set_responses).
|
||||||
claim_lookup: callable from patient_control_number → Claim or None.
|
claim_lookup: callable from patient_control_number → Claim or None.
|
||||||
|
Legacy fallback; rarely hits when batch_envelope_index is present.
|
||||||
|
batch_envelope_index: SP33 — mapping from SET control_number (the 837
|
||||||
|
envelope's ST02) to list of Claim.id for the claims in that SET.
|
||||||
|
Mirrors the SP28 fix in apply_999_acceptances so SET-level
|
||||||
|
rejections correctly cascade across every claim under the SET.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Apply999Result with lists of matched claim ids and orphan PCNs.
|
Apply999Result with lists of matched claim ids and orphan PCNs.
|
||||||
"""
|
"""
|
||||||
result = Apply999Result()
|
result = Apply999Result()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
index = batch_envelope_index or {}
|
||||||
|
|
||||||
for sr in parsed_999.set_responses:
|
for sr in parsed_999.set_responses:
|
||||||
code = sr.set_accept_reject.code
|
code = sr.set_accept_reject.code
|
||||||
if code not in ("R", "E", "X"):
|
if code not in ("R", "E", "X"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
claim = claim_lookup(sr.set_control_number)
|
# SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level
|
||||||
if claim is None:
|
# rejection correctly flips every claim in the SET. Fall back to
|
||||||
|
# the legacy claim_lookup when the index is empty for this SCN.
|
||||||
|
candidate_ids = index.get(sr.set_control_number, []) or []
|
||||||
|
claims_to_reject: list[Claim] = []
|
||||||
|
if candidate_ids:
|
||||||
|
claims_to_reject = (
|
||||||
|
session.query(Claim)
|
||||||
|
.filter(Claim.id.in_(candidate_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
legacy = claim_lookup(sr.set_control_number)
|
||||||
|
if legacy is not None:
|
||||||
|
claims_to_reject = [legacy]
|
||||||
|
else:
|
||||||
result.orphans.append(sr.set_control_number)
|
result.orphans.append(sr.set_control_number)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
for claim in claims_to_reject:
|
||||||
if claim.state == ClaimState.REJECTED:
|
if claim.state == ClaimState.REJECTED:
|
||||||
# Idempotent: don't double-mutate.
|
# Idempotent: don't double-mutate.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
claim.state = ClaimState.REJECTED
|
claim.state = ClaimState.REJECTED
|
||||||
claim.state_changed_at = now
|
claim.state_changed_at = now
|
||||||
claim.rejected_at = now
|
claim.rejected_at = now
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""SP33: apply_999_rejections must use batch_envelope_index (mirrors
|
||||||
|
SP28's apply_999_acceptances fix) so SET-level 999 rejections cascade
|
||||||
|
to claim state transitions.
|
||||||
|
|
||||||
|
The pre-SP33 bug: ``claim_lookup(sr.set_control_number)`` passed the
|
||||||
|
SET control_number to a PCN-based lookup, so no claim was ever matched
|
||||||
|
and rejections silently died. SP33 threads the same
|
||||||
|
``batch_envelope_index`` that ``apply_999_acceptances`` already has.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from cyclone.db import ClaimState
|
||||||
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_set_response(code: str = "R", scn: str = "991102994"):
|
||||||
|
"""Build a stand-in for ``ParseResult999.set_responses[0]``."""
|
||||||
|
return SimpleNamespace(
|
||||||
|
set_accept_reject=SimpleNamespace(code=code),
|
||||||
|
set_control_number=scn,
|
||||||
|
segment_errors=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_claim(cid: str = "claim-x", state=ClaimState.SUBMITTED):
|
||||||
|
c = MagicMock()
|
||||||
|
c.id = cid
|
||||||
|
c.state = state
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_level_rejection_cascades_via_envelope_index():
|
||||||
|
"""When the SET-level 999 has set_control_number=991102994 and the
|
||||||
|
batch_envelope_index maps it to a list of 3 claim_ids, all 3 should
|
||||||
|
transition to REJECTED.
|
||||||
|
"""
|
||||||
|
session = MagicMock()
|
||||||
|
claims = [_mk_claim(f"c-{i}") for i in range(3)]
|
||||||
|
session.query.return_value.filter.return_value.all.return_value = claims
|
||||||
|
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
|
||||||
|
index = {"991102994": [c.id for c in claims]}
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index=index,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sorted(result.matched) == sorted(c.id for c in claims)
|
||||||
|
assert result.orphans == []
|
||||||
|
session.commit.assert_called_once()
|
||||||
|
for c in claims:
|
||||||
|
# ClaimState.REJECTED is the lowercase 'rejected' string (the enum
|
||||||
|
# value stored in the DB column via SQLAlchemy Enum type).
|
||||||
|
assert c.state == ClaimState.REJECTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_level_rejection_without_index_still_uses_legacy_lookup():
|
||||||
|
"""Backwards compat: when batch_envelope_index is None, the function
|
||||||
|
must fall back to claim_lookup (existing behavior preserved).
|
||||||
|
"""
|
||||||
|
session = MagicMock()
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
|
||||||
|
legacy_claim = _mk_claim("legacy-claim")
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.matched == ["legacy-claim"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_envelope_index_no_match_becomes_orphan():
|
||||||
|
"""Empty index + empty legacy lookup => the SET becomes an orphan."""
|
||||||
|
session = MagicMock()
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102999")])
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index={}, # empty index
|
||||||
|
)
|
||||||
|
assert result.matched == []
|
||||||
|
assert "991102999" in result.orphans
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepted_999_does_not_reject():
|
||||||
|
"""An 'A' code must not transition any claim to REJECTED."""
|
||||||
|
session = MagicMock()
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="A", scn="991102994")])
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index={"991102994": ["claim-x"]},
|
||||||
|
)
|
||||||
|
assert result.matched == []
|
||||||
|
session.query.assert_not_called()
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_rejected_is_idempotent():
|
||||||
|
"""Pre-REJECTED claims in the index are left alone (no second commit)."""
|
||||||
|
session = MagicMock()
|
||||||
|
already = _mk_claim("already-rejected", state=ClaimState.REJECTED)
|
||||||
|
session.query.return_value.filter.return_value.all.return_value = [already]
|
||||||
|
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index={"991102994": ["already-rejected"]},
|
||||||
|
)
|
||||||
|
assert result.matched == []
|
||||||
|
assert already.state == ClaimState.REJECTED # untouched
|
||||||
@@ -8,8 +8,8 @@ def test_co_medicaid_defaults():
|
|||||||
assert cfg.allowed_claim_frequencies == {1, 7, 8}
|
assert cfg.allowed_claim_frequencies == {1, 7, 8}
|
||||||
assert cfg.require_ref_g1_for_adjustments is False # lenient in v1
|
assert cfg.require_ref_g1_for_adjustments is False # lenient in v1
|
||||||
assert cfg.allowed_bht06 == {"CH"}
|
assert cfg.allowed_bht06 == {"CH"}
|
||||||
assert cfg.payer_id == "SKCO0"
|
assert cfg.payer_id == "CO_TXIX"
|
||||||
assert cfg.payer_name == "COHCPF"
|
assert cfg.payer_name == "CO_TXIX"
|
||||||
assert cfg.no_patient_loop is True
|
assert cfg.no_patient_loop is True
|
||||||
assert cfg.encounter_claim_in_same_batch is False
|
assert cfg.encounter_claim_in_same_batch is False
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user