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:
@@ -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.require_ref_g1_for_adjustments is False # lenient in v1
|
||||
assert cfg.allowed_bht06 == {"CH"}
|
||||
assert cfg.payer_id == "SKCO0"
|
||||
assert cfg.payer_name == "COHCPF"
|
||||
assert cfg.payer_id == "CO_TXIX"
|
||||
assert cfg.payer_name == "CO_TXIX"
|
||||
assert cfg.no_patient_loop is True
|
||||
assert cfg.encounter_claim_in_same_batch is False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user