feat(sp8): outbound 837P serializer — full rebuild + round-trip tests
- backend/src/cyclone/parsers/serialize_837.py — full-rebuild 837P serializer. Emits envelope (ISA/GS/ST/SE/GE/IEA + BHT) + submitter/receiver/billing provider/subscriber/payer hierarchy + editable segments (CLM/REF*G1/HI) + per-service-line LX/SV1/DTP*472/REF*6R — all from canonical ClaimOutput fields. Pivoted from spec §3.1 hybrid to full rebuild because ClaimOutput.raw_segments only captures post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the envelope or hierarchies. A pass-through approach cannot regenerate those without expanding raw_segments in parse_837.py (out of scope for this SP). - backend/tests/test_serialize_837.py — 36 tests covering envelope shape, hierarchy segments, claim-level builders, service-line builders, edited-field propagation, round-trip, custom sender/receiver IDs, and resubmit helper. - backend/tests/test_prodfiles_smoke.py::test_claims_prodfile_round_trip — every file in docs/prodfiles/claims/ (113 files) round-trips through serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo validation, which is recomputed by the parser). - docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md — full plan with amendment note documenting the Approach A pivot. Per session convention, plan note about unrelated modifications to parse_835.py / fixtures stashed separately.
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
"""Serialize a :class:`ClaimOutput` to a complete X12 837P text.
|
||||
|
||||
Full rebuild (Approach A — see plan amendment 2026-06-20). Every segment
|
||||
in the output is emitted from canonical :class:`ClaimOutput` fields;
|
||||
``raw_segments`` is not consulted.
|
||||
|
||||
Why full rebuild instead of the spec's §3.1 hybrid:
|
||||
|
||||
- ``ClaimOutput.raw_segments`` only captures post-CLM segments (CLM,
|
||||
REF*G1, HI, LX, SV1 pairs). The envelope (ISA/GS/ST/BHT) and
|
||||
hierarchy segments (NM1, N3, N4, HL, PRV, SBR, DMG, REF*EI) are not
|
||||
captured, so a pass-through approach cannot regenerate them
|
||||
byte-for-byte. Expanding ``raw_segments`` would require modifying
|
||||
``parse_837.py`` (out of scope for this SP).
|
||||
|
||||
- The 837P spec is stable; the emitter only needs canonical fields
|
||||
that already live on ``ClaimOutput``. No data loss vs. the original
|
||||
inbound file for any field the user can edit (charge, frequency,
|
||||
prior_auth, diagnoses, service lines, dates).
|
||||
|
||||
The serializer emits:
|
||||
|
||||
- ISA / GS / ST / SE / GE / IEA — envelope (caller supplies the
|
||||
control numbers via ``interchange_control_number`` /
|
||||
``group_control_number`` so back-to-back resubmits get unique values)
|
||||
- BHT — from ``claim.transaction_type_code`` + ``claim.transaction_date``
|
||||
- Submitter block (NM1*41, PER) — ``sender_id`` / ``submitter_name`` /
|
||||
``submitter_contact_*`` (caller-supplied; defaults below)
|
||||
- Receiver block (NM1*40) — ``receiver_id`` / ``receiver_name``
|
||||
- Billing provider hierarchy (HL*1, NM1*85, N3, N4, REF*EI) — from
|
||||
``claim.billing_provider``
|
||||
- Subscriber hierarchy (HL*2, SBR, NM1*IL, N3, N4, DMG) — from
|
||||
``claim.subscriber``
|
||||
- Payer (NM1*PR) — from ``claim.payer``
|
||||
- CLM / REF*G1 / HI — from ``claim.claim`` / ``claim.diagnoses``
|
||||
- Per service line: LX / SV1 / DTP*472 / REF*6R — from
|
||||
``claim.service_lines``
|
||||
|
||||
If ``claim.service_lines`` is empty (a header-only 837), the LX/SV1
|
||||
loop emits nothing; the rest still produces a valid envelope.
|
||||
|
||||
Round-trip is verified by ``backend/tests/test_serialize_837.py`` and
|
||||
the prodfile parametrized smoke in
|
||||
``backend/tests/test_prodfiles_smoke.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from cyclone.parsers.models import ClaimOutput
|
||||
|
||||
_SEG = "~"
|
||||
_ELEM = "*"
|
||||
_ISA_COMPONENT_SEPARATOR = ":"
|
||||
_ISA_REPETITION_SEPARATOR = "^"
|
||||
_IMPLEMENTATION_GUIDE = "005010X222A1"
|
||||
_FUNCTIONAL_ID_HEALTH_CARE = "HC"
|
||||
|
||||
|
||||
class SerializeError(Exception):
|
||||
"""Raised when a claim cannot be serialized."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Envelope helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _today_yymmdd() -> str:
|
||||
t = date.today()
|
||||
return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}"
|
||||
|
||||
|
||||
def _today_yyyymmdd() -> str:
|
||||
t = date.today()
|
||||
return f"{t.year:04d}{t.month:02d}{t.day:02d}"
|
||||
|
||||
|
||||
def _today_hhmm() -> str:
|
||||
t = datetime.now()
|
||||
return f"{t.hour:02d}{t.minute:02d}"
|
||||
|
||||
|
||||
def _pad(s: str | None, width: int) -> str:
|
||||
return (s or "").ljust(width)[:width]
|
||||
|
||||
|
||||
def _build_isa(sender_id: str, receiver_id: str, interchange_control_number: str) -> str:
|
||||
"""Fixed-width 106-char ISA envelope (mirrors serialize_999 layout)."""
|
||||
parts = [
|
||||
"ISA",
|
||||
"00", _pad("", 10),
|
||||
"00", _pad("", 10),
|
||||
"ZZ", _pad(sender_id, 15),
|
||||
"ZZ", _pad(receiver_id, 15),
|
||||
_today_yymmdd(),
|
||||
_today_hhmm(),
|
||||
_ISA_REPETITION_SEPARATOR,
|
||||
"00501",
|
||||
_pad(interchange_control_number, 9),
|
||||
"0",
|
||||
"P",
|
||||
_ISA_COMPONENT_SEPARATOR,
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> str:
|
||||
parts = [
|
||||
"GS",
|
||||
_FUNCTIONAL_ID_HEALTH_CARE,
|
||||
sender_id,
|
||||
receiver_id,
|
||||
_today_yymmdd(),
|
||||
_today_hhmm(),
|
||||
group_control_number,
|
||||
"X",
|
||||
_IMPLEMENTATION_GUIDE,
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_st(control_number: str) -> str:
|
||||
return _ELEM.join(["ST", "837", control_number, _IMPLEMENTATION_GUIDE]) + _SEG
|
||||
|
||||
|
||||
def _build_se(count: int, control_number: str) -> str:
|
||||
return _ELEM.join(["SE", str(count), control_number]) + _SEG
|
||||
|
||||
|
||||
def _build_bht(
|
||||
*,
|
||||
transaction_type_code: str | None,
|
||||
reference_id: str,
|
||||
transaction_date: date | None,
|
||||
transaction_time: str | None,
|
||||
) -> str:
|
||||
"""BHT segment. BHT06 transaction type code defaults to 'CH' (chargeable)."""
|
||||
bht06 = transaction_type_code or "CH"
|
||||
date_str = transaction_date.strftime("%Y%m%d") if transaction_date else _today_yyyymmdd()
|
||||
time_str = transaction_time or _today_hhmm()
|
||||
parts = [
|
||||
"BHT",
|
||||
"0019", # BHT01 — hierarchical structure (originator)
|
||||
"00", # BHT02 — purpose code (original)
|
||||
reference_id, # BHT03 — reference identification
|
||||
date_str, # BHT04 — transaction set creation date
|
||||
time_str, # BHT05 — transaction set creation time
|
||||
bht06, # BHT06 — transaction type code
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hierarchy segment builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
|
||||
id_code_qualifier: str | None, id_code: str | None) -> str:
|
||||
"""Generic NM1 segment. entity_type is the 2nd element ('85', 'IL', 'PR', etc.)."""
|
||||
parts = [
|
||||
"NM1",
|
||||
entity_type, # NM101 — entity identifier code
|
||||
_FUNCTIONAL_ID_HEALTH_CARE if entity_type in {"85", "IL"} else "", # NM102 (1=person, 2=non-person)
|
||||
"", # NM103 — name last / organization
|
||||
"", # NM104 — name first
|
||||
"", # NM105 — name middle
|
||||
"", # NM106 — name prefix
|
||||
"", # NM107 — name suffix
|
||||
id_code_qualifier or "", # NM108 — id code qualifier
|
||||
id_code or "", # NM109 — id code
|
||||
]
|
||||
# Strip the empty 2nd/3rd slots for non-person entities.
|
||||
if entity_type in {"85", "PR", "40", "41"}:
|
||||
# Non-person: NM102=2, name goes in NM103
|
||||
parts[2] = "2"
|
||||
parts[3] = name
|
||||
else:
|
||||
# Person: NM102=1, last in NM103, first in NM104
|
||||
parts[2] = "1"
|
||||
names = (name or "").rsplit(" ", 1)
|
||||
parts[3] = names[0] if names else ""
|
||||
parts[4] = names[1] if len(names) > 1 else ""
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
|
||||
"""PER segment — submitter contact. Returns empty when no contact info."""
|
||||
if not contact_name and not contact_phone:
|
||||
return ""
|
||||
parts = [
|
||||
"PER",
|
||||
"IC", # PER01 — contact function code (Information Contact)
|
||||
contact_name or "",
|
||||
"TE", # PER03 — phone qualifier
|
||||
contact_phone or "",
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_n3(line1: str | None, line2: str | None) -> str:
|
||||
if not line1:
|
||||
return ""
|
||||
parts = ["N3", line1]
|
||||
if line2:
|
||||
parts.append(line2)
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_n4(city: str | None, state: str | None, zip_code: str | None) -> str:
|
||||
if not city and not state and not zip_code:
|
||||
return ""
|
||||
parts = ["N4", city or "", state or "", zip_code or ""]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_dmg(dob: date | None, gender: str | None) -> str:
|
||||
"""DMG segment — subscriber demographics."""
|
||||
if not dob and not gender:
|
||||
return ""
|
||||
parts = ["DMG", "D8", dob.strftime("%Y%m%d") if dob else "", gender or ""]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> str:
|
||||
"""HL segment — hierarchical level. child_code='0' when this HL has no children."""
|
||||
parts = [
|
||||
"HL",
|
||||
hl_id,
|
||||
parent_id if parent_id else "",
|
||||
level_code,
|
||||
child_code,
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_sbr(relationship_code: str | None, member_id: str | None,
|
||||
payer_name: str | None) -> str:
|
||||
"""SBR segment — subscriber information.
|
||||
|
||||
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
|
||||
the most common case for professional claims; the parser does not store
|
||||
this on the canonical Subscriber model so we cannot thread it through
|
||||
without adding a model field.
|
||||
"""
|
||||
parts = [
|
||||
"SBR",
|
||||
relationship_code or "P",
|
||||
"", # SBR02 — group number
|
||||
"", # SBR03 — group name
|
||||
"", # SBR04 — claim filing indicator code
|
||||
"", # SBR05 — sequence number code
|
||||
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
|
||||
"", # SBR07
|
||||
"", # SBR08
|
||||
member_id or "", # SBR09 — claim submitter's id
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_ref(qualifier: str, value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return _ELEM.join(["REF", qualifier, value]) + _SEG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claim-level segment builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_clm(claim) -> str:
|
||||
"""CLM01..CLM09.
|
||||
|
||||
The parser reads CLM05 as a composite ``place_of_service:facility_qualifier:frequency_code``
|
||||
and stores the three sub-parts back on ``ClaimHeader.place_of_service``,
|
||||
``.facility_code_qualifier``, ``.frequency_code``. CLM03 + CLM04 are
|
||||
non-institutional claim filing fields — emitted as empty strings.
|
||||
"""
|
||||
charge = f"{Decimal(claim.total_charge or 0):.2f}"
|
||||
|
||||
# CLM05 composite — composed from the three canonical fields.
|
||||
clm05_parts: list[str] = []
|
||||
if claim.place_of_service:
|
||||
clm05_parts.append(claim.place_of_service)
|
||||
if claim.facility_code_qualifier:
|
||||
clm05_parts.append(claim.facility_code_qualifier)
|
||||
if claim.frequency_code:
|
||||
clm05_parts.append(claim.frequency_code)
|
||||
clm05 = ":".join(clm05_parts)
|
||||
|
||||
parts = [
|
||||
"CLM",
|
||||
claim.claim_id, # CLM01
|
||||
charge, # CLM02
|
||||
"", # CLM03 — non-institutional claim filing indicator
|
||||
"", # CLM04 — non-institutional claim filing code
|
||||
clm05, # CLM05 — composite POS:qualifier:frequency_code
|
||||
claim.provider_signature or "Y", # CLM06
|
||||
claim.assignment or "Y", # CLM07
|
||||
"", # CLM08 — benefit assignment certification
|
||||
claim.release_of_info or "Y", # CLM09
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_ref_g1(prior_auth: str | None) -> str:
|
||||
if not prior_auth:
|
||||
return ""
|
||||
return _ELEM.join(["REF", "G1", prior_auth]) + _SEG
|
||||
|
||||
|
||||
def _build_hi(diagnoses) -> str:
|
||||
"""One HI segment with up to 12 diagnoses (qualifier:code)."""
|
||||
parts = ["HI"]
|
||||
for diag in (diagnoses or [])[:12]:
|
||||
qualifier = diag.qualifier or "BK"
|
||||
parts.append(f"{qualifier}:{diag.code}")
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_lx(line_number: int) -> str:
|
||||
return _ELEM.join(["LX", str(line_number)]) + _SEG
|
||||
|
||||
|
||||
def _build_sv1(line) -> str:
|
||||
"""SV1 segment — professional service line."""
|
||||
proc = line.procedure
|
||||
code = proc.code if proc else ""
|
||||
mods = proc.modifiers if proc else []
|
||||
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
|
||||
charge = f"{Decimal(line.charge or 0):.2f}"
|
||||
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
|
||||
parts = [
|
||||
"SV1",
|
||||
composite,
|
||||
charge,
|
||||
line.unit_type or "UN",
|
||||
units,
|
||||
line.place_of_service or "",
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_dtp_472(service_date: date | None) -> str:
|
||||
"""DTP*472 (service date). D8 qualifier with CCYYMMDD single date."""
|
||||
if service_date is None:
|
||||
return ""
|
||||
stamp = service_date.strftime("%Y%m%d")
|
||||
return _ELEM.join(["DTP", "472", "D8", stamp]) + _SEG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_submitter_block(sender_id: str, submitter_name: str | None,
|
||||
contact_name: str | None,
|
||||
contact_phone: str | None) -> list[str]:
|
||||
out = [
|
||||
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
|
||||
]
|
||||
per = _build_per(contact_name, contact_phone)
|
||||
if per:
|
||||
out.append(per)
|
||||
return out
|
||||
|
||||
|
||||
def _build_receiver_block(receiver_id: str, receiver_name: str | None) -> list[str]:
|
||||
return [
|
||||
_build_nm1("40", "40", receiver_name or receiver_id, "46", receiver_id),
|
||||
]
|
||||
|
||||
|
||||
def _build_billing_provider_block(provider) -> list[str]:
|
||||
"""HL*1 → NM1*85 → N3 → N4 → REF*EI."""
|
||||
out = [
|
||||
_build_hl("1", "", "20", "1"), # HL*1 — information source, 1 child (subscriber)
|
||||
_build_nm1("85", "85", provider.name, "XX", provider.npi),
|
||||
]
|
||||
addr = provider.address
|
||||
if addr:
|
||||
n3 = _build_n3(addr.line1, addr.line2)
|
||||
n4 = _build_n4(addr.city, addr.state, addr.zip)
|
||||
if n3:
|
||||
out.append(n3)
|
||||
if n4:
|
||||
out.append(n4)
|
||||
ref_ei = _build_ref("EI", provider.tax_id)
|
||||
if ref_ei:
|
||||
out.append(ref_ei)
|
||||
return out
|
||||
|
||||
|
||||
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
|
||||
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
|
||||
out = [
|
||||
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
|
||||
_build_sbr("18", subscriber.member_id, payer_name),
|
||||
_build_nm1(
|
||||
"IL", "IL",
|
||||
f"{subscriber.last_name} {subscriber.first_name}".strip(),
|
||||
"MI",
|
||||
subscriber.member_id,
|
||||
),
|
||||
]
|
||||
addr = subscriber.address
|
||||
if addr:
|
||||
n3 = _build_n3(addr.line1, addr.line2)
|
||||
n4 = _build_n4(addr.city, addr.state, addr.zip)
|
||||
if n3:
|
||||
out.append(n3)
|
||||
if n4:
|
||||
out.append(n4)
|
||||
dmg = _build_dmg(subscriber.dob, subscriber.gender)
|
||||
if dmg:
|
||||
out.append(dmg)
|
||||
return out
|
||||
|
||||
|
||||
def _build_payer_block(payer) -> list[str]:
|
||||
return [
|
||||
_build_nm1("PR", "PR", payer.name, "PI", payer.id),
|
||||
]
|
||||
|
||||
|
||||
def _build_service_lines_block(service_lines) -> list[str]:
|
||||
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
|
||||
out: list[str] = []
|
||||
for idx, line in enumerate(service_lines or [], start=1):
|
||||
out.append(_build_lx(idx))
|
||||
out.append(_build_sv1(line))
|
||||
dtp = _build_dtp_472(line.service_date)
|
||||
if dtp:
|
||||
out.append(dtp)
|
||||
ref_6r = _build_ref("6R", getattr(line, "provider_reference", None))
|
||||
if ref_6r:
|
||||
out.append(ref_6r)
|
||||
return out
|
||||
|
||||
|
||||
def serialize_837(
|
||||
claim: ClaimOutput,
|
||||
*,
|
||||
sender_id: str = "CYCLONE",
|
||||
receiver_id: str = "RECEIVER",
|
||||
submitter_name: str | None = None,
|
||||
submitter_contact_name: str | None = None,
|
||||
submitter_contact_phone: str | None = None,
|
||||
receiver_name: str | None = None,
|
||||
interchange_control_number: str = "000000001",
|
||||
group_control_number: str = "1",
|
||||
) -> str:
|
||||
"""Serialize a single claim to a complete 837P file (full rebuild).
|
||||
|
||||
Sender/receiver info is required for a valid envelope but isn't
|
||||
captured on the parsed ``ClaimOutput`` — it's deployment-specific
|
||||
(one billing office ↔ one trading partner). Defaults are provided
|
||||
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
|
||||
the configured values.
|
||||
|
||||
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
|
||||
emitted from the canonical ``ClaimOutput`` fields, so post-parse
|
||||
edits propagate to the output.
|
||||
|
||||
Returns the complete X12 document as a string (segments
|
||||
``~``-terminated).
|
||||
"""
|
||||
segments: list[str] = [
|
||||
_build_isa(sender_id, receiver_id, interchange_control_number),
|
||||
_build_gs(sender_id, receiver_id, group_control_number),
|
||||
_build_st(claim.control_number or "0001"),
|
||||
_build_bht(
|
||||
transaction_type_code=claim.transaction_type_code,
|
||||
reference_id=claim.claim_id,
|
||||
transaction_date=claim.transaction_date,
|
||||
transaction_time=None,
|
||||
),
|
||||
]
|
||||
segments.extend(_build_submitter_block(
|
||||
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
|
||||
))
|
||||
segments.extend(_build_receiver_block(receiver_id, receiver_name))
|
||||
segments.extend(_build_billing_provider_block(claim.billing_provider))
|
||||
segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
|
||||
segments.extend(_build_payer_block(claim.payer))
|
||||
|
||||
# Claim-level editable segments.
|
||||
segments.append(_build_clm(claim.claim))
|
||||
ref_g1 = _build_ref_g1(claim.claim.prior_auth)
|
||||
if ref_g1:
|
||||
segments.append(ref_g1)
|
||||
if claim.diagnoses:
|
||||
segments.append(_build_hi(claim.diagnoses))
|
||||
|
||||
# Service lines (LX / SV1 / DTP*472 / REF*6R).
|
||||
segments.extend(_build_service_lines_block(claim.service_lines))
|
||||
|
||||
# SE segment count includes ST (line 3, 1-based) through SE itself
|
||||
# — i.e. the entire ST..SE block inclusive.
|
||||
seg_count = len(segments) - 2 + 1 # exclude ISA, GS; +1 for SE
|
||||
segments.append(_build_se(seg_count, claim.control_number or "0001"))
|
||||
|
||||
segments.append(f"GE*1*{group_control_number}{_SEG}")
|
||||
segments.append(f"IEA*1*{interchange_control_number}{_SEG}")
|
||||
|
||||
return "".join(segments)
|
||||
|
||||
|
||||
def serialize_837_for_resubmit(
|
||||
claim: ClaimOutput,
|
||||
*,
|
||||
interchange_index: int,
|
||||
) -> str:
|
||||
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
||||
interchange + group control numbers for a bundle position.
|
||||
|
||||
Interchange number = ``f"{interchange_index:09d}"``.
|
||||
Group number = ``str(interchange_index)``.
|
||||
"""
|
||||
return serialize_837(
|
||||
claim,
|
||||
interchange_control_number=f"{interchange_index:09d}",
|
||||
group_control_number=str(interchange_index),
|
||||
)
|
||||
@@ -245,4 +245,79 @@ def _ack_count(store: CycloneStore) -> int:
|
||||
"""Count rows in the acks table."""
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
return s.query(db.Ack).count()
|
||||
return s.query(db.Ack).count()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound 837P serializer round-trip smoke (SP8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (DOCS_PRODFILES / "claims").exists(),
|
||||
reason="docs/prodfiles/claims/ not present",
|
||||
)
|
||||
def test_claims_prodfile_round_trip():
|
||||
"""Every prodfile in docs/prodfiles/claims/ round-trips through
|
||||
serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo
|
||||
validation, which is recomputed by the parser).
|
||||
|
||||
Parametrized over the glob but counted as 1 test in the suite total.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.payer import PayerConfig
|
||||
from cyclone.parsers.serialize_837 import serialize_837
|
||||
|
||||
cfg = PayerConfig(name="CO_MEDICAID")
|
||||
claims_dir = DOCS_PRODFILES / "claims"
|
||||
failures: list[str] = []
|
||||
for fixture_path in sorted(claims_dir.glob("*.x12")):
|
||||
text = fixture_path.read_text()
|
||||
source = parse(text, cfg)
|
||||
if not source.claims:
|
||||
failures.append(f"{fixture_path.name}: no claims parsed")
|
||||
continue
|
||||
for claim in source.claims:
|
||||
try:
|
||||
out = serialize_837(claim)
|
||||
reparsed = parse(out, cfg).claims[0]
|
||||
except Exception as e:
|
||||
failures.append(f"{fixture_path.name}: serialize failed: {e}")
|
||||
continue
|
||||
# Compare canonical fields (raw_segments won't match byte-for-byte
|
||||
# since we do a full rebuild; validation isn't compared because
|
||||
# the parser recomputes it).
|
||||
if reparsed.claim.claim_id != claim.claim.claim_id:
|
||||
failures.append(f"{fixture_path.name}: claim_id mismatch")
|
||||
if Decimal(reparsed.claim.total_charge) != claim.claim.total_charge:
|
||||
failures.append(f"{fixture_path.name}: total_charge mismatch")
|
||||
if claim.claim.place_of_service and reparsed.claim.place_of_service != claim.claim.place_of_service:
|
||||
failures.append(
|
||||
f"{fixture_path.name}: place_of_service mismatch "
|
||||
f"({claim.claim.place_of_service!r} vs {reparsed.claim.place_of_service!r})"
|
||||
)
|
||||
if claim.claim.frequency_code and reparsed.claim.frequency_code != claim.claim.frequency_code:
|
||||
failures.append(
|
||||
f"{fixture_path.name}: frequency_code mismatch "
|
||||
f"({claim.claim.frequency_code!r} vs {reparsed.claim.frequency_code!r})"
|
||||
)
|
||||
src_diags = sorted(d.code for d in claim.diagnoses)
|
||||
out_diags = sorted(d.code for d in reparsed.diagnoses)
|
||||
if src_diags != out_diags:
|
||||
failures.append(f"{fixture_path.name}: diagnoses mismatch ({src_diags} vs {out_diags})")
|
||||
if len(reparsed.service_lines) != len(claim.service_lines):
|
||||
failures.append(
|
||||
f"{fixture_path.name}: service_lines count mismatch "
|
||||
f"({len(claim.service_lines)} vs {len(reparsed.service_lines)})"
|
||||
)
|
||||
continue
|
||||
for src_line, out_line in zip(claim.service_lines, reparsed.service_lines):
|
||||
if src_line.procedure.code != out_line.procedure.code:
|
||||
failures.append(f"{fixture_path.name}: procedure code mismatch")
|
||||
if Decimal(src_line.charge) != Decimal(out_line.charge):
|
||||
failures.append(f"{fixture_path.name}: service line charge mismatch")
|
||||
if failures:
|
||||
sample = "\n".join(failures[:10])
|
||||
pytest.fail(
|
||||
f"{len(failures)} round-trip failure(s) across {len(list(claims_dir.glob('*.x12')))} files. "
|
||||
f"First 10:\n{sample}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Tests for the outbound 837P serializer (full rebuild approach)."""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.payer import PayerConfig
|
||||
from cyclone.parsers.serialize_837 import (
|
||||
SerializeError,
|
||||
_build_bht,
|
||||
_build_clm,
|
||||
_build_dmg,
|
||||
_build_dtp_472,
|
||||
_build_gs,
|
||||
_build_hl,
|
||||
_build_isa,
|
||||
_build_lx,
|
||||
_build_n3,
|
||||
_build_n4,
|
||||
_build_nm1,
|
||||
_build_per,
|
||||
_build_ref,
|
||||
_build_ref_g1,
|
||||
_build_se,
|
||||
_build_sbr,
|
||||
_build_st,
|
||||
_build_sv1,
|
||||
serialize_837,
|
||||
serialize_837_for_resubmit,
|
||||
)
|
||||
|
||||
|
||||
_CFG = PayerConfig(name="CO_MEDICAID")
|
||||
|
||||
|
||||
def _load_claim(path: str = "tests/fixtures/co_medicaid_837p.txt"):
|
||||
text = open(path).read()
|
||||
result = parse(text, _CFG)
|
||||
assert result.claims, f"no claims parsed from {path}"
|
||||
return result.claims[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Envelope helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_isa_is_106_chars_with_standard_delimiters():
|
||||
isa = _build_isa("SENDER", "RECEIVER", "000000001")
|
||||
assert isa.startswith("ISA*")
|
||||
# 106 chars total: 105 content + 1 segment terminator.
|
||||
assert len(isa) == 106
|
||||
# ISA16 (component separator, ':') sits right before the segment terminator.
|
||||
assert isa.endswith(":~")
|
||||
|
||||
|
||||
def test_build_gs_emits_gs_segment_with_hc_functional_id():
|
||||
gs = _build_gs("SENDER", "RECEIVER", "1")
|
||||
parts = gs.rstrip("~").split("*")
|
||||
assert parts[0] == "GS"
|
||||
assert parts[1] == "HC"
|
||||
assert parts[2] == "SENDER"
|
||||
assert parts[3] == "RECEIVER"
|
||||
assert parts[6] == "1"
|
||||
|
||||
|
||||
def test_build_st_emits_837_segment():
|
||||
st = _build_st("0001")
|
||||
assert st.startswith("ST*837*0001*005010X222A1~")
|
||||
|
||||
|
||||
def test_build_se_emits_se_segment():
|
||||
se = _build_se(10, "0001")
|
||||
assert se == "SE*10*0001~"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hierarchy helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_nm1_non_person_entity_uses_nm103_for_name():
|
||||
nm1 = _build_nm1("85", "85", "CLINIC A", "XX", "1234567890")
|
||||
parts = nm1.rstrip("~").split("*")
|
||||
assert parts[0] == "NM1"
|
||||
assert parts[1] == "85"
|
||||
assert parts[2] == "2" # non-person
|
||||
assert parts[3] == "CLINIC A"
|
||||
assert parts[8] == "XX"
|
||||
assert parts[9] == "1234567890"
|
||||
|
||||
|
||||
def test_build_nm1_person_entity_splits_first_last():
|
||||
nm1 = _build_nm1("IL", "IL", "Doe Jane", "MI", "M12345")
|
||||
parts = nm1.rstrip("~").split("*")
|
||||
assert parts[1] == "IL"
|
||||
assert parts[2] == "1"
|
||||
assert parts[3] == "Doe"
|
||||
assert parts[4] == "Jane"
|
||||
|
||||
|
||||
def test_build_per_returns_empty_when_no_contact():
|
||||
assert _build_per(None, None) == ""
|
||||
assert _build_per("", "") == ""
|
||||
|
||||
|
||||
def test_build_per_emits_segment_with_contact():
|
||||
per = _build_per("Jane Doe", "5551234567")
|
||||
parts = per.rstrip("~").split("*")
|
||||
assert parts[0] == "PER"
|
||||
assert parts[1] == "IC"
|
||||
assert parts[2] == "Jane Doe"
|
||||
assert parts[3] == "TE"
|
||||
assert parts[4] == "5551234567"
|
||||
|
||||
|
||||
def test_build_n3_returns_empty_when_no_address():
|
||||
assert _build_n3(None, None) == ""
|
||||
assert _build_n3("", "") == ""
|
||||
|
||||
|
||||
def test_build_n4_returns_empty_when_no_address():
|
||||
assert _build_n4(None, None, None) == ""
|
||||
|
||||
|
||||
def test_build_dmg_returns_empty_when_no_demographics():
|
||||
assert _build_dmg(None, None) == ""
|
||||
|
||||
|
||||
def test_build_hl_emits_segment():
|
||||
hl = _build_hl("1", "", "20", "1")
|
||||
assert hl == "HL*1**20*1~"
|
||||
|
||||
|
||||
def test_build_sbr_emits_segment():
|
||||
sbr = _build_sbr("18", "M123", "PAYER")
|
||||
parts = sbr.rstrip("~").split("*")
|
||||
assert parts[0] == "SBR"
|
||||
assert parts[1] == "18"
|
||||
assert parts[9] == "M123"
|
||||
|
||||
|
||||
def test_build_ref_returns_empty_when_no_value():
|
||||
assert _build_ref("EI", None) == ""
|
||||
assert _build_ref("EI", "") == ""
|
||||
|
||||
|
||||
def test_build_ref_emits_segment_with_value():
|
||||
assert _build_ref("EI", "123456789") == "REF*EI*123456789~"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claim-level segment builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stub_claim_header():
|
||||
from cyclone.parsers.models import ClaimHeader
|
||||
return ClaimHeader(
|
||||
claim_id="CLM-1",
|
||||
total_charge=Decimal("100.00"),
|
||||
place_of_service="11",
|
||||
frequency_code="1",
|
||||
)
|
||||
|
||||
|
||||
def test_build_bht_uses_default_transaction_type_ch_when_none():
|
||||
bht = _build_bht(
|
||||
transaction_type_code=None,
|
||||
reference_id="REF1",
|
||||
transaction_date=date(2026, 6, 20),
|
||||
transaction_time="1200",
|
||||
)
|
||||
parts = bht.rstrip("~").split("*")
|
||||
assert parts[0] == "BHT"
|
||||
assert parts[6] == "CH"
|
||||
|
||||
|
||||
def test_build_bht_propagates_transaction_type_code():
|
||||
bht = _build_bht(
|
||||
transaction_type_code="RP",
|
||||
reference_id="REF1",
|
||||
transaction_date=date(2026, 6, 20),
|
||||
transaction_time="1200",
|
||||
)
|
||||
parts = bht.rstrip("~").split("*")
|
||||
assert parts[6] == "RP"
|
||||
|
||||
|
||||
def test_build_clm_emits_clm01_to_clm05():
|
||||
claim = _stub_claim_header()
|
||||
clm = _build_clm(claim)
|
||||
parts = clm.rstrip("~").split("*")
|
||||
assert parts[0] == "CLM"
|
||||
assert parts[1] == "CLM-1"
|
||||
assert parts[2] == "100.00"
|
||||
# CLM05 is a composite "POS:facility_qualifier:frequency_code" — the stub
|
||||
# has POS="11" and frequency_code="1", no qualifier set.
|
||||
assert parts[5] == "11:1"
|
||||
|
||||
|
||||
def test_build_ref_g1_returns_empty_when_no_prior_auth():
|
||||
assert _build_ref_g1(None) == ""
|
||||
assert _build_ref_g1("") == ""
|
||||
|
||||
|
||||
def test_build_ref_g1_emits_segment_when_prior_auth_set():
|
||||
assert _build_ref_g1("AUTH123") == "REF*G1*AUTH123~"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service-line segment builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stub_service_line(charge="100.00", units=Decimal("1"), date_value=date(2026, 6, 15)):
|
||||
from cyclone.parsers.models import Procedure, ServiceLine
|
||||
return ServiceLine(
|
||||
line_number=1,
|
||||
procedure=Procedure(qualifier="HC", code="99213", modifiers=["25"]),
|
||||
charge=Decimal(charge),
|
||||
units=units,
|
||||
unit_type="UN",
|
||||
service_date=date_value,
|
||||
)
|
||||
|
||||
|
||||
def test_build_lx_emits_lx_segment():
|
||||
assert _build_lx(1) == "LX*1~"
|
||||
|
||||
|
||||
def test_build_sv1_emits_procedure_modifiers_charge_units():
|
||||
line = _stub_service_line()
|
||||
sv1 = _build_sv1(line)
|
||||
parts = sv1.rstrip("~").split("*")
|
||||
assert parts[0] == "SV1"
|
||||
assert parts[1].startswith("HC:99213")
|
||||
assert ":25" in parts[1]
|
||||
assert parts[2] == "100.00"
|
||||
assert parts[3] == "UN"
|
||||
assert parts[4] == "1"
|
||||
|
||||
|
||||
def test_build_dtp_472_emits_service_date():
|
||||
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
|
||||
|
||||
|
||||
def test_build_dtp_472_returns_empty_when_no_date():
|
||||
assert _build_dtp_472(None) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# serialize_837 — envelope + body composition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_serialize_837_envelope_has_106_char_isa():
|
||||
claim = _load_claim()
|
||||
text = serialize_837(claim)
|
||||
# The ISA segment is 106 chars including the segment terminator.
|
||||
isa_segment = next(part + "~" for part in text.split("~") if part.startswith("ISA"))
|
||||
assert len(isa_segment) == 106
|
||||
|
||||
|
||||
def test_serialize_837_emits_envelope_in_order():
|
||||
claim = _load_claim()
|
||||
text = serialize_837(claim)
|
||||
seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg]
|
||||
# Envelope order
|
||||
assert seg_ids[0] == "ISA"
|
||||
assert seg_ids[1] == "GS"
|
||||
assert seg_ids[2] == "ST"
|
||||
assert seg_ids[3] == "BHT"
|
||||
# Termination
|
||||
assert seg_ids[-2] == "GE"
|
||||
assert seg_ids[-1] == "IEA"
|
||||
# Body order (after BHT, before SE)
|
||||
body = seg_ids[3:-2]
|
||||
assert "CLM" in body
|
||||
assert "HI" in body
|
||||
assert "NM1" in body
|
||||
assert "HL" in body
|
||||
assert "SV1" in body
|
||||
|
||||
|
||||
def test_serialize_837_round_trips_canonical_fields():
|
||||
claim = _load_claim()
|
||||
text = serialize_837(claim)
|
||||
reparsed = parse(text, _CFG).claims[0]
|
||||
# Canonical header fields
|
||||
assert reparsed.claim.claim_id == claim.claim.claim_id
|
||||
assert Decimal(reparsed.claim.total_charge) == claim.claim.total_charge
|
||||
assert reparsed.claim.frequency_code == claim.claim.frequency_code
|
||||
assert reparsed.claim.place_of_service == claim.claim.place_of_service
|
||||
# Diagnoses
|
||||
assert [d.code for d in reparsed.diagnoses] == [d.code for d in claim.diagnoses]
|
||||
# Service lines (charge, units)
|
||||
assert len(reparsed.service_lines) == len(claim.service_lines)
|
||||
for src, out in zip(claim.service_lines, reparsed.service_lines):
|
||||
assert out.procedure.code == src.procedure.code
|
||||
assert Decimal(out.charge) == src.charge
|
||||
|
||||
|
||||
def test_serialize_837_edited_charge_propagates():
|
||||
claim = _load_claim()
|
||||
claim.claim.total_charge = Decimal("999.99")
|
||||
text = serialize_837(claim)
|
||||
assert "CLM*" in text and "*999.99*" in text
|
||||
reparsed = parse(text, _CFG).claims[0]
|
||||
assert Decimal(reparsed.claim.total_charge) == Decimal("999.99")
|
||||
|
||||
|
||||
def test_serialize_837_edited_frequency_propagates():
|
||||
claim = _load_claim()
|
||||
claim.claim.frequency_code = "7"
|
||||
text = serialize_837(claim)
|
||||
reparsed = parse(text, _CFG).claims[0]
|
||||
assert reparsed.claim.frequency_code == "7"
|
||||
|
||||
|
||||
def test_serialize_837_edited_prior_auth_propagates():
|
||||
claim = _load_claim()
|
||||
claim.claim.prior_auth = "AUTH999"
|
||||
text = serialize_837(claim)
|
||||
assert "REF*G1*AUTH999" in text
|
||||
reparsed = parse(text, _CFG).claims[0]
|
||||
assert reparsed.claim.prior_auth == "AUTH999"
|
||||
|
||||
|
||||
def test_serialize_837_added_diagnosis_propagates():
|
||||
claim = _load_claim()
|
||||
from cyclone.parsers.models import Diagnosis
|
||||
claim.diagnoses.append(Diagnosis(code="Z00.00", qualifier="BF"))
|
||||
text = serialize_837(claim)
|
||||
assert "BF:Z00.00" in text
|
||||
reparsed = parse(text, _CFG).claims[0]
|
||||
codes = [d.code for d in reparsed.diagnoses]
|
||||
assert "Z00.00" in codes
|
||||
|
||||
|
||||
def test_serialize_837_edited_service_line_charge_propagates():
|
||||
claim = _load_claim()
|
||||
claim.service_lines[0].charge = Decimal("555.55")
|
||||
text = serialize_837(claim)
|
||||
assert "555.55" in text
|
||||
reparsed = parse(text, _CFG).claims[0]
|
||||
assert any(Decimal(sl.charge) == Decimal("555.55") for sl in reparsed.service_lines)
|
||||
|
||||
|
||||
def test_serialize_837_edited_service_date_propagates():
|
||||
claim = _load_claim()
|
||||
claim.service_lines[0].service_date = date(2027, 1, 15)
|
||||
text = serialize_837(claim)
|
||||
assert "20270115" in text
|
||||
|
||||
|
||||
def test_serialize_837_uses_custom_sender_receiver_ids():
|
||||
claim = _load_claim()
|
||||
text = serialize_837(
|
||||
claim,
|
||||
sender_id="AXISCARE",
|
||||
receiver_id="CO_MED",
|
||||
submitter_name="AXISCARE BILLING",
|
||||
)
|
||||
assert "AXISCARE" in text
|
||||
assert "CO_MED" in text
|
||||
# Sanity: file still parses
|
||||
parse(text, _CFG)
|
||||
|
||||
|
||||
def test_serialize_error_is_an_exception():
|
||||
assert issubclass(SerializeError, Exception)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# serialize_837_for_resubmit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_serialize_837_for_resubmit_assigns_unique_control_numbers():
|
||||
claim = _load_claim()
|
||||
text_a = serialize_837_for_resubmit(claim, interchange_index=42)
|
||||
text_b = serialize_837_for_resubmit(claim, interchange_index=43)
|
||||
isa_a = next(line for line in text_a.split("~") if line.startswith("ISA"))
|
||||
isa_b = next(line for line in text_b.split("~") if line.startswith("ISA"))
|
||||
assert isa_a != isa_b
|
||||
assert "000000042" in isa_a
|
||||
assert "000000043" in isa_b
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user