feat(sp39): _normalize_payer_id normalizes SKCO0/CO_BHA/empty -> CO_TXIX
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user