From 0625c83a45b758ac9455575a22294bb48d899fcd Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 21:07:07 -0600 Subject: [PATCH] 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). --- backend/src/cyclone/handlers/handle_999.py | 4 +- backend/src/cyclone/inbox_state.py | 51 ++++++--- backend/tests/test_apply_999_rejections.py | 120 +++++++++++++++++++++ backend/tests/test_payer.py | 4 +- 4 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_apply_999_rejections.py diff --git a/backend/src/cyclone/handlers/handle_999.py b/backend/src/cyclone/handlers/handle_999.py index 6a9dbc0..920fe3e 100644 --- a/backend/src/cyclone/handlers/handle_999.py +++ b/backend/src/cyclone/handlers/handle_999.py @@ -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: diff --git a/backend/src/cyclone/inbox_state.py b/backend/src/cyclone/inbox_state.py index 4a807d1..c5a6cfd 100644 --- a/backend/src/cyclone/inbox_state.py +++ b/backend/src/cyclone/inbox_state.py @@ -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,34 +42,54 @@ 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: - result.orphans.append(sr.set_control_number) - continue + # 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 - if claim.state == ClaimState.REJECTED: - # Idempotent: don't double-mutate. - continue - - claim.state = ClaimState.REJECTED - claim.state_changed_at = now - claim.rejected_at = now - claim.rejection_reason = _build_reason( - code, len(sr.segment_errors or []) - ) - result.matched.append(claim.id) + 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 + claim.rejection_reason = _build_reason( + code, len(sr.segment_errors or []) + ) + result.matched.append(claim.id) if result.matched or result.orphans: session.commit() diff --git a/backend/tests/test_apply_999_rejections.py b/backend/tests/test_apply_999_rejections.py new file mode 100644 index 0000000..2b0310d --- /dev/null +++ b/backend/tests/test_apply_999_rejections.py @@ -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 diff --git a/backend/tests/test_payer.py b/backend/tests/test_payer.py index 814eff2..bf95d70 100644 --- a/backend/tests/test_payer.py +++ b/backend/tests/test_payer.py @@ -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