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:
Nora
2026-07-02 21:07:07 -06:00
parent cf7c343ff0
commit 0625c83a45
4 changed files with 161 additions and 18 deletions
+3 -1
View File
@@ -90,7 +90,9 @@ def handle(
.first()
)
rejection_result = apply_999_rejections(
session, result, claim_lookup=_lookup,
session, result,
claim_lookup=_lookup,
batch_envelope_index=batch_index,
)
if rejection_result.matched:
for cid in rejection_result.matched:
+24 -3
View File
@@ -33,6 +33,7 @@ def apply_999_rejections(
parsed_999,
*,
claim_lookup: Callable[[str], Claim | None],
batch_envelope_index: dict[str, list[str]] | None = None,
) -> Apply999Result:
"""For each set response with code R or E, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims.
@@ -41,27 +42,47 @@ def apply_999_rejections(
session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses).
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:
Apply999Result with lists of matched claim ids and orphan PCNs.
"""
result = Apply999Result()
now = datetime.now(timezone.utc)
index = batch_envelope_index or {}
for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code
if code not in ("R", "E", "X"):
continue
claim = claim_lookup(sr.set_control_number)
if claim is None:
# SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level
# 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)
continue
for claim in claims_to_reject:
if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
+120
View File
@@ -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
+2 -2
View File
@@ -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