From 4d3eef22efa62813b1d5616332467cd2e3fd0da4 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 17:04:07 -0600 Subject: [PATCH] feat(sp32): extract 837p NM1*82 (rendering provider NPI) per ClaimOutput --- backend/src/cyclone/parsers/models.py | 1 + backend/src/cyclone/parsers/parse_837.py | 9 ++- .../co_medicaid_837p_with_renderer.txt | 61 +++++++++++++++++++ backend/tests/test_parse_837.py | 13 ++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 backend/tests/fixtures/co_medicaid_837p_with_renderer.txt diff --git a/backend/src/cyclone/parsers/models.py b/backend/src/cyclone/parsers/models.py index 8506d9f..2f3090e 100644 --- a/backend/src/cyclone/parsers/models.py +++ b/backend/src/cyclone/parsers/models.py @@ -142,6 +142,7 @@ class ClaimOutput(_Base): subscriber: Subscriber payer: Payer claim: ClaimHeader + rendering_provider_npi: str | None = None # NM1*82 NM109 (Loop 2420A) diagnoses: list[Diagnosis] = Field(default_factory=list) service_lines: list[ServiceLine] = Field(default_factory=list) validation: ValidationReport diff --git a/backend/src/cyclone/parsers/parse_837.py b/backend/src/cyclone/parsers/parse_837.py index e242513..1953d49 100644 --- a/backend/src/cyclone/parsers/parse_837.py +++ b/backend/src/cyclone/parsers/parse_837.py @@ -210,6 +210,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in service_lines: list[ServiceLine] = [] raw: list[list[str]] = [seg] prior_auth: str | None = None + rendering_provider_npi: str | None = None idx += 1 while idx < len(segments) and segments[idx][0] not in {"HL", "CLM", "SE"}: @@ -226,6 +227,11 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in for code in parts[1:]: if code: diagnoses.append(Diagnosis(code=code, qualifier=qualifier)) + elif s[0] == "NM1" and len(s) > 1 and s[1] == "82": + # SP32: capture rendering provider NPI from Loop 2420A NM1*82. + # NM108 (idx 8) is the qualifier (typically "XX"), NM109 (idx 9) is the NPI. + if len(s) > 9 and s[8] == "XX" and len(s[9]) == 10 and s[9].isdigit(): + rendering_provider_npi = s[9] elif s[0] == "LX": line_no = int(s[1]) if len(s) > 1 and s[1].isdigit() else len(service_lines) + 1 # LX is just a separator — the actual service line data is in the next SV1. @@ -255,6 +261,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in subscriber=Subscriber(first_name="", last_name="", member_id="", address=Address(line1="", city="", state="", zip="")), payer=Payer(name="", id=""), claim=claim_header, + rendering_provider_npi=rendering_provider_npi, diagnoses=diagnoses, service_lines=service_lines, validation=ValidationReport(passed=True, errors=[], warnings=[]), @@ -296,7 +303,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) -> provider_ref: str | None = None idx += 1 - while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE"}: + while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE", "NM1"}: s = segments[idx] raw.append(s) if s[0] == "DTP" and len(s) > 2 and s[1] == "472": diff --git a/backend/tests/fixtures/co_medicaid_837p_with_renderer.txt b/backend/tests/fixtures/co_medicaid_837p_with_renderer.txt new file mode 100644 index 0000000..1132e8c --- /dev/null +++ b/backend/tests/fixtures/co_medicaid_837p_with_renderer.txt @@ -0,0 +1,61 @@ +ISA*00* *00* *ZZ*11525703 *ZZ*COMEDASSISTPROG*260617*1937*^*00501*991102984*1*P*:~ +GS*HC*11525703*COMEDASSISTPROG*20260617*193715*991102984*X*005010X222A1~ +ST*837*991102984*005010X222A1~ +BHT*0019*00*co-fixture-001*20260617*193715*CH~ +NM1*41*2*Dzinesco*****46*11525703~ +PER*IC*Tester*EM*tester@example.com~ +NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~ +HL*1**20*1~ +PRV*BI*PXC*251E00000X~ +NM1*85*2*TOC, Inc.*****XX*1881068062~ +N3*1100 East Main St*Suite A~ +N4*Montrose*CO*814014063~ +REF*EI*721587149~ +HL*2*1*22*0~ +SBR*P*18*******MC~ +NM1*IL*1*Balliache*Marianela****MI*P060946~ +N3*1811 PAVILION DR APT 303~ +N4*Montrose*CO*814016072~ +DMG*D8*19590223*F~ +NM1*PR*2*COHCPF*****PI*SKCO0~ +CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~ +REF*G1*3173~ +HI*ABK:R69~ +LX*1~ +SV1*HC:T1019:U2*42.70*UN*6.00***1~ +DTP*472*D8*20260602~ +REF*6R*t991102984v769804d~ +LX*2~ +SV1*HC:T1019:U2*42.70*UN*6.00***1~ +DTP*472*D8*20260603~ +REF*6R*t991102984v770058d~ +NM1*82*2*RENDERING PROVIDER*****XX*1234567893~ +HL*3*1*22*0~ +SBR*P*18*******MC~ +NM1*IL*1*Barella*Victoria****MI*H582447~ +N3*1900 Kellie DR~ +N4*Montrose*CO*814019524~ +DMG*D8*19570727*F~ +NM1*PR*2*COHCPF*****PI*SKCO0~ +CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~ +REF*G1*3173~ +HI*ABK:R69~ +LX*1~ +SV1*HC:S5130:U2*38.94*UN*6.00***1~ +DTP*472*D8*20260602~ +REF*6R*t991102984v769805d~ +LX*2~ +SV1*HC:S5130:U2*38.94*UN*6.00***1~ +DTP*472*D8*20260603~ +REF*6R*t991102984v770059d~ +LX*3~ +SV1*HC:S5130:U2*38.94*UN*6.00***1~ +DTP*472*D8*20260604~ +REF*6R*t991102984v770308d~ +LX*4~ +SV1*HC:S5130:U2*38.94*UN*6.00***1~ +DTP*472*D8*20260605~ +REF*6R*t991102984v770668d~ +SE*45*991102984~ +GE*1*991102984~ +IEA*1*991102984~ \ No newline at end of file diff --git a/backend/tests/test_parse_837.py b/backend/tests/test_parse_837.py index 2e76067..f904106 100644 --- a/backend/tests/test_parse_837.py +++ b/backend/tests/test_parse_837.py @@ -68,3 +68,16 @@ def test_parse_uses_generic_config_when_requested(): assert len(result.claims) == 1 # No patient-loop rule on generic config assert result.claims[0].validation.passed is True + + +def test_parse_837_extracts_rendering_provider_npi_from_nm1_82(): + """SP32: NM1*82 (rendering provider) populates ClaimOutput.rendering_provider_npi.""" + from pathlib import Path + + text = Path("tests/fixtures/co_medicaid_837p_with_renderer.txt").read_text() + result = parse(text, PayerConfig.co_medicaid()) + assert len(result.claims) >= 1 + # Find the claim that has the rendering NPI (others may not). + matches = [c for c in result.claims if c.rendering_provider_npi is not None] + assert len(matches) == 1 + assert matches[0].rendering_provider_npi == "1234567893"