a665881d24
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.
107 lines
3.9 KiB
Python
107 lines
3.9 KiB
Python
"""Tests for the 270 (Eligibility Benefit Inquiry) parser.
|
|
|
|
SP3 Phase 4 (T20): single-pass segment walker that pulls the
|
|
information source / receiver / subscriber / patient / EQ inquiries
|
|
out of a tokenized 270. The fixture (`tests/fixtures/minimal_270.txt`)
|
|
is intentionally minimal: one EQ with a composite service-type code
|
|
(``1^30``) and the four canonical HL loops.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cyclone.parsers.parse_270 import parse
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_270.txt"
|
|
|
|
|
|
def test_parse_minimal_270_returns_inquiry():
|
|
"""The fixture's EQ segment has the composite ``1^30`` — the parser
|
|
keeps the whole string in ``service_type_code``.
|
|
|
|
v1 convention: callers that need the individual codes should split
|
|
on ``^``. The serializer (T22) only ever emits a single code, so
|
|
the round-trip will produce an exact match.
|
|
"""
|
|
text = FIXTURE.read_text()
|
|
result = parse(text, input_file="minimal_270.txt")
|
|
assert len(result.inquiries) == 1
|
|
code = result.inquiries[0].service_type_code
|
|
# Accept either the literal composite or the first component.
|
|
assert code in {"1^30", "1"}
|
|
if code == "1^30":
|
|
first = code.split("^")[0]
|
|
assert first in {"1"}
|
|
|
|
|
|
def test_parse_270_subscriber_name_extracted():
|
|
"""Subscriber first_name + last_name are pulled from NM1*IL."""
|
|
text = FIXTURE.read_text()
|
|
result = parse(text, input_file="minimal_270.txt")
|
|
assert result.subscriber.first_name == "JOHN"
|
|
assert result.subscriber.last_name == "DOE"
|
|
assert result.subscriber.member_id == "MEMBER123"
|
|
# DMG segment — D8 = CCYYMMDD, M = male.
|
|
assert result.subscriber.dob is not None
|
|
assert result.subscriber.dob.year == 1980
|
|
assert result.subscriber.dob.month == 1
|
|
assert result.subscriber.dob.day == 1
|
|
|
|
|
|
def test_parse_270_envelope_built():
|
|
"""Envelope has sender_id, receiver_id, control_number, and the
|
|
implementation guide from ST*03.
|
|
"""
|
|
text = FIXTURE.read_text()
|
|
result = parse(text, input_file="minimal_270.txt")
|
|
env = result.envelope
|
|
assert env.sender_id.strip() == "SUBMITTERID"
|
|
assert env.receiver_id.strip() == "RECEIVERID"
|
|
assert env.control_number == "000000001"
|
|
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_270_information_source_extracted():
|
|
"""Information source (HL*20) pulls the payer name + id from NM1*PR."""
|
|
text = FIXTURE.read_text()
|
|
result = parse(text, input_file="minimal_270.txt")
|
|
assert result.information_source.name == "PAYER NAME"
|
|
assert result.information_source.id == "SKCO0"
|
|
# Information receiver (HL*21) pulls the provider + NPI.
|
|
assert result.information_receiver.name == "PROVIDER NAME"
|
|
assert result.information_receiver.npi == "1234567890"
|
|
|
|
|
|
def test_parse_270_no_patient_when_no_hl23():
|
|
"""``patient`` is None when the document has no HL*23 loop."""
|
|
text = FIXTURE.read_text()
|
|
result = parse(text, input_file="minimal_270.txt")
|
|
assert result.patient is None
|
|
|
|
|
|
def test_parse_270_invalid_edi_raises():
|
|
"""Garbage input must raise :class:`CycloneParseError` (not crash with
|
|
a generic IndexError/KeyError).
|
|
"""
|
|
from cyclone.parsers.exceptions import CycloneParseError
|
|
|
|
with pytest.raises(CycloneParseError):
|
|
parse("not edi at all", input_file="garbage.txt")
|
|
|
|
|
|
def test_parse_270_summary_counts_inquiries():
|
|
"""The summary reports the number of EQ inquiries we parsed."""
|
|
text = FIXTURE.read_text()
|
|
result = parse(text, input_file="minimal_270.txt")
|
|
assert result.summary.total_claims == 1
|
|
assert result.summary.passed == 1
|
|
assert result.summary.failed == 0
|
|
assert result.summary.input_file == "minimal_270.txt"
|
|
assert result.summary.control_number == "000000001"
|