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:
Tyler
2026-06-20 08:22:31 -06:00
parent f3002e4cbe
commit a665881d24
15 changed files with 3286 additions and 1 deletions
+106
View File
@@ -0,0 +1,106 @@
"""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"