feat(sp39): _normalize_payer_id normalizes SKCO0/CO_BHA/empty -> CO_TXIX

This commit is contained in:
Nora
2026-07-07 17:19:18 -06:00
parent 8516b90601
commit d328c0b224
2 changed files with 126 additions and 2 deletions
+36 -1
View File
@@ -45,6 +45,7 @@ the prodfile parametrized smoke in
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from decimal import Decimal
@@ -484,11 +485,45 @@ def _build_subscriber_block(
def _build_payer_block(payer) -> list[str]:
name, pid = _normalize_payer_id(payer)
return [
_build_nm1("PR", "PR", payer.name, "PI", payer.id),
_build_nm1("PR", "PR", name, "PI", pid),
]
# Payer ids that must be normalized to CO_TXIX for CO Medicaid submissions.
# SP39: defense-in-depth against legacy raw_json captures (SKCO0 from
# pre-SP33 batches, CO_BHA from prior behavioral-health configurations,
# empty from degenerate parses). Foreign payer IDs are emitted verbatim.
_NORMALIZE_TO_CO_TXIX_IDS = frozenset({"", "SKCO0", "CO_BHA"})
_NORMALIZE_TO_CO_TXIX_NAMES = frozenset({"", "COHCPF", "CO_BHA"})
_CO_TXIX = "CO_TXIX"
_log = logging.getLogger(__name__)
def _normalize_payer_id(payer) -> tuple[str, str]:
"""Return (name, id) normalized so CO Medicaid claims always emit CO_TXIX.
SP39. Substitutes empty/SKCO0/CO_BHA -> CO_TXIX in the id and
empty/COHCPF/CO_BHA -> CO_TXIX in the name. Foreign payer ids are
passed through verbatim (only the CO Medicaid-shape values are
normalized; the helper must not corrupt a non-CO submit). Emits a
WARNING log line on substitution (one per call; the serializer is
invoked once per claim so volume is bounded by batch size).
"""
raw_id = (getattr(payer, "id", None) or "").strip()
raw_name = (getattr(payer, "name", None) or "").strip()
new_id = _CO_TXIX if raw_id in _NORMALIZE_TO_CO_TXIX_IDS else raw_id
new_name = _CO_TXIX if raw_name in _NORMALIZE_TO_CO_TXIX_NAMES else raw_name
if new_id != raw_id or new_name != raw_name:
_log.warning(
"SP39 2010BB payer normalization: id %r -> %r, name %r -> %r",
raw_id, new_id, raw_name, new_name,
)
return new_name, new_id
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R.
+90 -1
View File
@@ -593,4 +593,93 @@ def test_serialize_837_for_resubmit_assigns_unique_control_numbers():
isa_b = next(line for line in text_b.split("~") if line.startswith("ISA"))
assert isa_a != isa_b
assert "000000042" in isa_a
assert "000000043" in isa_b
assert "000000043" in isa_b
# ---------------------------------------------------------------------------
# SP39: _build_payer_block normalizes legacy payer ids to CO_TXIX
# ---------------------------------------------------------------------------
def _first_pr_segment(text: str) -> str:
"""Extract the first NM1*PR segment from a serialized 837P document."""
for seg in text.split("~"):
if seg.startswith("NM1*PR"):
return seg
raise AssertionError("no NM1*PR segment found in output")
def test_2010bb_normalizes_skco0_to_co_txix(caplog):
"""SP39: legacy SKCO0 + COHCPF must be normalized to CO_TXIX."""
import logging
claim = _load_claim()
claim.payer.id = "SKCO0"
claim.payer.name = "COHCPF"
with caplog.at_level(logging.WARNING, logger="cyclone.parsers.serialize_837"):
text = serialize_837_for_resubmit(claim, interchange_index=1)
parts = _first_pr_segment(text).split("*")
# parts: NM1, PR, 2, NM103, NM104, NM105, NM106, NM107, NM108, NM109
assert parts[8] == "PI", f"NM108 must be PI, got {parts[8]!r}"
assert parts[9] == "CO_TXIX", f"NM109 must be CO_TXIX, got {parts[9]!r}"
assert parts[3] == "CO_TXIX", f"NM103 must be CO_TXIX, got {parts[3]!r}"
assert "SKCO0" not in text, "SKCO0 must not appear in output bytes"
assert "COHCPF" not in text, "COHCPF must not appear in output bytes"
# WARNING log was emitted
assert any(
"SP39" in rec.message and "SKCO0" in rec.message
for rec in caplog.records
), "expected SP39 substitution WARNING log for SKCO0->CO_TXIX"
def test_2010bb_normalizes_empty_payer_id_to_co_txix(caplog):
"""SP39: empty payer.id (degenerate parse artifact) -> CO_TXIX."""
import logging
claim = _load_claim()
claim.payer.id = ""
claim.payer.name = ""
with caplog.at_level(logging.WARNING, logger="cyclone.parsers.serialize_837"):
text = serialize_837_for_resubmit(claim, interchange_index=1)
parts = _first_pr_segment(text).split("*")
assert parts[8] == "PI"
assert parts[9] == "CO_TXIX"
assert parts[3] == "CO_TXIX"
def test_2010bb_normalizes_co_bha_to_co_txix(caplog):
"""SP39: CO_BHA also gets normalized to CO_TXIX (operator policy)."""
import logging
claim = _load_claim()
claim.payer.id = "CO_BHA"
claim.payer.name = "CO_BHA"
with caplog.at_level(logging.WARNING, logger="cyclone.parsers.serialize_837"):
text = serialize_837_for_resubmit(claim, interchange_index=1)
parts = _first_pr_segment(text).split("*")
assert parts[8] == "PI"
assert parts[9] == "CO_TXIX"
assert parts[3] == "CO_TXIX"
assert "CO_BHA" not in text
def test_2010bb_preserves_foreign_payer_id_verbatim():
"""SP39: helper must NOT corrupt non-CO submits. Foreign payer id
is emitted verbatim with no substitution log."""
import logging
claim = _load_claim()
claim.payer.id = "OTHER_PAYER"
claim.payer.name = "OTHER PAYER NAME"
import io
log_stream = io.StringIO()
handler = logging.StreamHandler(log_stream)
handler.setLevel(logging.WARNING)
log = logging.getLogger("cyclone.parsers.serialize_837")
log.addHandler(handler)
try:
text = serialize_837_for_resubmit(claim, interchange_index=1)
finally:
log.removeHandler(handler)
parts = _first_pr_segment(text).split("*")
assert parts[8] == "PI"
assert parts[9] == "OTHER_PAYER"
assert parts[3] == "OTHER PAYER NAME"
# No substitution log for a foreign payer id
assert "SP39" not in log_stream.getvalue()