From daf55d9a38cafae83d8b4755ad05fb6fcf923e41 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:23:53 -0600 Subject: [PATCH 01/10] docs(spec): SP24 design + plan for reissue-claims CLI + IG-correctness regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in: - cyclone.reissue subpackage (parse_inputs / emit_outputs / zip_outputs / ig_correctness_check — pure functions, no Click). - @main.command('reissue-claims') thin wrapper in cli.py. - PATIENT_LOOP_DEFAULT_INCLUDED public constant in serialize_837.py (replaces inspect.signature on the private _build_subscriber_block). - IG-correctness regression test in test_serialize_837.py pinning the constant to False. - deprecation shim at scripts/reissue_claims.py. - RUNBOOK entry under 'Operator workflows → Reissue claims'. Workflow coverage: the dzinesco July-8 batches (4 files, 358 claims) that triggered the 999 '2000C HL must be absent when 2000B SBR02=18' rejection on 2026-07-08. --- .../2026-07-08-cyclone-reissue-claims.md | 489 ++++++++++++++++++ ...026-07-08-cyclone-reissue-claims-design.md | 250 +++++++++ 2 files changed, 739 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-cyclone-reissue-claims.md create mode 100644 docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md diff --git a/docs/superpowers/plans/2026-07-08-cyclone-reissue-claims.md b/docs/superpowers/plans/2026-07-08-cyclone-reissue-claims.md new file mode 100644 index 0000000..9ee8ddb --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-cyclone-reissue-claims.md @@ -0,0 +1,489 @@ +# Reissue Claims CLI + IG-Correctness Regression Test Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move `scripts/reissue_claims.py` into a `cyclone.reissue` subpackage, expose it as `cyclone reissue-claims`, and lock in the IG-correct serializer default via a regression test. +**Architecture:** `cyclone/reissue/core.py` (pure functions, no Click) + a thin `@main.command("reissue-claims")` wrapper in `cli.py`. The IG-correctness default is promoted from `inspect.signature` introspection of `_build_subscriber_block` to a public `PATIENT_LOOP_DEFAULT_INCLUDED` constant in `serialize_837.py`, exposed via `__all__` and read by the guard. +**Tech Stack:** Python 3.11, Click 8, pytest, `cyclone.parsers.parse_837` + `cyclone.parsers.serialize_837` + `cyclone.edi.filenames.build_outbound_filename`. +**Spec:** [`docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`](../specs/2026-07-08-cyclone-reissue-claims-design.md) + +--- + +## File structure + +``` +backend/src/cyclone/ + reissue/ ← new subpackage + __init__.py re-exports the public surface + core.py parse_inputs / emit_outputs / + zip_outputs / ig_correctness_check + cli.py ← registers @main.command("reissue-claims") + (~50 LOC, calls into cyclone.reissue.core) + parsers/serialize_837.py ← adds PATIENT_LOOP_DEFAULT_INCLUDED constant + in __all__; _build_subscriber_block reads + from it instead of hardcoding `False` + +backend/tests/ + test_reissue_core.py ← new (pure-unit) + test_cli_reissue_claims.py ← new (CliRunner smoke) + test_serialize_837.py ← adds test_serialize_837_patient_loop_default_is_false + +scripts/ + reissue_claims.py ← 1-line deprecation shim; prints WARNING + at import time, delegates to cyclone.reissue.core + +docs/ + RUNBOOK.md ← adds "Operator workflows → Reissue claims" + section pointing at `cyclone reissue-claims` + +/tmp/refactor-cyclone.md ← progress tracker (already created) +``` + +## Task 0: branch + worktree + +- [ ] Branch `sp24-reissue-claims` from `main` (no worktree — the user is + already in the main checkout; the branch is local). +- [ ] Verify `git log --oneline main -1` shows + `ea68ae8 feat(sp41): add CYCLONE_EDIFABRIC_DISABLED=1 bypass hook`. +- [ ] First commit on the branch: `docs(spec): design for reissue-claims CLI + + IG-correctness regression test (Step 0)` (commits the spec file). + +## Task 1: promote `PATIENT_LOOP_DEFAULT_INCLUDED` to a public constant + +- [ ] In `backend/src/cyclone/parsers/serialize_837.py`, add the constant + after the imports (around line 65): + + ```python + #: Default value for ``_build_subscriber_block(include_patient_loop=...)``. + #: Per X12 005010X222A1, the 2000C Patient Hierarchical Level + #: (HL*3 → PAT → NM1*QC) is REQUIRED only when Patient != Subscriber + #: (i.e. SBR02 != "18"). When SBR02 == "18" (Self), the 2000C loop + #: MUST be absent. The serializer's default of False matches the + #: CO-Medicaid IHSS workflow and is the IG-correct shape. + #: + #: The IG-correctness regression test + #: ``test_serialize_837_patient_loop_default_is_false`` pins this + #: value; flipping it requires an explicit PR-level discussion. + PATIENT_LOOP_DEFAULT_INCLUDED: bool = False + ``` + +- [ ] Replace the existing hardcoded `include_patient_loop: bool = False` + default on `_build_subscriber_block(...)` with + `include_patient_loop: bool = PATIENT_LOOP_DEFAULT_INCLUDED`. +- [ ] Add `PATIENT_LOOP_DEFAULT_INCLUDED` to the module's `__all__` + tuple (find the existing `__all__` near the top of the file; + if missing, create one with the public functions plus the + constant). +- [ ] **Live-test:** `cd backend && .venv/bin/pytest tests/test_serialize_837.py -v` + (56 cases should still pass; the default behavior is unchanged). +- [ ] **Autoreview:** no flake, no lint regressions. Run + `cd backend && .venv/bin/python -c "from cyclone.parsers.serialize_837 import PATIENT_LOOP_DEFAULT_INCLUDED; print(PATIENT_LOOP_DEFAULT_INCLUDED)"` + and confirm it prints `False`. +- [ ] Commit: `feat(sp24): expose PATIENT_LOOP_DEFAULT_INCLUDED constant in serialize_837`. + +## Task 2: create `cyclone.reissue` subpackage + +- [ ] Create `backend/src/cyclone/reissue/__init__.py` with re-exports: + + ```python + """cyclone.reissue — offline 837P re-emission workflow. + + See :mod:`cyclone.reissue.core` for the public functions. + """ + from cyclone.reissue.core import ( + parse_inputs, + emit_outputs, + zip_outputs, + ig_correctness_check, + ) + + __all__ = [ + "parse_inputs", + "emit_outputs", + "zip_outputs", + "ig_correctness_check", + ] + ``` + +- [ ] Create `backend/src/cyclone/reissue/core.py` mirroring the script's + logic but as pure functions. The signatures match the script: + + ```python + def ig_correctness_check(*, logger: logging.Logger | None = None) -> bool: + """Return True if the serializer's IG-correct patient-loop default is in place.""" + ... + + def parse_inputs(input_dir: Path, payer_config) -> tuple[list[ClaimOutput], list[tuple[Path, str]]]: + """Parse every *.x12 / *.txt / *.edi under input_dir. Tolerates per-file failures.""" + ... + + def emit_outputs( + claims: list[ClaimOutput], + *, + payer_config, + output_dir: Path, + sender_id: str, + receiver_id: str, + submitter_name: str, + submitter_contact_name: str, + submitter_contact_email: str, + receiver_name: str, + ) -> list[Path]: + """Emit one X12 file per claim under output_dir using HCPF-spec filenames.""" + ... + + def zip_outputs(files: list[Path], zip_path: Path) -> None: + """Zip the per-claim X12 files into a single flat archive with integrity check.""" + ... + ``` + + Implementation details: + - `ig_correctness_check` reads + `cyclone.parsers.serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED`; + returns True iff it is False. Logs a WARNING (via the optional + `logger` argument, or `logging.getLogger(__name__)`) if False + is not what the caller expected. + - `parse_inputs` skips AppleDouble (`._*`) files, tolerates + per-file parse errors (returns them as the second tuple + element), and skips claims with hard validation errors. + - `emit_outputs` uses `datetime.now(tz=ZoneInfo("America/Denver"))` + for the base timestamp + `timedelta(milliseconds=i)` for + per-claim uniqueness. Sorts claims by `claim_id`. Writes + `_serialize_summary.json` next to the files (caller does this). + - `zip_outputs` uses `zipfile.ZIP_DEFLATED` and runs + `ZipFile.testzip()` after writing; raises `RuntimeError` if + any entry is corrupt. + +- [ ] **Live-test:** + `cd backend && .venv/bin/python -c "from cyclone.reissue import parse_inputs, emit_outputs, zip_outputs, ig_correctness_check; print('ok')"` + should print `ok`. +- [ ] Commit: `feat(sp24): add cyclone.reissue subpackage with pure core functions`. + +## Task 3: write `test_reissue_core.py` (pure-unit) + +- [ ] Create `backend/tests/test_reissue_core.py` with: + - `test_ig_correctness_check_returns_true_when_default_is_false` + (default state — guard silent). + - `test_ig_correctness_check_returns_false_when_default_flipped` + (monkeypatches `PATIENT_LOOP_DEFAULT_INCLUDED = True` via + `monkeypatch.setattr`; assert False). + - `test_parse_inputs_skips_apple_double_metadata` + (creates `tmp_path/in/foo.x12` and `tmp_path/in/._foo.x12`; + asserts only foo.x12 is parsed). + - `test_parse_inputs_tolerates_per_file_failures` + (creates one valid + one malformed file; asserts the malformed + one is in the errors list and the valid one is in claims). + - `test_emit_outputs_writes_hcpf_spec_filenames` + (parses a minimal 837P, emits to `tmp_path/out`, asserts the + filenames match `tp*-837P-*-1of1.x12`). + - `test_emit_outputs_sorts_claims_by_claim_id` + (two-claim fixture, asserts the filename suffixes are sorted + by claim_id). + - `test_zip_outputs_round_trip_integrity` + (writes a tiny zip, asserts `ZipFile.testzip()` returns None). + +- [ ] **Live-test:** `cd backend && .venv/bin/pytest tests/test_reissue_core.py -v` + (all new cases should pass). +- [ ] **Autoreview:** confirm no test relies on network, no test + reaches into `docs/prodfiles/`, no test uses wall-clock sleep. +- [ ] Commit: `test(sp24): pure-unit tests for cyclone.reissue.core`. + +## Task 4: register `@main.command("reissue-claims")` in cli.py + +- [ ] Append the CLI subcommand to `backend/src/cyclone/cli.py` after + the existing `rebill-from-835` block. The Click signature follows + §2 Decision 3 of the spec: + + ```python + @main.command("reissue-claims") + @click.option("--input-dir", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True) + @click.option("--output-root", type=click.Path(file_okay=False, path_type=Path), + default=Path("dev/rebills"), show_default=True) + @click.option("--date", default=None, help="YYYY-MM-DD subfolder under output-root (default: today MT).") + @click.option("--pipeline", default="initial", show_default=True) + @click.option("--payer", default="co_medicaid", show_default=True) + @click.option("--sender-id", default="11525703", show_default=True) + @click.option("--receiver-id", default="COMEDASSISTPROG", show_default=True) + @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) + @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 hard-error claims. + 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; 1 IG-correctness guard tripped or unexpected + error; 2 parse / validation failure on a file (zero claims survived). + """ + setup_logging(level=log_level) + import logging as _logging + from cyclone.reissue import ( + parse_inputs, emit_outputs, zip_outputs, ig_correctness_check, + ) + from cyclone.parsers.payer import PayerConfig + + log = _logging.getLogger("reissue-claims") + 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) + + payer_factories = {"co_medicaid": PayerConfig.co_medicaid} + if payer not in payer_factories: + raise click.BadParameter(f"Unknown payer {payer!r}. Choose from: {', '.join(payer_factories)}") + payer_config = payer_factories[payer]() + + 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: + for p in out_dir.glob("tp*.x12"): + p.unlink() + log.info("cleaned prior .x12 outputs under %s", out_dir) + + 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, + ) + + import json as _json + summary_path = out_dir / "_serialize_summary.json" + summary = [ + {"claim_id": c.claim_id, "output_file": str(p), "byte_size": p.stat().st_size} + for c, p in zip(sorted(claims, key=lambda c: c.claim_id), written) + ] + summary_path.write_text(_json.dumps(summary, indent=2), encoding="utf-8") + log.info("serialize: %d files, %d bytes, summary=%s", + len(written), sum(p.stat().st_size for p in written), 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) + ``` + +- [ ] **Live-test:** + `cd backend && .venv/bin/python -m cyclone.cli reissue-claims --help` + should print the help with the long-form flags listed in §2 Decision 3. +- [ ] **Live-test end-to-end:** + `cd backend && .venv/bin/python -m cyclone.cli reissue-claims \ + --input-dir /home/tyler/dev/cyclone/july8billing \ + --date 2026-07-08 \ + --output-root /tmp/sp24-smoke \ + --zip-output /tmp/sp24-smoke.zip` + should produce `/tmp/sp24-smoke/2026-07-08/initial/` with 358 files + and `/tmp/sp24-smoke.zip` with 358 entries. +- [ ] **Autoreview:** confirm the help text is readable, exit codes + behave per the spec, and no click.UsageError leaks a + CycloneParseError stack trace. +- [ ] Commit: `feat(sp24): register cyclone reissue-claims CLI subcommand`. + +## Task 5: write `test_cli_reissue_claims.py` (CliRunner smoke) + +- [ ] Create `backend/tests/test_cli_reissue_claims.py` with: + - `test_reissue_claims_help_renders` — `CliRunner().invoke(main, + ["reissue-claims", "--help"])` exits 0 and contains + `--input-dir`, `--output-root`, `--zip-output`, + `--no-clean`. + - `test_reissue_claims_happy_path` — writes a minimal 837P fixture + to `tmp_path/in`, runs the CLI with `--input-dir tmp_path/in + --output-root tmp_path/out --date 2026-07-08`, asserts exit 0 + and 1+ .x12 file in `tmp_path/out/2026-07-08/initial/`. + - `test_reissue_claims_empty_input_exits_2` — empty input dir, + asserts exit 2 with "no claims" / "PARSE FAILED" in stderr. + - `test_reissue_claims_ig_correctness_guard_fires` — + `monkeypatch.setattr("cyclone.parsers.serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED", + True, raising=False)`, asserts exit 1 with `REFUSING to run` + in captured output. Use `monkeypatch.undo()` via a + `with`-block or `addfinalizer` so other tests are unaffected. + - `test_reissue_claims_zip_output_round_trip` — happy path + zip, + asserts the zip file exists and `ZipFile(path).testzip() is None`. + +- [ ] **Live-test:** + `cd backend && .venv/bin/pytest tests/test_cli_reissue_claims.py -v` + (all new cases should pass). +- [ ] Commit: `test(sp24): CliRunner smoke tests for reissue-claims`. + +## Task 6: write the IG-correctness regression test in `test_serialize_837.py` + +- [ ] Add a new test to `backend/tests/test_serialize_837.py`: + + ```python + def test_serialize_837_patient_loop_default_is_false(): + """SP24: the IG-correct default for include_patient_loop is False. + + Per X12 005010X222A1, the 2000C Patient Hierarchical Level + (HL*3 → PAT → NM1*QC) is REQUIRED only when Patient != Subscriber. + For SBR02 == "18" (Self-pay, the CO Medicaid IHSS workflow), + the 2000C loop MUST be absent. Flipping this default back to + True reintroduces the Edifabric 999 "2000C HL must be absent + when 2000B SBR02 = '18'" rejection — see the 2026-07-08 audit. + """ + from cyclone.parsers.serialize_837 import ( + PATIENT_LOOP_DEFAULT_INCLUDED, + _build_subscriber_block, + ) + from cyclone.parsers.models import Subscriber + + assert PATIENT_LOOP_DEFAULT_INCLUDED is False, ( + f"PATIENT_LOOP_DEFAULT_INCLUDED must be False (got {PATIENT_LOOP_DEFAULT_INCLUDED!r}); " + "the IG-correct serializer shape for SBR02='18' claims is 2000C-absent." + ) + + # Construct a minimal subscriber + payer fixture and call + # _build_subscriber_block without the kwarg; verify no HL*3 + # segment appears in the output. + from datetime import date + sub = Subscriber( + member_id="TEST123", + first_name="Test", last_name="Patient", + dob=date(1990, 1, 1), gender="F", + ) + out = _build_subscriber_block(sub, "MC") + assert not any(seg.startswith("HL*3") for seg in out), ( + f"HL*3 emitted with the IG-correct default; segments={out}" + ) + ``` + +- [ ] **Live-test:** `cd backend && .venv/bin/pytest + tests/test_serialize_837.py::test_serialize_837_patient_loop_default_is_false -v` + passes. +- [ ] **Autoreview:** try flipping the constant to `True` locally, + re-run the test, confirm it fails with the assertion message; + restore the constant. +- [ ] Commit: `test(sp24): regression test pinning PATIENT_LOOP_DEFAULT_INCLUDED to False`. + +## Task 7: deprecation shim for `scripts/reissue_claims.py` + +- [ ] Replace `scripts/reissue_claims.py` with a 1-line shim: + + ```python + """DEPRECATED shim — use `cyclone reissue-claims` instead. + + See `docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md` + for the canonical entry point. + """ + import warnings + warnings.warn( + "`scripts/reissue_claims.py` is deprecated; use " + "`cyclone reissue-claims` (the Click subcommand in cli.py) " + "for the canonical offline reissue workflow. This shim will be " + "removed in a follow-up increment.", + DeprecationWarning, + stacklevel=2, + ) + + from cyclone.cli import main + import sys + if __name__ == "__main__": + # Strip the script argv prefix so `python scripts/reissue_claims.py --help` + # behaves like `cyclone reissue-claims --help`. The Click group + # expects the subcommand as argv[1]. + sys.exit(main()) + ``` + +- [ ] **Live-test:** + `python scripts/reissue_claims.py --help` prints the canonical + Click help for the `cyclone` group (and emits a DeprecationWarning). +- [ [ ] Commit: `refactor(sp24): deprecation shim for scripts/reissue_claims.py`. + +## Task 8: `docs/RUNBOOK.md` operator-workflow entry + +- [ ] Add a section under "Operator workflows" pointing at the new + CLI subcommand with a worked example: + + ```markdown + ### Reissue claims (SP24) + + The canonical workflow to rebuild a batch of 837P files into + single-claim, IG-correct X12 files: + + ```bash + cd backend && .venv/bin/python -m cyclone.cli reissue-claims \ + --input-dir \ + --date 2026-07-08 \ + --output-root dev/rebills \ + --zip-output xxxclaims.zip + ``` + + Exit codes: 0 = success; 1 = IG-correctness guard tripped or + unexpected error; 2 = parse / validation failure on a file. + ``` + +- [ ] Commit: `docs(sp24): RUNBOOK entry for cyclone reissue-claims`. + +## Task 9: autoreview (the merge gate) + +- [ ] `cd backend && .venv/bin/pytest -q` — full backend suite passes. +- [ ] `cd backend && .venv/bin/pytest tests/test_serialize_837.py tests/test_reissue_core.py tests/test_cli_reissue_claims.py -v` + — all new + adjacent tests pass. +- [ ] `cd backend && .venv/bin/python -m cyclone.cli reissue-claims --help` + — CLI help renders cleanly. +- [ ] End-to-end live-test against `/home/tyler/dev/cyclone/july8billing/` + produces 358 files + a valid zip (per acceptance criterion #2 + #3). +- [ ] No `git status --short` shows any unexpected untracked files. +- [ ] No regressions in the existing `rebill-from-835` or + `submit-batch` CLIs (they don't import from `cyclone.reissue`). + +## Task 10: merge to `main` + +- [ ] `git checkout main && git merge --no-ff --no-squash --no-rebase + sp24-reissue-claims -m "merge: SP24 reissue-claims CLI + IG-correctness + regression test into main"` +- [ ] `git log --oneline main -1` shows the merge commit with the + expected subject. +- [ ] `git push origin main` only after the operator has reviewed + and approved the merge (per cyclone-spec — the merge commit is + the audit trail; the operator must approve). \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md b/docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md new file mode 100644 index 0000000..e6ecb60 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md @@ -0,0 +1,250 @@ +# Sub-project 24 — Reissue Claims CLI + IG-Correctness Regression Test: Design Spec + +**Date:** 2026-07-08 +**Status:** Draft, awaiting user sign-off +**Branch:** `sp24-reissue-claims` +**Aesthetic direction:** No new UI (operator-only CLI; the workflow is the on-call page already documented in `docs/RUNBOOK.md`). + +## 1. Scope + +On 2026-07-08 the operator rebuilt the four dzinesco July-8 837P batches +into 358 single-claim, IG-correct X12 files (zipped at `/home/tyler/dev/cyclone/xxxclaims.zip`) +using a one-shot script at `scripts/reissue_claims.py`. The script solved +an immediate crisis: an Edifabric 999 had rejected the original four +multi-claim files because the serializer was unconditionally emitting the +Loop 2000C patient hierarchy (`HL*3 → PAT*01 → NM1*QC`) when +`SBR02 == "18"` (Self-pay), which is forbidden by X12 005010X222A1. + +The serializer default was fixed in `serialize_837.py` (see commit +`ee1a397` on `sp41-inwindow-rebill-pipeline`), but the workflow that +proved the fix — parse + per-claim re-serialize + HCPF-spec filename + +zip — still lives in a script the rest of the cyclone pipeline doesn't +know about. SP24 brings that workflow into the canonical CLI surface as +`cyclone reissue-claims`, behind a regression test that locks in the +IG-correct serializer default forever so a future code change cannot +silently reintroduce the same 999 rejection. + +**In scope:** + +- A new subpackage `backend/src/cyclone/reissue/` containing the pure + functions `parse_inputs`, `emit_outputs`, `zip_outputs`, and + `ig_correctness_check`. No Click imports inside the core module — + it accepts plain Python types so the CLI is a thin wrapper and the + functions are unit-testable without `click.testing.CliRunner`. +- A new CLI subcommand `cyclone reissue-claims` registered against + the existing `@main` Click group in `backend/src/cyclone/cli.py`, + following the conventions in `cyclone-cli` (long-form flags, + exit codes 0/1/2, `click.echo(..., err=True)` for errors). +- A new module-level public constant in `serialize_837.py`: + `PATIENT_LOOP_DEFAULT_INCLUDED = False`, exported in the module's + `__all__`. The default is read by the IG-correctness guard instead + of `inspect.signature`-ing a private helper, so a future rename of + `_build_subscriber_block` cannot silently break the guard. +- A regression test + `test_serialize_837_patient_loop_default_is_false` in + `backend/tests/test_serialize_837.py` that asserts the constant is + `False` and that calling `_build_subscriber_block` with no + `include_patient_loop` kwarg emits `HL*2 child_count=0` (no `HL*3`). +- Smoke tests under `backend/tests/test_cli_reissue_claims.py` covering + the help screen, an empty-input path, a happy-path re-issue, and + the IG-correctness guard firing when the constant is monkeypatched. +- Pure-unit tests under `backend/tests/test_reissue_core.py` covering + parse_inputs (AppleDouble filter, per-file failure tolerance), + emit_outputs (HCPF-spec filenames, unique-per-millisecond, sorted + by claim_id), zip_outputs (round-trip integrity check), and + ig_correctness_check (fires on mismatch, silent on match). +- A deprecation shim at `scripts/reissue_claims.py` that re-exports + the new CLI subcommand as a thin wrapper with a WARNING printed at + import time telling the operator `cyclone reissue-claims` is the + canonical entry. The shim is removed in a follow-up increment. +- A `docs/RUNBOOK.md` entry under "Operator workflows" pointing at + the new CLI subcommand with a worked example against the dzinesco + July-8 billing layout. + +**Out of scope:** + +- New UI exposure for the reissue workflow. The CLI is the operator's + on-call surface today; a dashboard widget is a future increment. +- A bulk-ingest pipeline for the reissue path. The operator's flow is + point-in-time ("rebuild yesterday's 358 files from this batch"), + not a streaming one. `cyclone bulk-ingest` already handles the + streaming case for inbound files; the outbound reissue path stays + explicit. +- The SFTP-upload step. `reissue-claims` writes files to a local + directory (and optionally zips them). The operator moves the + resulting zip to Gainwell via their SFTP client per the + "Manual SFTP mode" posture documented in `docs/RUNBOOK.md`. +- An automatic regeneration of historical files. The four dzinesco + July-8 batches are the immediate operator concern; running + `reissue-claims` against older batches is a deliberate operator + action with the explicit `--input-dir` path. +- Edifabric validation. The original script did not gate through + Edifabric (the July-8 files were validated by the operator's manual + 999 audit). A follow-up SP wires the SP41 SP40 Edifabric gate into + the reissue path; this increment keeps the surface minimal. + +## 2. Decisions (locked during brainstorming) + +1. **Subpackage layout, not a single `cli.py` block.** The other + cross-cutting workflows (`cyclone.rebill`, `cyclone.submission`, + `cyclone.bulk_ingest`) follow the `cyclone//` layout. Putting + the reissue logic inside `cli.py` directly — like + `rebill-from-835` does — would make `cli.py` grow past 2,200 LOC + (already at 2,054) and force the unit tests to live in the + CLI-flavored `test_cli_*.py` file. The subpackage layout keeps the + pure functions unit-testable without Click, matches the rest of the + codebase, and lets `cli.py` stay a thin registration surface. + +2. **Public `PATIENT_LOOP_DEFAULT_INCLUDED` constant, not + `inspect.signature` introspection.** The original script + introspected the private `_build_subscriber_block` helper via + `inspect.signature`, which is fragile against rename / refactor. + Promoting the default to a named public constant in + `serialize_837.py` and exporting it in `__all__` gives the guard a + stable handle that survives any future refactor of the helper. + +3. **`cyclone reissue-claims` long-form CLI flags.** `--input-dir`, + `--output-root`, `--date`, `--pipeline`, `--payer`, + `--sender-id`, `--receiver-id`, `--submitter-name`, + `--submitter-contact-name`, `--submitter-contact-email`, + `--receiver-name`, `--zip-output`, `--no-clean`, `--log-level`. + Long-form, not positional, so the destructive surface is + self-documenting. Defaults match the dzinesco canonical sender + (`11525703`) / receiver (`COMEDASSISTPROG`) so the typical + invocation is just `cyclone reissue-claims --input-dir `. + +4. **IG-correctness guard fires on every CLI invocation.** Not gated + behind a `--strict` flag — the constant is part of the canonical + contract the spec requires. The guard's only job is to fail loud if + the constant flips to `True` in a future refactor; running it on + every call is the cheapest possible regression test. + +5. **Exit codes 0 / 1 / 2 mirror the existing CLI conventions.** + `0` = success (all files written, zip optional); `1` = IG-correctness + guard tripped OR a configuration / unexpected error; `2` = + parse / validation failure on a file (per-file failures reported + in stdout, but other files continue; only exits 2 if **zero** + claims survived). + +6. **Deprecation shim, not deletion.** The original + `scripts/reissue_claims.py` stays as a 1-line shim that prints a + WARNING at import time and re-exports the new module's `main()` + function. The shim is removed in a follow-up increment once the + operator's muscle memory has switched over. + +7. **The CLI subcommand does NOT call `cyclone.submission.submit_file`.** + Reissue is an offline-rebuild workflow; it produces local X12 + files for the operator to inspect / upload manually. Wiring the + SFTP path would conflate "regenerate clean files" with + "submit them to Gainwell", and the operator wants the inspection + step in between. + +8. **HCPF-spec filename unchanged.** The canonical + `cyclone.edi.filenames.build_outbound_filename(sender_id, "837P")` + produces the file names already in use (`tp11525703-837P-...-1of1.x12`); + no change to the filename format. Per-claim 1 ms timestamp offsets + (via `datetime.now(tz=ZoneInfo("America/Denver"))` + per-claim + `timedelta(milliseconds=i)`) guarantee uniqueness within a batch. + +## 3. Threat model + +This increment does not change the auth boundary or the network posture. +The threat model is unchanged from SP24 (this spec): the auth boundary +is HTTP (login required, bcrypt + HttpOnly session cookie); the +file-system threat is local-only and handled by SQLCipher at rest and +the macOS Keychain. The CLI subcommand runs against the operator's +local environment only — there is no HTTP surface, no auth gate, no +network call. The IG-correctness guard is a static check against the +serializer's documented default; it does not change the security +posture. + +The regression test in `test_serialize_837.py` is structural: it pins +a constant to a specific value. If a future refactor flips the constant +back to `True` (the broken pattern), the test fails, the CLI guard +fires, and the operator sees a clear `REFUSING to run` log line before +any X12 byte is emitted. + +## 4. Test impact + +Following the `cyclone-tests` conventions: + +- **Unit tests (pure-unit):** + - `backend/tests/test_reissue_core.py` — covers `parse_inputs` + (AppleDouble metadata filter, per-file failure tolerance, claim + validation error path), `emit_outputs` (HCPF-spec filename + format, sorted by `claim_id`, per-claim unique timestamps, + directory creation), `zip_outputs` (round-trip integrity via + `testzip()`), and `ig_correctness_check` (fires on mismatch, + silent on match). Each test uses `tmp_path` + a fixture + `*.x12` file containing a minimal valid 837P — no prodfiles + dropped into `fixtures/`. + - `backend/tests/test_serialize_837.py` — adds + `test_serialize_837_patient_loop_default_is_false` asserting + that `serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED is False` and + that calling `_build_subscriber_block` with no kwargs emits + `HL*2` with `child_count = 0` (no `HL*3`). This test is the + long-term regression guard; flipping it should require an + explicit PR-level discussion. +- **Integration tests (CLI smoke):** + - `backend/tests/test_cli_reissue_claims.py` — `CliRunner`-based + smoke tests for `cyclone reissue-claims`. Covers: + - `--help` exits 0 with the expected long-form flags listed. + - happy path: a single-file input dir produces the expected + number of output files under `tmp_path/out`. + - empty input dir: exits 2 with a clear "no claims found" message. + - IG-correctness guard: monkeypatches + `serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED` to `True`, asserts + the CLI exits 1 with the `REFUSING to run` log line in the + captured stderr. +- **Fixture additions:** none. The CLI smoke test synthesizes a + minimal 837P file inline via `parse_837` round-tripping a stub + `ClaimOutput`. No prodfiles are dropped into `fixtures/`. +- **Frontend tests:** none (no UI in this increment). +- **Live-test smoke:** a canonical end-to-end run against + `/home/tyler/dev/cyclone/july8billing/` to confirm the CLI produces + the same 358 files + zip as the original script. The smoke command + is documented at the top of `/tmp/refactor-cyclone.md`. + +## 5. Spec & plan anchors + +- **Spec anchor (this file):** + `docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md` +- **Plan anchor:** `docs/superpowers/plans/2026-07-08-cyclone-reissue-claims.md` +- **Related serializer fix:** commit `ee1a397` on + `sp41-inwindow-rebill-pipeline` (already merged to `main`), + which flipped the `_build_subscriber_block.include_patient_loop` + default from `True` to `False` and updated the docstring. +- **Original operator workflow:** `scripts/reissue_claims.py` (the + one-shot script this increment supersedes). +- **Edifabric 999 that surfaced the bug:** `/tmp/ig_check.log` (the + operator's 999 audit reference for the IG rule). + +## 6. Acceptance criteria + +1. `cyclone reissue-claims --help` exits 0 and lists the long-form + flags listed in §2 Decision 3. +2. `cyclone reissue-claims --input-dir /home/tyler/dev/cyclone/july8billing --date 2026-07-08 --output-root /tmp/sp24-acceptance-out` + produces exactly 358 `.x12` files under + `/tmp/sp24-acceptance-out/2026-07-08/initial/`, plus a + `_serialize_summary.json` sidecar with one row per file. +3. `cyclone reissue-claims --input-dir ... --zip-output /tmp/sp24-acceptance.zip` + produces a zip whose `testzip()` returns `None` (integrity OK) + and which contains exactly 358 entries. +4. The IG-correctness guard exits 1 with the `REFUSING to run` log + line when the constant is monkeypatched to `True`. Verified by the + `test_cli_reissue_claims.py` smoke test. +5. `pytest backend/tests/test_reissue_core.py -v` exits 0 with all + cases passing. +6. `pytest backend/tests/test_cli_reissue_claims.py -v` exits 0 with + all cases passing. +7. `pytest backend/tests/test_serialize_837.py -v` exits 0 with the + new `test_serialize_837_patient_loop_default_is_false` case + passing alongside the existing 56 cases. +8. `pytest` (full backend suite) exits 0; no regressions. +9. The CLI runs without requiring a Keychain entry or DB connection — + `reissue-claims` is a pure parser + serializer + filesystem + workflow. This matches the original script's posture. +10. After the merge to `main`, `scripts/reissue_claims.py` is a + 1-line shim that prints a WARNING at import time telling the + operator `cyclone reissue-claims` is the canonical entry. The + shim does NOT duplicate the argparse surface. \ No newline at end of file From 9e422fdadb0bb84bffd929faa4be93adf346af59 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:27:21 -0600 Subject: [PATCH 02/10] feat(sp24): expose PATIENT_LOOP_DEFAULT_INCLUDED constant in serialize_837 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the default value of _build_subscriber_block(include_patient_loop=...) from a hardcoded True (the broken IG-incorrect state that emitted HL*3 / PAT*01 / NM1*QC for SBR02='18' claims) to a public module-level constant PATIENT_LOOP_DEFAULT_INCLUDED = False (the IG-correct state). Per X12 005010X222A1, the 2000C Patient Hierarchical Level MUST be absent when SBR02 == '18' (Self-pay, the CO-Medicaid IHSS workflow). The previous default of True emitted the forbidden 2000C block on every self-pay claim, which Edifabric rejected with '2000C HL must be absent when 2000B SBR02 = "18"'. Also adds __all__ to the module so PATIENT_LOOP_DEFAULT_INCLUDED is documented as part of the public surface. The IG-correctness guard (see cyclone.reissue.ig_correctness_check in the next commit) reads this constant rather than inspect.signature'ing _build_subscriber_block — that handle survives any future refactor of the helper. --- backend/src/cyclone/parsers/serialize_837.py | 26 +++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/src/cyclone/parsers/serialize_837.py b/backend/src/cyclone/parsers/serialize_837.py index e3a2ba6..fc2d3c9 100644 --- a/backend/src/cyclone/parsers/serialize_837.py +++ b/backend/src/cyclone/parsers/serialize_837.py @@ -52,6 +52,14 @@ from types import SimpleNamespace from cyclone.parsers.models import ClaimOutput +__all__ = [ + "PATIENT_LOOP_DEFAULT_INCLUDED", + "SerializeError", + "serialize_837", + "serialize_837_for_resubmit", + "serialize_member_week_batch", +] + _SEG = "~" _ELEM = "*" _ISA_COMPONENT_SEPARATOR = ":" @@ -64,6 +72,22 @@ class SerializeError(Exception): """Raised when a claim cannot be serialized.""" +#: Default value for ``_build_subscriber_block(include_patient_loop=...)``. +#: +#: Per X12 005010X222A1, the 2000C Patient Hierarchical Level +#: (``HL*3 → PAT → NM1*QC``) is REQUIRED only when Patient != Subscriber +#: (i.e. ``SBR02 != "18"``). When ``SBR02 == "18"`` (Self-pay, the +#: CO-Medicaid IHSS workflow), the 2000C loop MUST be absent — +#: otherwise Edifabric / pyX12 reject the file with +#: ``2000C HL must be absent when 2000B SBR02 = "18"``. +#: +#: The regression test +#: ``tests/test_serialize_837.py::test_serialize_837_patient_loop_default_is_false`` +#: pins this value to ``False``. Flipping it back to ``True`` requires +#: an explicit PR-level discussion (SP24 2026-07-08). +PATIENT_LOOP_DEFAULT_INCLUDED: bool = False + + # --------------------------------------------------------------------------- # Envelope helpers # --------------------------------------------------------------------------- @@ -502,7 +526,7 @@ def _build_subscriber_block( subscriber, claim_filing_indicator_code: str | None, payer=None, - include_patient_loop: bool = True, + include_patient_loop: bool = PATIENT_LOOP_DEFAULT_INCLUDED, ) -> list[str]: """Loop 2000B (HL*2) → SBR → 2010BA (NM1*IL) → 2010BB (NM1*PR). From df3e5e401d3b978910ed4a7d3948736fba00622b Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:28:59 -0600 Subject: [PATCH 03/10] feat(sp24): add cyclone.reissue subpackage with pure core functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script at scripts/reissue_claims.py was the operator's ad-hoc workflow for rebuilding the 2026-07-08 dzinesco July-8 batches into 358 IG-correct single-claim X12 files. This commit lifts that workflow into the cyclone namespace as cyclone.reissue — a pure package with no Click imports inside. Public surface (see cyclone/reissue/__init__.py): - parse_inputs(input_dir, payer_config) -> (claims, errors) AppleDouble-aware glob; per-file failure tolerance. - emit_outputs(claims, ..., output_dir, ...) -> list[Path] HCPF-spec filenames, per-claim 1ms offsets for uniqueness. - write_summary_sidecar(claims, written, output_dir) -> Path Operator-facing _serialize_summary.json sidecar. - zip_outputs(files, zip_path) -> None zipfile.ZIP_DEFLATED + testzip() integrity check. - ig_correctness_check(logger=None) -> bool Verifies serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED is False. The CLI wrapper (cyclone reissue-claims) lands in the next commit; this commit is just the pure layer so the unit tests in test_reissue_core.py can exercise the functions without a CliRunner. --- backend/src/cyclone/reissue/__init__.py | 43 +++++ backend/src/cyclone/reissue/core.py | 237 ++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 backend/src/cyclone/reissue/__init__.py create mode 100644 backend/src/cyclone/reissue/core.py diff --git a/backend/src/cyclone/reissue/__init__.py b/backend/src/cyclone/reissue/__init__.py new file mode 100644 index 0000000..1daa64b --- /dev/null +++ b/backend/src/cyclone/reissue/__init__.py @@ -0,0 +1,43 @@ +"""cyclone.reissue — offline 837P re-emission workflow. + +Pure functions for parsing raw 837P files into ``ClaimOutput`` Pydantic +models and re-emitting one IG-correct single-claim X12 file per claim. +No Click imports inside :mod:`cyclone.reissue.core`; the CLI wrapper at +:mod:`cyclone.cli` is a thin shell over these functions. + +Public surface: + +- :func:`parse_inputs` — parse every ``*.x12`` / ``*.txt`` / ``*.edi`` + under a directory; tolerates per-file failures and skips claims with + hard validation errors. +- :func:`emit_outputs` — write one X12 per claim to ``output_dir`` + using HCPF-spec filenames; per-claim 1 ms timestamp offsets guarantee + unique filenames within a batch. +- :func:`write_summary_sidecar` — write a ``_serialize_summary.json`` + sidecar with one row per emitted file (operator audit trail). +- :func:`zip_outputs` — zip the per-claim X12 files into a single flat + archive with a ``testzip()`` integrity check. +- :func:`ig_correctness_check` — verify the serializer's + ``PATIENT_LOOP_DEFAULT_INCLUDED`` is ``False`` (the IG-correct + shape for SBR02 == "18" claims). See + ``docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`` + for the SP24 rationale. + +See `scripts/reissue_claims.py` for the deprecation shim; the +canonical entry point is ``cyclone reissue-claims`` (SP24). +""" +from cyclone.reissue.core import ( + emit_outputs, + ig_correctness_check, + parse_inputs, + write_summary_sidecar, + zip_outputs, +) + +__all__ = [ + "parse_inputs", + "emit_outputs", + "write_summary_sidecar", + "zip_outputs", + "ig_correctness_check", +] \ No newline at end of file diff --git a/backend/src/cyclone/reissue/core.py b/backend/src/cyclone/reissue/core.py new file mode 100644 index 0000000..f1c8c10 --- /dev/null +++ b/backend/src/cyclone/reissue/core.py @@ -0,0 +1,237 @@ +"""SP24 — pure functions for the offline 837P reissue workflow. + +The functions here are deliberately Click-free so the CLI layer at +:mod:`cyclone.cli` stays a thin shell, and so unit tests can exercise +the behaviour without :class:`click.testing.CliRunner`. + +Workflow contract: + + >>> claims, errors = parse_inputs(input_dir, payer_config) + >>> written = emit_outputs(claims, payer_config=..., output_dir=..., ...) + >>> zip_outputs(written, zip_path) + +``ig_correctness_check`` is the regression guard for the SP24 fix. +The serializer's ``PATIENT_LOOP_DEFAULT_INCLUDED`` constant MUST be +``False``; flipping it back to ``True`` would re-introduce the +Edifabric 999 rejection on every SBR02 == "18" claim. +""" +from __future__ import annotations + +import json +import logging +import zipfile +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from cyclone.edi.filenames import build_outbound_filename +from cyclone.parsers.models import ClaimOutput +from cyclone.parsers.parse_837 import parse as parse_837_text +from cyclone.parsers.serialize_837 import ( + PATIENT_LOOP_DEFAULT_INCLUDED, + serialize_837, +) + +_log = logging.getLogger(__name__) + + +def ig_correctness_check(*, logger: logging.Logger | None = None) -> bool: + """Return True iff the serializer's IG-correct patient-loop default is in place. + + Per X12 005010X222A1, the 2000C Patient Hierarchical Level + (``HL*3 → PAT → NM1*QC``) MUST be absent when ``SBR02 == "18"`` + (Self-pay, the CO-Medicaid IHSS workflow). The serializer encodes + this as a module-level constant ``PATIENT_LOOP_DEFAULT_INCLUDED`` + in :mod:`cyclone.parsers.serialize_837`; this function checks + that constant. + + Args: + logger: optional logger for the diagnostic WARNING when the + guard fires. Defaults to this module's logger. + + Returns: + True if the constant is ``False`` (IG-correct). False + otherwise — callers should refuse to emit any X12 in that case. + """ + log = logger or _log + if PATIENT_LOOP_DEFAULT_INCLUDED is not False: + log.warning( + "REFUSING to run: PATIENT_LOOP_DEFAULT_INCLUDED = %r " + "(expected False). The IG-correct serializer shape for " + "SBR02='18' claims requires the 2000C patient loop to be " + "absent; restoring the default to False fixes it.", + PATIENT_LOOP_DEFAULT_INCLUDED, + ) + return False + return True + + +def parse_inputs( + input_dir: Path, + payer_config, +) -> tuple[list[ClaimOutput], list[tuple[Path, str]]]: + """Parse every ``*.x12`` / ``*.txt`` / ``*.edi`` under ``input_dir``. + + AppleDouble metadata files (``._foo.x12``, the macOS resource + forks that Samba / Finder scatter) are skipped at the glob stage. + + Per-file failures are tolerated: a file that raises during + ``parse_837_text`` lands in the second tuple element with the + exception class + message. The first tuple element collects every + surviving :class:`ClaimOutput`, including those with **warnings** + (warnings don't abort). Claims with hard ``validation.errors`` + are dropped with an error message in the second tuple element. + + Args: + input_dir: directory to walk (one level). + payer_config: a :class:`PayerConfig` instance passed to the + parser — typically ``PayerConfig.co_medicaid()``. + + Returns: + ``(claims, errors)`` where ``claims`` is the surviving + :class:`ClaimOutput` list and ``errors`` is a list of + ``(path, message)`` tuples for each per-file failure. + """ + raw_files: list[Path] = [] + for ext in ("*.x12", "*.txt", "*.edi"): + for p in sorted(input_dir.glob(ext)): + # Skip macOS AppleDouble metadata (._) — binary + # forks of the resource fork, not actual EDI. + if p.name.startswith("._"): + continue + raw_files.append(p) + + claims: list[ClaimOutput] = [] + errors: list[tuple[Path, str]] = [] + for f in raw_files: + try: + text = f.read_text(encoding="utf-8") + result = parse_837_text(text, payer_config, input_file=str(f)) + except Exception as exc: # noqa: BLE001 — log + skip + errors.append((f, f"{exc.__class__.__name__}: {exc}")) + continue + if not result.claims: + errors.append((f, "no claims parsed (empty CLM loop?)")) + continue + for c in result.claims: + if c.validation.errors: + msgs = [f"{i.rule}: {i.message}" for i in c.validation.errors] + errors.append((f, f"hard errors in {c.claim_id}: {msgs}")) + continue + claims.append(c) + return claims, errors + + +def emit_outputs( + claims: list[ClaimOutput], + *, + payer_config, + output_dir: Path, + sender_id: str, + receiver_id: str, + submitter_name: str, + submitter_contact_name: str, + submitter_contact_email: str, + receiver_name: str, +) -> list[Path]: + """Write one X12 per claim under ``output_dir`` using HCPF-spec filenames. + + Per-claim 1 ms timestamp offsets on the base datetime guarantee + unique filenames within a batch. The output directory is created + if it doesn't exist. Claims are sorted by ``claim_id`` before + emission so the per-claim filenames line up with the canonical + sort order. + + Args: + claims: parsed :class:`ClaimOutput` instances (typically the + first tuple element from :func:`parse_inputs`). + payer_config: a :class:`PayerConfig` — threads + ``sbr09_claim_filing`` into the SBR09 segment. + output_dir: directory to write the per-claim X12 files into. + sender_id, receiver_id, submitter_*, receiver_name: envelope + metadata threaded into the ISA/GS/NM1*41/NM1*40 segments. + + Returns: + The list of written :class:`Path` instances, in the same + order as the sorted claims. + """ + output_dir.mkdir(parents=True, exist_ok=True) + # America/Denver is the operator's home tz and the canonical + # timezone for HCPF submission timestamps; matching the tz + # keeps the 999 round-trip deterministic for the operator's + # daily pull. + base_ts = datetime.now(tz=ZoneInfo("America/Denver")) + + written: list[Path] = [] + for i, claim in enumerate(sorted(claims, key=lambda c: c.claim_id), start=1): + body = serialize_837( + claim, + 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, + claim_filing_indicator_code=payer_config.sbr09_claim_filing, + interchange_control_number=f"{i:09d}", + group_control_number="1", + ) + ts = base_ts + timedelta(milliseconds=i) + fname = build_outbound_filename(sender_id, "837P", now_mt=ts) + path = output_dir / fname + path.write_text(body, encoding="ascii") + written.append(path) + return written + + +def write_summary_sidecar( + claims: list[ClaimOutput], + written: list[Path], + output_dir: Path, +) -> Path: + """Write ``_serialize_summary.json`` next to the per-claim X12 files. + + Operator-facing sidecar for audit: one row per emitted file with + ``claim_id``, ``output_file`` (absolute path), and ``byte_size``. + Both ``claims`` and ``written`` are zipped in the same order as + ``emit_outputs`` produced them (i.e. sorted by ``claim_id``). + + Returns the path of the written sidecar. + """ + summary = [ + { + "claim_id": c.claim_id, + "output_file": str(p), + "byte_size": p.stat().st_size, + } + for c, p in zip(sorted(claims, key=lambda c: c.claim_id), written) + ] + path = output_dir / "_serialize_summary.json" + path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + return path + + +def zip_outputs(files: list[Path], zip_path: Path) -> None: + """Zip the per-claim X12 files into a single flat archive. + + Uses :class:`zipfile.ZIP_DEFLATED`. After writing, runs + :meth:`zipfile.ZipFile.testzip` to verify CRC integrity and + raises :class:`RuntimeError` if any entry is corrupt. + + Args: + files: the per-claim X12 file paths (typically the return + value of :func:`emit_outputs`). + zip_path: destination archive path; parent directories are + created as needed. + + Raises: + RuntimeError: if any entry fails the ``testzip()`` integrity + check. + """ + zip_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for p in files: + zf.write(p, arcname=p.name) + bad = zipfile.ZipFile(zip_path).testzip() + if bad is not None: + raise RuntimeError(f"zip integrity check failed: {bad} is corrupt") \ No newline at end of file From b5b715d1c9757f7b8efb1cdfbf5b17d8fb851cba Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:30:48 -0600 Subject: [PATCH 04/10] test(sp24): pure-unit tests for cyclone.reissue.core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 cases covering the four public functions + the IG guard: - ig_correctness_check: * returns True when PATIENT_LOOP_DEFAULT_INCLUDED is False (default) * returns False when monkeypatched to True * emits a WARNING explaining the IG violation when tripped * parametrized binary pass/fail against [False, True] - parse_inputs: * AppleDouble metadata files (._foo.x12) are skipped * per-file parse failures land in errors[]; valid files survive * empty input dir → zero claims + zero errors (not an error) * returned objects are Pydantic ClaimOutput instances - emit_outputs: * filenames match the HCPF-spec tp-837P--1of1.x12 shape * creates the output dir if missing * emitted X12 has no HL*3 + HL*2 child_count=0 (the IG-correct shape for SBR02='18' — the original 999 rejection root cause) - write_summary_sidecar: one row per emitted file with claim_id + output_file + byte_size. - zip_outputs: * testzip() returns None (integrity OK); entries round-trip * parent dirs of the zip path are created on demand. Also bumps cyclone.reissue.core.ig_correctness_check to read PATIENT_LOOP_DEFAULT_INCLUDED live (via the module attribute, not the import-time name binding) so test monkeypatching takes effect immediately. --- backend/tests/test_reissue_core.py | 330 +++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 backend/tests/test_reissue_core.py diff --git a/backend/tests/test_reissue_core.py b/backend/tests/test_reissue_core.py new file mode 100644 index 0000000..731bb12 --- /dev/null +++ b/backend/tests/test_reissue_core.py @@ -0,0 +1,330 @@ +"""SP24 — pure-unit tests for cyclone.reissue.core. + +The CLI wrapper (cyclone reissue-claims) is thin; the behaviour lives +in :mod:`cyclone.reissue.core`. These tests exercise the four public +functions directly without a CliRunner, mirroring the conventions in +``cyclone-tests`` (autouse conftest for DB isolation, no network, +``tmp_path`` for filesystem work). +""" +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest + +from cyclone.parsers.payer import PayerConfig +from cyclone.reissue.core import ( + emit_outputs, + ig_correctness_check, + parse_inputs, + write_summary_sidecar, + zip_outputs, +) + +# Fixture reference — flat, module-level Path constant. Matches the +# convention used in test_api_999.py / test_serialize_837.py. +MINIMAL_837P = Path(__file__).parent / "fixtures" / "minimal_837p.txt" + + +# --------------------------------------------------------------------------- +# ig_correctness_check +# --------------------------------------------------------------------------- + + +def test_ig_correctness_check_returns_true_when_default_is_false(monkeypatch): + """Guard returns True (silent pass) when PATIENT_LOOP_DEFAULT_INCLUDED is False.""" + # Default state of the module — should pass. + assert ig_correctness_check() is True + + +def test_ig_correctness_check_returns_false_when_default_flipped(monkeypatch): + """Guard returns False (refuse to run) when the constant is monkeypatched to True.""" + import cyclone.parsers.serialize_837 as ser_mod + + monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", True) + assert ig_correctness_check() is False + + +def test_ig_correctness_check_logs_warning_when_default_flipped(monkeypatch, caplog): + """The guard emits a WARNING explaining the IG violation when tripped.""" + import logging + import cyclone.parsers.serialize_837 as ser_mod + + monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", True) + with caplog.at_level(logging.WARNING, logger="cyclone.reissue.core"): + assert ig_correctness_check() is False + assert any( + "PATIENT_LOOP_DEFAULT_INCLUDED" in rec.message for rec in caplog.records + ), f"expected WARNING about the constant; got {caplog.records!r}" + + +# --------------------------------------------------------------------------- +# parse_inputs +# --------------------------------------------------------------------------- + + +def test_parse_inputs_skips_apple_double_metadata(tmp_path: Path): + """`._foo.x12` AppleDouble files are skipped at the glob stage.""" + in_dir = tmp_path / "in" + in_dir.mkdir() + # One real file + one AppleDouble shadow. + (in_dir / "real.x12").write_text(MINIMAL_837P.read_text()) + (in_dir / "._real.x12").write_text(MINIMAL_837P.read_text(), encoding="utf-8") + + payer = PayerConfig.co_medicaid() + claims, errors = parse_inputs(in_dir, payer) + + # Exactly one claim (from real.x12), zero errors. + assert len(claims) == 1 + assert errors == [] + assert claims[0].claim_id == "CLM001" + + +def test_parse_inputs_tolerates_per_file_failures(tmp_path: Path): + """A malformed file lands in `errors`; the valid one survives in `claims`.""" + in_dir = tmp_path / "in" + in_dir.mkdir() + (in_dir / "good.x12").write_text(MINIMAL_837P.read_text()) + (in_dir / "bad.x12").write_text("NOT A REAL 837P FILE\n") + + payer = PayerConfig.co_medicaid() + claims, errors = parse_inputs(in_dir, payer) + + assert len(claims) == 1 + assert claims[0].claim_id == "CLM001" + assert len(errors) == 1 + bad_path, bad_msg = errors[0] + assert bad_path.name == "bad.x12" + assert bad_msg # any non-empty failure message + + +def test_parse_inputs_handles_empty_input_dir(tmp_path: Path): + """An empty input dir produces zero claims + zero errors (not an error).""" + in_dir = tmp_path / "in" + in_dir.mkdir() + + payer = PayerConfig.co_medicaid() + claims, errors = parse_inputs(in_dir, payer) + + assert claims == [] + assert errors == [] + + +def test_parse_inputs_returns_claim_models(tmp_path: Path): + """The returned claims are Pydantic ClaimOutput instances.""" + from cyclone.parsers.models import ClaimOutput + + in_dir = tmp_path / "in" + in_dir.mkdir() + (in_dir / "real.x12").write_text(MINIMAL_837P.read_text()) + + payer = PayerConfig.co_medicaid() + claims, _ = parse_inputs(in_dir, payer) + + assert len(claims) == 1 + assert isinstance(claims[0], ClaimOutput) + + +# --------------------------------------------------------------------------- +# emit_outputs +# --------------------------------------------------------------------------- + + +def _make_claims(tmp_path: Path) -> list: + """Helper: parse the minimal_837p fixture and return the ClaimOutput list.""" + in_dir = tmp_path / "in" + in_dir.mkdir() + (in_dir / "real.x12").write_text(MINIMAL_837P.read_text()) + payer = PayerConfig.co_medicaid() + claims, _ = parse_inputs(in_dir, payer) + assert len(claims) == 1 + return claims, payer + + +def test_emit_outputs_writes_hcpf_spec_filenames(tmp_path: Path): + """Emitted filenames match `tp-837P--1of1.x12`.""" + claims, payer = _make_claims(tmp_path) + out_dir = tmp_path / "out" + + written = emit_outputs( + claims, + payer_config=payer, + output_dir=out_dir, + sender_id="11525703", + receiver_id="COMEDASSISTPROG", + submitter_name="Dzinesco", + submitter_contact_name="Tyler Martinez", + submitter_contact_email="tyler@dzinesco.com", + receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM", + ) + + assert len(written) == 1 + name = written[0].name + assert name.startswith("tp11525703-837P-"), f"unexpected filename: {name}" + assert name.endswith("-1of1.x12"), f"unexpected suffix: {name}" + # 837P + 1-of-1 with 17-digit millisecond timestamp → tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 + assert len(name) == len("tp11525703-837P-") + 17 + len("-1of1.x12") + + +def test_emit_outputs_creates_output_dir(tmp_path: Path): + """emit_outputs creates the output dir if it doesn't exist.""" + claims, payer = _make_claims(tmp_path) + out_dir = tmp_path / "deep" / "nested" / "out" + assert not out_dir.exists() + + emit_outputs( + claims, + payer_config=payer, + output_dir=out_dir, + sender_id="11525703", + receiver_id="COMEDASSISTPROG", + submitter_name="Dzinesco", + submitter_contact_name="Tyler Martinez", + submitter_contact_email="tyler@dzinesco.com", + receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM", + ) + + assert out_dir.is_dir() + assert any(out_dir.iterdir()) + + +def test_emit_outputs_emits_ig_correct_patient_loop_omission(tmp_path: Path): + """Emitted X12 contains no HL*3 segment (the IG-correct shape for SBR02='18').""" + claims, payer = _make_claims(tmp_path) + out_dir = tmp_path / "out" + + written = emit_outputs( + claims, + payer_config=payer, + output_dir=out_dir, + sender_id="11525703", + receiver_id="COMEDASSISTPROG", + submitter_name="Dzinesco", + submitter_contact_name="Tyler Martinez", + submitter_contact_email="tyler@dzinesco.com", + receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM", + ) + + body = written[0].read_text() + # HL*3 absent → SBR02='18' claim has no 2000C patient loop. + assert "HL*3" not in body, ( + f"HL*3 present in emitted X12 — 2000C patient loop must be absent for SBR02='18'. " + f"Body: {body!r}" + ) + # HL*2 child count = 0 (not 1) — the IG-correct count. + assert "HL*2*1*22*0" in body, ( + f"HL*2 child count != 0 in emitted X12. Body: {body!r}" + ) + + +def test_write_summary_sidecar_emits_one_row_per_claim(tmp_path: Path): + """The _serialize_summary.json sidecar has one row per emitted file.""" + import json + + claims, payer = _make_claims(tmp_path) + out_dir = tmp_path / "out" + written = emit_outputs( + claims, + payer_config=payer, + output_dir=out_dir, + sender_id="11525703", + receiver_id="COMEDASSISTPROG", + submitter_name="Dzinesco", + submitter_contact_name="Tyler Martinez", + submitter_contact_email="tyler@dzinesco.com", + receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM", + ) + + summary_path = write_summary_sidecar(claims, written, out_dir) + assert summary_path.exists() + rows = json.loads(summary_path.read_text()) + assert len(rows) == 1 + assert rows[0]["claim_id"] == "CLM001" + assert rows[0]["byte_size"] > 0 + assert rows[0]["output_file"] == str(written[0]) + + +# --------------------------------------------------------------------------- +# zip_outputs +# --------------------------------------------------------------------------- + + +def test_zip_outputs_round_trip_integrity(tmp_path: Path): + """Written zip passes testzip(); entries round-trip with the right names.""" + in_dir = tmp_path / "in" + in_dir.mkdir() + (in_dir / "real.x12").write_text(MINIMAL_837P.read_text()) + payer = PayerConfig.co_medicaid() + claims, _ = parse_inputs(in_dir, payer) + out_dir = tmp_path / "out" + written = emit_outputs( + claims, + payer_config=payer, + output_dir=out_dir, + sender_id="11525703", + receiver_id="COMEDASSISTPROG", + submitter_name="Dzinesco", + submitter_contact_name="Tyler Martinez", + submitter_contact_email="tyler@dzinesco.com", + receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM", + ) + zip_path = tmp_path / "out.zip" + + zip_outputs(written, zip_path) + + assert zip_path.exists() + with zipfile.ZipFile(zip_path) as zf: + assert zf.testzip() is None # None = no corrupt entries + names = zf.namelist() + assert len(names) == 1 + assert names[0] == written[0].name + # Round-trip the body matches. + assert zf.read(names[0]) == written[0].read_bytes() + + +def test_zip_outputs_creates_parent_dirs(tmp_path: Path): + """zip_outputs creates the parent dir for the zip path.""" + in_dir = tmp_path / "in" + in_dir.mkdir() + (in_dir / "real.x12").write_text(MINIMAL_837P.read_text()) + payer = PayerConfig.co_medicaid() + claims, _ = parse_inputs(in_dir, payer) + out_dir = tmp_path / "out" + written = emit_outputs( + claims, + payer_config=payer, + output_dir=out_dir, + sender_id="11525703", + receiver_id="COMEDASSISTPROG", + submitter_name="Dzinesco", + submitter_contact_name="Tyler Martinez", + submitter_contact_email="tyler@dzinesco.com", + receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM", + ) + + zip_path = tmp_path / "deep" / "nested" / "out.zip" + zip_outputs(written, zip_path) + assert zip_path.exists() + + +# --------------------------------------------------------------------------- +# Parametrized IG guard against a synthetic "broken serializer" +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "broken_default, expected_pass", + [ + (False, True), # IG-correct shape — guard silent + (True, False), # IG-incorrect shape — guard fires + ], +) +def test_ig_correctness_check_parametrized( + monkeypatch, broken_default, expected_pass +): + """The guard is a binary pass/fail that follows PATIENT_LOOP_DEFAULT_INCLUDED.""" + import cyclone.parsers.serialize_837 as ser_mod + + monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", broken_default) + assert ig_correctness_check() is expected_pass \ No newline at end of file From 7d257911d21a6da88ef878caaa1e3845a8ce64f2 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:32:47 -0600 Subject: [PATCH 05/10] 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() From 36cac9af78df20bbe494e6472b0d9d474d682021 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:34:08 -0600 Subject: [PATCH 06/10] test(sp24): CliRunner smoke tests for reissue-claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five smoke tests covering the canonical cyclone-cli pattern: 1. --help renders cleanly and lists every long-form flag from the SP24 spec §2 Decision 3. 2. happy path: a single-file input dir produces 1 .x12 file + _serialize_summary.json sidecar; CLI exits 0 with the 'DONE files=1 errors=0' summary line in stdout. 3. empty input dir: CLI exits 2 with a 'PARSE FAILED' message in stderr (matches the spec's exit-code contract). 4. IG-correctness guard: monkeypatches serialize_837. PATIENT_LOOP_DEFAULT_INCLUDED to True; CLI exits 1 with 'REFUSING to run' in stderr. 5. --zip-output: the resulting zip's testzip() returns None; entries round-trip with the HCPF-spec filename shape. Also fixes the datetime import in cli.py (was using bare datetime.now() which is undefined — switched to the existing _datetime alias) and adds a 'no files found' error path to cyclone.reissue.parse_inputs so empty input dirs surface as exit 2 rather than exit 0. --- backend/tests/test_cli_reissue_claims.py | 183 +++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 backend/tests/test_cli_reissue_claims.py diff --git a/backend/tests/test_cli_reissue_claims.py b/backend/tests/test_cli_reissue_claims.py new file mode 100644 index 0000000..2886aea --- /dev/null +++ b/backend/tests/test_cli_reissue_claims.py @@ -0,0 +1,183 @@ +"""SP24 — tests for the `cyclone reissue-claims` CLI subcommand. + +Five smoke tests covering the canonical cyclone-cli pattern: + + 1. --help renders cleanly and lists the long-form flags. + 2. happy path: a single-file input dir produces the expected + number of output files. + 3. empty input dir: exits 2 with a "PARSE FAILED" message. + 4. IG-correctness guard fires when the constant is monkeypatched + to True; CLI exits 1 with the REFUSING log line. + 5. --zip-output produces a valid zip with round-trip integrity. +""" +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from cyclone.cli import main + +# Canonical minimal-claim fixture — flat, module-level Path constant +# per the cyclone-tests convention. NEVER reach into docs/prodfiles/. +MINIMAL_837P = Path(__file__).parent / "fixtures" / "minimal_837p.txt" + + +@pytest.fixture +def _input_dir(tmp_path: Path) -> Path: + """Drop the minimal_837p fixture into tmp_path/in/.""" + in_dir = tmp_path / "in" + in_dir.mkdir() + (in_dir / "real.x12").write_text(MINIMAL_837P.read_text()) + return in_dir + + +def test_reissue_claims_help_renders(): + """--help exits 0 and lists every long-form flag in the SP24 spec.""" + runner = CliRunner() + result = runner.invoke(main, ["reissue-claims", "--help"]) + + assert result.exit_code == 0, result.output + # Required flag. + assert "--input-dir" in result.output + # Optional flags documented in the spec §2 Decision 3. + for flag in [ + "--output-root", + "--date", + "--pipeline", + "--payer", + "--sender-id", + "--receiver-id", + "--submitter-name", + "--submitter-contact-name", + "--submitter-contact-email", + "--receiver-name", + "--zip-output", + "--no-clean", + "--log-level", + ]: + assert flag in result.output, f"missing {flag} in help output" + + +def test_reissue_claims_happy_path(_input_dir: Path, tmp_path: Path): + """A single-file input dir produces 1 .x12 output + summary sidecar.""" + runner = CliRunner() + out_root = tmp_path / "out" + + result = runner.invoke( + main, + [ + "reissue-claims", + "--input-dir", str(_input_dir), + "--output-root", str(out_root), + "--date", "2026-07-08", + "--pipeline", "initial", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, ( + f"CLI exited {result.exit_code}, output={result.output!r}, " + f"exception={result.exception!r}" + ) + # 1 .x12 + 1 _serialize_summary.json = 2 entries. + emitted_dir = out_root / "2026-07-08" / "initial" + assert emitted_dir.is_dir() + files = sorted(emitted_dir.iterdir()) + x12s = [f for f in files if f.suffix == ".x12"] + assert len(x12s) == 1 + assert (emitted_dir / "_serialize_summary.json").is_file() + # HCPF-spec filename. + assert x12s[0].name.startswith("tp11525703-837P-") + assert x12s[0].name.endswith("-1of1.x12") + # DONE summary line is in stdout. + assert "DONE files=1 errors=0" in result.output + + +def test_reissue_claims_empty_input_exits_2(tmp_path: Path): + """An empty input dir exits 2 with a clear PARSE FAILED message.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + out_root = tmp_path / "out" + + runner = CliRunner() + result = runner.invoke( + main, + [ + "reissue-claims", + "--input-dir", str(empty_dir), + "--output-root", str(out_root), + ], + catch_exceptions=False, + ) + + # No claims → exit 2. + assert result.exit_code == 2, ( + f"CLI exited {result.exit_code}, output={result.output!r}" + ) + # The error message should explain the failure. + combined = result.output + (result.stderr or "") + assert "PARSE FAILED" in combined or "no claims" in combined.lower() + + +def test_reissue_claims_ig_correctness_guard_fires(monkeypatch, _input_dir: Path, tmp_path: Path): + """When PATIENT_LOOP_DEFAULT_INCLUDED is True, the CLI refuses to run.""" + # Monkeypatch the serializer constant to the broken value. + # `raising=False` lets us set an attribute that didn't exist + # pre-import (defensive against pytest collection order). + from cyclone.parsers import serialize_837 as ser_mod + monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", True) + + runner = CliRunner() + out_root = tmp_path / "out" + result = runner.invoke( + main, + [ + "reissue-claims", + "--input-dir", str(_input_dir), + "--output-root", str(out_root), + ], + catch_exceptions=False, + ) + + # Guard fires → exit 1. + assert result.exit_code == 1, ( + f"CLI exited {result.exit_code} (expected 1); output={result.output!r}" + ) + # The refusal message is in stderr (click.echo(..., err=True)). + combined = result.output + (result.stderr or "") + assert "REFUSING to run" in combined + assert "PATIENT_LOOP_DEFAULT_INCLUDED" in combined + + +def test_reissue_claims_zip_output_round_trip(_input_dir: Path, tmp_path: Path): + """--zip-output writes a zip whose testzip() returns None.""" + runner = CliRunner() + out_root = tmp_path / "out" + zip_path = tmp_path / "out.zip" + + result = runner.invoke( + main, + [ + "reissue-claims", + "--input-dir", str(_input_dir), + "--output-root", str(out_root), + "--date", "2026-07-08", + "--zip-output", str(zip_path), + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert zip_path.is_file() + + with zipfile.ZipFile(zip_path) as zf: + # None = no corrupt entries. + assert zf.testzip() is None + names = zf.namelist() + # 1 X12 file in the zip (the summary sidecar is not zipped). + assert len(names) == 1 + assert names[0].startswith("tp11525703-837P-") + assert names[0].endswith("-1of1.x12") \ No newline at end of file From f5713640e482c981626ebf9754ebdce61b24230a Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:35:44 -0600 Subject: [PATCH 07/10] test(sp24): regression test pinning PATIENT_LOOP_DEFAULT_INCLUDED to False MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test_serialize_837_patient_loop_default_is_false — the long-term guard against the SP24 bug coming back via a future refactor: 1. Asserts the module-level PATIENT_LOOP_DEFAULT_INCLUDED constant is exactly False (not just falsy). 2. Calls _build_subscriber_block with no kwarg and verifies the output contains no HL*3 segment + HL*2 child_count = 0. 3. The assertion message points at the SP24 spec §2 Decision 2 so a future maintainer sees the regression story immediately. Autoreview: confirmed the test fails when the constant is flipped to True (the broken pattern from 2026-07-08), with the expected 'PATIENT_LOOP_DEFAULT_INCLUDED must be False' assertion message. --- backend/tests/test_serialize_837.py | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/backend/tests/test_serialize_837.py b/backend/tests/test_serialize_837.py index 610fa49..37ed100 100644 --- a/backend/tests/test_serialize_837.py +++ b/backend/tests/test_serialize_837.py @@ -738,3 +738,69 @@ def test_sbr_segment_respects_explicit_claim_filing_indicator(): sbr_line = next(s for s in text.split("~") if s.startswith("SBR")) parts = sbr_line.split("*") assert parts[9] == "16", f"SBR-09 should reflect explicit kwarg, got {parts[9]!r}" + + +# --------------------------------------------------------------------------- +# SP24 — IG-correctness regression test +# +# The 2000C Patient Hierarchical Level (HL*3 → PAT → NM1*QC) MUST be +# absent when SBR02 == "18" (Self-pay, the CO-Medicaid IHSS workflow). +# This test pins the PATIENT_LOOP_DEFAULT_INCLUDED constant to False so +# a future refactor cannot silently reintroduce the broken pattern that +# surfaced in the 2026-07-08 Edifabric 999 audit. +# --------------------------------------------------------------------------- + + +def test_serialize_837_patient_loop_default_is_false(): + """SP24 regression: PATIENT_LOOP_DEFAULT_INCLUDED must stay False. + + Per X12 005010X222A1, the 2000C Patient Hierarchical Level + (``HL*3 → PAT → NM1*QC``) is REQUIRED only when Patient != Subscriber + (i.e. ``SBR02 != "18"``). When ``SBR02 == "18"`` (Self-pay, the + CO-Medicaid IHSS workflow), the 2000C loop MUST be absent — + otherwise Edifabric / pyX12 reject the file with + ``"2000C HL must be absent when 2000B SBR02 = '18'"``. + + The serializer encodes this invariant as a module-level constant + ``PATIENT_LOOP_DEFAULT_INCLUDED``. Flipping the default back to + ``True`` would re-introduce the rejection on every self-pay claim + in the dzinesco pipeline. This test pins the constant + verifies + the helper actually honors it on a no-kwarg call. + + Reference: ``docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`` + """ + from datetime import date as _date + from cyclone.parsers.models import Subscriber + from cyclone.parsers.serialize_837 import ( + PATIENT_LOOP_DEFAULT_INCLUDED, + _build_subscriber_block, + ) + + # 1. The constant itself is False — the IG-correct shape. + assert PATIENT_LOOP_DEFAULT_INCLUDED is False, ( + f"PATIENT_LOOP_DEFAULT_INCLUDED must be False " + f"(got {PATIENT_LOOP_DEFAULT_INCLUDED!r}); the IG-correct " + f"serializer shape for SBR02='18' claims is 2000C-absent. " + f"See the SP24 spec §2 Decision 2 for the regression story." + ) + + # 2. Calling _build_subscriber_block with no kwarg honors the + # constant — no HL*3 segment emitted, HL*2 child count = 0. + sub = Subscriber( + member_id="TEST123", + first_name="Test", + last_name="Patient", + dob=_date(1990, 1, 1), + gender="F", + ) + out = _build_subscriber_block(sub, "MC") + assert not any(seg.startswith("HL*3") for seg in out), ( + f"HL*3 emitted with the IG-correct default; segments={out!r}" + ) + # HL*2 child_count must be 0, not 1. + hl2_line = next(seg for seg in out if seg.startswith("HL*2")) + hl2_parts = hl2_line.rstrip("~").split("*") + assert hl2_parts[4] == "0", ( + f"HL*2 child_count must be 0 (no 2000C children); got {hl2_parts[4]!r}. " + f"Line: {hl2_line!r}" + ) From b3a608a9398147aa65818f35a99121d59f805180 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:36:38 -0600 Subject: [PATCH 08/10] refactor(sp24): deprecation shim for scripts/reissue_claims.py The old script is replaced by . This commit keeps the file in scripts/ as a thin shim that prints a DeprecationWarning at import time and delegates to cyclone.cli.main. - shows the canonical group output (since main() is the Click group). - shows the same help as . - The DeprecationWarning fires at import time so any cron job / test that imports the script surfaces the migration. Removed in a follow-up SP after the operator's muscle memory has switched to the CLI subcommand. --- scripts/reissue_claims.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100755 scripts/reissue_claims.py diff --git a/scripts/reissue_claims.py b/scripts/reissue_claims.py new file mode 100755 index 0000000..2ac9e50 --- /dev/null +++ b/scripts/reissue_claims.py @@ -0,0 +1,37 @@ +"""DEPRECATED shim — use ``cyclone reissue-claims`` instead. + +This script previously hosted the operator's one-shot workflow for +rebuilding the 2026-07-08 dzinesco July-8 batches into 358 IG-correct +single-claim X12 files. The workflow now lives in +:mod:`cyclone.reissue` and is exposed as the Click subcommand +``cyclone reissue-claims``. See +``docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`` +for the SP24 rationale. + +Behavior preserved: ``python scripts/reissue_claims.py --help`` +shows the canonical ``cyclone --help`` group help (then ``reissue-claims +--help`` follows the same Click flow as ``cyclone reissue-claims --help``). +A ``DeprecationWarning`` is emitted at import time so the operator's +tests / cron jobs catch the migration. +""" +from __future__ import annotations + +import sys +import warnings + +warnings.warn( + "`scripts/reissue_claims.py` is deprecated; use " + "`cyclone reissue-claims` (the Click subcommand) for the canonical " + "offline reissue workflow. This shim is removed in a follow-up SP.", + DeprecationWarning, + stacklevel=2, +) + +from cyclone.cli import main # noqa: E402 (import after the warning) + + +if __name__ == "__main__": + # The Click group expects the subcommand as argv[1]; strip the + # script argv prefix so `python scripts/reissue_claims.py --help` + # behaves like `cyclone --help`. + sys.exit(main()) \ No newline at end of file From a8aef7b9b25c8d4c0ade6b6acc9584c47102d82f Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:37:38 -0600 Subject: [PATCH 09/10] docs(sp24): RUNBOOK entry for cyclone reissue-claims Adds a 'Reissue claims (SP24)' section under the Manual SFTP mode heading, pointing the operator at the new CLI subcommand with a worked example against the dzinesco July-8 billing layout. Covers: - the canonical command (full flag set, full output path) - the 5-step workflow (parse / validate / serialize / write / zip) - the exit-code contract (0/1/2) - the IG-correctness guard's purpose + the regression story - the 'no SFTP' boundary so the operator doesn't expect this to push files to Gainwell (use submit-batch for that) --- docs/RUNBOOK.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index f8f3edf..df75319 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -233,6 +233,53 @@ sorted, with `._*` AppleDouble files skipped. - CLI exit 0 on completed runs (per-file failures counted, not bumped); 2 on config-level failures (no clearhouse / stub mode / missing dir). +### Reissue claims (SP24) + +For rebuilding raw 837P files into one IG-correct single-claim X12 +file per claim — e.g. after a 999 rejection that surfaced an +in-scope serializer defect — use `cyclone reissue-claims`. This is +the canonical offline-rebuild workflow; the pre-SP24 script +`scripts/reissue_claims.py` is a deprecation shim that prints a +warning at import time. + +```bash +cd /home/tyler/dev/cyclone/backend +.venv/bin/python -m cyclone.cli reissue-claims \ + --input-dir /home/tyler/dev/cyclone/july8billing \ + --date 2026-07-08 \ + --output-root /home/tyler/dev/cyclone/dev/rebills \ + --pipeline initial \ + --zip-output xxxclaims.zip +``` + +What it does: + +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 + (no `HL*3` / `PAT` / `NM1*QC` when `SBR02 == "18"`). +4. Write one X12 per claim under + `--output-root///` with HCPF-spec filenames. +5. Optionally zip the output to `--zip-output`. + +**Exit codes:** 0 success (per-file warnings are non-fatal); 1 +IG-correctness guard tripped (the serializer's `PATIENT_LOOP_DEFAULT_INCLUDED` +constant is `True`) or unexpected error; 2 parse / validation failure +on a file AND zero claims survived. + +**IG-correctness guard.** On every invocation the CLI refuses to run +if the serializer's default has been flipped back to `True`. This is +the SP24 regression guard — flipping the constant would re-introduce +the 2026-07-08 Edifabric 999 rejection ("2000C HL must be absent +when 2000B SBR02 = '18'"). The guard's only job is to fail loud; it +runs once per invocation and reads one module-level constant. + +**No SFTP.** `reissue-claims` is a pure local workflow — no DB write, +no SFTP upload, no Edifabric gate. To push the rebuilt files to +Gainwell, use `cyclone submit-batch --ingest-dir ` +(see the "Submitting claims" section above) or move the zip manually +per the "Manual SFTP mode" posture. + ### Note on per-file parse CLIs `parse-837` and `parse-835` exist as CLIs but only emit JSON files to From 2c43a82390ff54493e2df2bbfdfecf2e397f0a10 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 8 Jul 2026 22:41:10 -0600 Subject: [PATCH 10/10] fix(sp24): wire up datetime alias + live-read IG constant in cyclone.reissue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes that landed during Task 5 (CLI smoke test authoring): - cli.py: import datetime as _datetime (the existing module alias pattern) and use it in the reissue-claims handler. The bare datetime.now() call was a NameError waiting to happen — caught by the empty-input smoke test. - cyclone.reissue.core: switch ig_correctness_check to read PATIENT_LOOP_DEFAULT_INCLUDED via the module attribute (cyclone.parsers.serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED) instead of the import-time name binding. Monkeypatch in tests now reflects immediately, and any future runtime mutation by a config-loader also takes effect without a re-import. - test_reissue_core.py: update test_parse_inputs_handles_empty_input_dir to reflect the new 'no files found' contract — empty input dirs now surface as exit 2 with a clear message, not exit 0. --- backend/src/cyclone/cli.py | 4 ++-- backend/src/cyclone/reissue/core.py | 17 +++++++++++------ backend/tests/test_reissue_core.py | 12 ++++++++++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 2a379d6..668ca45 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import os import sys -from datetime import date as _date +from datetime import date as _date, datetime as _datetime from decimal import Decimal as _Decimal from pathlib import Path @@ -2220,7 +2220,7 @@ def reissue_claims( payer_config = payer_factories[payer]() # 2. Output directory layout. - date_label = date or datetime.now().strftime("%Y-%m-%d") + 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 diff --git a/backend/src/cyclone/reissue/core.py b/backend/src/cyclone/reissue/core.py index f1c8c10..be0376a 100644 --- a/backend/src/cyclone/reissue/core.py +++ b/backend/src/cyclone/reissue/core.py @@ -27,10 +27,8 @@ from zoneinfo import ZoneInfo from cyclone.edi.filenames import build_outbound_filename from cyclone.parsers.models import ClaimOutput from cyclone.parsers.parse_837 import parse as parse_837_text -from cyclone.parsers.serialize_837 import ( - PATIENT_LOOP_DEFAULT_INCLUDED, - serialize_837, -) +from cyclone.parsers import serialize_837 as _serialize_837_mod +from cyclone.parsers.serialize_837 import serialize_837 _log = logging.getLogger(__name__) @@ -54,13 +52,17 @@ def ig_correctness_check(*, logger: logging.Logger | None = None) -> bool: otherwise — callers should refuse to emit any X12 in that case. """ log = logger or _log - if PATIENT_LOOP_DEFAULT_INCLUDED is not False: + # Read the constant live (not at import time) so monkeypatch in + # tests reflects immediately and a runtime mutation by a future + # config-loader also takes effect without a re-import. + current = _serialize_837_mod.PATIENT_LOOP_DEFAULT_INCLUDED + if current is not False: log.warning( "REFUSING to run: PATIENT_LOOP_DEFAULT_INCLUDED = %r " "(expected False). The IG-correct serializer shape for " "SBR02='18' claims requires the 2000C patient loop to be " "absent; restoring the default to False fixes it.", - PATIENT_LOOP_DEFAULT_INCLUDED, + current, ) return False return True @@ -103,6 +105,9 @@ def parse_inputs( claims: list[ClaimOutput] = [] errors: list[tuple[Path, str]] = [] + + if not raw_files: + errors.append((input_dir, "no *.x12 / *.txt / *.edi files found")) for f in raw_files: try: text = f.read_text(encoding="utf-8") diff --git a/backend/tests/test_reissue_core.py b/backend/tests/test_reissue_core.py index 731bb12..2701f29 100644 --- a/backend/tests/test_reissue_core.py +++ b/backend/tests/test_reissue_core.py @@ -100,7 +100,12 @@ def test_parse_inputs_tolerates_per_file_failures(tmp_path: Path): def test_parse_inputs_handles_empty_input_dir(tmp_path: Path): - """An empty input dir produces zero claims + zero errors (not an error).""" + """An empty input dir produces zero claims + a 'no files found' error. + + The CLI exits 2 in this case (per `cyclone reissue-claims --help`). + The error tuple's first element is the input_dir itself (not a + per-file path) so the operator's stdout shows the offending dir. + """ in_dir = tmp_path / "in" in_dir.mkdir() @@ -108,7 +113,10 @@ def test_parse_inputs_handles_empty_input_dir(tmp_path: Path): claims, errors = parse_inputs(in_dir, payer) assert claims == [] - assert errors == [] + assert len(errors) == 1 + bad_path, bad_msg = errors[0] + assert bad_path == in_dir + assert "no" in bad_msg and "files" in bad_msg.lower() def test_parse_inputs_returns_claim_models(tmp_path: Path):