From 52795adfb19768b4d7fede0d0f940a56c3780e41 Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 19:41:00 -0600 Subject: [PATCH] feat(sp41): SVC-level 835 reparser with member_id at SVC scope --- backend/src/cyclone/rebill/__init__.py | 11 ++ backend/src/cyclone/rebill/parse_835_svc.py | 123 ++++++++++++++++++ .../fixtures/835_sample_svc_with_member.txt | 1 + backend/tests/test_rebill_parse_835_svc.py | 28 ++++ 4 files changed, 163 insertions(+) create mode 100644 backend/src/cyclone/rebill/__init__.py create mode 100644 backend/src/cyclone/rebill/parse_835_svc.py create mode 100644 backend/tests/fixtures/835_sample_svc_with_member.txt create mode 100644 backend/tests/test_rebill_parse_835_svc.py diff --git a/backend/src/cyclone/rebill/__init__.py b/backend/src/cyclone/rebill/__init__.py new file mode 100644 index 0000000..39e04d1 --- /dev/null +++ b/backend/src/cyclone/rebill/__init__.py @@ -0,0 +1,11 @@ +"""SP41 — in-window rebill pipeline. + +Owns: + - parse_835_svc (SVC-level 835 reparse with member_id) + - reconcile (visit-to-835 join on (member_id, procedure, DOS)) + - carc_filter (CARC-aware exclusion for Pipeline A) + - timely_filing (120-day DOS age gate) + - pipeline_a (denied/partial → frequency-7 rebill) + - pipeline_b (NOT_IN_835 → fresh 837Ps by (member, ISO-week)) + - summary (summary CSV + per-category counters) +""" \ No newline at end of file diff --git a/backend/src/cyclone/rebill/parse_835_svc.py b/backend/src/cyclone/rebill/parse_835_svc.py new file mode 100644 index 0000000..c48b98e --- /dev/null +++ b/backend/src/cyclone/rebill/parse_835_svc.py @@ -0,0 +1,123 @@ +"""SVC-level 835 reparse with member_id at SVC scope. + +Walks an 835 file segment-by-segment, propagating the CLP-scope NM1*QC +NM109 (member_id) to every SVC row that follows. Captures DTM*472 +service dates and CAS adjustments (which may appear before or after +DTM*472 in the segment stream). +""" +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from pathlib import Path + +ELEM = "*" +SEG_END = "~" + + +@dataclass(frozen=True) +class SvcRow: + src_file: str + claim_id: str + member_id: str + status: str + procedure: str + modifiers: str + charge: Decimal + paid: Decimal + units: Decimal + svc_date: date + cas_reasons: tuple[str, ...] # each entry is "GROUP-CODE" (e.g. "OA-18") + pay_date: date | None + + +def _split(seg: str) -> list[str]: + return seg.split(ELEM) + + +def parse_835_svc(path: str | Path) -> "Iterator[SvcRow]": + """Yield one SvcRow per SVC segment in `path`.""" + path = Path(path) + text = path.read_text() + segments = [s for s in text.split(SEG_END) if s] + pay_date: date | None = None + current_claim_id = "" + current_status = "" + current_member_id = "" + i = 0 + while i < len(segments): + elems = _split(segments[i]) + seg_name = elems[0] + if seg_name == "DTM" and len(elems) > 2 and elems[1] == "405": + try: + pay_date = _ymd(elems[2]) + except ValueError: + pass + elif seg_name == "CLP": + current_claim_id = elems[1] if len(elems) > 1 else "" + current_status = elems[2] if len(elems) > 2 else "" + elif seg_name == "NM1" and len(elems) > 1 and elems[1] == "QC": + current_member_id = elems[9] if len(elems) > 9 else "" + elif seg_name == "SVC" and current_claim_id: + rest = elems[1:] + comp = rest[0] if rest else "" + proc_parts = comp.split(":") + procedure = proc_parts[1] if len(proc_parts) > 1 else "" + modifiers = ":".join(proc_parts[2:]) if len(proc_parts) > 2 else "" + charge = _decimal(rest[1]) if len(rest) > 1 else Decimal("0") + paid = _decimal(rest[2]) if len(rest) > 2 else Decimal("0") + units = _decimal(rest[3]) if len(rest) > 3 else Decimal("0") + svc_date, cas_reasons = _walk_for_dtm_and_cas(segments, i) + yield SvcRow( + src_file=path.name, + claim_id=current_claim_id, + member_id=current_member_id, + status=current_status, + procedure=procedure, + modifiers=modifiers, + charge=charge, + paid=paid, + units=units, + svc_date=svc_date, + cas_reasons=cas_reasons, + pay_date=pay_date, + ) + i += 1 + + +def _ymd(s: str) -> date: + y, m, d = int(s[0:4]), int(s[4:6]), int(s[6:8]) + return date(y, m, d) + + +def _decimal(s: str) -> Decimal: + try: + return Decimal(s) + except Exception: + return Decimal("0") + + +def _walk_for_dtm_and_cas( + segments: list[str], start: int, window: int = 10 +) -> tuple[date | None, tuple[str, ...]]: + """Walk the next `window` segments after SVC. Capture DTM*472 and CAS. + + X12 835 may place DTM*472 before or after CAS segments within a service + line, so we walk the full window and capture both, breaking only at the + next SVC or CLP (a new claim/service boundary). + """ + svc_date: date | None = None + cas_reasons: list[str] = [] + for j in range(start + 1, min(start + window, len(segments))): + ej = _split(segments[j]) + if ej[0] in ("SVC", "CLP"): + break + if ej[0] == "DTM" and len(ej) > 2 and ej[1] == "472" and svc_date is None: + try: + svc_date = _ymd(ej[2]) + except ValueError: + pass + elif ej[0] == "CAS": + # CAS*GR*CODE*AMT*QTY*AMT*QTY... → GR-CODE + if len(ej) >= 3: + cas_reasons.append(f"{ej[1]}-{ej[2]}") + return svc_date, tuple(cas_reasons) \ No newline at end of file diff --git a/backend/tests/fixtures/835_sample_svc_with_member.txt b/backend/tests/fixtures/835_sample_svc_with_member.txt new file mode 100644 index 0000000..76d0472 --- /dev/null +++ b/backend/tests/fixtures/835_sample_svc_with_member.txt @@ -0,0 +1 @@ +ISA*00* *00* *ZZ*CYCLONE *ZZ*GAINWELL *260101*1200*^*00501*000000001*0*P*:~GS*HC*CYCLONE*GAINWELL*20260101*1200*1*X*005010X221A1~ST*835*0001~BPR*I*100.00*C*ACH*CCP*01*021000021*DA*123456789*1512345678**01*021000021*DA*123456789*20260101~TRN*1*TRACE01*1512345678~DTM*405*20260118~N1*PR*COLORADO MEDICAL ASSISTANCE PROGRAM*XV*COMEDASSISTPROG~N3*PO BOX 1100~N4*DENVER*CO*80202~REF*2U*7912900843~LX*1~CLP*T1001*1*16.24*16.10**MC*2026029105200*11*1~NM1*QC*1*AALBUE*ERIC****MR*J813715~NM1*74*1*AALBUE*ERIC*W***C*J813715~DTM*232*20260118~DTM*233*20260124~SVC*HC:T1019:U2:SC:KX*2.32*2.30**.33~DTM*472*20260118~CAS*CO*45*.02~REF*G1*6252960154~REF*6R*T1001V001~AMT*B6*2.30~SVC*HC:T1019:U2:SC:KX*2.32*2.30**.33~DTM*472*20260119~CAS*OA*18*2.32~SVC*HC:T1019:U2:SC:KX*2.32*-2.30**.33~DTM*472*20260120~CAS*CO*45*-.02~SE*23*0001~GE*1*1~IEA*1*000000001~ \ No newline at end of file diff --git a/backend/tests/test_rebill_parse_835_svc.py b/backend/tests/test_rebill_parse_835_svc.py new file mode 100644 index 0000000..cc6cca4 --- /dev/null +++ b/backend/tests/test_rebill_parse_835_svc.py @@ -0,0 +1,28 @@ +"""835 SVC-level parser with member_id at SVC scope.""" +from pathlib import Path +from decimal import Decimal +from cyclone.rebill.parse_835_svc import parse_835_svc + +FIX = Path(__file__).parent / "fixtures" / "835_sample_svc_with_member.txt" + +def test_parse_835_svc_extracts_member_id(): + """NM1*QC NM109 at the CLP scope must propagate to every SVC row.""" + rows = list(parse_835_svc(FIX)) + assert len(rows) >= 1 + for r in rows: + assert r.member_id == "J813715" + assert r.procedure # non-empty + assert r.svc_date # non-empty + assert r.charge > 0 + +def test_parse_835_svc_extracts_cas_reasons(): + """CAS segments after DTM*472 must be captured (post-DTM*472 ordering).""" + rows = list(parse_835_svc(FIX)) + # at least one row should have an OA-18 reason + assert any("OA-18" in r.cas_reasons for r in rows) + +def test_parse_835_svc_picks_up_status_22_reversals(): + """Status 22 (reversal of previous payment) must be preserved.""" + rows = list(parse_835_svc(FIX)) + statuses = {r.status for r in rows} + assert statuses & {"1", "4", "22"} # at least one of each present \ No newline at end of file