feat(sp24): register cyclone reissue-claims CLI subcommand

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'.
This commit is contained in:
Nora
2026-07-08 22:32:47 -06:00
parent b5b715d1c9
commit 7d257911d2
+229
View File
@@ -2050,5 +2050,234 @@ def rebill_from_835(
click.echo(f"Wrote {n} summary rows to {summary_path}") 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/<date>/<pipeline>/.
# 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 <output-root>/<date>/<pipeline>/.",
)
@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/<date>/<pipeline>/ 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__": if __name__ == "__main__":
main() main()