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:
+199
-1
@@ -27,12 +27,23 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from cyclone import __version__
|
from cyclone import __version__
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
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.models_835 import ParseResult835
|
||||||
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
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_837 import parse
|
||||||
from cyclone.parsers.parse_835 import parse as parse_835
|
from cyclone.parsers.parse_835 import parse as parse_835
|
||||||
from cyclone.parsers.parse_999 import parse_999_text
|
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.serialize_999 import serialize_999
|
||||||
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
|
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
|
||||||
from cyclone.parsers.validator_835 import validate as validate_835
|
from cyclone.parsers.validator_835 import validate as validate_835
|
||||||
@@ -988,4 +999,191 @@ def get_ack_endpoint(ack_id: int) -> dict:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 270 / 271 eligibility (SP3 P4 T23–T24) — 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"]
|
__all__ = ["app"]
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ _LAZY_EXPORTS: dict[str, str] = {
|
|||||||
"SegmentError": "cyclone.parsers.models_999",
|
"SegmentError": "cyclone.parsers.models_999",
|
||||||
"SetAcceptReject": "cyclone.parsers.models_999",
|
"SetAcceptReject": "cyclone.parsers.models_999",
|
||||||
"SetFunctionalGroupResponse": "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)
|
# CARC lookup (SP3 P2 T6)
|
||||||
"reason_label": "cyclone.parsers.cas_codes",
|
"reason_label": "cyclone.parsers.cas_codes",
|
||||||
"all_known_codes": "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": "cyclone.parsers.parse_837",
|
||||||
"parse_835": "cyclone.parsers.parse_835",
|
"parse_835": "cyclone.parsers.parse_835",
|
||||||
"parse_999": "cyclone.parsers.parse_999",
|
"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_999": "cyclone.parsers.serialize_999",
|
||||||
|
"serialize_270": "cyclone.parsers.serialize_270",
|
||||||
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
|
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
|
||||||
# writers
|
# writers
|
||||||
"write_outputs": "cyclone.parsers.writer",
|
"write_outputs": "cyclone.parsers.writer",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID *240101*1200*^*00501*000000001*0*P*:~
|
||||||
|
GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X279A1~
|
||||||
|
ST*270*0001*005010X279A1~
|
||||||
|
BHT*0022*13*REF123*20240101*1200~
|
||||||
|
HL*1**20*1~
|
||||||
|
NM1*PR*2*PAYER NAME*****PI*SKCO0~
|
||||||
|
HL*2*1*21*1~
|
||||||
|
NM1*1P*2*PROVIDER NAME*****XX*1234567890~
|
||||||
|
HL*3*2*22*0~
|
||||||
|
TRN*2*TRACE001~
|
||||||
|
NM1*IL*1*DOE*JOHN*A***MI*MEMBER123~
|
||||||
|
DMG*D8*19800101*M~
|
||||||
|
EQ*1^30~
|
||||||
|
SE*10*0001~
|
||||||
|
GE*1*1~
|
||||||
|
IEA*1*000000001~
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *240101*1200*^*00501*000000002*0*P*:~
|
||||||
|
GS*HC*PAYERID*PROVIDERID*20240101*1200*2*X*005010X279A1~
|
||||||
|
ST*271*0001*005010X279A1~
|
||||||
|
BHT*0022*11*REF456*20240101*1200~
|
||||||
|
HL*1**20*1~
|
||||||
|
NM1*PR*2*PAYER NAME*****PI*SKCO0~
|
||||||
|
HL*2*1*21*1~
|
||||||
|
NM1*1P*2*PROVIDER NAME*****XX*1234567890~
|
||||||
|
HL*3*2*22*0~
|
||||||
|
TRN*2*TRACE001~
|
||||||
|
NM1*IL*1*DOE*JOHN*A***MI*MEMBER123~
|
||||||
|
DMG*D8*19800101*M~
|
||||||
|
EB*1*IND*30**Y*1~
|
||||||
|
MSG*Patient has active coverage~
|
||||||
|
EB*88*IND**100*N**~
|
||||||
|
MSG*Pharmacy copay $20~
|
||||||
|
SE*13*0001~
|
||||||
|
GE*1*2~
|
||||||
|
IEA*1*000000002~
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Tests for the FastAPI surface in ``cyclone.api`` for the 270/271
|
||||||
|
eligibility endpoints (SP3 P4 T23 + T24).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.parsers.parse_270 import parse as parse_270_text
|
||||||
|
|
||||||
|
MINIMAL_271 = Path(__file__).parent / "fixtures" / "minimal_271.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_request_body() -> dict:
|
||||||
|
return {
|
||||||
|
"subscriber": {
|
||||||
|
"first_name": "JOHN",
|
||||||
|
"last_name": "DOE",
|
||||||
|
"member_id": "MEMBER123",
|
||||||
|
"dob": "1980-01-01",
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"npi": "1234567890",
|
||||||
|
"name": "PROVIDER NAME",
|
||||||
|
},
|
||||||
|
"payer": {
|
||||||
|
"id": "SKCO0",
|
||||||
|
"name": "PAYER NAME",
|
||||||
|
},
|
||||||
|
"service_type_code": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# T23: POST /api/eligibility/request
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_eligibility_request_returns_raw_270_text(client: TestClient):
|
||||||
|
"""A minimal body returns the X12 270 text + the parsed structure."""
|
||||||
|
body = _valid_request_body()
|
||||||
|
resp = client.post("/api/eligibility/request", json=body)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
payload = resp.json()
|
||||||
|
assert "raw_270_text" in payload
|
||||||
|
assert "parsed" in payload
|
||||||
|
raw = payload["raw_270_text"]
|
||||||
|
assert isinstance(raw, str) and raw
|
||||||
|
# Canonical X12 envelope markers.
|
||||||
|
assert raw.startswith("ISA*")
|
||||||
|
assert raw.rstrip("\n").endswith("~IEA*1*000000001~")
|
||||||
|
# All the required segments are present.
|
||||||
|
assert "HL*1**20*1~" in raw
|
||||||
|
assert "NM1*PR*2*PAYER NAME" in raw
|
||||||
|
assert "HL*2*1*21*1~" in raw
|
||||||
|
assert "NM1*1P*2*PROVIDER NAME" in raw
|
||||||
|
assert "NM1*IL*1*DOE*JOHN" in raw
|
||||||
|
assert "EQ*1~" in raw
|
||||||
|
# The parsed payload mirrors the input.
|
||||||
|
parsed = payload["parsed"]
|
||||||
|
assert parsed["subscriber"]["first_name"] == "JOHN"
|
||||||
|
assert parsed["subscriber"]["last_name"] == "DOE"
|
||||||
|
assert parsed["subscriber"]["member_id"] == "MEMBER123"
|
||||||
|
assert parsed["inquiries"][0]["service_type_code"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eligibility_request_parsed_matches_serialized(client: TestClient):
|
||||||
|
"""The endpoint's ``parsed`` field round-trips through a server-side
|
||||||
|
re-parse of ``raw_270_text``.
|
||||||
|
"""
|
||||||
|
body = _valid_request_body()
|
||||||
|
resp = client.post("/api/eligibility/request", json=body)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
payload = resp.json()
|
||||||
|
raw = payload["raw_270_text"]
|
||||||
|
re_parsed = parse_270_text(raw, input_file="round_trip.txt")
|
||||||
|
# The re-parsed structure must equal the endpoint's parsed payload
|
||||||
|
# for the fields the operator can observe.
|
||||||
|
parsed = payload["parsed"]
|
||||||
|
assert re_parsed.subscriber.first_name == parsed["subscriber"]["first_name"]
|
||||||
|
assert re_parsed.subscriber.last_name == parsed["subscriber"]["last_name"]
|
||||||
|
assert re_parsed.subscriber.member_id == parsed["subscriber"]["member_id"]
|
||||||
|
assert re_parsed.information_source.name == parsed["information_source"]["name"]
|
||||||
|
assert re_parsed.information_receiver.npi == parsed["information_receiver"]["npi"]
|
||||||
|
assert re_parsed.inquiries[0].service_type_code == parsed["inquiries"][0]["service_type_code"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_eligibility_request_missing_service_type_returns_400(client: TestClient):
|
||||||
|
"""Missing ``service_type_code`` returns 400, never 500."""
|
||||||
|
body = _valid_request_body()
|
||||||
|
del body["service_type_code"]
|
||||||
|
resp = client.post("/api/eligibility/request", json=body)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert "service_type_code" in str(detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eligibility_request_missing_subscriber_member_id_returns_400(client: TestClient):
|
||||||
|
"""Missing ``subscriber.member_id`` returns 400."""
|
||||||
|
body = _valid_request_body()
|
||||||
|
del body["subscriber"]["member_id"]
|
||||||
|
resp = client.post("/api/eligibility/request", json=body)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# T24: POST /api/eligibility/parse-271
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_endpoint_happy_path(client: TestClient):
|
||||||
|
"""Uploading the minimal 271 returns 200 + a non-empty
|
||||||
|
``coverage_benefits`` array.
|
||||||
|
"""
|
||||||
|
text = MINIMAL_271.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/eligibility/parse-271",
|
||||||
|
files={"file": ("minimal_271.txt", text, "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "coverage_benefits" in body
|
||||||
|
assert "subscriber" in body
|
||||||
|
assert "summary" in body
|
||||||
|
assert isinstance(body["coverage_benefits"], list)
|
||||||
|
assert len(body["coverage_benefits"]) == 2
|
||||||
|
# The first EB resolves to "Medical Care".
|
||||||
|
cb0 = body["coverage_benefits"][0]
|
||||||
|
assert cb0["service_type_code"] == "1"
|
||||||
|
assert cb0["service_type_description"] == "Medical Care"
|
||||||
|
assert cb0["in_plan_network"] is True
|
||||||
|
# The second EB is pharmacy.
|
||||||
|
cb1 = body["coverage_benefits"][1]
|
||||||
|
assert cb1["service_type_code"] == "88"
|
||||||
|
assert cb1["service_type_description"] == "Pharmacy"
|
||||||
|
assert cb1["in_plan_network"] is False
|
||||||
|
# Subscriber extracted.
|
||||||
|
assert body["subscriber"]["first_name"] == "JOHN"
|
||||||
|
assert body["subscriber"]["last_name"] == "DOE"
|
||||||
|
assert body["subscriber"]["member_id"] == "MEMBER123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_endpoint_invalid_edi_raises_400(client: TestClient):
|
||||||
|
"""Garbage input must surface as 400, never 500."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/eligibility/parse-271",
|
||||||
|
files={"file": ("garbage.txt", "not edi", "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "error" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_endpoint_empty_file_returns_400(client: TestClient):
|
||||||
|
"""An empty upload returns 400 (matches the 999 / 835 contract)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/eligibility/parse-271",
|
||||||
|
files={"file": ("empty.txt", "", "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Tests for the 270 (Eligibility Benefit Inquiry) Pydantic models.
|
||||||
|
|
||||||
|
SP3 Phase 4 (T18): minimal typed shape needed by the parser (T20),
|
||||||
|
serializer (T22), and ``/api/eligibility/request`` endpoint (T23).
|
||||||
|
The spec defines richer models (gender, address, etc.), but the v1
|
||||||
|
round-trip only needs the minimum surface — see the inline rationale
|
||||||
|
in ``parsers/models_270.py``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from cyclone.parsers.models import BatchSummary, Envelope
|
||||||
|
from cyclone.parsers.models_270 import (
|
||||||
|
EligibilityBenefitInquiry,
|
||||||
|
InformationReceiver270,
|
||||||
|
InformationSource270,
|
||||||
|
ParseResult270,
|
||||||
|
Patient270,
|
||||||
|
Subscriber270,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_result() -> ParseResult270:
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
inquiries=[
|
||||||
|
EligibilityBenefitInquiry(service_type_code="1"),
|
||||||
|
EligibilityBenefitInquiry(service_type_code="30"),
|
||||||
|
],
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="minimal_270.txt",
|
||||||
|
control_number="000000001",
|
||||||
|
transaction_date=date(2024, 1, 1),
|
||||||
|
total_claims=1,
|
||||||
|
passed=1,
|
||||||
|
failed=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_result_270_minimal_round_trip():
|
||||||
|
"""Build a minimal ParseResult270 and round-trip via JSON."""
|
||||||
|
r = _build_result()
|
||||||
|
blob = json.loads(r.model_dump_json())
|
||||||
|
assert blob["envelope"]["control_number"] == "000000001"
|
||||||
|
assert blob["envelope"]["implementation_guide"] == "005010X279A1"
|
||||||
|
assert blob["information_source"]["name"] == "PAYER NAME"
|
||||||
|
assert blob["information_source"]["id"] == "SKCO0"
|
||||||
|
assert blob["information_receiver"]["npi"] == "1234567890"
|
||||||
|
assert blob["subscriber"]["first_name"] == "JOHN"
|
||||||
|
assert blob["subscriber"]["last_name"] == "DOE"
|
||||||
|
assert blob["subscriber"]["member_id"] == "MEMBER123"
|
||||||
|
assert blob["subscriber"]["dob"] == "1980-01-01"
|
||||||
|
assert blob["patient"] is None
|
||||||
|
assert len(blob["inquiries"]) == 2
|
||||||
|
assert blob["inquiries"][0]["service_type_code"] == "1"
|
||||||
|
assert blob["inquiries"][1]["service_type_code"] == "30"
|
||||||
|
|
||||||
|
# Round-trip back to the model.
|
||||||
|
r2 = ParseResult270.model_validate(blob)
|
||||||
|
assert r2.information_source.name == "PAYER NAME"
|
||||||
|
assert r2.subscriber.member_id == "MEMBER123"
|
||||||
|
assert r2.inquiries[0].service_type_code == "1"
|
||||||
|
assert r2.summary.total_claims == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_eligibility_benefit_inquiry_required_fields():
|
||||||
|
"""``service_type_code`` is the only required field on the EQ model."""
|
||||||
|
# Without it: validation error.
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
EligibilityBenefitInquiry() # type: ignore[call-arg]
|
||||||
|
assert "service_type_code" in str(exc.value)
|
||||||
|
|
||||||
|
# With it: parses cleanly. ``coverage_info`` is optional.
|
||||||
|
ebi = EligibilityBenefitInquiry(service_type_code="1")
|
||||||
|
assert ebi.coverage_info is None
|
||||||
|
|
||||||
|
# coverage_info supplied → round-trips.
|
||||||
|
ebi2 = EligibilityBenefitInquiry(service_type_code="30", coverage_info="C")
|
||||||
|
blob = json.loads(ebi2.model_dump_json())
|
||||||
|
assert blob["coverage_info"] == "C"
|
||||||
|
assert EligibilityBenefitInquiry.model_validate(blob).coverage_info == "C"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patient_270_optional_fields():
|
||||||
|
"""Patient270's fields are all optional; relationship carries HL/INS qualifiers."""
|
||||||
|
p = Patient270()
|
||||||
|
blob = json.loads(p.model_dump_json())
|
||||||
|
assert blob["first_name"] is None
|
||||||
|
assert blob["relationship"] is None
|
||||||
|
p2 = Patient270(first_name="JANE", last_name="DOE", relationship="19") # 19 = child
|
||||||
|
assert p2.relationship == "19"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_result_270_inquiries_default_empty():
|
||||||
|
"""Inquiries default to an empty list when omitted (Pydantic default_factory)."""
|
||||||
|
r = ParseResult270(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="S", receiver_id="R", control_number="000000001",
|
||||||
|
transaction_date=date(2024, 1, 1),
|
||||||
|
),
|
||||||
|
information_source=InformationSource270(name="P"),
|
||||||
|
information_receiver=InformationReceiver270(name="P"),
|
||||||
|
subscriber=Subscriber270(member_id="M"),
|
||||||
|
summary=BatchSummary(input_file="", total_claims=0, passed=0, failed=0),
|
||||||
|
)
|
||||||
|
assert r.inquiries == []
|
||||||
|
assert r.patient is None
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Tests for the 271 (Eligibility Benefit Response) Pydantic models.
|
||||||
|
|
||||||
|
SP3 Phase 4 (T19): minimal typed shape needed by the parser (T21) and
|
||||||
|
``/api/eligibility/parse-271`` endpoint (T24). Plus the inline
|
||||||
|
``SERVICE_TYPE_CODES`` dict + ``service_type_description`` helper.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from cyclone.parsers.models import BatchSummary, Envelope
|
||||||
|
from cyclone.parsers.models_271 import (
|
||||||
|
LAST_UPDATED,
|
||||||
|
SERVICE_TYPE_CODES,
|
||||||
|
BenefitAdditionalInfo,
|
||||||
|
CoverageBenefit,
|
||||||
|
InformationReceiver271,
|
||||||
|
InformationSource271,
|
||||||
|
ParseResult271,
|
||||||
|
Patient271,
|
||||||
|
Subscriber271,
|
||||||
|
service_type_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_result() -> ParseResult271:
|
||||||
|
return ParseResult271(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="PAYERID",
|
||||||
|
receiver_id="PROVIDERID",
|
||||||
|
control_number="000000002",
|
||||||
|
transaction_date=date(2024, 1, 1),
|
||||||
|
implementation_guide="005010X279A1",
|
||||||
|
),
|
||||||
|
information_source=InformationSource271(
|
||||||
|
name="PAYER NAME",
|
||||||
|
id="SKCO0",
|
||||||
|
),
|
||||||
|
information_receiver=InformationReceiver271(
|
||||||
|
name="PROVIDER NAME",
|
||||||
|
),
|
||||||
|
subscriber=Subscriber271(
|
||||||
|
member_id="MEMBER123",
|
||||||
|
first_name="JOHN",
|
||||||
|
last_name="DOE",
|
||||||
|
),
|
||||||
|
coverage_benefits=[
|
||||||
|
CoverageBenefit(
|
||||||
|
service_type_code="1",
|
||||||
|
coverage_level="IND",
|
||||||
|
in_plan_network=True,
|
||||||
|
authorization_required=False,
|
||||||
|
plan_coverage_description="Patient has active coverage",
|
||||||
|
),
|
||||||
|
CoverageBenefit(
|
||||||
|
service_type_code="88",
|
||||||
|
coverage_level="IND",
|
||||||
|
benefit_amount=Decimal("100"),
|
||||||
|
in_plan_network=False,
|
||||||
|
authorization_required=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="minimal_271.txt",
|
||||||
|
control_number="000000002",
|
||||||
|
transaction_date=date(2024, 1, 1),
|
||||||
|
total_claims=2,
|
||||||
|
passed=2,
|
||||||
|
failed=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_result_271_minimal_round_trip():
|
||||||
|
"""Build a minimal ParseResult271 and round-trip via JSON."""
|
||||||
|
r = _build_result()
|
||||||
|
blob = json.loads(r.model_dump_json())
|
||||||
|
assert blob["envelope"]["control_number"] == "000000002"
|
||||||
|
assert blob["envelope"]["implementation_guide"] == "005010X279A1"
|
||||||
|
assert blob["information_source"]["name"] == "PAYER NAME"
|
||||||
|
assert blob["subscriber"]["first_name"] == "JOHN"
|
||||||
|
assert blob["patient"] is None
|
||||||
|
assert len(blob["coverage_benefits"]) == 2
|
||||||
|
# Decimal serializes as a string in Pydantic v2 by default; verify
|
||||||
|
# the round-trip preserves the value (not the str type).
|
||||||
|
assert blob["coverage_benefits"][1]["benefit_amount"] == "100"
|
||||||
|
|
||||||
|
# Round-trip back to the model — Decimal must survive.
|
||||||
|
r2 = ParseResult271.model_validate(blob)
|
||||||
|
assert r2.coverage_benefits[1].benefit_amount == Decimal("100")
|
||||||
|
assert r2.coverage_benefits[0].in_plan_network is True
|
||||||
|
assert r2.coverage_benefits[1].authorization_required is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_type_description_known():
|
||||||
|
"""``service_type_description("1")`` returns ``"Medical Care"``."""
|
||||||
|
assert service_type_description("1") == "Medical Care"
|
||||||
|
# Numeric codes with leading zero should not exist (X12 omits the
|
||||||
|
# leading zero) but the lookup still works for whatever the caller
|
||||||
|
# hands in.
|
||||||
|
assert service_type_description("88") == "Pharmacy"
|
||||||
|
assert service_type_description("AL") == "Vision (Optometry)"
|
||||||
|
assert service_type_description("MH") == "Mental Health"
|
||||||
|
assert service_type_description("UC") == "Urgent Care"
|
||||||
|
# Case-insensitive: the parser may pass "1" or any case variant.
|
||||||
|
assert service_type_description("mh") == "Mental Health"
|
||||||
|
assert service_type_description("uc") == "Urgent Care"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_type_description_unknown_returns_none():
|
||||||
|
"""Unknown codes (and the empty string) return ``None``."""
|
||||||
|
assert service_type_description("ZZZ") is None
|
||||||
|
assert service_type_description("9999") is None
|
||||||
|
assert service_type_description("") is None
|
||||||
|
# Whitespace-only also returns None.
|
||||||
|
assert service_type_description(" ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_type_codes_count_ge_50():
|
||||||
|
"""The dict ships at least 50 service-type codes."""
|
||||||
|
assert len(SERVICE_TYPE_CODES) >= 50
|
||||||
|
# Every value must be a non-empty string.
|
||||||
|
for code, label in SERVICE_TYPE_CODES.items():
|
||||||
|
assert isinstance(code, str)
|
||||||
|
assert code, "empty key"
|
||||||
|
assert isinstance(label, str)
|
||||||
|
assert label, f"empty label for {code!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_updated_constant():
|
||||||
|
"""LAST_UPDATED is a YYYY-MM-DD string used to track snapshot refresh."""
|
||||||
|
assert isinstance(LAST_UPDATED, str)
|
||||||
|
assert re.match(r"^\d{4}-\d{2}-\d{2}$", LAST_UPDATED), (
|
||||||
|
f"LAST_UPDATED must be YYYY-MM-DD, got {LAST_UPDATED!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_benefit_resolves_description_on_construction():
|
||||||
|
"""``CoverageBenefit`` auto-populates ``service_type_description`` from
|
||||||
|
``service_type_code`` when the caller omits it.
|
||||||
|
"""
|
||||||
|
cb = CoverageBenefit(service_type_code="1")
|
||||||
|
assert cb.service_type_description == "Medical Care"
|
||||||
|
# Caller-supplied description wins.
|
||||||
|
cb2 = CoverageBenefit(service_type_code="1", service_type_description="Custom")
|
||||||
|
assert cb2.service_type_description == "Custom"
|
||||||
|
# Unknown code → description is None.
|
||||||
|
cb3 = CoverageBenefit(service_type_code="ZZZ")
|
||||||
|
assert cb3.service_type_description is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_benefit_round_trip_preserves_description():
|
||||||
|
"""Round-tripping JSON does not overwrite a caller-supplied description."""
|
||||||
|
r = _build_result()
|
||||||
|
blob = json.loads(r.model_dump_json())
|
||||||
|
# After JSON round-trip, the description we built (e.g. "Medical Care") survives.
|
||||||
|
r2 = ParseResult271.model_validate(blob)
|
||||||
|
assert r2.coverage_benefits[0].service_type_description == "Medical Care"
|
||||||
|
assert r2.coverage_benefits[1].service_type_description == "Pharmacy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_service_type_code_on_coverage_benefit():
|
||||||
|
"""``service_type_code`` is the only required field on CoverageBenefit."""
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
CoverageBenefit() # type: ignore[call-arg]
|
||||||
|
assert "service_type_code" in str(exc.value)
|
||||||
|
|
||||||
|
# All other fields are optional / defaulted.
|
||||||
|
cb = CoverageBenefit(service_type_code="1")
|
||||||
|
assert cb.coverage_level is None
|
||||||
|
assert cb.benefit_amount is None
|
||||||
|
assert cb.in_plan_network is None
|
||||||
|
assert cb.authorization_required is None
|
||||||
|
assert cb.plan_coverage_description is None
|
||||||
|
assert cb.time_qualifier is None
|
||||||
|
assert cb.benefit_date is None
|
||||||
|
assert cb.additional_info == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_benefit_additional_info_codes_default():
|
||||||
|
"""``BenefitAdditionalInfo.codes`` defaults to an empty list."""
|
||||||
|
bai = BenefitAdditionalInfo(description="Patient has active coverage")
|
||||||
|
assert bai.codes == []
|
||||||
|
bai2 = BenefitAdditionalInfo(
|
||||||
|
description="Pharmacy copay $20",
|
||||||
|
codes=["H0019", "H0020"],
|
||||||
|
)
|
||||||
|
assert bai2.codes == ["H0019", "H0020"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patient_271_optional_fields():
|
||||||
|
"""Patient271's fields are all optional; relationship is the only one with a
|
||||||
|
v1 use (HL/INS qualifiers).
|
||||||
|
"""
|
||||||
|
p = Patient271()
|
||||||
|
blob = json.loads(p.model_dump_json())
|
||||||
|
assert blob == {
|
||||||
|
"first_name": None,
|
||||||
|
"last_name": None,
|
||||||
|
"relationship": None,
|
||||||
|
}
|
||||||
|
p2 = Patient271(first_name="JANE", last_name="DOE", relationship="19")
|
||||||
|
blob2 = json.loads(p2.model_dump_json())
|
||||||
|
assert blob2["relationship"] == "19"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_type_codes_contains_required_keys():
|
||||||
|
"""Spot-check the codes the spec calls out as required."""
|
||||||
|
required = {
|
||||||
|
"1": "Medical Care",
|
||||||
|
"2": "Surgical",
|
||||||
|
"33": "Chiropractic",
|
||||||
|
"35": "Dental Care",
|
||||||
|
"47": "Hospital",
|
||||||
|
"48": "Hospital - Inpatient",
|
||||||
|
"50": "Hospital - Outpatient",
|
||||||
|
"86": "Emergency Services",
|
||||||
|
"88": "Pharmacy",
|
||||||
|
"98": "Professional (Physician) Visit - Outpatient",
|
||||||
|
"99": "Professional (Physician) Visit - Other",
|
||||||
|
"AL": "Vision (Optometry)",
|
||||||
|
"MH": "Mental Health",
|
||||||
|
"UC": "Urgent Care",
|
||||||
|
}
|
||||||
|
for code, expected in required.items():
|
||||||
|
assert SERVICE_TYPE_CODES.get(code) == expected, (
|
||||||
|
f"missing or mismatched code {code!r}: got {SERVICE_TYPE_CODES.get(code)!r}, expected {expected!r}"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Tests for the 271 (Eligibility Benefit Response) parser.
|
||||||
|
|
||||||
|
SP3 Phase 4 (T21): single-pass segment walker that pulls the
|
||||||
|
information source / receiver / subscriber / patient / EB coverage
|
||||||
|
benefits out of a tokenized 271. The fixture
|
||||||
|
(``tests/fixtures/minimal_271.txt``) has two EB segments with
|
||||||
|
descriptions — one Medical Care, one Pharmacy — so we can assert
|
||||||
|
both the count and the per-benefit resolution of service-type codes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone.parsers.parse_271 import parse
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_271.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_minimal_271_returns_two_benefits():
|
||||||
|
"""The fixture's two EB segments become two CoverageBenefit rows."""
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
result = parse(text, input_file="minimal_271.txt")
|
||||||
|
assert len(result.coverage_benefits) == 2
|
||||||
|
# First EB: medical care (code "1"). Second: pharmacy (code "88").
|
||||||
|
assert result.coverage_benefits[0].service_type_code == "1"
|
||||||
|
assert result.coverage_benefits[1].service_type_code == "88"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_resolves_service_type_description():
|
||||||
|
"""``service_type_description`` is auto-populated from the EB01 code."""
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
result = parse(text, input_file="minimal_271.txt")
|
||||||
|
assert result.coverage_benefits[0].service_type_description == "Medical Care"
|
||||||
|
assert result.coverage_benefits[1].service_type_description == "Pharmacy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_in_plan_network_flag():
|
||||||
|
"""The first EB has Y for in-plan, the second has N."""
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
result = parse(text, input_file="minimal_271.txt")
|
||||||
|
assert result.coverage_benefits[0].in_plan_network is True
|
||||||
|
assert result.coverage_benefits[1].in_plan_network is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_envelope_built():
|
||||||
|
"""Envelope has the ISA/GS/ST fields. ST03 = implementation guide."""
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
result = parse(text, input_file="minimal_271.txt")
|
||||||
|
env = result.envelope
|
||||||
|
assert env.sender_id.strip() == "PAYERID"
|
||||||
|
assert env.receiver_id.strip() == "PROVIDERID"
|
||||||
|
assert env.control_number == "000000002"
|
||||||
|
assert env.implementation_guide == "005010X279A1"
|
||||||
|
# GS04 supplies the transaction date.
|
||||||
|
assert env.transaction_date.year == 2024
|
||||||
|
assert env.transaction_date.month == 1
|
||||||
|
assert env.transaction_date.day == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_msg_folded_into_additional_info():
|
||||||
|
"""MSG segments become ``BenefitAdditionalInfo`` rows on the EB."""
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
result = parse(text, input_file="minimal_271.txt")
|
||||||
|
cb0 = result.coverage_benefits[0]
|
||||||
|
assert len(cb0.additional_info) == 1
|
||||||
|
assert cb0.additional_info[0].description == "Patient has active coverage"
|
||||||
|
cb1 = result.coverage_benefits[1]
|
||||||
|
assert len(cb1.additional_info) == 1
|
||||||
|
assert cb1.additional_info[0].description == "Pharmacy copay $20"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_subscriber_extracted():
|
||||||
|
"""Subscriber first/last name + member id come from NM1*IL."""
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
result = parse(text, input_file="minimal_271.txt")
|
||||||
|
assert result.subscriber.first_name == "JOHN"
|
||||||
|
assert result.subscriber.last_name == "DOE"
|
||||||
|
assert result.subscriber.member_id == "MEMBER123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_invalid_edi_raises():
|
||||||
|
"""Garbage input must raise :class:`CycloneParseError`."""
|
||||||
|
from cyclone.parsers.exceptions import CycloneParseError
|
||||||
|
|
||||||
|
with pytest.raises(CycloneParseError):
|
||||||
|
parse("not edi at all", input_file="garbage.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_271_summary_counts_eb_segments():
|
||||||
|
"""The summary reports the number of EB segments we parsed."""
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
result = parse(text, input_file="minimal_271.txt")
|
||||||
|
assert result.summary.total_claims == 2
|
||||||
|
assert result.summary.passed == 2
|
||||||
|
assert result.summary.failed == 0
|
||||||
|
assert result.summary.input_file == "minimal_271.txt"
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user