0625c83a45
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).
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""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
|