feat(parsers+api): 270/271 eligibility request + response (SP3 P4)
Adds API-only 270/271 eligibility flow per spec section 3.4 (no UI, no
DB persistence). Operator pastes a 270 query, gets back raw 270 text,
submits manually, pastes the 271 back.
New models (270 + 271):
- InformationSource, InformationReceiver, Subscriber, Patient
- EligibilityBenefitInquiry (EQ) and CoverageBenefit (EB)
- Inline SERVICE_TYPE_CODES dict (566 codes, snapshot 2026-06-20)
- service_type_description() lookup helper
Parsers (single segment walker, defensive, mirrors 835 pattern):
- parse_270: HL*20/21/22/23 loops + EQ, NM1, DMG, TRN
- parse_271: HL*20/21/22/23 loops + EB, DTP, MSG, AAA
Serializer:
- serialize_270: ISA/GS/ST/BHT/HL*20/21/22/patient/EQ/SE/GE/IEA
- round-trips: serialize -> parse -> serialize == identical
API:
- POST /api/eligibility/request (JSON body -> {raw_270_text, parsed})
- POST /api/eligibility/parse-271 (file upload -> structured summary)
- 400 on missing fields / garbage; never 500.
Tests: 365 -> 408 backend (+43 new), 10 frontend unchanged.
TypeScript clean.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""Tests for the 271 (Eligibility Benefit Response) parser.
|
||||
|
||||
SP3 Phase 4 (T21): single-pass segment walker that pulls the
|
||||
information source / receiver / subscriber / patient / EB coverage
|
||||
benefits out of a tokenized 271. The fixture
|
||||
(``tests/fixtures/minimal_271.txt``) has two EB segments with
|
||||
descriptions — one Medical Care, one Pharmacy — so we can assert
|
||||
both the count and the per-benefit resolution of service-type codes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.parse_271 import parse
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_271.txt"
|
||||
|
||||
|
||||
def test_parse_minimal_271_returns_two_benefits():
|
||||
"""The fixture's two EB segments become two CoverageBenefit rows."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert len(result.coverage_benefits) == 2
|
||||
# First EB: medical care (code "1"). Second: pharmacy (code "88").
|
||||
assert result.coverage_benefits[0].service_type_code == "1"
|
||||
assert result.coverage_benefits[1].service_type_code == "88"
|
||||
|
||||
|
||||
def test_parse_271_resolves_service_type_description():
|
||||
"""``service_type_description`` is auto-populated from the EB01 code."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.coverage_benefits[0].service_type_description == "Medical Care"
|
||||
assert result.coverage_benefits[1].service_type_description == "Pharmacy"
|
||||
|
||||
|
||||
def test_parse_271_in_plan_network_flag():
|
||||
"""The first EB has Y for in-plan, the second has N."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.coverage_benefits[0].in_plan_network is True
|
||||
assert result.coverage_benefits[1].in_plan_network is False
|
||||
|
||||
|
||||
def test_parse_271_envelope_built():
|
||||
"""Envelope has the ISA/GS/ST fields. ST03 = implementation guide."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
env = result.envelope
|
||||
assert env.sender_id.strip() == "PAYERID"
|
||||
assert env.receiver_id.strip() == "PROVIDERID"
|
||||
assert env.control_number == "000000002"
|
||||
assert env.implementation_guide == "005010X279A1"
|
||||
# GS04 supplies the transaction date.
|
||||
assert env.transaction_date.year == 2024
|
||||
assert env.transaction_date.month == 1
|
||||
assert env.transaction_date.day == 1
|
||||
|
||||
|
||||
def test_parse_271_msg_folded_into_additional_info():
|
||||
"""MSG segments become ``BenefitAdditionalInfo`` rows on the EB."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
cb0 = result.coverage_benefits[0]
|
||||
assert len(cb0.additional_info) == 1
|
||||
assert cb0.additional_info[0].description == "Patient has active coverage"
|
||||
cb1 = result.coverage_benefits[1]
|
||||
assert len(cb1.additional_info) == 1
|
||||
assert cb1.additional_info[0].description == "Pharmacy copay $20"
|
||||
|
||||
|
||||
def test_parse_271_subscriber_extracted():
|
||||
"""Subscriber first/last name + member id come from NM1*IL."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.subscriber.first_name == "JOHN"
|
||||
assert result.subscriber.last_name == "DOE"
|
||||
assert result.subscriber.member_id == "MEMBER123"
|
||||
|
||||
|
||||
def test_parse_271_invalid_edi_raises():
|
||||
"""Garbage input must raise :class:`CycloneParseError`."""
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
|
||||
with pytest.raises(CycloneParseError):
|
||||
parse("not edi at all", input_file="garbage.txt")
|
||||
|
||||
|
||||
def test_parse_271_summary_counts_eb_segments():
|
||||
"""The summary reports the number of EB segments we parsed."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.summary.total_claims == 2
|
||||
assert result.summary.passed == 2
|
||||
assert result.summary.failed == 0
|
||||
assert result.summary.input_file == "minimal_271.txt"
|
||||
Reference in New Issue
Block a user