563 lines
22 KiB
Python
563 lines
22 KiB
Python
r"""Orchestrate parsing an X12 835 ERA file into a :class:`ParseResult835`.
|
|
|
|
Single-pass walker over the tokenized segment list:
|
|
|
|
- ISA / GS / ST / BPR / TRN — envelope + financial + trace
|
|
- Loop 1000A (N1*PR + N3/N4/PER/REF) — payer
|
|
- Loop 1000B (N1*PE + N3/N4) — payee
|
|
- Loop 2100 (LX + CLP + CAS + NM1 + REF + DTM + QTY) — claim payment
|
|
- Loop 2110 (SVC + CAS + REF + DTM + LQ) — service payment
|
|
|
|
Errors at the file level raise :class:`CycloneParseError`. Per-segment
|
|
deficiencies are recorded in :class:`ValidationReport` and never abort
|
|
the whole batch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from cyclone.parsers.exceptions import CycloneParseError
|
|
from cyclone.parsers.models import Address, BatchSummary, Envelope
|
|
from cyclone.parsers.models_835 import (
|
|
ClaimAdjustment,
|
|
ClaimPayment,
|
|
FinancialInfo,
|
|
ParseResult835,
|
|
Payer835,
|
|
Payee835,
|
|
ReassociationTrace,
|
|
ServicePayment,
|
|
claim_status_label,
|
|
)
|
|
from cyclone.parsers.payer import PayerConfig835
|
|
from cyclone.parsers.segments import tokenize
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Date parsing
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _parse_ccyymmdd(yymmdd: str) -> date:
|
|
"""Parse a CCYYMMDD (8-char) or YY?? (6-char) date string."""
|
|
if not yymmdd:
|
|
raise ValueError("empty date")
|
|
if len(yymmdd) == 8:
|
|
return date(int(yymmdd[0:4]), int(yymmdd[4:6]), int(yymmdd[6:8]))
|
|
if len(yymmdd) == 6:
|
|
yy = int(yymmdd[0:2])
|
|
mm = int(yymmdd[2:4])
|
|
dd = int(yymmdd[4:6])
|
|
# Pivot year 30: 00-29 = 2000s, 30-99 = 1900s.
|
|
year = 2000 + yy if yy < 30 else 1900 + yy
|
|
return date(year, mm, dd)
|
|
raise ValueError(f"unexpected date length: {yymmdd!r}")
|
|
|
|
|
|
def _safe_date(s: str) -> date | None:
|
|
try:
|
|
return _parse_ccyymmdd(s) if s else None
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Envelope / BPR / TRN
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, BatchSummary]:
|
|
"""Build the 835 envelope from ISA/GS/ST. Raises on missing/malformed ISA."""
|
|
summary = BatchSummary(input_file=input_file)
|
|
envelope: Envelope | 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(2026, 1, 1), # overwritten by BPR
|
|
implementation_guide=None,
|
|
)
|
|
except (IndexError, ValueError) as exc:
|
|
raise CycloneParseError(f"Bad ISA: {exc}") from exc
|
|
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")
|
|
return envelope, summary
|
|
|
|
|
|
def _consume_bpr(segments: list[list[str]], idx: int) -> tuple[FinancialInfo, int]:
|
|
seg = segments[idx]
|
|
try:
|
|
handling = seg[1] if len(seg) > 1 else ""
|
|
paid = Decimal(seg[2]) if len(seg) > 2 and seg[2] else Decimal("0")
|
|
credit_debit = seg[3] if len(seg) > 3 else ""
|
|
method = seg[4] if len(seg) > 4 and seg[4] else None
|
|
# BPR10 = Payer Identifier (Origin Company ID), per the CO Medicaid
|
|
# guide: "81-1725341" (TXIX) or "84-0644739" (BHA).
|
|
payer_tax = seg[10] if len(seg) > 10 and seg[10] else None
|
|
# BPR16 = Effective Entry Date (CCYYMMDD). The segment is sparse —
|
|
# positions 11..15 (BPR11..BPR15) are usually empty, so we look for
|
|
# any 8-digit CCYYMMDD token from index 11 onward.
|
|
payment_date = None
|
|
for k in range(11, len(seg)):
|
|
if seg[k] and len(seg[k]) == 8 and seg[k].isdigit():
|
|
payment_date = _safe_date(seg[k])
|
|
if payment_date is not None:
|
|
break
|
|
except (IndexError, ValueError) as exc:
|
|
raise CycloneParseError(f"Bad BPR: {exc}", segment_index=idx) from exc
|
|
return (
|
|
FinancialInfo(
|
|
handling_code=handling,
|
|
paid_amount=paid,
|
|
credit_debit_flag=credit_debit,
|
|
payment_method=method,
|
|
payer_tax_id=payer_tax,
|
|
payment_date=payment_date,
|
|
),
|
|
idx + 1,
|
|
)
|
|
|
|
|
|
def _consume_trn(segments: list[list[str]], idx: int) -> tuple[ReassociationTrace, int]:
|
|
seg = segments[idx]
|
|
trace_type = seg[1] if len(seg) > 1 else ""
|
|
trace_number = seg[2] if len(seg) > 2 else ""
|
|
originating = seg[3] if len(seg) > 3 else ""
|
|
payer_id = seg[4] if len(seg) > 4 and seg[4] else None
|
|
return (
|
|
ReassociationTrace(
|
|
trace_type_code=trace_type,
|
|
trace_number=trace_number,
|
|
originating_company_id=originating,
|
|
payer_id=payer_id,
|
|
),
|
|
idx + 1,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Loop 1000A (payer) / 1000B (payee)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _consume_address(segments: list[list[str]], idx: int) -> tuple[Address | None, int]:
|
|
"""Walk N3/N4 (if present) and return the assembled Address, plus new idx."""
|
|
addr: Address | None = None
|
|
while idx < len(segments) and segments[idx][0] in {"N3", "N4"}:
|
|
seg = segments[idx]
|
|
if seg[0] == "N3":
|
|
line1 = seg[1] if len(seg) > 1 else ""
|
|
line2 = seg[2] if len(seg) > 2 else None
|
|
addr = (addr or Address(line1=line1, city="", state="", zip="")).model_copy(
|
|
update={"line1": line1, "line2": line2}
|
|
)
|
|
elif seg[0] == "N4":
|
|
city = seg[1] if len(seg) > 1 else ""
|
|
state = seg[2] if len(seg) > 2 else ""
|
|
zip_ = seg[3] if len(seg) > 3 else ""
|
|
addr = (addr or Address(line1="", city=city, state=state, zip=zip_)).model_copy(
|
|
update={"city": city, "state": state, "zip": zip_}
|
|
)
|
|
idx += 1
|
|
return addr, idx
|
|
|
|
|
|
def _consume_1000a(segments: list[list[str]], idx: int) -> tuple[Payer835, int]:
|
|
"""Read N1*PR + N3 + N4 + PER* + REF* up to next N1 or CLP/LX/SE."""
|
|
name = ""
|
|
pid: str | None = None
|
|
contact_url: str | None = None
|
|
# First segment is N1*PR
|
|
seg = segments[idx]
|
|
if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PR":
|
|
# NM103 is the name (or organization name for non-person entities).
|
|
name = seg[3] if len(seg) > 3 and seg[3] else (seg[2] if len(seg) > 2 else "")
|
|
# NM108/NM109 carry the ID code qualifier and value. The CO guide
|
|
# documents XV for the Health Plan ID, but other payers may use
|
|
# PI / 24 / etc., so we accept any qualifier.
|
|
for j, qual in enumerate(seg):
|
|
if qual in {"XV", "PI", "24"} and j + 1 < len(seg):
|
|
pid = seg[j + 1]
|
|
break
|
|
idx += 1
|
|
addr: Address | None = None
|
|
while idx < len(segments) and segments[idx][0] not in {"N1", "LX", "CLP", "SE"}:
|
|
seg = segments[idx]
|
|
if seg[0] in {"N3", "N4"}:
|
|
addr, idx = _consume_address(segments, idx)
|
|
continue
|
|
if seg[0] == "PER":
|
|
# PER03 is the communication number qualifier; PER04 is the
|
|
# value. The CO guide documents PER04 as the URL but in real
|
|
# 835s the URL is tagged with PER03=UR. We scan for the UR
|
|
# qualifier to be safe.
|
|
for j in range(1, len(seg) - 1, 2):
|
|
if seg[j] == "UR" and j + 1 < len(seg):
|
|
contact_url = seg[j + 1]
|
|
break
|
|
# Fall back to PER04 directly if it looks like a URL (matches
|
|
# the literal CO guide wording).
|
|
if contact_url is None and len(seg) > 4 and seg[4]:
|
|
value = seg[4]
|
|
if "@" in value or "http" in value or "." in value:
|
|
contact_url = value
|
|
idx += 1
|
|
return Payer835(name=name, id=pid, address=addr, contact_url=contact_url), idx
|
|
|
|
|
|
def _consume_1000b(segments: list[list[str]], idx: int) -> tuple[Payee835, int]:
|
|
"""Read N1*PE + N3 + N4 up to next N1 or CLP/LX/SE."""
|
|
name = ""
|
|
npi = ""
|
|
seg = segments[idx]
|
|
if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PE":
|
|
name = seg[3] if len(seg) > 3 and seg[3] else (seg[2] if len(seg) > 2 else "")
|
|
for j, qual in enumerate(seg):
|
|
if qual == "XX" and j + 1 < len(seg):
|
|
npi = seg[j + 1]
|
|
break
|
|
idx += 1
|
|
addr: Address | None = None
|
|
while idx < len(segments) and segments[idx][0] not in {"N1", "LX", "CLP", "SE"}:
|
|
seg = segments[idx]
|
|
if seg[0] in {"N3", "N4"}:
|
|
addr, idx = _consume_address(segments, idx)
|
|
continue
|
|
idx += 1
|
|
return Payee835(name=name, npi=npi, address=addr), idx
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Loop 2100 (claim payment) and 2110 (service payment)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _cas_segments(segments: list[list[str]], idx: int) -> tuple[list[ClaimAdjustment], int]:
|
|
"""Read consecutive CAS segments starting at ``idx`` and return aggregated adjustments."""
|
|
adjustments: list[ClaimAdjustment] = []
|
|
while idx < len(segments) and segments[idx][0] == "CAS":
|
|
seg = segments[idx]
|
|
try:
|
|
group = seg[1] if len(seg) > 1 else ""
|
|
# Each CAS carries up to 6 reason/amount/quantity triplets
|
|
# (elements 2..19). We expand each one into its own row.
|
|
# Indexes 2,3,4 | 5,6,7 | 8,9,10 | 11,12,13 | 14,15,16 | 17,18,19
|
|
triplet_starts = [2, 5, 8, 11, 14, 17]
|
|
for start in triplet_starts:
|
|
if len(seg) <= start:
|
|
break
|
|
reason = seg[start]
|
|
if not reason:
|
|
continue
|
|
amount_s = seg[start + 1] if len(seg) > start + 1 else ""
|
|
qty_s = seg[start + 2] if len(seg) > start + 2 else ""
|
|
amount = Decimal(amount_s) if amount_s else Decimal("0")
|
|
quantity = Decimal(qty_s) if qty_s else None
|
|
adjustments.append(
|
|
ClaimAdjustment(
|
|
group_code=group,
|
|
reason_code=reason,
|
|
amount=amount,
|
|
quantity=quantity,
|
|
)
|
|
)
|
|
except (IndexError, ValueError) as exc:
|
|
log.warning("Bad CAS at segment %d: %s", idx, exc)
|
|
idx += 1
|
|
return adjustments, idx
|
|
|
|
|
|
def _consume_service_payment(segments: list[list[str]], idx: int, line_no: int) -> tuple[ServicePayment, int, list[list[str]]]:
|
|
"""Read SVC + its child segments. Returns (ServicePayment, new_idx, raw_segments).
|
|
|
|
The third return value is the list of raw segments consumed (SVC itself
|
|
plus any DTM/REF/CAS children) so the caller can fold them into the
|
|
claim's raw_segments. :class:`ServicePayment` does not carry a
|
|
``raw_segments`` field per the 835 spec, so we surface them at the
|
|
claim level instead.
|
|
"""
|
|
seg = segments[idx]
|
|
raw: list[list[str]] = [seg]
|
|
qualifier = ""
|
|
code = ""
|
|
modifiers: list[str] = []
|
|
if len(seg) > 1 and seg[1]:
|
|
svc01 = seg[1].split(":")
|
|
if len(svc01) > 0:
|
|
qualifier = svc01[0]
|
|
if len(svc01) > 1:
|
|
code = svc01[1]
|
|
modifiers = [m for m in svc01[2:] if m]
|
|
charge = Decimal(seg[2]) if len(seg) > 2 and seg[2] else Decimal("0")
|
|
payment = Decimal(seg[3]) if len(seg) > 3 and seg[3] else Decimal("0")
|
|
units: Decimal | None = None
|
|
unit_type: str | None = None
|
|
# X12 005010X221A1: SVC04 = Unit or Basis for Measurement Code
|
|
# (UN, MJ, DA, ...); SVC05 = Service Unit Count.
|
|
if len(seg) > 4 and seg[4]:
|
|
unit_type = seg[4]
|
|
if len(seg) > 5 and seg[5]:
|
|
try:
|
|
units = Decimal(seg[5])
|
|
except Exception:
|
|
units = None
|
|
# Some payers omit SVC04 but still mean "UN" when SVC05 carries a
|
|
# count. Be conservative: only default to UN if no other value was
|
|
# set above.
|
|
if unit_type is None and units is not None:
|
|
unit_type = "UN"
|
|
service_date: date | None = None
|
|
ref_benefit_plan: str | None = None
|
|
adjustments: list[ClaimAdjustment] = []
|
|
|
|
idx += 1
|
|
while idx < len(segments) and segments[idx][0] not in {"SVC", "LX", "CLP", "SE"}:
|
|
s = segments[idx]
|
|
raw.append(s)
|
|
if s[0] == "DTM" and len(s) > 2 and s[1] == "472":
|
|
service_date = _safe_date(s[2]) if s[2] else None
|
|
elif s[0] == "REF" and len(s) > 2 and s[1] == "0K":
|
|
ref_benefit_plan = s[2]
|
|
elif s[0] == "CAS":
|
|
cas, idx = _cas_segments(segments, idx)
|
|
raw.extend(c[0] for c in cas if False) # CAS rows are already in adjustments; nothing more to do
|
|
adjustments.extend(cas)
|
|
continue
|
|
idx += 1
|
|
|
|
return (
|
|
ServicePayment(
|
|
line_number=line_no,
|
|
procedure_qualifier=qualifier,
|
|
procedure_code=code,
|
|
modifiers=modifiers,
|
|
charge=charge,
|
|
payment=payment,
|
|
units=units,
|
|
unit_type=unit_type,
|
|
service_date=service_date,
|
|
adjustments=adjustments,
|
|
ref_benefit_plan=ref_benefit_plan,
|
|
),
|
|
idx,
|
|
raw,
|
|
)
|
|
|
|
|
|
def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPayment, int]:
|
|
"""Read one CLP and all its child segments (CAS / NM1 / REF / DTM / QTY / SVC)."""
|
|
clp = segments[idx]
|
|
raw: list[list[str]] = [clp]
|
|
try:
|
|
pcn = clp[1] if len(clp) > 1 else ""
|
|
status = clp[2] if len(clp) > 2 else ""
|
|
total_charge = Decimal(clp[3]) if len(clp) > 3 and clp[3] else Decimal("0")
|
|
total_paid = Decimal(clp[4]) if len(clp) > 4 and clp[4] else Decimal("0")
|
|
patient_resp = Decimal(clp[5]) if len(clp) > 5 and clp[5] else None
|
|
claim_filing = clp[6] if len(clp) > 6 and clp[6] else None
|
|
original_claim_id = clp[7] if len(clp) > 7 and clp[7] else None
|
|
facility_type = clp[8] if len(clp) > 8 and clp[8] else None
|
|
# CLP09 is a single element (no composite qualifier on the 835 side).
|
|
frequency = clp[9] if len(clp) > 9 and clp[9] else None
|
|
except (IndexError, ValueError) as exc:
|
|
raise CycloneParseError(f"Bad CLP: {exc}", segment_index=idx) from exc
|
|
|
|
service_payments: list[ServicePayment] = []
|
|
ref_benefit_plan: str | None = None
|
|
service_provider_npi: str | None = None
|
|
per_diem: Decimal | None = None
|
|
status_label = claim_status_label(status)
|
|
|
|
idx += 1
|
|
while idx < len(segments) and segments[idx][0] not in {"CLP", "SE"}:
|
|
s = segments[idx]
|
|
# Only consume this CLP's children; an LX starts a new 2100 loop.
|
|
if s[0] == "LX":
|
|
# Skip the LX and expect an SVC; if absent, stop.
|
|
raw.append(s)
|
|
idx += 1
|
|
if idx < len(segments) and segments[idx][0] == "SVC":
|
|
line_no = (
|
|
int(s[1]) if len(s) > 1 and s[1].isdigit() else len(service_payments) + 1
|
|
)
|
|
sp, idx, svc_raw = _consume_service_payment(segments, idx, line_no)
|
|
raw.extend(svc_raw)
|
|
service_payments.append(sp)
|
|
continue
|
|
if s[0] == "SVC":
|
|
sp, idx, svc_raw = _consume_service_payment(segments, idx, len(service_payments) + 1)
|
|
raw.extend(svc_raw)
|
|
service_payments.append(sp)
|
|
continue
|
|
raw.append(s)
|
|
if s[0] == "CAS":
|
|
cas, idx = _cas_segments(segments, idx)
|
|
# Top-level claim adjustments are aggregated into a synthesized
|
|
# ServicePayment with line_number=0 if there are no SVC rows.
|
|
# Per the spec, ClaimPayment doesn't carry adjustments directly,
|
|
# so we record them on the first service payment if any,
|
|
# otherwise drop them on the floor (rare in practice).
|
|
if service_payments:
|
|
first = service_payments[0]
|
|
service_payments[0] = first.model_copy(
|
|
update={"adjustments": first.adjustments + cas}
|
|
)
|
|
continue
|
|
if s[0] == "REF" and len(s) > 2 and s[1] == "CE":
|
|
ref_benefit_plan = s[2]
|
|
elif s[0] == "QTY" and len(s) > 2 and s[1] == "CA":
|
|
try:
|
|
per_diem = Decimal(s[2]) if s[2] else None
|
|
except ValueError:
|
|
per_diem = None
|
|
elif s[0] == "NM1":
|
|
# SP32: capture service-provider NPI from NM1*1P (Loop 2100).
|
|
# NM108 (idx 8) carries the ID qualifier (typically "XX");
|
|
# NM109 (idx 9) is the value. Some senders omit NM108; accept
|
|
# both forms but require a 10-digit ID.
|
|
if len(s) > 9 and s[1] == "1P":
|
|
if len(s) > 8 and s[8] == "XX" and s[9]:
|
|
if s[9].isdigit() and len(s[9]) == 10:
|
|
service_provider_npi = s[9]
|
|
elif s[9] and s[9].isdigit() and len(s[9]) == 10:
|
|
service_provider_npi = s[9]
|
|
elif s[0] == "DTM":
|
|
# Claim-level dates — captured in raw_segments.
|
|
pass
|
|
idx += 1
|
|
|
|
return (
|
|
ClaimPayment(
|
|
payer_claim_control_number=pcn,
|
|
status_code=status,
|
|
status_label=status_label,
|
|
total_charge=total_charge,
|
|
total_paid=total_paid,
|
|
patient_responsibility=patient_resp,
|
|
claim_filing_indicator=claim_filing,
|
|
original_claim_id=original_claim_id,
|
|
facility_type=facility_type,
|
|
frequency_code=frequency,
|
|
per_diem_covered_days=per_diem,
|
|
ref_benefit_plan=ref_benefit_plan,
|
|
service_payments=service_payments,
|
|
raw_segments=raw,
|
|
service_provider_npi=service_provider_npi,
|
|
),
|
|
idx,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Top-level orchestrator
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> ParseResult835: # noqa: ARG001 — payer_config reserved for future use
|
|
"""Parse a complete 835 ERA document and return a :class:`ParseResult835`.
|
|
|
|
``payer_config`` is accepted for symmetry with the 837P parser and
|
|
for future payer-aware heuristics; in v1 the structural validation
|
|
runs against the full result via :func:`cyclone.parsers.validator_835.validate`.
|
|
"""
|
|
segments = tokenize(text)
|
|
envelope, summary = _build_envelope(segments, input_file=input_file)
|
|
|
|
financial: FinancialInfo | None = None
|
|
trace: ReassociationTrace | None = None
|
|
payer = Payer835(name="", id=None, address=None, contact_url=None)
|
|
payee = Payee835(name="", npi="", address=None)
|
|
claims: list[ClaimPayment] = []
|
|
|
|
i = 0
|
|
n_bprs = 0 # CO Medicaid sends multiple BPRs in one 835; we sum them.
|
|
# Pre-envelope segments (ISA/GS) are already consumed by _build_envelope.
|
|
# Walk forward looking for the header (BPR/TRN), then loops.
|
|
while i < len(segments):
|
|
seg = segments[i]
|
|
if seg[0] == "ST":
|
|
# Skip the ST control segment.
|
|
i += 1
|
|
continue
|
|
if seg[0] == "BPR":
|
|
new_fin, i = _consume_bpr(segments, i)
|
|
if financial is None:
|
|
financial = new_fin
|
|
else:
|
|
# CO Medicaid "split payment" pattern: multiple BPRs whose
|
|
# BPR02 amounts sum to the total paid. X12 835 only allows
|
|
# one BPR per transaction set; we accept the data and warn.
|
|
# Keep the first BPR's handling_code, tax_id, payment_date;
|
|
# only the paid_amount is additive.
|
|
financial = financial.model_copy(update={
|
|
"paid_amount": financial.paid_amount + new_fin.paid_amount,
|
|
})
|
|
n_bprs += 1
|
|
continue
|
|
if seg[0] == "TRN":
|
|
trace, i = _consume_trn(segments, i)
|
|
continue
|
|
if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PR":
|
|
payer, i = _consume_1000a(segments, i)
|
|
continue
|
|
if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PE":
|
|
payee, i = _consume_1000b(segments, i)
|
|
continue
|
|
if seg[0] == "CLP":
|
|
try:
|
|
claim, i = _consume_claim_payment(segments, i)
|
|
claims.append(claim)
|
|
except CycloneParseError:
|
|
raise
|
|
except Exception as exc: # pragma: no cover - safety net
|
|
log.warning("Claim parse failed at segment %d: %s", i, exc)
|
|
i += 1
|
|
continue
|
|
if seg[0] == "LX" and not claims and not payee.npi:
|
|
# Defensive: a stray LX before any CLP (no 1000B seen) is ignored.
|
|
i += 1
|
|
continue
|
|
i += 1
|
|
|
|
if financial is None:
|
|
raise CycloneParseError("No BPR (Financial Information) segment found")
|
|
if trace is None:
|
|
raise CycloneParseError("No TRN (Reassociation Trace) segment found")
|
|
|
|
# Update the envelope's transaction date from the BPR payment date if available.
|
|
if financial.payment_date is not None:
|
|
envelope = envelope.model_copy(update={"transaction_date": financial.payment_date})
|
|
|
|
summary = summary.model_copy(update={
|
|
"control_number": envelope.control_number,
|
|
"transaction_date": envelope.transaction_date,
|
|
"total_claims": len(claims),
|
|
})
|
|
|
|
return ParseResult835(
|
|
envelope=envelope,
|
|
financial_info=financial,
|
|
trace=trace,
|
|
payer=payer,
|
|
payee=payee,
|
|
claims=claims,
|
|
multi_bpr=(n_bprs > 1),
|
|
summary=summary,
|
|
validation=None,
|
|
)
|
|
|
|
|
|
__all__ = ["parse"]
|