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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -484,11 +485,45 @@ def _build_subscriber_block(
|
|||||||
|
|
||||||
|
|
||||||
def _build_payer_block(payer) -> list[str]:
|
def _build_payer_block(payer) -> list[str]:
|
||||||
|
name, pid = _normalize_payer_id(payer)
|
||||||
return [
|
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]:
|
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
|
||||||
"""Per line: LX / SV1 / DTP*472 / REF*6R.
|
"""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"))
|
isa_b = next(line for line in text_b.split("~") if line.startswith("ISA"))
|
||||||
assert isa_a != isa_b
|
assert isa_a != isa_b
|
||||||
assert "000000042" in isa_a
|
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