From dc990cfde3f188c57b9d74ca6469cd5bff6b7f36 Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 11:10:10 -0600 Subject: [PATCH] feat(sp37): `cyclone submit-batch` CLI Walks batch-*-claims/*.x12 under --ingest-dir, calls submit_file per file, prints submitted/skipped/failed counts. Exits 0 even on per-file failures (details in stdout); exits 2 on config-level errors (no clearhouse, stub mode, missing dir). This is the canonical outbound path; resubmit-rejected-claims remains for one-off cases where no DB row is wanted. --- backend/src/cyclone/cli.py | 93 ++++++++++++++++++++++++++++++++ backend/tests/test_submission.py | 13 +++++ 2 files changed, 106 insertions(+) diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index e9f3b9e..7e6b358 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -768,6 +768,99 @@ def resubmit_rejected_claims( ) +@main.command("submit-batch") +@click.option("--ingest-dir", default="ingest", + show_default=True, + help="Root dir holding batch-*-claims/ subfolders of .x12 files.") +@click.option("--actor", default="cli-submit-batch", + show_default=True, + help="Audit-log actor tag for the clearhouse.submitted events.") +@click.option("--validate/--no-validate", default=True, + help="Parse each file via parse_837 before upload.") +@click.option("--limit", type=int, default=None, + help="Stop after N files (smoke-tests).") +def submit_batch( + ingest_dir: str, + actor: str, + validate: bool, + limit: int | None, +) -> None: + """Parse → DB-write → SFTP-upload each batch-*-claims/*.x12 (SP37). + + Canonical outbound path. Mirrors ``resubmit-rejected-claims`` but + routes through ``cyclone.submission.submit_file``, which writes to + the DB before uploading. Use this as the preferred outbound path; + ``resubmit-rejected-claims`` remains for one-off cases where you + don't want a DB row (dzinesco-generated fixes not ready for + canonical tracking). + + Exit codes: 0 = run completed (even with per-file failures), 1 = + unexpected exception, 2 = config-level failure (no clearhouse, + SFTP block in stub mode, ingest-dir missing). + """ + from cyclone import db as db_mod + from cyclone.submission.core import submit_file + from cyclone.submission.result import SubmitOutcome + from cyclone.store import store as cycl_store + + db_mod.init_db() + cycl_store.ensure_clearhouse_seeded() + clearhouse = cycl_store.get_clearhouse() + if clearhouse is None: + click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True) + sys.exit(2) + sftp_block = clearhouse.sftp_block + if sftp_block.stub: + click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True) + sys.exit(2) + + root = Path(ingest_dir).resolve() + if not root.exists(): + click.echo(f"--ingest-dir does not exist: {root}", err=True) + sys.exit(2) + + files: list[Path] = [] + for batch_dir in sorted(root.glob("batch-*-claims")): + files.extend(sorted(p for p in batch_dir.glob("*.x12") + if not p.name.startswith("._"))) + if not files: + click.echo(f"No .x12 files under {root}", err=True) + sys.exit(2) + + click.echo(f"found {len(files)} files under {root}") + + submitted = 0 + skipped = 0 + failed = 0 + for i, src in enumerate(files, 1): + if limit is not None and i > limit: + break + try: + result = submit_file( + src, sftp_block=sftp_block, actor=actor, validate=validate, + ) + except Exception as exc: # noqa: BLE001 + click.echo(f"UNEXPECTED {src.name}: {exc.__class__.__name__}: {exc}", err=True) + failed += 1 + continue + if result.outcome == SubmitOutcome.SUBMITTED: + submitted += 1 + click.echo(f"submitted {src.name} batch={result.batch_id}") + elif result.outcome == SubmitOutcome.SKIPPED: + skipped += 1 + click.echo(f"skipped {src.name} (already uploaded)") + else: + failed += 1 + click.echo( + f"{result.outcome.value:<10} {src.name} {result.error or ''}", + err=True, + ) + + click.echo(f"\nsummary: submitted={submitted} skipped={skipped} failed={failed}") + # Per-file failures don't bump exit code; details in stdout. + # (Per cyclone-cli convention.) + + @main.group() def backup() -> None: """Encrypted DB backup management (SP17).""" diff --git a/backend/tests/test_submission.py b/backend/tests/test_submission.py index 635e0d4..f2a56ce 100644 --- a/backend/tests/test_submission.py +++ b/backend/tests/test_submission.py @@ -193,3 +193,16 @@ def test_submit_file_uses_default_paramiko_factory(monkeypatch): ) assert result.outcome == SubmitOutcome.SUBMITTED assert len(fake_paramiko_sftp.put_calls) == 1 + + +def test_submit_batch_cli_help(): + """`cyclone submit-batch --help` exits 0 and shows the flags.""" + import subprocess + import sys + result = subprocess.run( + [sys.executable, "-m", "cyclone.cli", "submit-batch", "--help"], + capture_output=True, text=True, + cwd=str(Path(__file__).parent.parent), + ) + assert result.returncode == 0 + assert "--ingest-dir" in result.stdout