Files
cyclone/backend/tests/test_apply_999_rejections.py
Nora 0625c83a45 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).
2026-07-02 21:07:07 -06:00

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