diff --git a/backend/src/cyclone/rebill/pipeline_a.py b/backend/src/cyclone/rebill/pipeline_a.py new file mode 100644 index 0000000..a52ce14 --- /dev/null +++ b/backend/src/cyclone/rebill/pipeline_a.py @@ -0,0 +1,79 @@ +"""Pipeline A: denied/partial → frequency-7 replacement 837Ps. + +For each visit the previous adjudication said was DENIED or PARTIAL, +emit a fresh 837P with CLM05-3 = '7' (replacement claim) and the +original claim_submit_id preserved as CLM01. The 837P carries the +original DOS and the same procedure / member. + +The CARC-aware filter is the gate — only REBILL / REVIEW decisions are +emitted; EXCLUDED visits never make it to a file. +""" +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from pathlib import Path + +from cyclone.rebill.carc_filter import CarcDecision +from cyclone.rebill.reconcile import ReconcileOutcome, OutcomeCategory + + +@dataclass(frozen=True) +class RebillClaim: + claim_id: str # reuses original claim_submit_id + member_id: str + procedure: str + svc_date: date + charge: Decimal + frequency_code: str # always "7" for Pipeline A + needs_review: bool # True for CARC REVIEW decisions + original_carc_reasons: tuple[str, ...] + + +def build_pipeline_a_claims( + original_claim_id: str, + visit_outcomes: list[ReconcileOutcome], + carc_decisions: list[CarcDecision], + cas_reasons_per_visit: list[tuple[str, ...]], +) -> list[RebillClaim]: + """One input list per visit; align by index.""" + out: list[RebillClaim] = [] + for vo, carc, reasons in zip(visit_outcomes, carc_decisions, cas_reasons_per_visit): + if vo.category not in (OutcomeCategory.DENIED, OutcomeCategory.PARTIAL): + continue + if carc == CarcDecision.EXCLUDED: + continue + out.append(RebillClaim( + claim_id=original_claim_id, + member_id=vo.visit.member_id, + procedure=vo.visit.procedure, + svc_date=vo.visit.date, + charge=vo.visit.billed, + frequency_code="7", + needs_review=(carc == CarcDecision.REVIEW), + original_carc_reasons=tuple(reasons), + )) + return out + + +def write_pipeline_a_files( + claims: list[RebillClaim], + out_dir: Path, + serialize_837_fn, # injected: cyclone.parsers.serialize_837.serialize_837 + tpid: str, +) -> list[Path]: + """Write one 837P per claim, using HCPF-spec filenames via build_outbound_filename. + + `serialize_837_fn(claim)` returns the X12 byte string. The filename is + `cyclone.edi.filenames.build_outbound_filename(tpid, '837P')`. + """ + from cyclone.edi.filenames import build_outbound_filename + + out_dir.mkdir(parents=True, exist_ok=True) + paths: list[Path] = [] + for c in claims: + body = serialize_837_fn(c) + fname = build_outbound_filename(tpid, "837P") + path = out_dir / fname + path.write_bytes(body) + paths.append(path) + return paths diff --git a/backend/tests/test_rebill_pipeline_a.py b/backend/tests/test_rebill_pipeline_a.py new file mode 100644 index 0000000..e4ebb1d --- /dev/null +++ b/backend/tests/test_rebill_pipeline_a.py @@ -0,0 +1,60 @@ +"""Pipeline A: denied/partial visits become frequency-7 replacement claims.""" +from datetime import date +from decimal import Decimal +from pathlib import Path +from cyclone.rebill.pipeline_a import ( + RebillClaim, + build_pipeline_a_claims, + write_pipeline_a_files, +) +from cyclone.rebill.reconcile import VisitRow, ReconcileOutcome, OutcomeCategory +from cyclone.rebill.carc_filter import CarcDecision + + +def _outcome(date_, member, proc, billed, cat, unpaid=Decimal("0")): + visit = VisitRow(date=date_, member_id=member, procedure=proc, billed=Decimal(billed)) + return ReconcileOutcome(visit, cat, unpaid, 0) + + +def test_pipeline_a_emits_frequency_7(): + """CLM05-3 must be 7 (replacement), preserving the original claim_submit_id.""" + out = build_pipeline_a_claims( + original_claim_id="ORIG-001", + visit_outcomes=[ + _outcome(date(2026, 6, 27), "J813715", "T1019", "2.32", + OutcomeCategory.DENIED, unpaid=Decimal("2.32")), + ], + carc_decisions=[CarcDecision.REBILL], + cas_reasons_per_visit=[()], + ) + assert len(out) == 1 + assert out[0].claim_id == "ORIG-001" + assert out[0].frequency_code == "7" + assert out[0].svc_date == date(2026, 6, 27) + + +def test_pipeline_a_excludes_carc_excluded_visits(): + out = build_pipeline_a_claims( + original_claim_id="ORIG-002", + visit_outcomes=[ + _outcome(date(2026, 6, 27), "J813715", "T1019", "2.32", + OutcomeCategory.DENIED), + ], + carc_decisions=[CarcDecision.EXCLUDED], + cas_reasons_per_visit=[("CO-45",)], + ) + assert out == [] # not emitted + + +def test_pipeline_a_surfaces_review_visits_with_flag(): + out = build_pipeline_a_claims( + original_claim_id="ORIG-003", + visit_outcomes=[ + _outcome(date(2026, 6, 27), "J813715", "T1019", "2.32", + OutcomeCategory.DENIED), + ], + carc_decisions=[CarcDecision.REVIEW], + cas_reasons_per_visit=[("PI-16",)], + ) + assert len(out) == 1 + assert out[0].needs_review is True