feat(sp41): orchestrator run_rebill() wires the pipeline end-to-end

This commit is contained in:
Nora
2026-07-07 22:54:36 -06:00
parent cc41c73d2a
commit 262ad00481
+267
View File
@@ -0,0 +1,267 @@
"""Top-level orchestrator for SP41.
Wires the 835 SVC reparse → reconciliation → CARC filter → timely-filing
gate → pipeline A → pipeline B → summary CSV → Edifabric validation.
The CLI and the HTTP endpoint both call this. CLI passes filesystem
paths from --visits / --ingest / --out; HTTP endpoint passes the same.
"""
from __future__ import annotations
import csv as _csv
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from cyclone.rebill.carc_filter import CarcDecision, decide_carc
from cyclone.rebill.parse_835_svc import SvcRow, parse_835_svc
from cyclone.rebill.pipeline_a import RebillClaim
from cyclone.rebill.pipeline_b import build_pipeline_b_batches
from cyclone.rebill.reconcile import (
OutcomeCategory,
VisitRow,
reconcile_visits_to_835,
)
from cyclone.rebill.summary import (
EXCLUDED_CARC,
EXCLUDED_TIMELY_FILING,
REBILLED_A,
REBILLED_B,
SummaryRow,
write_summary_csv,
)
from cyclone.rebill.timely_filing import timely_filing_decision
@dataclass
class RunResult:
"""The return value of run_rebill — where the summary CSV landed, the
per-disposition counts, and the list of pipeline A / B file paths.
Mutable (no frozen=True) because the contained `counts` dict and
`pipeline_*_files` lists are mutated by callers who aggregate results
across runs.
"""
summary_path: Path
counts: dict[str, int]
pipeline_a_files: list[Path]
pipeline_b_files: list[Path]
def run_rebill(
window_start: date,
window_end: date,
override_filing: bool,
visits_csv_path: str | Path | None = None,
ingest_dir: str | Path | None = None,
out_dir: str | Path | None = None,
tpid: str = "11525703",
as_of: date | None = None,
) -> RunResult:
"""Run the full SP41 rebill pipeline for the given DOS window.
Pipeline shape:
1. Walk ``ingest_dir`` for *.835 / *.x12 (skipping AppleDouble shadow
files matching ``._*``), reparse each into SvcRows via
``parse_835_svc``, keep those with svc_date in [window_start, window_end].
2. Load the AxisCare visits CSV (``visits_csv_path``) and keep the rows
whose Visit Date is in the same DOS window.
3. Reconcile visits against the in-window SVCs on the
(member_id, procedure, DOS) match key. Best-of-N: PAID if
total_paid >= 95% of billed, else PARTIAL if any payment, else DENIED.
4. Per-visit disposition:
- PAID → skip (not in summary)
- NOT_IN_835 + within-window → Pipeline B (REBILLED_B)
- NOT_IN_835 + past-window → EXCLUDED_TIMELY_FILING (no override)
- DENIED/PARTIAL + EXCLUDED CARC → EXCLUDED_CARC
- DENIED/PARTIAL + REVIEW/REBILL → Pipeline A (REBILLED_A)
5. Emit Pipeline A files (``<out>/pipeline-a/x-{claim_id}.837``) and
Pipeline B files (``<out>/pipeline-b/x-{member_id}-{iso_year}-W{iso_week:02d}.837``).
Files are ``touch()``ed placeholders here — Task 13 wraps each emit
in a serialize-then-Edifabric-validate step, with quarantine on failure.
6. Write ``<out>/summary.csv`` with one row per non-PAID visit.
The ``tpid`` argument is preserved on the signature for Task 13 / Task 14
to thread into the real filename builder; unused in this placeholder emit.
``as_of`` defaults to ``date.today()`` — pin it from tests / HTTP so the
120-day timely-filing gate is deterministic.
"""
as_of_date = as_of or date.today()
out_dir_p = Path(out_dir or f"dev/rebills/{date.today().isoformat()}")
out_dir_p.mkdir(parents=True, exist_ok=True)
visits_csv_p = Path(visits_csv_path or "data/source/apr-jun27.csv")
ingest_dir_p = Path(ingest_dir or "ingest")
# 1) Ingest 835s — walk *.835 and *.x12, skip AppleDouble shadow files
# (._foo.835 are macOS resource forks that the SFTP client surfaces
# alongside real files; matches the convention used by cli, submit-batch,
# and api_routers/submission.py).
svcs: list[SvcRow] = []
for p in sorted(
list(ingest_dir_p.glob("*.835")) + list(ingest_dir_p.glob("*.x12"))
):
if p.name.startswith("._"):
continue
for s in parse_835_svc(p):
if window_start <= s.svc_date <= window_end:
svcs.append(s)
# 2) Load visits CSV — header:
# ``Visit Date,Member ID,Procedure Code,Billable Amount``
# DOS is MM/DD/YYYY; Billable Amount may carry a ``$`` prefix and
# ``,`` thousands separators.
visits: list[VisitRow] = []
with visits_csv_p.open(newline="") as f:
for r in _csv.DictReader(f):
dos = datetime.strptime(r["Visit Date"].strip(), "%m/%d/%Y").date()
if not (window_start <= dos <= window_end):
continue
amt = Decimal(
r["Billable Amount"].strip().lstrip("$").replace(",", "")
)
visits.append(VisitRow(
date=dos,
member_id=r["Member ID"].strip(),
procedure=r["Procedure Code"].strip(),
billed=amt,
))
# 3) Reconcile visits against the in-window SVCs
outcomes = reconcile_visits_to_835(visits, svcs)
# 4) Per-visit disposition
summary: list[SummaryRow] = []
counts: dict[str, int] = {
REBILLED_A: 0,
REBILLED_B: 0,
EXCLUDED_CARC: 0,
EXCLUDED_TIMELY_FILING: 0,
"PAID": 0,
"DENIED_SKIPPED": 0,
}
pipeline_a_claims: list[RebillClaim] = []
pipeline_b_visits: list[VisitRow] = []
svc_lookup: dict[tuple[str, str, date], SvcRow] = {
(s.member_id, s.procedure, s.svc_date): s for s in svcs
}
for o in outcomes:
if o.category == OutcomeCategory.PAID:
counts["PAID"] += 1
continue
if o.category == OutcomeCategory.NOT_IN_835:
tf = timely_filing_decision(o.visit.date, as_of_date, override_filing)
if tf.rebillable:
pipeline_b_visits.append(o.visit)
counts[REBILLED_B] += 1
summary.append(SummaryRow(
visit=o.visit,
disposition=REBILLED_B,
unpaid=o.unpaid,
cas_reasons=(),
file_path="pipeline-b/",
))
else:
counts[EXCLUDED_TIMELY_FILING] += 1
summary.append(SummaryRow(
visit=o.visit,
disposition=EXCLUDED_TIMELY_FILING,
unpaid=o.unpaid,
cas_reasons=(),
file_path="",
))
continue
# DENIED or PARTIAL — Pipeline A (after CARC filter).
s = svc_lookup.get(
(o.visit.member_id, o.visit.procedure, o.visit.date)
)
reasons: tuple[str, ...] = s.cas_reasons if s else ()
decision = decide_carc(reasons)
if decision == CarcDecision.EXCLUDED:
counts[EXCLUDED_CARC] += 1
summary.append(SummaryRow(
visit=o.visit,
disposition=EXCLUDED_CARC,
unpaid=o.unpaid,
cas_reasons=reasons,
file_path="",
))
continue
# REBILL or REVIEW — emit a Pipeline A RebillClaim.
# Each matched SVC carries its own original claim_id; preserve it
# per-claim so the freq-7 replacement is anchored to the right
# claim_submit_id. If the visit somehow matched nothing (shouldn't
# happen for DENIED/PARTIAL — those categories only arise from a
# match), mint a NEW-* fallback.
claim_id = (
s.claim_id if s
else f"NEW-{o.visit.member_id}-{o.visit.date.isoformat()}"
)
claim = RebillClaim(
claim_id=claim_id,
member_id=o.visit.member_id,
procedure=o.visit.procedure,
svc_date=o.visit.date,
charge=o.visit.billed,
frequency_code="7",
needs_review=(decision == CarcDecision.REVIEW),
original_carc_reasons=reasons,
)
pipeline_a_claims.append(claim)
counts[REBILLED_A] += 1
summary.append(SummaryRow(
visit=o.visit,
disposition=REBILLED_A,
unpaid=o.unpaid,
cas_reasons=reasons,
file_path="pipeline-a/",
))
# 5) Emit Pipeline A files — placeholder; Task 13 wraps with the
# serialize-then-Edifabric-validate step. Filename shape:
# ``x-{claim_id}.837`` so the audit trail ties back to the
# original claim_submit_id via the SummaryRow.cas_reasons chain.
a_dir = out_dir_p / "pipeline-a"
a_dir.mkdir(parents=True, exist_ok=True)
a_files: list[Path] = []
for claim in pipeline_a_claims:
p = a_dir / f"x-{claim.claim_id}.837"
p.touch()
a_files.append(p)
# 6) Build + emit Pipeline B batches — placeholder; Task 13 wraps
# with the Edifabric gate. Filename shape mirrors the HCPF
# member-week pattern: ``x-{member_id}-{iso_year}-W{iso_week:02d}.837``.
b_batches = build_pipeline_b_batches(
pipeline_b_visits, as_of=as_of_date, override=override_filing,
)
b_dir = out_dir_p / "pipeline-b"
b_dir.mkdir(parents=True, exist_ok=True)
b_files: list[Path] = []
for b in b_batches:
p = b_dir / f"x-{b.member_id}-{b.iso_year}-W{b.iso_week:02d}.837"
p.touch()
b_files.append(p)
# 7) Summary CSV — one row per non-PAID visit, regardless of pipeline.
summary_path = out_dir_p / "summary.csv"
write_summary_csv(summary, summary_path)
# Touching `tpid` so it doesn't lint as unused — Task 13 / Task 14 will
# thread it into the real filename builder (cyclone.edi.filenames.
# build_outbound_filename) once the serializer adapter lands.
_ = tpid
return RunResult(
summary_path=summary_path,
counts=counts,
pipeline_a_files=a_files,
pipeline_b_files=b_files,
)