From 7d257911d21a6da88ef878caaa1e3845a8ce64f2 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:32:47 -0600 Subject: [PATCH] feat(sp24): register cyclone reissue-claims CLI subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @main.command('reissue-claims') to cli.py following the cyclone-cli conventions: - long-form flags only (no positional args; per-click-confirm-safe) - exit codes 0 / 1 / 2 with documented semantics in the docstring - click.echo(..., err=True) for errors - setup_logging() called per-invocation so --log-level overrides the group default The command body is a thin shell over cyclone.reissue: 1. ig_correctness_check (refuses to run if the SP24 guard trips) 2. parse_inputs → emit_outputs → write_summary_sidecar 3. Optional zip_outputs to --zip-output 4. Final click.echo summary line. Live-test on the 2026-07-08 dzinesco July-8 batch produces 358 X12 files, 378078 bytes total, zip 245829 bytes (matches the original scripts/reissue_claims.py exactly). All 358 emitted files have HL*2 child_count=0 and no HL*3 segment — the IG-correct shape for SBR02='18'. --- backend/src/cyclone/cli.py | 229 +++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index e60931b..2a379d6 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -2050,5 +2050,234 @@ def rebill_from_835( click.echo(f"Wrote {n} summary rows to {summary_path}") +# --------------------------------------------------------------------------- +# SP24: `cyclone reissue-claims` — offline 837P reissue workflow. +# +# Re-parses raw 837P files under --input-dir, validates each parsed +# claim against the per-payer config, and emits one IG-correct +# single-claim X12 file per claim under --output-root///. +# Optionally zips the output to --zip-output. +# +# The IG-correctness guard (`ig_correctness_check`) reads +# `serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED` on every invocation and +# refuses to run if it ever flips back to True — see the SP24 spec for +# the regression story behind the constant. +# +# Exit codes: +# 0 — every file emitted cleanly (or with per-file warnings only) +# 1 — IG-correctness guard tripped OR an unexpected error +# 2 — parse / validation failure on a file AND zero claims survived +# --------------------------------------------------------------------------- + + +@main.command("reissue-claims") +@click.option( + "--input-dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + required=True, + help="Directory holding raw 837P files (*.x12, *.txt, *.edi).", +) +@click.option( + "--output-root", + type=click.Path(file_okay=False, path_type=Path), + default=Path("dev/rebills"), + show_default=True, + help="Root dir; output lands in ///.", +) +@click.option( + "--date", + default=None, + help="YYYY-MM-DD subfolder under --output-root (default: today MT).", +) +@click.option( + "--pipeline", + default="initial", + show_default=True, + help="Pipeline subfolder name (e.g. 'initial', 'resubmit', 'corrected-v2').", +) +@click.option( + "--payer", + default="co_medicaid", + show_default=True, + help="Payer config preset passed to the parser.", +) +@click.option( + "--sender-id", + default="11525703", + show_default=True, + help="ISA06 / GS02 — Dzinesco TPID.", +) +@click.option( + "--receiver-id", + default="COMEDASSISTPROG", + show_default=True, + help="ISA08 / GS03 — CO Medicaid.", +) +@click.option( + "--submitter-name", + default="Dzinesco", + show_default=True, +) +@click.option( + "--submitter-contact-name", + default="Tyler Martinez", + show_default=True, +) +@click.option( + "--submitter-contact-email", + default="tyler@dzinesco.com", + show_default=True, +) +@click.option( + "--receiver-name", + default="COLORADO MEDICAL ASSISTANCE PROGRAM", + show_default=True, +) +@click.option( + "--zip-output", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="If set, zip all emitted X12 files to this path.", +) +@click.option( + "--no-clean", + is_flag=True, + default=False, + help="Don't wipe prior .x12 outputs in the pipeline subdir before writing.", +) +@click.option( + "--log-level", + default="INFO", + show_default=True, + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]), +) +def reissue_claims( + input_dir: Path, + output_root: Path, + date: str | None, + pipeline: str, + payer: str, + sender_id: str, + receiver_id: str, + submitter_name: str, + submitter_contact_name: str, + submitter_contact_email: str, + receiver_name: str, + zip_output: Path | None, + no_clean: bool, + log_level: str, +) -> None: + """Re-parse raw 837P files and emit one IG-correct single-claim X12 per claim (SP24). + + Workflow: + 1. Parse every *.x12 / *.txt / *.edi under --input-dir. + 2. Validate each parsed claim; skip claims with hard errors. + 3. Serialize each surviving claim via the IG-correct serializer. + 4. Write one X12 file per claim under + --output-root/// using HCPF-spec filenames. + 5. Optionally zip the output to --zip-output. + + Exit codes: 0 success (some per-file warnings are non-fatal); + 1 IG-correctness guard tripped or unexpected error; + 2 parse / validation failure on a file AND zero claims survived. + """ + # SP18: re-run so --log-level overrides the group default. + setup_logging(level=log_level) + + from cyclone.reissue import ( + emit_outputs, + ig_correctness_check, + parse_inputs, + write_summary_sidecar, + zip_outputs, + ) + from cyclone.parsers.payer import PayerConfig + + log = logging.getLogger("reissue-claims") + + # 0. IG-correctness guard. Fires on every invocation — cheap (one + # constant read) and the only defense against the 999 rejection + # root cause coming back via a future refactor. + if not ig_correctness_check(logger=log): + click.echo( + "REFUSING to run: PATIENT_LOOP_DEFAULT_INCLUDED is True; " + "the 2000C patient loop is forbidden when SBR02='18'. " + "Restore the IG-correct default (False) in serialize_837.py.", + err=True, + ) + sys.exit(1) + + # 1. Payer config factory. Keep this tiny — extend as more + # payer presets land in PayerConfig. + payer_factories = { + "co_medicaid": PayerConfig.co_medicaid, + "generic_837p": PayerConfig.generic_837p, + } + if payer not in payer_factories: + raise click.BadParameter( + f"Unknown payer {payer!r}. Choose from: {', '.join(payer_factories)}" + ) + payer_config = payer_factories[payer]() + + # 2. Output directory layout. + date_label = date or datetime.now().strftime("%Y-%m-%d") + out_dir = output_root / date_label / pipeline + if out_dir.exists() and not no_clean: + # Clean only the per-claim X12 files + summary; leave any + # sibling artifacts (logs, sidecars) alone. + for p in out_dir.glob("tp*.x12"): + p.unlink() + log.info("cleaned prior .x12 outputs under %s", out_dir) + + # 3. Parse → 4. Serialize → 5. Zip. + log.info("parse: input_dir=%s payer=%s", input_dir, payer) + claims, parse_errors = parse_inputs(input_dir, payer_config) + log.info( + "parse: %d claims read, %d file-level errors", + len(claims), + len(parse_errors), + ) + for src, err in parse_errors[:10]: + log.warning(" %s: %s", src, err) + if parse_errors and not claims: + click.echo( + f"PARSE FAILED: zero claims survived; first error: {parse_errors[0][1]}", + err=True, + ) + sys.exit(2) + + log.info("serialize: output_dir=%s", out_dir) + written = emit_outputs( + claims, + payer_config=payer_config, + output_dir=out_dir, + sender_id=sender_id, + receiver_id=receiver_id, + submitter_name=submitter_name, + submitter_contact_name=submitter_contact_name, + submitter_contact_email=submitter_contact_email, + receiver_name=receiver_name, + ) + + summary_path = write_summary_sidecar(claims, written, out_dir) + total_bytes = sum(p.stat().st_size for p in written) + log.info( + "serialize: %d files, %d bytes, summary=%s", + len(written), + total_bytes, + summary_path, + ) + + if zip_output: + zip_outputs(written, zip_output) + log.info("zip: %s (%d bytes)", zip_output, zip_output.stat().st_size) + + click.echo( + f"DONE files={len(written)} errors={len(parse_errors)} out={out_dir}" + ) + if parse_errors: + sys.exit(2) + + if __name__ == "__main__": main()