feat(sp41): SVC-level 835 reparser with member_id at SVC scope
This commit is contained in:
@@ -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)
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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~
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user