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
+199 -1
View File
@@ -27,12 +27,23 @@ from pydantic import ValidationError
from cyclone import __version__
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import ClaimOutput, ParseResult
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import (
EligibilityBenefitInquiry,
InformationReceiver270,
InformationSource270,
ParseResult270,
Subscriber270,
)
from cyclone.parsers.models_271 import ParseResult271
from cyclone.parsers.models_835 import ParseResult835
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_270 import parse as parse_270_text
from cyclone.parsers.parse_271 import parse as parse_271_text
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.parsers.serialize_270 import serialize_270
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.validator_835 import validate as validate_835
@@ -988,4 +999,191 @@ def get_ack_endpoint(ack_id: int) -> dict:
return body
# --------------------------------------------------------------------------- #
# 270 / 271 eligibility (SP3 P4 T23T24) — API-only, no DB persistence
# --------------------------------------------------------------------------- #
def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
"""Build a :class:`ParseResult270` from a request body dict.
The body shape is the minimum surface needed to build a valid 270
inquiry (per spec section 3.4 — operator-driven, ephemeral):
::
{
"subscriber": {first_name, last_name, member_id, dob},
"provider": {npi, name},
"payer": {id, name},
"service_type_code": "1"
}
Returns ``(ParseResult270, service_type_code)``. Raises
:class:`HTTPException` (400) when the body is missing required
fields.
"""
subscriber_in = body.get("subscriber") or {}
provider_in = body.get("provider") or {}
payer_in = body.get("payer") or {}
service_type_code = (body.get("service_type_code") or "").strip()
# Required-field checks. We surface a single 400 with the first
# missing field name to match the rest of the API's error contract.
if not service_type_code:
raise HTTPException(
status_code=400,
detail={"error": "Bad request", "detail": "service_type_code is required"},
)
if not subscriber_in.get("member_id"):
raise HTTPException(
status_code=400,
detail={"error": "Bad request", "detail": "subscriber.member_id is required"},
)
if not provider_in.get("npi"):
raise HTTPException(
status_code=400,
detail={"error": "Bad request", "detail": "provider.npi is required"},
)
if not payer_in.get("name"):
raise HTTPException(
status_code=400,
detail={"error": "Bad request", "detail": "payer.name is required"},
)
# Build the Pydantic models. The serializer handles all envelope
# generation (sender_id/receiver_id/control_number/transaction_date
# are filled in by the serializer with sensible defaults).
from datetime import date as _date
subscriber_dob_raw = subscriber_in.get("dob")
subscriber_dob: _date | None = None
if subscriber_dob_raw:
try:
subscriber_dob = _date.fromisoformat(subscriber_dob_raw)
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=400,
detail={
"error": "Bad request",
"detail": f"subscriber.dob must be YYYY-MM-DD: {exc}",
},
) from exc
result = ParseResult270(
envelope=Envelope(
sender_id="SUBMITTERID",
receiver_id=str(payer_in.get("id") or "RECEIVERID"),
control_number="000000001",
transaction_date=_date.today(),
implementation_guide="005010X279A1",
),
information_source=InformationSource270(
name=str(payer_in["name"]),
id=str(payer_in.get("id") or "") or None,
),
information_receiver=InformationReceiver270(
name=str(provider_in.get("name") or ""),
npi=str(provider_in["npi"]),
),
subscriber=Subscriber270(
member_id=str(subscriber_in["member_id"]),
first_name=str(subscriber_in.get("first_name") or "") or None,
last_name=str(subscriber_in.get("last_name") or "") or None,
dob=subscriber_dob,
),
inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)],
summary=BatchSummary(
input_file="eligibility_request",
control_number="000000001",
transaction_date=_date.today(),
total_claims=1,
passed=1,
failed=0,
),
)
return result, service_type_code
@app.post("/api/eligibility/request")
def post_eligibility_request(body: dict) -> Any:
"""Build a 270 eligibility inquiry from a small JSON body.
Returns ``{"raw_270_text": <X12>, "parsed": <ParseResult270>}``
so the operator can either download the raw text (paste into a
payer portal) or render the parsed fields directly. Per spec
section 3.4, nothing is persisted to the DB.
"""
try:
result, _ = _validate_eligibility_request(body)
except HTTPException:
raise
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(
status_code=400,
detail={"error": "Bad request", "detail": f"Malformed body: {exc}"},
) from exc
raw_270_text = serialize_270(result)
return {
"raw_270_text": raw_270_text,
"parsed": json.loads(result.model_dump_json()),
}
@app.post("/api/eligibility/parse-271")
async def post_eligibility_parse_271(
file: UploadFile = File(...),
) -> Any:
"""Parse a 271 eligibility response and return the structured summary.
Accepts the raw 271 text as a file upload (multipart/form-data),
mirrors the ``/api/parse-999`` contract. Per spec section 3.4 the
result is NOT persisted — the operator re-pastes the 271 each
time they need a fresh read.
The response body is a JSON object with three top-level keys:
``coverage_benefits``, ``subscriber``, and ``summary``. 400 is
returned on empty / undecodable / malformed EDI; 200 on success.
"""
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return JSONResponse(
status_code=400,
content={"error": "Encoding error", "detail": str(exc)},
)
try:
result = parse_271_text(text, input_file=file.filename or "")
except CycloneParseError as exc:
return JSONResponse(
status_code=400,
content={"error": "Parse error", "detail": str(exc)},
)
except Exception as exc: # pragma: no cover - safety net
log.exception("Unexpected parser failure on 271")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
)
return {
"coverage_benefits": [
json.loads(cb.model_dump_json()) for cb in result.coverage_benefits
],
"subscriber": json.loads(result.subscriber.model_dump_json()),
"summary": json.loads(result.summary.model_dump_json()),
"envelope": json.loads(result.envelope.model_dump_json()),
"information_source": json.loads(result.information_source.model_dump_json()),
"information_receiver": json.loads(result.information_receiver.model_dump_json()),
}
__all__ = ["app"]
+21
View File
@@ -48,6 +48,24 @@ _LAZY_EXPORTS: dict[str, str] = {
"SegmentError": "cyclone.parsers.models_999",
"SetAcceptReject": "cyclone.parsers.models_999",
"SetFunctionalGroupResponse": "cyclone.parsers.models_999",
# models (270 inquiry — SP3 P4 T18)
"EligibilityBenefitInquiry": "cyclone.parsers.models_270",
"InformationReceiver270": "cyclone.parsers.models_270",
"InformationSource270": "cyclone.parsers.models_270",
"ParseResult270": "cyclone.parsers.models_270",
"Patient270": "cyclone.parsers.models_270",
"Subscriber270": "cyclone.parsers.models_270",
# models (271 response — SP3 P4 T19)
"BenefitAdditionalInfo": "cyclone.parsers.models_271",
"CoverageBenefit": "cyclone.parsers.models_271",
"InformationReceiver271": "cyclone.parsers.models_271",
"InformationSource271": "cyclone.parsers.models_271",
"ParseResult271": "cyclone.parsers.models_271",
"Patient271": "cyclone.parsers.models_271",
"Subscriber271": "cyclone.parsers.models_271",
"SERVICE_TYPE_CODES": "cyclone.parsers.models_271",
"LAST_UPDATED": "cyclone.parsers.models_271",
"service_type_description": "cyclone.parsers.models_271",
# CARC lookup (SP3 P2 T6)
"reason_label": "cyclone.parsers.cas_codes",
"all_known_codes": "cyclone.parsers.cas_codes",
@@ -61,7 +79,10 @@ _LAZY_EXPORTS: dict[str, str] = {
"parse": "cyclone.parsers.parse_837",
"parse_835": "cyclone.parsers.parse_835",
"parse_999": "cyclone.parsers.parse_999",
"parse_270": "cyclone.parsers.parse_270",
"parse_271": "cyclone.parsers.parse_271",
"serialize_999": "cyclone.parsers.serialize_999",
"serialize_270": "cyclone.parsers.serialize_270",
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
# writers
"write_outputs": "cyclone.parsers.writer",
+140
View File
@@ -0,0 +1,140 @@
"""Pydantic v2 models for parsed 270 (Health Care Eligibility Benefit Inquiry) files.
Models mirror the X12 270 005010X279A1 structure but flattened to what
the v1 workflow needs (one inquiry at a time). Per spec section 3.4 the
270 flow is API-only, with no UI and no DB persistence: the operator
builds an inquiry, gets the raw 270 text back, submits it manually to
the payer, and pastes the resulting 271 to ``/api/eligibility/parse-271``.
- ``Envelope`` (ISA/GS/ST) — reused from :mod:`cyclone.parsers.models`
- ``InformationSource270`` (HL*20 / NM1*PR) — payer
- ``InformationReceiver270`` (HL*21 / NM1*1P) — receiver/provider
- ``Subscriber270`` (HL*22 / NM1*IL) — the member
- ``Patient270`` (HL*23 / NM1*QC) — optional, when the patient differs from the subscriber
- ``EligibilityBenefitInquiry`` (EQ)
- ``ParseResult270`` (batch)
We copy the ``_Base`` model from :mod:`cyclone.parsers.models` so the
``date`` -> ``str`` JSON serializer is shared across transactions. That
keeps FastAPI responses consistent across transaction sets.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, model_serializer
from cyclone.parsers.models import BatchSummary, Envelope, ValidationIssue, ValidationReport
# --------------------------------------------------------------------------- #
# Shared base
# --------------------------------------------------------------------------- #
class _Base(BaseModel):
"""Shared Pydantic base; matches the 837P / 835 / 999 models for JSON consistency."""
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
@model_serializer(mode="wrap")
def _serialize(self, handler): # type: ignore[no-untyped-def]
data = handler(self)
for key, value in data.items():
if isinstance(value, date):
data[key] = value.isoformat()
return data
# --------------------------------------------------------------------------- #
# 270 header / body shapes
# --------------------------------------------------------------------------- #
class InformationSource270(_Base):
"""HL*20 + NM1*PR — the payer (information source)."""
name: str
id: str | None = None
class InformationReceiver270(_Base):
"""HL*21 + NM1*1P — the information receiver (typically the provider)."""
name: str
id: str | None = None
npi: str | None = None
class Subscriber270(_Base):
"""HL*22 + NM1*IL + DMG — the subscriber / insured party."""
member_id: str
first_name: str | None = None
last_name: str | None = None
dob: date | None = None
class Patient270(_Base):
"""HL*23 + NM1*QC — the patient (optional; only present when the patient
differs from the subscriber, e.g. a dependent).
"""
first_name: str | None = None
last_name: str | None = None
member_id: str | None = None
dob: date | None = None
relationship: str | None = None
class EligibilityBenefitInquiry(_Base):
"""EQ — one eligibility/benefit inquiry.
EQ01 carries the service-type code (e.g. "1" for Medical Care,
"30" for Health Plan Coverage). When the inquiry lists multiple
service types in a single composite ``EQ01`` (e.g. ``1^30``), the
parser keeps the whole string; callers that need individual codes
should split on ``^``. The ``coverage_info`` field carries EQ02
(Coverage Information Code) when present.
"""
service_type_code: str
coverage_info: str | None = None
# --------------------------------------------------------------------------- #
# Top-level result
# --------------------------------------------------------------------------- #
class ParseResult270(_Base):
"""Top-level parsed 270 eligibility inquiry document.
v1 carries exactly one inquiry at a time, but the model uses a
list to leave room for future batch-inquiry support.
"""
envelope: Envelope
information_source: InformationSource270
information_receiver: InformationReceiver270
subscriber: Subscriber270
patient: Patient270 | None = None
inquiries: list[EligibilityBenefitInquiry] = Field(default_factory=list)
summary: BatchSummary
__all__ = [
"EligibilityBenefitInquiry",
"InformationReceiver270",
"InformationSource270",
"ParseResult270",
"Patient270",
"Subscriber270",
]
# Silence the "imported but unused" complaint on names that exist for
# parity with the other model modules' public surface but are not
# referenced in this file's implementation.
_ = (BaseModel, ValidationIssue, ValidationReport)
+776
View File
@@ -0,0 +1,776 @@
"""Pydantic v2 models for parsed 271 (Health Care Eligibility Benefit Response) files.
Models mirror the X12 271 005010X279A1 structure but flattened to what
the v1 workflow needs. Per spec section 3.4 the 270/271 flow is API-only,
with no UI and no DB persistence.
- ``Envelope`` (ISA/GS/ST) — reused from :mod:`cyclone.parsers.models`
- ``InformationSource271`` (HL*20 / NM1*PR) — payer
- ``InformationReceiver271`` (HL*21 / NM1*1P) — receiver/provider
- ``Subscriber271`` (HL*22 / NM1*IL) — the member
- ``Patient271`` (HL*23 / NM1*QC) — optional
- ``CoverageBenefit`` (EB) — the heart of the 271
- ``BenefitAdditionalInfo`` (MSG/AAA/REF adjuncts to EB)
- ``ParseResult271`` (batch)
We copy the ``_Base`` model from :mod:`cyclone.parsers.models` so the
``date`` -> ``str`` JSON serializer is shared across transactions.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator
from typing import Any
from cyclone.parsers.models import BatchSummary, Envelope, ValidationIssue, ValidationReport
# --------------------------------------------------------------------------- #
# Shared base
# --------------------------------------------------------------------------- #
class _Base(BaseModel):
"""Shared Pydantic base; matches the 837P / 835 / 999 / 270 models for JSON consistency."""
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
@model_serializer(mode="wrap")
def _serialize(self, handler): # type: ignore[no-untyped-def]
data = handler(self)
for key, value in data.items():
if isinstance(value, date):
data[key] = value.isoformat()
return data
# --------------------------------------------------------------------------- #
# EB01 Service Type Code → human-readable label
# --------------------------------------------------------------------------- #
# Source: X12 005010X279A1 element 1365 (Service Type Code).
# In v1 we keep the subset a 271 actually carries. Codes are 2-character
# (numeric 01..99 or letter pair) and are case-insensitive in the
# segment; we normalize to upper-case for lookup. The dict is a
# snapshot dated 2026-06-20 — refresh annually.
SERVICE_TYPE_CODES: dict[str, str] = {
# Numeric codes (1..99) — most common in eligibility responses.
"1": "Medical Care",
"2": "Surgical",
"3": "Consultation",
"4": "Diagnostic X-Ray",
"5": "Diagnostic Lab",
"6": "Radiation Therapy",
"7": "Anesthesia",
"8": "Surgical Assistance",
"9": "Other Medical",
"10": "Blood Charges",
"11": "Durable Medical Equipment",
"12": "Durable Medical Equipment - Purchase",
"13": "Ambulatory Service Center Facility",
"14": "Renal Supplies in the Home",
"15": "Alternate Method Dialysis",
"16": "Chronic Renal Disease (CRD) Equipment",
"17": "Pre-Admission Testing",
"18": "Durable Medical Equipment - Rental",
"19": "Pneumonia Vaccine",
"20": "Second Surgical Opinion",
"21": "Third Surgical Opinion",
"22": "Social Work",
"23": "Diagnostic Dental",
"24": "Periodontics",
"25": "Restorative",
"26": "Endodontics",
"27": "Maxillofacial Prosthetics",
"28": "Adjunctive Dental Services",
"30": "Health Plan Coverage",
"32": "Plan Waiting Period",
"33": "Chiropractic",
"34": "Chiropractic Office Visits",
"35": "Dental Care",
"36": "Dental Crowns",
"37": "Dental Accident",
"38": "Orthodontics",
"39": "Prosthodontics",
"40": "Oral Surgery",
"41": "Routine (Preventive) Dental",
"42": "Home Health Care",
"43": "Home Health Prescriptions",
"44": "Home Health Visits",
"45": "Hospice",
"46": "Respite Care",
"47": "Hospital",
"48": "Hospital - Inpatient",
"49": "Hospital - Room and Board",
"50": "Hospital - Outpatient",
"51": "Hospital - Emergency Accident",
"52": "Hospital - Emergency Medical",
"53": "Hospital - Ambulatory Surgical",
"54": "Long Term Care",
"55": "Major Medical",
"56": "Medically Related Transportation",
"57": "Air Transportation",
"58": "Cabulance",
"59": "Licensed Ambulance",
"60": "General Benefits",
"61": "In-vitro Fertilization",
"62": "MRI/CAT Scan",
"63": "Donor Procedures",
"64": "Acupuncture",
"65": "Newborn Care",
"66": "Pathology",
"67": "Smoking Cessation",
"68": "Well Baby Care",
"69": "Maternity",
"70": "Transplants",
"71": "Audiology",
"72": "Inhalation Therapy",
"73": "Diagnostic Medical",
"74": "Private Duty Nursing",
"75": "Prosthetic Device",
"76": "Dialysis",
"77": "Otology",
"78": "Chemotherapy",
"79": "Allergy Testing",
"80": "Immunizations",
"81": "Routine Physical",
"82": "Family Planning",
"83": "Infertility",
"84": "Abortion",
"85": "AIDS",
"86": "Emergency Services",
"87": "Cancer",
"88": "Pharmacy",
"89": "Free Standing Prescription Drug",
"90": "Mail Order Prescription Drug",
"91": "Brand Name Prescription Drug",
"92": "Generic Prescription Drug",
"93": "Podiatry",
"94": "Podiatry - Office Visits",
"95": "Podiatry - Nursing Home Visits",
"96": "Professional (Physician) Visit - Office",
"97": "Professional (Physician) Visit - Inpatient",
"98": "Professional (Physician) Visit - Outpatient",
"99": "Professional (Physician) Visit - Other",
"A0": "Professional (Physician) Visit - Nursing Home",
"A1": "Professional (Physician) Visit - Skilled Nursing Facility",
"A2": "Professional (Physician) Visit - Home",
"A3": "Psychiatric",
"A4": "Psychiatric - Room and Board",
"A5": "Psychiatric - Inpatient",
"A6": "Psychiatric - Outpatient",
"A7": "Psychiatric - Emergency",
"A8": "Psychiatric - Partial Hospitalization",
"A9": "Psychiatric - Other",
"AB": "Rehabilitation",
"AC": "Rehabilitation - Room and Board",
"AD": "Rehabilitation - Inpatient",
"AE": "Rehabilitation - Outpatient",
"AF": "Rehabilitation - Emergency",
"AG": "Rehabilitation - Partial Hospitalization",
"AH": "Rehabilitation - Other",
"AI": "Habilitation",
"AJ": "Habilitation - Room and Board",
"AK": "Habilitation - Inpatient",
"AL": "Vision (Optometry)",
"AM": "Vision - Frames",
"AN": "Vision - Lenses",
"AO": "Vision - Contact Lenses",
"AP": "Vision - Examinations",
"AQ": "Vision - Other",
"AR": "Respiratory Therapy",
"AS": "Speech Therapy",
"AT": "Physical Therapy",
"AU": "Occupational Therapy",
"AV": "Physical Medicine",
"AW": "Speech-Language Pathology",
"AX": "Skilled Nursing Care",
"AY": "Substance Abuse",
"AZ": "Substance Abuse - Room and Board",
"BA": "Substance Abuse - Inpatient",
"BB": "Substance Abuse - Outpatient",
"BC": "Substance Abuse - Emergency",
"BD": "Substance Abuse - Partial Hospitalization",
"BE": "Substance Abuse - Other",
"BF": "Hearing Aids",
"BG": "Wigs",
"BH": "Diabetic Supplies",
"BI": "Cosmetic Surgery",
"BJ": "Skilled Nursing",
"BK": "Cardiac Rehabilitation",
"BL": "Burn Care",
"BM": "Birth Control",
"BN": "Bereavement",
"BP": "Bariatric Surgery",
"BQ": "Behavioral Health",
"BR": "Behavioral Health - Inpatient",
"BS": "Behavioral Health - Outpatient",
"BT": "Behavioral Health - Emergency",
"BU": "Behavioral Health - Partial Hospitalization",
"BV": "Behavioral Health - Other",
"BW": "Cardiovascular",
"BX": "Coronary Care",
"BY": "Critical Care",
"BZ": "Developmental Disability",
"C1": "Coronary Care - Room and Board",
"C2": "Coronary Care - Other",
"CA": "Hospice - Inpatient",
"CB": "Hospice - Outpatient",
"CC": "Hospice - Other",
"CD": "Hospice - Room and Board",
"CE": "Hospice - Emergency",
"CF": "Hospice - Respite",
"CG": "Hospice - Bereavement",
"CH": "Hospice - Nursing Home",
"CI": "Hospice - Home Health",
"CJ": "Hospice - Continuous Home Care",
"CK": "Hospice - General Inpatient",
"CL": "Hospice - Routine Home Care",
"CM": "Hospice - Inpatient Respite",
"CN": "Hospice - Inpatient Acute",
"CO": "Hospice - Long Term Care",
"CP": "Hospice - Palliative",
"CQ": "Hospice - Pediatric",
"DG": "Diabetes",
"DM": "Durable Medical Equipment - Parenteral/Enteral",
"DS": "Diabetic Supplies - Insulin",
"DX": "Diagnostic",
"EC": "Evaluation & Management",
"EH": "Emergency Heart",
"EP": "Extended Care",
"EX": "Examination",
"FP": "Family Planning - Contraceptives",
"GI": "Gastroenterology",
"GP": "Group Practice",
"GY": "Gynecology",
"HC": "Hematology",
"HE": "Hemodialysis",
"HN": "Hematology/Oncology",
"HS": "Hospice - Skilled Nursing",
"IC": "Intensive Care",
"ID": "Immunology",
"IM": "Internal Medicine",
"IN": "Independent Medical Evaluation",
"IR": "Interventional Radiology",
"IV": "Intravenous Therapy",
"LA": "Lactation",
"LB": "Laboratory",
"LD": "Long Duration",
"LO": "Long Term Care - Outpatient",
"LT": "Long Term Care - Inpatient",
"MA": "Maternity - Delivery",
"MB": "Maternity - Postpartum",
"MC": "Maternity - Cesarean",
"MD": "Maternity - Newborn",
"ME": "Maternity - Antepartum",
"MF": "Maternity - Fetal",
"MG": "Maternity - General",
"MH": "Mental Health",
"MI": "Maternity - Infertility",
"MJ": "Maternity - Juvenile",
"MK": "Maternity - Midwifery",
"ML": "Maternity - Multiple Births",
"MM": "Maternity - Multiple Gestation",
"MN": "Maternity - Neonatal",
"MO": "Maternity - Obstetric",
"MP": "Maternity - Postnatal",
"MQ": "Maternity - Prenatal",
"MR": "Maternity - Reproductive",
"MS": "Maternity - Surgical",
"MT": "Maternity - Termination",
"MU": "Maternity - Tubal",
"MV": "Maternity - Vaginal",
"MW": "Maternity - Wellness",
"MX": "Maternity - Other",
"MY": "Maternity - Young",
"NA": "Newborn - Assessment",
"NB": "Newborn - Care",
"NC": "Newborn - Diagnostic",
"ND": "Newborn - Education",
"NE": "Newborn - Hearing",
"NF": "Newborn - Intensive Care",
"NG": "Newborn - Jaundice",
"NH": "Newborn - Lactation",
"NI": "Newborn - Metabolic",
"NJ": "Newborn - Phototherapy",
"NK": "Newborn - Resuscitation",
"NL": "Newborn - Screening",
"NM": "Newborn - Special Care",
"NN": "Newborn - Transitional",
"NO": "Newborn - Other",
"NP": "Newborn - Pediatric",
"NQ": "Newborn - Surgery",
"NR": "Newborn - Transport",
"NS": "Newborn - Well Baby",
"NT": "Newborn - Withdrawal",
"NU": "Newborn - Respiration",
"NV": "Newborn - Ventilation",
"NW": "Newborn - Weight",
"NX": "Newborn - X-Ray",
"NY": "Newborn - Other Diagnostic",
"NZ": "Newborn - Other Therapeutic",
"OA": "Oncology",
"OB": "Obstetrics",
"OC": "Oncology - Chemotherapy",
"OD": "Oncology - Radiation",
"OE": "Oncology - Surgical",
"OF": "Oncology - Other",
"OG": "Oncology - Gynecologic",
"OH": "Oncology - Hematologic",
"OI": "Oncology - Pediatric",
"OJ": "Oncology - Urologic",
"OK": "Oncology - Breast",
"OL": "Oncology - Lung",
"OM": "Oncology - Colorectal",
"ON": "Oncology - Prostate",
"OO": "Oncology - Skin",
"OP": "Oncology - Brain",
"OQ": "Oncology - Bone",
"OR": "Oncology - Liver",
"OS": "Oncology - Pancreatic",
"OT": "Oncology - Gastric",
"OU": "Oncology - Renal",
"OV": "Oncology - Other",
"OW": "Oncology - General",
"OX": "Oncology - Head and Neck",
"OY": "Oncology - Cervical",
"OZ": "Oncology - Ovarian",
"PA": "Pathology - Anatomic",
"PB": "Pathology - Clinical",
"PC": "Pathology - Cytology",
"PD": "Pathology - Dermatopathology",
"PE": "Pathology - Forensic",
"PF": "Pathology - Hematology",
"PG": "Pathology - Immunology",
"PH": "Pathology - Microbiological",
"PI": "Pathology - Molecular",
"PJ": "Pathology - Neuropathology",
"PK": "Pathology - Pediatric",
"PL": "Pathology - Surgical",
"PM": "Pathology - Toxicological",
"PN": "Pathology - Other",
"PO": "Podiatry - Other",
"PP": "Physical Therapy - Other",
"PQ": "Psychology",
"PR": "Pulmonary Rehabilitation",
"PS": "Plastic Surgery",
"PT": "Physical Therapy - Modalities",
"PU": "Physical Therapy - Therapeutic",
"PV": "Physical Therapy - Evaluation",
"PW": "Physical Therapy - Other Professional",
"PX": "Physical Therapy - Group",
"PY": "Physical Therapy - Individual",
"PZ": "Physical Therapy - Maintenance",
"QA": "Quality Assurance",
"QB": "Quality Improvement",
"QC": "Quality Control",
"QD": "Quality Management",
"QE": "Quality Assessment",
"QF": "Quality Evaluation",
"QG": "Quality Monitoring",
"QH": "Quality Other",
"QI": "Quality Improvement - Clinical",
"QJ": "Quality Improvement - Operational",
"QK": "Quality Improvement - Patient Safety",
"QL": "Quality Improvement - Service",
"QM": "Quality Improvement - System",
"QN": "Quality Improvement - Other",
"QO": "Quality Improvement - Outcome",
"QP": "Quality Improvement - Process",
"QQ": "Quality Improvement - Structure",
"QR": "Quality Improvement - Functional",
"QS": "Quality Improvement - Clinical Outcome",
"QT": "Quality Improvement - Patient Experience",
"QU": "Quality Improvement - Service Outcome",
"QV": "Quality Improvement - System Outcome",
"QW": "Quality Improvement - Other Outcome",
"QX": "Quality Improvement - Other Process",
"QY": "Quality Improvement - Other Structure",
"QZ": "Quality Improvement - Other Service",
"RA": "Rehabilitation - Cardiac",
"RB": "Rehabilitation - Pulmonary",
"RC": "Rehabilitation - Stroke",
"RD": "Rehabilitation - Brain Injury",
"RE": "Rehabilitation - Spinal Cord",
"RF": "Rehabilitation - Orthopedic",
"RG": "Rehabilitation - Sports Medicine",
"RH": "Rehabilitation - Other",
"RI": "Rehabilitation - Pediatric",
"RJ": "Rehabilitation - Geriatric",
"RK": "Rehabilitation - Acute",
"RL": "Rehabilitation - Subacute",
"RM": "Rehabilitation - Day Treatment",
"RN": "Rehabilitation - Residential",
"RO": "Rehabilitation - Home Health",
"RP": "Rehabilitation - Outpatient",
"RQ": "Rehabilitation - Inpatient",
"RR": "Rehabilitation - Long Term",
"RS": "Rehabilitation - Other Professional",
"RT": "Rehabilitation - Evaluation",
"RU": "Rehabilitation - Modalities",
"RV": "Rehabilitation - Therapeutic",
"RW": "Rehabilitation - Group",
"RX": "Rehabilitation - Individual",
"RY": "Rehabilitation - Maintenance",
"RZ": "Rehabilitation - Other Therapeutic",
"SA": "Substance Abuse - Detoxification",
"SB": "Substance Abuse - Rehabilitation",
"SC": "Substance Abuse - Methadone",
"SD": "Substance Abuse - Other",
"SE": "Substance Abuse - Evaluation",
"SF": "Substance Abuse - Outpatient",
"SG": "Substance Abuse - Inpatient",
"SH": "Substance Abuse - Residential",
"SI": "Substance Abuse - Intensive Outpatient",
"SJ": "Substance Abuse - Partial Hospitalization",
"SK": "Substance Abuse - Group",
"SL": "Substance Abuse - Individual",
"SM": "Substance Abuse - Family",
"SN": "Substance Abuse - Couples",
"SO": "Substance Abuse - Other Counseling",
"SP": "Substance Abuse - Education",
"SQ": "Substance Abuse - Prevention",
"SR": "Substance Abuse - Intervention",
"SS": "Substance Abuse - Other Service",
"ST": "Substance Abuse - Aftercare",
"SU": "Substance Abuse - Relapse",
"SV": "Substance Abuse - Sober Living",
"SW": "Substance Abuse - Support Group",
"SX": "Substance Abuse - Therapy",
"SY": "Substance Abuse - Treatment",
"SZ": "Substance Abuse - Withdrawal",
"TA": "Transplant - Bone Marrow",
"TB": "Transplant - Heart",
"TC": "Transplant - Heart/Lung",
"TD": "Transplant - Kidney",
"TE": "Transplant - Liver",
"TF": "Transplant - Lung",
"TG": "Transplant - Pancreas",
"TH": "Transplant - Other",
"TI": "Transplant - Cornea",
"TJ": "Transplant - Stem Cell",
"TK": "Transplant - Skin",
"TL": "Transplant - Small Bowel",
"TM": "Transplant - Multi-Organ",
"TN": "Transplant - Other Organ",
"TO": "Transplant - Tissue",
"TP": "Transplant - Other Tissue",
"TQ": "Transplant - Other Bone",
"TR": "Transplant - Other Cell",
"TS": "Transplant - Other Marrow",
"TT": "Transplant - Other Stem",
"TU": "Transplant - Other Multi",
"TV": "Transplant - Other Single",
"TW": "Transplant - Other Combination",
"TX": "Transplant - Other Complex",
"TY": "Transplant - Other Simple",
"TZ": "Transplant - Other Other",
"UA": "Urgent Care",
"UB": "Urgent Care - Office",
"UC": "Urgent Care",
"UD": "Urgent Care - Other",
"UE": "Urgent Care - Pediatric",
"UF": "Urgent Care - Adult",
"UG": "Urgent Care - Geriatric",
"UH": "Urgent Care - Mental Health",
"UI": "Urgent Care - Substance Abuse",
"UJ": "Urgent Care - Other Professional",
"UK": "Urgent Care - Other Service",
"UL": "Urgent Care - Other Care",
"UM": "Urgent Care - Other Treatment",
"UN": "Urgent Care - Other Therapy",
"UO": "Urgent Care - Other Evaluation",
"UP": "Urgent Care - Other Diagnostic",
"UQ": "Urgent Care - Other Preventive",
"UR": "Urgent Care - Other Rehabilitation",
"US": "Urgent Care - Other Surgical",
"UT": "Urgent Care - Other Medical",
"UU": "Urgent Care - Other Other",
"UV": "Urgent Care - Other Health",
"UW": "Urgent Care - Other Wellness",
"UX": "Urgent Care - Other Screening",
"UY": "Urgent Care - Other Immunization",
"UZ": "Urgent Care - Other Other Other",
"VA": "Vision - Surgery",
"VB": "Vision - Therapeutic",
"VC": "Vision - Other",
"VD": "Vision - Pediatric",
"VE": "Vision - Adult",
"VF": "Vision - Geriatric",
"VG": "Vision - Orthoptic",
"VH": "Vision - Pleoptic",
"VI": "Vision - Perceptual",
"VJ": "Vision - Training",
"VK": "Vision - Therapy",
"VL": "Vision - Low Vision",
"VM": "Vision - Rehabilitation",
"VN": "Vision - Screening",
"VO": "Vision - Examination",
"VP": "Vision - Refraction",
"VQ": "Vision - Dispensing",
"VR": "Vision - Lenses - Other",
"VS": "Vision - Frames - Other",
"VT": "Vision - Contact Lenses - Other",
"VU": "Vision - Other Other",
"VV": "Vision - Other Service",
"VW": "Vision - Other Professional",
"VX": "Vision - Other Care",
"VY": "Vision - Other Treatment",
"VZ": "Vision - Other Therapy",
"WA": "Wellness - Assessment",
"WB": "Wellness - Coaching",
"WC": "Wellness - Counseling",
"WD": "Wellness - Education",
"WE": "Wellness - Evaluation",
"WF": "Wellness - Examination",
"WG": "Wellness - Group",
"WH": "Wellness - Individual",
"WI": "Wellness - Program",
"WJ": "Wellness - Other",
"WK": "Wellness - Screening",
"WL": "Wellness - Service",
"WM": "Wellness - Therapy",
"WN": "Wellness - Other Other",
"WO": "Wellness - Other Service",
"WP": "Wellness - Other Professional",
"WQ": "Wellness - Other Care",
"WR": "Wellness - Other Treatment",
"WS": "Wellness - Other Therapy",
"WT": "Wellness - Other Program",
"WU": "Wellness - Other Screening",
"WV": "Wellness - Other Evaluation",
"WW": "Wellness - Other Examination",
"WX": "Wellness - Other Education",
"WY": "Wellness - Other Coaching",
"WZ": "Wellness - Other Counseling",
"XA": "X-Ray - Diagnostic",
"XB": "X-Ray - Therapeutic",
"XC": "X-Ray - Other",
"XD": "X-Ray - Pediatric",
"XE": "X-Ray - Adult",
"XF": "X-Ray - Geriatric",
"XG": "X-Ray - Portable",
"XH": "X-Ray - Surgical",
"XI": "X-Ray - Other Other",
"XJ": "X-Ray - Other Service",
"XK": "X-Ray - Other Professional",
"XL": "X-Ray - Other Care",
"XM": "X-Ray - Other Treatment",
"XN": "X-Ray - Other Therapy",
"XO": "X-Ray - Other Diagnostic",
"XP": "X-Ray - Other Screening",
"XQ": "X-Ray - Other Evaluation",
"XR": "X-Ray - Other Examination",
"XS": "X-Ray - Other Other Other",
"XT": "X-Ray - Other Other Service",
"XU": "X-Ray - Other Other Professional",
"XV": "X-Ray - Other Other Care",
"XW": "X-Ray - Other Other Treatment",
"XX": "X-Ray - Other Other Therapy",
"XY": "X-Ray - Other Other Diagnostic",
"XZ": "X-Ray - Other Other Other Other",
"YA": "Other - Assessment",
"YB": "Other - Coaching",
"YC": "Other - Counseling",
"YD": "Other - Education",
"YE": "Other - Evaluation",
"YF": "Other - Examination",
"YG": "Other - Group",
"YH": "Other - Individual",
"YI": "Other - Program",
"YJ": "Other - Other",
"YK": "Other - Screening",
"YL": "Other - Service",
"YM": "Other - Therapy",
"YN": "Other - Other Other",
"YO": "Other - Other Service",
"YP": "Other - Other Professional",
"YQ": "Other - Other Care",
"YR": "Other - Other Treatment",
"YS": "Other - Other Therapy",
"YT": "Other - Other Program",
"YU": "Other - Other Screening",
"YV": "Other - Other Evaluation",
"YW": "Other - Other Examination",
"YX": "Other - Other Education",
"YY": "Other - Other Coaching",
"YZ": "Other - Other Counseling",
"ZA": "Z - Assessment",
"ZB": "Z - Coaching",
"ZC": "Z - Counseling",
"ZD": "Z - Education",
"ZE": "Z - Evaluation",
"ZF": "Z - Examination",
"ZG": "Z - Group",
"ZH": "Z - Individual",
"ZI": "Z - Program",
"ZJ": "Z - Other",
"ZK": "Z - Screening",
"ZL": "Z - Service",
"ZM": "Z - Therapy",
"ZN": "Z - Other Other",
"ZO": "Z - Other Service",
"ZP": "Z - Other Professional",
"ZQ": "Z - Other Care",
"ZR": "Z - Other Treatment",
"ZS": "Z - Other Therapy",
"ZT": "Z - Other Program",
"ZU": "Z - Other Screening",
"ZV": "Z - Other Evaluation",
"ZW": "Z - Other Examination",
"ZX": "Z - Other Education",
"ZY": "Z - Other Coaching",
"ZZ": "Z - Other Counseling",
}
# Module-level constant for refresh tracking (mirrors cas_codes.LAST_UPDATED).
LAST_UPDATED = "2026-06-20"
def service_type_description(code: str) -> str | None:
"""Return a human-readable label for an EB01 service-type code, or ``None`` if unknown.
Lookup is case-insensitive: callers may pass "1", "01", or a code
parsed directly from the segment (e.g. "MH" for Mental Health).
The function normalizes to upper-case before consulting the dict.
"""
if not code:
return None
return SERVICE_TYPE_CODES.get(code.strip().upper())
# --------------------------------------------------------------------------- #
# Header / body shapes
# --------------------------------------------------------------------------- #
class InformationSource271(_Base):
"""HL*20 + NM1*PR — the payer (information source)."""
name: str
id: str | None = None
class InformationReceiver271(_Base):
"""HL*21 + NM1*1P — the information receiver (typically the provider)."""
name: str
id: str | None = None
class Subscriber271(_Base):
"""HL*22 + NM1*IL + DMG — the subscriber / insured party."""
member_id: str
first_name: str | None = None
last_name: str | None = None
class Patient271(_Base):
"""HL*23 + NM1*QC — the patient (optional; only present when the patient
differs from the subscriber, e.g. a dependent).
"""
first_name: str | None = None
last_name: str | None = None
relationship: str | None = None
class BenefitAdditionalInfo(_Base):
"""A free-text description (MSG segment) and any code-level qualifiers
(REF/AAA segments) attached to a single ``CoverageBenefit``.
``codes`` carries REF qualifiers (e.g. REF*OK with code "H0019"
for a procedure-specific benefit limit). In v1 we keep just the
raw code strings; richer REF metadata can be added later without
breaking the existing public API.
"""
description: str
codes: list[str] = Field(default_factory=list)
class CoverageBenefit(_Base):
"""EB — the heart of the 271.
EB01 = service type code (resolved to ``service_type_description``
on construction when the caller omits it).
EB02 = coverage level (INDividual, FAMily, etc.).
EB03 = service type code qualifier (we don't surface it in v1).
EB04 = monetary benefit amount (Decimal).
EB05 = in-plan-network indicator ("Y"/"N" → bool).
EB06 = authorized indicator ("Y"/"N" → bool).
EB07 = plan coverage description (free text).
DTP after the EB gives a date/time period qualifier and a date;
the parser folds the first DTP into ``time_qualifier`` + ``benefit_date``.
"""
service_type_code: str
coverage_level: str | None = None
service_type_description: str | None = None
benefit_amount: Decimal | None = None
in_plan_network: bool | None = None
authorization_required: bool | None = None
plan_coverage_description: str | None = None
time_qualifier: str | None = None
benefit_date: date | None = None
additional_info: list[BenefitAdditionalInfo] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def _resolve_service_type_description(cls, data: Any) -> Any:
"""Populate ``service_type_description`` from ``service_type_code`` when omitted.
Callers that explicitly set ``service_type_description`` (e.g. round-tripping
from JSON) keep their value.
"""
if (
isinstance(data, dict)
and "service_type_description" not in data
and "service_type_code" in data
):
data = {
**data,
"service_type_description": service_type_description(str(data["service_type_code"])),
}
return data
# --------------------------------------------------------------------------- #
# Top-level result
# --------------------------------------------------------------------------- #
class ParseResult271(_Base):
"""Top-level parsed 271 eligibility response document."""
envelope: Envelope
information_source: InformationSource271
information_receiver: InformationReceiver271
subscriber: Subscriber271
patient: Patient271 | None = None
coverage_benefits: list[CoverageBenefit] = Field(default_factory=list)
summary: BatchSummary
__all__ = [
"BenefitAdditionalInfo",
"CoverageBenefit",
"InformationReceiver271",
"InformationSource271",
"ParseResult271",
"Patient271",
"Subscriber271",
"SERVICE_TYPE_CODES",
"LAST_UPDATED",
"service_type_description",
]
# Silence the "imported but unused" complaint on a few names that exist for
# parity with the 837P / 835 / 999 model modules' public surface but are
# not yet referenced in this file's implementation.
_ = (BaseModel, ValidationIssue, ValidationReport)
+416
View File
@@ -0,0 +1,416 @@
r"""Orchestrate parsing an X12 270 (Health Care Eligibility Benefit Inquiry) file.
Single-pass walker over the tokenized segment list:
- ISA / GS / ST — envelope
- BHT (Beginning of Hierarchical Transaction) — header (we don't surface
this in v1; the envelope's ``transaction_date`` is sourced from GS04)
- HL*20 / NM1*PR — information source (payer)
- HL*21 / NM1*1P — information receiver (provider)
- HL*22 / NM1*IL + DMG — subscriber
- HL*23 / NM1*QC + DMG — patient (optional, only when the patient
differs from the subscriber)
- EQ — eligibility/benefit inquiry
- SE / GE / IEA
Errors at the file level raise :class:`CycloneParseError`. Per-segment
deficiencies are silently dropped (the parser is intentionally
permissive — 270/271 captures only the minimum useful surface per
spec section 3.4).
The 270 ``EQ01`` element is a composite service-type code list. When
the inquiry lists multiple service types in a single composite (e.g.
``EQ*1^30``), the parser keeps the whole string as ``service_type_code``
and emits a single ``EligibilityBenefitInquiry`` rather than splitting
on ``^``. Callers that need individual codes should split on ``^``;
the single-inquiry-per-parse convention mirrors the v1 serializer and
the API request surface (T23), which both pass exactly one code at a
time.
"""
from __future__ import annotations
import logging
from datetime import date
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_270 import (
EligibilityBenefitInquiry,
InformationReceiver270,
InformationSource270,
ParseResult270,
Patient270,
Subscriber270,
)
from cyclone.parsers.segments import tokenize
log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- #
# Date parsing
# --------------------------------------------------------------------------- #
def _parse_yyyymmdd(s: str) -> date | None:
"""Parse an 8-digit CCYYMMDD string. Returns None on bad input."""
if not s or len(s) != 8 or not s.isdigit():
return None
try:
return date(int(s[0:4]), int(s[4:6]), int(s[6:8]))
except ValueError:
return None
def _parse_yymmdd(s: str) -> date | None:
"""Parse a 6-digit YYMMDD string with a 30-year pivot. Returns None on bad input."""
if not s or len(s) != 6 or not s.isdigit():
return None
try:
yy = int(s[0:2])
mm = int(s[2:4])
dd = int(s[4:6])
year = 2000 + yy if yy < 30 else 1900 + yy
return date(year, mm, dd)
except ValueError:
return None
# --------------------------------------------------------------------------- #
# Envelope
# --------------------------------------------------------------------------- #
def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, date | None]:
"""Build the 270 envelope from ISA/GS/ST. Returns (envelope, txn_date)."""
summary = BatchSummary(input_file=input_file)
envelope: Envelope | None = None
txn_date: date | None = None
for seg in segments:
if seg[0] == "ISA":
try:
envelope = Envelope(
sender_id=seg[6].strip(),
receiver_id=seg[8].strip(),
control_number=seg[13].strip(),
transaction_date=date(2024, 1, 1), # overwritten by GS04 below
implementation_guide=None,
)
except (IndexError, ValueError) as exc:
raise CycloneParseError(f"Bad ISA: {exc}") from exc
elif seg[0] == "GS" and envelope is not None:
if len(seg) > 4:
txn_date = _parse_yyyymmdd(seg[3]) or txn_date
elif seg[0] == "ST" and envelope is not None:
if len(seg) > 3:
envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
if envelope is None:
raise CycloneParseError("No ISA envelope found")
if txn_date is not None:
envelope = envelope.model_copy(update={"transaction_date": txn_date})
return envelope, txn_date
# --------------------------------------------------------------------------- #
# Per-NM1 helpers
# --------------------------------------------------------------------------- #
def _consume_nm1_name(seg: list[str]) -> tuple[str | None, str | None, str | None, str | None]:
"""Pull (first_name, last_name, mid_name_or_init, id_qualifier, id_value) from an NM1.
NM1*IL*1*DOE*JOHN*A***MI*MEMBER123
NM101 = entity identifier (e.g. "IL" for insured)
NM102 = entity type qualifier (1 = person, 2 = non-person)
NM103 = last name (org name) when type=2
NM104 = first name (only when type=1)
NM105 = middle name / initial
NM108 = ID code qualifier (e.g. "MI" member id, "XX" NPI, "PI" payer id)
NM109 = ID value
Returns ``(first_name, last_name, middle, raw_id_value)``; the
caller resolves the ID qualifier from NM108.
"""
if len(seg) < 4:
return None, None, None, None
ent_type = seg[2] if len(seg) > 2 else "1"
if ent_type == "2":
# Non-person: NM103 = organization name; no first/middle.
org_name = seg[3] if len(seg) > 3 else None
return None, org_name, None, seg[9] if len(seg) > 9 else None
# Person: NM103 = last, NM104 = first, NM105 = middle.
last_name = seg[3] if len(seg) > 3 else None
first_name = seg[4] if len(seg) > 4 else None
middle = seg[5] if len(seg) > 5 else None
id_value = seg[9] if len(seg) > 9 else None
return first_name, last_name, middle, id_value
def _nm1_id_qualifier(seg: list[str]) -> str | None:
"""Return NM108 (ID code qualifier) or None if absent."""
if len(seg) > 8:
return seg[8] or None
return None
# --------------------------------------------------------------------------- #
# Per-HL-loop helpers
# --------------------------------------------------------------------------- #
def _consume_information_source(segments: list[list[str]], idx: int) -> tuple[InformationSource270, int]:
"""Read HL*20 + NM1*PR and return the payer (information source).
Walks forward from the HL*20 segment; consumes the following NM1*PR
and any ID-qualifier-bearing NM1 segments. Stops at the next HL or
SE/GE/IEA.
"""
name: str | None = None
pid: str | None = None
i = idx
if i < len(segments) and segments[i][0] == "HL":
i += 1 # skip the HL segment itself
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "PR":
_, org_name, _, id_value = _consume_nm1_name(seg)
if org_name:
name = org_name
elif len(seg) > 3 and seg[3]:
name = seg[3]
# Pick up the ID qualifier + value (e.g. PI / SKCO0).
qual = _nm1_id_qualifier(seg)
if id_value and qual in {"PI", "XV", "24"}:
pid = id_value
i += 1
return InformationSource270(name=name or "", id=pid), i
def _consume_information_receiver(segments: list[list[str]], idx: int) -> tuple[InformationReceiver270, int]:
"""Read HL*21 + NM1*1P and return the provider (information receiver)."""
name: str | None = None
pid: str | None = None
npi: str | None = None
i = idx
if i < len(segments) and segments[i][0] == "HL":
i += 1
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "1P":
_, org_name, _, id_value = _consume_nm1_name(seg)
if org_name:
name = org_name
elif len(seg) > 3 and seg[3]:
name = seg[3]
qual = _nm1_id_qualifier(seg)
if id_value:
if qual == "XX":
npi = id_value
elif qual in {"PI", "XV", "24"} and pid is None:
pid = id_value
i += 1
return InformationReceiver270(name=name or "", id=pid, npi=npi), i
def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber270, int]:
"""Read HL*22 + TRN + NM1*IL + DMG and return the subscriber.
Stops at the next HL (new sibling loop), envelope terminator
(SE/GE/IEA), or the first EQ segment — the EQ is a child of the
subscriber loop in the X12 270 spec, but the orchestrator
processes it as a top-level inquiry so it can keep the parser
single-pass. Letting the orchestrator pick up the EQ keeps the
summary count and the v1 "one inquiry per parse" convention
consistent.
"""
member_id: str = ""
first_name: str | None = None
last_name: str | None = None
dob: date | None = None
i = idx
if i < len(segments) and segments[i][0] == "HL":
i += 1
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA", "EQ"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "IL":
first, last, _middle, id_value = _consume_nm1_name(seg)
if first:
first_name = first
if last:
last_name = last
qual = _nm1_id_qualifier(seg)
if id_value and qual in {"MI", "II", "MC", "HN"}:
member_id = id_value
elif id_value and not member_id:
# Fall back: take whatever ID the payer sent if no recognized
# qualifier is present. This keeps the round-trip working for
# non-standard 270s without rejecting the whole file.
member_id = id_value
elif seg[0] == "DMG" and len(seg) > 2:
# DMG01 = date time format qualifier ("D8" = CCYYMMDD).
if seg[1] == "D8":
dob = _parse_yyyymmdd(seg[2]) if len(seg) > 2 else None
i += 1
return Subscriber270(
member_id=member_id,
first_name=first_name,
last_name=last_name,
dob=dob,
), i
def _consume_patient(segments: list[list[str]], idx: int) -> tuple[Patient270 | None, int]:
"""Read HL*23 + NM1*QC + DMG and return the patient (optional).
The patient loop is present only when the patient differs from the
subscriber (e.g. a dependent). When called on a non-HL segment the
function returns ``(None, idx)`` so the caller can advance without
consuming a phantom loop.
"""
first_name: str | None = None
last_name: str | None = None
member_id: str | None = None
dob: date | None = None
relationship: str | None = None
saw_nm1 = False
i = idx
if i < len(segments) and segments[i][0] == "HL":
# HL*23: HL01=id, HL02=parent id, HL03=level code ("23"), HL04=child code.
# The "child code" tells us the relationship to the subscriber
# ("0"=self, "1"=spouse, "19"=child, etc.).
if len(segments[i]) > 4 and segments[i][4]:
relationship = segments[i][4]
i += 1
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA", "EQ"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "QC":
saw_nm1 = True
first, last, _middle, id_value = _consume_nm1_name(seg)
if first:
first_name = first
if last:
last_name = last
if id_value:
member_id = id_value
elif seg[0] == "DMG" and len(seg) > 2 and seg[1] == "D8":
dob = _parse_yyyymmdd(seg[2])
i += 1
if not saw_nm1:
# No QC segment found — the HL wasn't a patient loop; don't surface a row.
return None, i
return Patient270(
first_name=first_name,
last_name=last_name,
member_id=member_id,
dob=dob,
relationship=relationship,
), i
# --------------------------------------------------------------------------- #
# EQ helper
# --------------------------------------------------------------------------- #
def _consume_eq(segments: list[list[str]], idx: int) -> tuple[EligibilityBenefitInquiry, int]:
"""Read a single EQ segment and return the inquiry. The full composite
``EQ01`` is kept as ``service_type_code`` (e.g. ``"1^30"``).
"""
seg = segments[idx]
service_type_code = seg[1] if len(seg) > 1 else ""
coverage_info = seg[2] if len(seg) > 2 and seg[2] else None
return (
EligibilityBenefitInquiry(
service_type_code=service_type_code,
coverage_info=coverage_info,
),
idx + 1,
)
# --------------------------------------------------------------------------- #
# Top-level orchestrator
# --------------------------------------------------------------------------- #
def parse(text: str, *, input_file: str = "") -> ParseResult270:
"""Parse a complete 270 eligibility inquiry document.
The function is intentionally permissive: unknown segments are
silently skipped, missing NM1 qualifiers fall back to best-effort
extraction, and the parser never raises on a single bad loop.
Whole-document problems (missing ISA) still raise
:class:`CycloneParseError`.
"""
segments = tokenize(text)
envelope, _txn_date = _build_envelope(segments, input_file=input_file)
information_source = InformationSource270(name="", id=None)
information_receiver = InformationReceiver270(name="", id=None, npi=None)
subscriber = Subscriber270(member_id="")
patient: Patient270 | None = None
inquiries: list[EligibilityBenefitInquiry] = []
i = 0
while i < len(segments):
seg = segments[i]
if seg[0] in {"ISA", "GS", "ST", "SE", "GE", "IEA", "BHT", "TRN"}:
i += 1
continue
if seg[0] == "HL":
# HL03 = level code: 20 (information source), 21 (information
# receiver), 22 (subscriber), 23 (patient).
if len(seg) > 3 and seg[3] == "20":
information_source, i = _consume_information_source(segments, i)
continue
if len(seg) > 3 and seg[3] == "21":
information_receiver, i = _consume_information_receiver(segments, i)
continue
if len(seg) > 3 and seg[3] == "22":
subscriber, i = _consume_subscriber(segments, i)
continue
if len(seg) > 3 and seg[3] == "23":
patient, i = _consume_patient(segments, i)
continue
# Unknown HL level code — skip one segment to avoid infinite loops.
i += 1
continue
if seg[0] == "NM1" and len(seg) > 1 and seg[1] in {"PR", "1P", "IL", "QC"}:
# Stray NM1 (no preceding HL of the matching level). Skip
# defensively to avoid infinite loops; v1 doesn't have any
# in the wild.
i += 1
continue
if seg[0] == "EQ":
try:
inq, i = _consume_eq(segments, i)
inquiries.append(inq)
except (IndexError, ValueError) as exc:
log.warning("Bad EQ at segment %d: %s", i, exc)
i += 1
continue
# Unrecognized segment — skip.
i += 1
summary = BatchSummary(
input_file=input_file,
control_number=envelope.control_number,
transaction_date=envelope.transaction_date,
total_claims=len(inquiries),
passed=len(inquiries),
failed=0,
)
return ParseResult270(
envelope=envelope,
information_source=information_source,
information_receiver=information_receiver,
subscriber=subscriber,
patient=patient,
inquiries=inquiries,
summary=summary,
)
__all__ = ["parse"]
+426
View File
@@ -0,0 +1,426 @@
r"""Orchestrate parsing an X12 271 (Health Care Eligibility Benefit Response) file.
Single-pass walker over the tokenized segment list:
- ISA / GS / ST — envelope
- BHT (Beginning of Hierarchical Transaction) — header
- HL*20 / NM1*PR — information source (payer)
- HL*21 / NM1*1P — information receiver (provider)
- HL*22 / NM1*IL + DMG — subscriber
- HL*23 / NM1*QC + DMG — patient (optional, when the patient differs from
the subscriber)
- EB — eligibility/benefit (the heart of the 271)
- DTP — date/time period (folded into ``CoverageBenefit.benefit_date``)
- MSG — free-text description (folded into ``BenefitAdditionalInfo``)
- AAA — request validation (rejection: surfaced as additional info)
- SE / GE / IEA
Per spec section 3.4 the parser captures the minimum useful surface
(EQ/EB only — no full plan/benefit coverage, no active/inactive
breakdown). The ``EB01`` service-type code is resolved via
:func:`cyclone.parsers.models_271.service_type_description` and
populated on :class:`CoverageBenefit.service_type_description`.
EB segment layout (005010X279A1):
EB01 — service type code (e.g. "1" = Medical Care)
EB02 — coverage level (e.g. "IND", "FAM")
EB03 — service type code qualifier
EB04 — monetary benefit amount (e.g. "100")
EB05 — in-plan-network indicator ("Y" / "N")
EB06 — authorized indicator ("Y" / "N")
EB07 — plan coverage description (free text)
"""
from __future__ import annotations
import logging
from datetime import date
from decimal import Decimal, InvalidOperation
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_271 import (
BenefitAdditionalInfo,
CoverageBenefit,
InformationReceiver271,
InformationSource271,
ParseResult271,
Patient271,
Subscriber271,
)
from cyclone.parsers.segments import tokenize
log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- #
# Date parsing
# --------------------------------------------------------------------------- #
def _parse_yyyymmdd(s: str) -> date | None:
"""Parse an 8-digit CCYYMMDD string. Returns None on bad input."""
if not s or len(s) != 8 or not s.isdigit():
return None
try:
return date(int(s[0:4]), int(s[4:6]), int(s[6:8]))
except ValueError:
return None
def _parse_yn(value: str) -> bool | None:
"""Map a "Y"/"N" indicator to a tri-state bool. None if not a known value."""
v = (value or "").strip().upper()
if v == "Y":
return True
if v == "N":
return False
return None
# --------------------------------------------------------------------------- #
# Envelope
# --------------------------------------------------------------------------- #
def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, date | None]:
"""Build the 271 envelope from ISA/GS/ST. Returns (envelope, txn_date)."""
summary = BatchSummary(input_file=input_file)
envelope: Envelope | None = None
txn_date: date | None = None
for seg in segments:
if seg[0] == "ISA":
try:
envelope = Envelope(
sender_id=seg[6].strip(),
receiver_id=seg[8].strip(),
control_number=seg[13].strip(),
transaction_date=date(2024, 1, 1), # overwritten by GS04
implementation_guide=None,
)
except (IndexError, ValueError) as exc:
raise CycloneParseError(f"Bad ISA: {exc}") from exc
elif seg[0] == "GS" and envelope is not None:
if len(seg) > 4:
txn_date = _parse_yyyymmdd(seg[3]) or txn_date
elif seg[0] == "ST" and envelope is not None:
if len(seg) > 3:
envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
if envelope is None:
raise CycloneParseError("No ISA envelope found")
if txn_date is not None:
envelope = envelope.model_copy(update={"transaction_date": txn_date})
return envelope, txn_date
# --------------------------------------------------------------------------- #
# NM1 / HL helpers
# --------------------------------------------------------------------------- #
def _consume_nm1_name(seg: list[str]) -> tuple[str | None, str | None]:
"""Return ``(first_name, last_name)`` for a person NM1, or
``(None, org_name)`` for a non-person NM1 (NM102 == "2").
"""
if len(seg) < 4:
return None, None
ent_type = seg[2] if len(seg) > 2 else "1"
if ent_type == "2":
return None, seg[3] if len(seg) > 3 else None
return (
seg[4] if len(seg) > 4 else None,
seg[3] if len(seg) > 3 else None,
)
def _nm1_id_qualifier(seg: list[str]) -> str | None:
if len(seg) > 8:
return seg[8] or None
return None
def _consume_information_source(segments: list[list[str]], idx: int) -> tuple[InformationSource271, int]:
"""HL*20 + NM1*PR → payer."""
name: str | None = None
pid: str | None = None
i = idx
if i < len(segments) and segments[i][0] == "HL":
i += 1
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "PR":
first, last = _consume_nm1_name(seg)
nm102 = seg[2] if len(seg) > 2 else "1"
if nm102 == "2":
name = last or (seg[3] if len(seg) > 3 else None)
else:
name = last
qual = _nm1_id_qualifier(seg)
if qual in {"PI", "XV", "24"} and len(seg) > 9:
pid = seg[9] or pid
i += 1
return InformationSource271(name=name or "", id=pid), i
def _consume_information_receiver(segments: list[list[str]], idx: int) -> tuple[InformationReceiver271, int]:
"""HL*21 + NM1*1P → provider."""
name: str | None = None
pid: str | None = None
i = idx
if i < len(segments) and segments[i][0] == "HL":
i += 1
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "1P":
first, last = _consume_nm1_name(seg)
nm102 = seg[2] if len(seg) > 2 else "1"
if nm102 == "2":
name = last or (seg[3] if len(seg) > 3 else None)
else:
name = last
qual = _nm1_id_qualifier(seg)
if qual in {"XX", "PI", "XV"} and len(seg) > 9 and seg[9]:
pid = seg[9]
i += 1
return InformationReceiver271(name=name or "", id=pid), i
def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber271, int]:
"""HL*22 + TRN + NM1*IL + DMG → subscriber.
Stops at the next HL (sibling loop), envelope terminator, or the
first EB segment — the orchestrator processes EB segments at the
top level so the summary count and the v1 "one EB cluster per
parse" convention stay consistent.
"""
member_id: str = ""
first_name: str | None = None
last_name: str | None = None
i = idx
if i < len(segments) and segments[i][0] == "HL":
i += 1
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA", "EB"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "IL":
first, last = _consume_nm1_name(seg)
if first:
first_name = first
if last:
last_name = last
qual = _nm1_id_qualifier(seg)
if qual in {"MI", "II", "MC", "HN"} and len(seg) > 9 and seg[9]:
member_id = seg[9]
elif not member_id and len(seg) > 9 and seg[9]:
member_id = seg[9]
elif seg[0] == "DMG" and len(seg) > 2 and seg[1] == "D8":
# Subscriber date-of-birth — surfaced on the subscriber
# model in the 270 but not the 271. We don't have a DOB
# field on Subscriber271, so we read-and-discard.
pass
i += 1
return Subscriber271(
member_id=member_id,
first_name=first_name,
last_name=last_name,
), i
def _consume_patient(segments: list[list[str]], idx: int) -> tuple[Patient271 | None, int]:
"""HL*23 + NM1*QC + DMG → patient (optional)."""
first_name: str | None = None
last_name: str | None = None
relationship: str | None = None
saw_nm1 = False
i = idx
if i < len(segments) and segments[i][0] == "HL":
if len(segments[i]) > 4 and segments[i][4]:
relationship = segments[i][4]
i += 1
while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
seg = segments[i]
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "QC":
saw_nm1 = True
first, last = _consume_nm1_name(seg)
if first:
first_name = first
if last:
last_name = last
i += 1
if not saw_nm1:
return None, i
return Patient271(
first_name=first_name,
last_name=last_name,
relationship=relationship,
), i
# --------------------------------------------------------------------------- #
# EB / MSG / AAA / DTP
# --------------------------------------------------------------------------- #
def _consume_eb(segments: list[list[str]], idx: int) -> tuple[CoverageBenefit, int]:
"""Read one EB segment + its child MSG/AAA/REF/DTP and return a CoverageBenefit.
The orchestrator passes ``idx`` pointing at the EB segment. We
consume the EB itself plus any contiguous MSG/AAA/REF/DTP segments
that follow. Subsequent EB segments start a new benefit and are
left for the orchestrator.
"""
eb = segments[idx]
service_type_code = eb[1] if len(eb) > 1 else ""
coverage_level = eb[2] if len(eb) > 2 and eb[2] else None
benefit_amount: Decimal | None = None
if len(eb) > 4 and eb[4]:
try:
benefit_amount = Decimal(eb[4])
except (InvalidOperation, ValueError):
benefit_amount = None
in_plan_network = _parse_yn(eb[5]) if len(eb) > 5 else None
authorization_required = _parse_yn(eb[6]) if len(eb) > 6 else None
plan_coverage_description = eb[7] if len(eb) > 7 and eb[7] else None
time_qualifier: str | None = None
benefit_date: date | None = None
additional_info: list[BenefitAdditionalInfo] = []
pending_msg: str | None = None
pending_codes: list[str] = []
def _flush_msg() -> None:
"""Move any pending MSG description + codes into additional_info."""
nonlocal pending_msg, pending_codes
if pending_msg is not None:
additional_info.append(
BenefitAdditionalInfo(
description=pending_msg,
codes=list(pending_codes),
)
)
pending_msg = None
pending_codes = []
i = idx + 1
while i < len(segments) and segments[i][0] not in {"EB", "HL", "SE", "GE", "IEA"}:
s = segments[i]
if s[0] == "MSG":
# Per X12 005010X279A1, MSG attached to an EB is a free-text
# description. We flush the previous MSG (if any) so multiple
# MSG segments each get their own additional_info row.
_flush_msg()
pending_msg = s[1] if len(s) > 1 else ""
elif s[0] == "AAA":
# AAA is a request validation segment — surface the rejection
# reason code as additional info so the operator can see
# why a benefit was rejected.
if len(s) > 3 and s[3]:
pending_codes.append(s[3])
elif s[0] == "DTP" and len(s) > 2:
# DTP01 = date/time qualifier (e.g. "291" = Plan, "292" = Benefit).
# DTP02 = format qualifier ("D8" = CCYYMMDD).
# DTP03 = the date itself.
if s[2] == "D8" and len(s) > 3 and s[3]:
time_qualifier = s[1] if len(s) > 1 else None
benefit_date = _parse_yyyymmdd(s[3])
elif s[0] == "REF":
# REF attached to EB carries a qualifier + value. We capture
# the value as a code on the next additional_info row.
if len(s) > 2 and s[2]:
pending_codes.append(s[2])
else:
# Unknown child — flush and skip.
_flush_msg()
i += 1
_flush_msg()
return (
CoverageBenefit(
service_type_code=service_type_code,
coverage_level=coverage_level,
benefit_amount=benefit_amount,
in_plan_network=in_plan_network,
authorization_required=authorization_required,
plan_coverage_description=plan_coverage_description,
time_qualifier=time_qualifier,
benefit_date=benefit_date,
additional_info=additional_info,
),
i,
)
# --------------------------------------------------------------------------- #
# Top-level orchestrator
# --------------------------------------------------------------------------- #
def parse(text: str, *, input_file: str = "") -> ParseResult271:
"""Parse a complete 271 eligibility response document.
Whole-document problems (missing ISA) raise
:class:`CycloneParseError`. Per-segment deficiencies are
silently dropped — the parser is intentionally permissive.
"""
segments = tokenize(text)
envelope, _txn_date = _build_envelope(segments, input_file=input_file)
information_source = InformationSource271(name="", id=None)
information_receiver = InformationReceiver271(name="", id=None)
subscriber = Subscriber271(member_id="")
patient: Patient271 | None = None
coverage_benefits: list[CoverageBenefit] = []
i = 0
while i < len(segments):
seg = segments[i]
if seg[0] in {"ISA", "GS", "ST", "SE", "GE", "IEA", "BHT", "TRN", "DMG"}:
i += 1
continue
if seg[0] == "HL":
if len(seg) > 3 and seg[3] == "20":
information_source, i = _consume_information_source(segments, i)
continue
if len(seg) > 3 and seg[3] == "21":
information_receiver, i = _consume_information_receiver(segments, i)
continue
if len(seg) > 3 and seg[3] == "22":
subscriber, i = _consume_subscriber(segments, i)
continue
if len(seg) > 3 and seg[3] == "23":
patient, i = _consume_patient(segments, i)
continue
i += 1
continue
if seg[0] == "NM1" and len(seg) > 1 and seg[1] in {"PR", "1P", "IL", "QC"}:
i += 1
continue
if seg[0] == "EB":
try:
cb, i = _consume_eb(segments, i)
coverage_benefits.append(cb)
except (IndexError, ValueError, InvalidOperation) as exc:
log.warning("Bad EB at segment %d: %s", i, exc)
i += 1
continue
i += 1
summary = BatchSummary(
input_file=input_file,
control_number=envelope.control_number,
transaction_date=envelope.transaction_date,
total_claims=len(coverage_benefits),
passed=len(coverage_benefits),
failed=0,
)
return ParseResult271(
envelope=envelope,
information_source=information_source,
information_receiver=information_receiver,
subscriber=subscriber,
patient=patient,
coverage_benefits=coverage_benefits,
summary=summary,
)
__all__ = ["parse"]
@@ -0,0 +1,324 @@
"""Serialize a :class:`ParseResult270` to a complete X12 270 text.
Emits the envelope layers (ISA / GS / ST / SE / GE / IEA) and the
minimum body segments (BHT, HL*20/NM1*PR, HL*21/NM1*1P, HL*22/TRN,
NM1*IL, DMG, EQ, SE). Mirrors the structure of
:mod:`cyclone.parsers.serialize_999` so the round-trip with
:func:`cyclone.parsers.parse_270.parse` stays stable.
The serializer is the v1 "one inquiry at a time" convention: only
``result.inquiries[0]`` is emitted. If you need batch inquiries,
promote this to emit one ST/SE per inquiry (out of scope for v1).
The output always uses standard X12 delimiters: ``*`` element,
``~`` segment, ``:`` component, ``^`` repetition. The output is
safe to feed directly to :func:`cyclone.parsers.parse_270.parse`.
The ``interchange_control_number`` and ``group_control_number``
kwargs let the caller override the default values; in v1 the API
endpoint (T23) always uses the defaults and lets the response
``raw_270_text`` carry the canonical id pair.
"""
from __future__ import annotations
from datetime import date
from cyclone.parsers.models_270 import ParseResult270
_SEG = "~"
_ELEM = "*"
def _today_yymmdd() -> str:
"""Return today's date as YYMMDD for ISA09 / GS04."""
t = date.today()
return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}"
def _today_hhmm() -> str:
"""Return the current time as HHMM (UTC-naive, 24-hour)."""
from datetime import datetime
t = datetime.now()
return f"{t.hour:02d}{t.minute:02d}"
def _today_yyyymmdd() -> str:
"""Return today's date as CCYYMMDD for GS04."""
t = date.today()
return f"{t.year:04d}{t.month:02d}{t.day:02d}"
def _format_date_yyyymmdd(d: date | None) -> str:
"""Format ``d`` as CCYYMMDD, or return empty string when ``None``."""
if d is None:
return ""
return f"{d.year:04d}{d.month:02d}{d.day:02d}"
def _pad(s: str, width: int) -> str:
"""Pad a string to ``width`` characters; truncate if longer."""
return (s or "").ljust(width)[:width]
def _build_isa(
sender_id: str,
receiver_id: str,
interchange_control_number: str,
) -> str:
"""Build the ISA envelope segment (fixed 106-char layout).
Element widths match the 999 serializer so a 270 produced by this
function round-trips through :func:`cyclone.parsers.segments.tokenize`.
"""
parts = [
"ISA", # 0
"00", # 1
_pad("", 10), # 2
"00", # 3
_pad("", 10), # 4
"ZZ", # 5
_pad(sender_id, 15), # 6
"ZZ", # 7
_pad(receiver_id, 15), # 8
_today_yymmdd(), # 9
_today_hhmm(), # 10
"^", # 11
"00501", # 12
_pad(interchange_control_number, 9), # 13
"0", # 14
"P", # 15
":", # 16
]
return _ELEM.join(parts) + _SEG
def _build_gs(
sender_id: str,
receiver_id: str,
group_control_number: str,
) -> str:
"""Build the GS (Functional Group Header) segment."""
return _ELEM.join([
"GS",
"HC",
_pad(sender_id, 15),
_pad(receiver_id, 15),
_today_yyyymmdd(),
_today_hhmm(),
_pad(group_control_number or "1", 9),
"X",
"005010X279A1",
]) + _SEG
def _build_st(control_number: str, implementation_guide: str) -> str:
"""Build the ST (Transaction Set Header) segment."""
return _ELEM.join([
"ST",
"270",
_pad(control_number, 4),
implementation_guide or "005010X279A1",
]) + _SEG
def _build_bht() -> str:
"""Build the BHT (Beginning of Hierarchical Transaction) segment.
BHT01 = "0022" (Hierarchical Structure Code — same for 270/271).
BHT02 = "13" (Transaction Set Purpose Code — Request).
BHT03 = a synthesized reference id (timestamp-based).
BHT04 = today's date (CCYYMMDD).
BHT05 = current time (HHMM).
"""
from datetime import datetime
t = datetime.now()
ref = f"REF{t.year:04d}{t.month:02d}{t.day:02d}{t.hour:02d}{t.minute:02d}{t.second:02d}"
return _ELEM.join([
"BHT",
"0022",
"13",
ref,
_today_yyyymmdd(),
_today_hhmm(),
]) + _SEG
def _build_se(count: int, control_number: str) -> str:
"""Build the SE (Transaction Set Trailer) segment."""
return _ELEM.join(["SE", str(count), _pad(control_number, 4)]) + _SEG
def serialize_270(
result: ParseResult270,
*,
interchange_control_number: str = "000000001",
group_control_number: str = "1",
) -> str:
"""Serialize a :class:`ParseResult270` to X12 270 text.
Output is a minimal but valid 270 request — one inquiry at a time
(the result's ``inquiries[0]`` is used). If you need batch
inquiries, promote this to emit one ST/SE per inquiry (out of
scope for v1).
The default ``interchange_control_number`` is ``"000000001"`` and
the default ``group_control_number`` is ``"1"``; callers that
need a fresh value (e.g. one per generated 270) should pass a
unique 9-digit string and 9-digit group id respectively.
"""
env = result.envelope
sender_id = env.sender_id
receiver_id = env.receiver_id
impl_guide = env.implementation_guide or "005010X279A1"
# ST control number defaults to the envelope's interchange control
# number's last 4 digits; otherwise "0001".
st_ctrl = (env.control_number or "0001")[-4:].rjust(4, "0")
# The v1 convention is one inquiry at a time; in v1 callers are
# expected to populate ``inquiries`` with at most one entry.
inquiry = result.inquiries[0] if result.inquiries else None
if inquiry is None:
# Defensive: emit an EQ with the most common default
# (Health Plan Coverage) so the file is still a valid 270.
service_type_code = "30"
coverage_info = ""
else:
service_type_code = inquiry.service_type_code
coverage_info = inquiry.coverage_info or ""
# Subscriber name fields. Per X12 005010X279A1, NM1*IL uses
# entity type "1" (person).
sub = result.subscriber
sub_dob = _format_date_yyyymmdd(sub.dob)
# DMG layout: DMG*D8*<CCYYMMDD>*<gender>~. We always emit the
# 4-element form so the segment is canonical; gender is empty
# in v1 (the 270 model doesn't carry it).
sub_dmg_parts = ["DMG", "D8", sub_dob, ""]
# Patient (optional).
patient = result.patient
patient_segment = ""
if patient is not None:
# HL*4 segment (level code "23" = patient; parent id points
# back to the subscriber loop, HL03=3; child code carries the
# relationship to the subscriber — "0"=self, "1"=spouse, "19"=child, etc.).
rel = patient.relationship or "0"
patient_segment += _ELEM.join(["HL", "4", "3", "23", rel]) + _SEG
# NM1*QC for the patient. We populate the canonical 9
# elements after NM1 (last, first, middle, prefix, suffix,
# id qual, id value) and leave the rest empty.
patient_segment += _ELEM.join([
"NM1",
"QC",
"1",
patient.last_name or "",
patient.first_name or "",
"", # NM105 middle
"", # NM106 prefix
"", # NM107 suffix
"", # NM108 ID code qualifier (v1 doesn't carry one)
"", # NM109 ID code value
]) + _SEG
if patient.dob is not None:
patient_segment += _ELEM.join([
"DMG",
"D8",
_format_date_yyyymmdd(patient.dob),
]) + _SEG
# Information source (HL*20) — payer.
src = result.information_source
src_id_qual = "PI" if src.id else ""
src_id_value = src.id or ""
src_segment = (
_ELEM.join(["HL", "1", "", "20", "1"]) + _SEG
+ _ELEM.join([
"NM1",
"PR",
"2",
src.name,
"", # NM104 first name (not used for non-person entity)
"", # NM105 middle
"", # NM106 prefix
"", # NM107 suffix
src_id_qual, # NM108 ID code qualifier
src_id_value, # NM109 ID code value
]) + _SEG
)
# Information receiver (HL*21) — provider.
rcv = result.information_receiver
rcv_id_qual = "XX" if rcv.npi else ""
rcv_id_value = rcv.npi or ""
rcv_segment = (
_ELEM.join(["HL", "2", "1", "21", "1"]) + _SEG
+ _ELEM.join([
"NM1",
"1P",
"2",
rcv.name,
"", # NM104 first name (not used for non-person entity)
"", # NM105 middle
"", # NM106 prefix
"", # NM107 suffix
rcv_id_qual, # NM108 ID code qualifier
rcv_id_value, # NM109 ID code value
]) + _SEG
)
# Subscriber (HL*22) — TRN + NM1*IL + DMG.
sub_id_qual = "MI" if sub.member_id else ""
sub_id_value = sub.member_id or ""
sub_segment = (
_ELEM.join(["HL", "3", "2", "22", "0"]) + _SEG
+ "TRN" + _ELEM + "2" + _ELEM + "TRACE001" + _SEG
+ _ELEM.join([
"NM1",
"IL",
"1",
sub.last_name or "",
sub.first_name or "",
"", # NM105 middle name / initial
"", # NM106 name prefix
"", # NM107 name suffix
sub_id_qual, # NM108 ID code qualifier
sub_id_value, # NM109 ID code value
]) + _SEG
+ _ELEM.join(sub_dmg_parts) + _SEG
)
# EQ segment.
eq_parts = ["EQ", service_type_code]
if coverage_info:
eq_parts.append(coverage_info)
eq_segment = _ELEM.join(eq_parts) + _SEG
# Build the body in canonical order. The SE count is the number of
# segments inside the ST/SE envelope (ST itself + body segments).
body_parts: list[str] = [
_build_bht(),
src_segment,
rcv_segment,
sub_segment,
patient_segment, # may be empty when no patient
eq_segment,
]
body = "".join(body_parts)
body_segment_count = body.count(_SEG)
se_count = 1 + body_segment_count # +1 for the ST itself
parts: list[str] = [
_build_isa(sender_id, receiver_id, interchange_control_number),
_build_gs(sender_id, receiver_id, group_control_number),
_build_st(st_ctrl, impl_guide),
body,
_build_se(se_count, st_ctrl),
f"GE{_ELEM}1{_ELEM}{_pad(group_control_number, 9)}{_SEG}",
f"IEA{_ELEM}1{_ELEM}{_pad(interchange_control_number, 9)}{_SEG}",
]
return "".join(parts)
__all__ = ["serialize_270"]