diff --git a/backend/src/cyclone/parsers/serialize_837.py b/backend/src/cyclone/parsers/serialize_837.py index 6ec6b7e..c27f931 100644 --- a/backend/src/cyclone/parsers/serialize_837.py +++ b/backend/src/cyclone/parsers/serialize_837.py @@ -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. diff --git a/backend/tests/test_serialize_837.py b/backend/tests/test_serialize_837.py index 6b09611..34643d3 100644 --- a/backend/tests/test_serialize_837.py +++ b/backend/tests/test_serialize_837.py @@ -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 \ No newline at end of file + 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() \ No newline at end of file