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.
210 lines
8.1 KiB
Python
210 lines
8.1 KiB
Python
"""Tests for the 270 (Eligibility Benefit Inquiry) serializer.
|
|
|
|
SP3 Phase 4 (T22 + T25): build a ``ParseResult270``, serialize to
|
|
X12, then re-parse and assert the round-trip is stable. The cross-
|
|
parser round-trip test (T25) lives at the bottom of this file.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from cyclone.parsers.models import BatchSummary, Envelope
|
|
from cyclone.parsers.models_270 import (
|
|
EligibilityBenefitInquiry,
|
|
InformationReceiver270,
|
|
InformationSource270,
|
|
ParseResult270,
|
|
Subscriber270,
|
|
)
|
|
from cyclone.parsers.parse_270 import parse
|
|
from cyclone.parsers.serialize_270 import serialize_270
|
|
|
|
|
|
def _build_result(
|
|
service_type_code: str = "1",
|
|
with_patient: bool = False,
|
|
) -> ParseResult270:
|
|
patient = None
|
|
if with_patient:
|
|
from cyclone.parsers.models_270 import Patient270
|
|
patient = Patient270(
|
|
first_name="JANE",
|
|
last_name="DOE",
|
|
dob=date(2010, 5, 5),
|
|
relationship="19",
|
|
)
|
|
return ParseResult270(
|
|
envelope=Envelope(
|
|
sender_id="SUBMITTERID",
|
|
receiver_id="RECEIVERID",
|
|
control_number="000000001",
|
|
transaction_date=date(2024, 1, 1),
|
|
implementation_guide="005010X279A1",
|
|
),
|
|
information_source=InformationSource270(name="PAYER NAME", id="SKCO0"),
|
|
information_receiver=InformationReceiver270(name="PROVIDER NAME", npi="1234567890"),
|
|
subscriber=Subscriber270(
|
|
member_id="MEMBER123",
|
|
first_name="JOHN",
|
|
last_name="DOE",
|
|
dob=date(1980, 1, 1),
|
|
),
|
|
patient=patient,
|
|
inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)],
|
|
summary=BatchSummary(
|
|
input_file="round_trip.txt",
|
|
total_claims=1,
|
|
passed=1,
|
|
failed=0,
|
|
),
|
|
)
|
|
|
|
|
|
def test_serialize_270_envelope_segments():
|
|
"""Output starts with ``ISA*`` and ends with ``IEA*``; canonical envelope
|
|
layers are all present in order.
|
|
"""
|
|
text = serialize_270(_build_result())
|
|
assert text.startswith("ISA*")
|
|
# Strip trailing newline if any, then check the IEA tail.
|
|
body = text.rstrip("\n")
|
|
assert body.endswith("~IEA*1*000000001~")
|
|
# Sender/receiver ids in the GS are padded to 15 chars (X12 fixed width).
|
|
assert "GS*HC*SUBMITTERID *RECEIVERID *" in text
|
|
assert "ST*270*0001*005010X279A1~" in text
|
|
assert "BHT*0022*13*" in text
|
|
assert "HL*1**20*1~" in text
|
|
assert "NM1*PR*2*PAYER NAME" in text
|
|
assert "HL*2*1*21*1~" in text
|
|
assert "NM1*1P*2*PROVIDER NAME" in text
|
|
assert "HL*3*2*22*0~" in text
|
|
assert "TRN*2*TRACE001~" in text
|
|
assert "NM1*IL*1*DOE*JOHN" in text
|
|
assert "DMG*D8*19800101" in text
|
|
assert "SE*" in text
|
|
assert "GE*1*1 ~" in text # group_control_number padded to 9 chars
|
|
assert "IEA*1*000000001~" in text
|
|
|
|
|
|
def test_serialize_270_eq_segment_present():
|
|
"""Output contains a single ``EQ*`` segment carrying the service type code."""
|
|
text = serialize_270(_build_result(service_type_code="30"))
|
|
assert "EQ*30~" in text
|
|
|
|
|
|
def test_serialize_270_minimal_round_trip():
|
|
"""Build → serialize → parse → assert fields match (envelope + subscriber + EQ)."""
|
|
original = _build_result(service_type_code="1")
|
|
text = serialize_270(original)
|
|
re_parsed = parse(text, input_file="round_trip.txt")
|
|
|
|
# Envelope
|
|
assert re_parsed.envelope.sender_id.strip() == original.envelope.sender_id
|
|
assert re_parsed.envelope.receiver_id.strip() == original.envelope.receiver_id
|
|
assert re_parsed.envelope.control_number == "000000001"
|
|
assert re_parsed.envelope.implementation_guide == "005010X279A1"
|
|
|
|
# Information source / receiver
|
|
assert re_parsed.information_source.name == "PAYER NAME"
|
|
assert re_parsed.information_source.id == "SKCO0"
|
|
assert re_parsed.information_receiver.name == "PROVIDER NAME"
|
|
assert re_parsed.information_receiver.npi == "1234567890"
|
|
|
|
# Subscriber
|
|
assert re_parsed.subscriber.first_name == "JOHN"
|
|
assert re_parsed.subscriber.last_name == "DOE"
|
|
assert re_parsed.subscriber.member_id == "MEMBER123"
|
|
assert re_parsed.subscriber.dob == date(1980, 1, 1)
|
|
|
|
# Inquiry
|
|
assert len(re_parsed.inquiries) == 1
|
|
assert re_parsed.inquiries[0].service_type_code == "1"
|
|
|
|
|
|
def test_serialize_270_with_patient_round_trip():
|
|
"""Build a result with a Patient270, serialize, re-parse, assert the
|
|
patient loop comes back.
|
|
"""
|
|
original = _build_result(with_patient=True)
|
|
text = serialize_270(original)
|
|
re_parsed = parse(text, input_file="round_trip_with_patient.txt")
|
|
assert re_parsed.patient is not None
|
|
assert re_parsed.patient.first_name == "JANE"
|
|
assert re_parsed.patient.last_name == "DOE"
|
|
assert re_parsed.patient.dob == date(2010, 5, 5)
|
|
|
|
|
|
def test_serialize_270_custom_interchange_control_number():
|
|
"""A custom interchange control number propagates into the ISA and IEA segments."""
|
|
text = serialize_270(_build_result(), interchange_control_number="000000042")
|
|
assert "ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID" in text
|
|
# IEA*1*<ICN>~ at the end.
|
|
assert text.rstrip("\n").endswith("~IEA*1*000000042~")
|
|
# ISA also carries it.
|
|
assert "*000000042*" in text.split("~", 1)[0]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# T25 — Cross-parser round-trip (full)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_cross_parser_270_round_trip():
|
|
"""Build a fully populated ParseResult270, serialize, parse, assert each
|
|
field matches the original (except the raw envelope control_number
|
|
which the serializer regenerates from defaults).
|
|
"""
|
|
original = _build_result(
|
|
service_type_code="88", with_patient=True,
|
|
)
|
|
text = serialize_270(original)
|
|
re_parsed = parse(text, input_file="cross.txt")
|
|
|
|
# Envelope — sender/receiver/guide are stable; control_number is regenerated
|
|
# by the serializer's default "000000001" (the parser pulls it from ISA13).
|
|
assert re_parsed.envelope.control_number == "000000001"
|
|
assert re_parsed.envelope.sender_id.strip() == "SUBMITTERID"
|
|
assert re_parsed.envelope.receiver_id.strip() == "RECEIVERID"
|
|
assert re_parsed.envelope.implementation_guide == "005010X279A1"
|
|
|
|
# Information source + receiver
|
|
assert re_parsed.information_source.name == original.information_source.name
|
|
assert re_parsed.information_source.id == original.information_source.id
|
|
assert re_parsed.information_receiver.name == original.information_receiver.name
|
|
assert re_parsed.information_receiver.npi == original.information_receiver.npi
|
|
|
|
# Subscriber
|
|
assert re_parsed.subscriber.member_id == original.subscriber.member_id
|
|
assert re_parsed.subscriber.first_name == original.subscriber.first_name
|
|
assert re_parsed.subscriber.last_name == original.subscriber.last_name
|
|
assert re_parsed.subscriber.dob == original.subscriber.dob
|
|
|
|
# Patient (set on the original).
|
|
assert re_parsed.patient is not None
|
|
assert re_parsed.patient.first_name == original.patient.first_name
|
|
assert re_parsed.patient.last_name == original.patient.last_name
|
|
assert re_parsed.patient.dob == original.patient.dob
|
|
|
|
# Inquiries
|
|
assert len(re_parsed.inquiries) == len(original.inquiries)
|
|
for got, exp in zip(re_parsed.inquiries, original.inquiries):
|
|
assert got.service_type_code == exp.service_type_code
|
|
|
|
# Re-serializing the parsed result must be identical to the first
|
|
# serialization (idempotent round-trip — except for BHT03 which is
|
|
# timestamp-based, and ISA09/10/GS04/05 which use the current date).
|
|
text2 = serialize_270(re_parsed)
|
|
# Normalize both texts to drop the date/time-driven segments before
|
|
# comparing for "shape" stability: we keep envelope, HL/NM1, EQ.
|
|
def _shape(t: str) -> list[str]:
|
|
return [
|
|
s for s in t.split("~")
|
|
if s and not s.startswith("BHT") and not s.startswith("ISA")
|
|
and not s.startswith("GS") and not s.startswith("GE")
|
|
and not s.startswith("IEA")
|
|
]
|
|
|
|
assert _shape(text) == _shape(text2)
|