diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 7e6b358..6b8a4f3 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -1284,5 +1284,97 @@ def pull_inbound( click.echo(f" {e}", err=True) +# --------------------------------------------------------------------------- +# SP38: ack-orphans status + reconcile +# --------------------------------------------------------------------------- + + +@main.group("ack-orphans") +def ack_orphans_group() -> None: + """Inspect and reconcile 999 acks with no resolvable source claim. + + "Orphans" are 999 acks whose source 837 batch is not present in + the ``batches`` table — typically because the source 837 was + submitted to HPE before the current ``cyclone.db`` snapshot was + created. They are real production data (valid audit history) but + cannot be auto-linked to claims. + + See ``docs/superpowers/specs/2026-07-07-cyclone-orphan-ack-housekeeping-design.md`` + for the full design and operator triage workflow. + """ + + +@ack_orphans_group.command("status") +def ack_orphans_status_cmd() -> None: + """Print a per-ST02 summary of orphan 999 acks. + + One row per distinct orphan ``set_control_number`` (ST02), ranked + by ``ack_count DESC`` so the heaviest backlog surfaces first. + Plus a TOTAL row at the bottom. + + Exit codes: 0 on success, 1 on DB error. + """ + from cyclone import db as db_mod + from cyclone.store import store as cycl_store + + try: + db_mod.init_db() + summary = cycl_store.find_ack_orphan_st02_summary() + except Exception as exc: # noqa: BLE001 + click.echo(f"ack-orphans status failed: {type(exc).__name__}: {exc}", err=True) + sys.exit(1) + + if not summary: + click.echo("no orphans (acks is empty or all are linked)") + return + + # Table layout: ST02 | ACK COUNT | HAS BATCH + click.echo(f"{'ST02':<15} {'ACK COUNT':>10} HAS BATCH") + click.echo(f"{'----':-<15} {'---------':->10} ---------") + total = 0 + for row in summary: + marker = "yes" if row["has_batch"] else "no" + click.echo( + f"{row['st02']:<15} {row['ack_count']:>10} {marker}" + ) + total += row["ack_count"] + click.echo(f"{'TOTAL':<15} {total:>10}") + + +@ack_orphans_group.command("reconcile") +@click.option("--dry-run", is_flag=True, + help="Print the plan but do not insert any rows.") +def ack_orphans_reconcile_cmd(dry_run: bool) -> None: + """Insert synthetic batches rows for orphan ST02s that lack one. + + For every orphan ST02 where no ``batches`` row exists, insert a + synthetic row marked with ``input_filename = + ''`` so future 999 acks for the same + ST02s can resolve against the ``batch_envelope_index`` (though + they still won't link to claims — the source 837s were never + ingested). + + Idempotent: re-running after a successful pass is a no-op. + + Exit codes: 0 on success, 1 on DB error. + """ + from cyclone import db as db_mod + from cyclone.store import store as cycl_store + + try: + db_mod.init_db() + plan = cycl_store.reconcile_orphan_st02s(dry_run=dry_run) + except Exception as exc: # noqa: BLE001 + click.echo(f"ack-orphans reconcile failed: {type(exc).__name__}: {exc}", err=True) + sys.exit(1) + + click.echo( + f"Created: {plan['created']} synthetic batch rows. " + f"Skipped: {plan['skipped']} (already had a batch row)." + ) + if dry_run: + click.echo("(dry-run — no rows inserted)") + + if __name__ == "__main__": main() diff --git a/backend/tests/test_ack_orphans_cli.py b/backend/tests/test_ack_orphans_cli.py new file mode 100644 index 0000000..1535279 --- /dev/null +++ b/backend/tests/test_ack_orphans_cli.py @@ -0,0 +1,210 @@ +"""SP38: CLI tests for ``cyclone ack-orphans {status,reconcile}``. + +Uses ``click.testing.CliRunner`` (matching the SP37-followup #5 +pattern that replaced ``subprocess.run`` with the in-process +runner). The CLI commands are thin wrappers over the store helpers +tested in ``test_ack_orphan_summary.py`` — these tests pin the +shell-facing shape (table format, exit codes, --dry-run flag). + +The autouse ``_auto_init_db`` fixture in ``conftest.py`` provides +a fresh ``tmp_path/test.db`` for every test; the CLI commands pick +it up via the ``CYCLONE_DB_URL`` env var the fixture sets. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest +from click.testing import CliRunner + +from cyclone import db +from cyclone.cli import main +from cyclone.db import Ack, Batch + + +def _now(): + return datetime.now(timezone.utc) + + +def _ingest_orphan(s, st02: str) -> int: + """Insert a 999 ack row whose set_responses[0].set_control_number == st02. + + Returns the new ack id. The 999 is an orphan (no claim_acks row) + so it surfaces in the summary. + """ + raw = { + "envelope": {"sender_id": "S", "receiver_id": "R", + "control_number": "000000099", "transaction_date": "2024-01-01", + "implementation_guide": "005010X231A1"}, + "functional_group_acks": [], + "set_responses": [ + {"set_control_number": st02, "transaction_set_identifier": "837", + "ak2": {"functional_id_code": "837"}, "segment_errors": [], + "set_accept_reject": {"code": "A"}} + ], + "summary": {"accepted_count": 1, "rejected_count": 0}, + } + ack = Ack( + source_batch_id=f"999-{st02}-cli-test", + accepted_count=1, rejected_count=0, received_count=1, + ack_code="A", parsed_at=_now(), raw_json=json.dumps(raw), + ) + s.add(ack) + s.flush() + return ack.id + + +# ---- cyclone ack-orphans status ------------------------------------------ # + + +def test_status_prints_table_with_orphans(): + """``cyclone ack-orphans status`` prints a table with ST02 + + ack count + has-batch flag, plus a TOTAL line at the bottom. + + Mirrors the spec's intent: operators want a one-line-per-orphan + view that ranks the heaviest backlog first. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + _ingest_orphan(s, "991102988") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "status"]) + assert result.exit_code == 0, result.output + # Table header + two data rows + total line. + assert "ST02" in result.output + assert "ACK COUNT" in result.output + assert "991102989" in result.output + assert "991102988" in result.output + assert "TOTAL" in result.output + + +def test_status_exits_zero_on_empty_db(): + """With no orphans, status exits 0 and prints a 'no orphans' note.""" + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "status"]) + assert result.exit_code == 0, result.output + # The empty case should be informative, not silent. + assert "no orphans" in result.output.lower() or "0" in result.output + + +def test_status_table_includes_has_batch_column(): + """When a ST02 already has a batches row, the table marks it + with ``yes`` (or similar) so operators can see which orphans + are unbacked vs already-covered. + """ + with db.SessionLocal()() as s: + # Orphan with NO batch. + _ingest_orphan(s, "991102988") + # Orphan WITH a pre-existing batch row. + import uuid + bid = uuid.uuid4().hex + s.add(Batch( + id=bid, kind="837p", input_filename="test.837p", + transaction_set_control_number="991102977", parsed_at=_now(), + )) + _ingest_orphan(s, "991102977") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "status"]) + assert result.exit_code == 0, result.output + # Look for "yes" / "no" markers in the table. + lines = result.output.splitlines() + has_yes = any("yes" in ln.lower() for ln in lines) + has_no = any("no" in ln.lower() for ln in lines) + assert has_yes and has_no, ( + f"Expected table to mark 'yes' for ST02s with batches and " + f"'no' for ST02s without. Got:\n{result.output}" + ) + + +# ---- cyclone ack-orphans reconcile --------------------------------------- # + + +def test_reconcile_creates_synthetic_batches(): + """``cyclone ack-orphans reconcile`` inserts a synthetic batch + row for each orphan ST02 without one. Confirms the count + the + sentinel filename. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + _ingest_orphan(s, "991102988") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "reconcile"]) + assert result.exit_code == 0, result.output + + # The CLI should print the created/skipped counts. + assert "Created" in result.output or "created" in result.output + assert "Skipped" in result.output or "skipped" in result.output + + with db.SessionLocal()() as s: + synthetic = ( + s.query(Batch) + .filter(Batch.input_filename == "") + .all() + ) + assert len(synthetic) == 2 + st02s = {r.transaction_set_control_number for r in synthetic} + assert st02s == {"991102989", "991102988"} + + +def test_reconcile_dry_run_does_not_write(): + """``--dry-run`` returns the plan shape but does NOT insert rows. + Operators use this to preview before committing. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "reconcile", "--dry-run"]) + assert result.exit_code == 0, result.output + # The plan reports the would-create count. + assert "1" in result.output # would-create 1 row + + # But no synthetic batch row was inserted. + with db.SessionLocal()() as s: + n = ( + s.query(Batch) + .filter(Batch.input_filename == "") + .count() + ) + assert n == 0 + + +def test_reconcile_is_idempotent(): + """A second reconcile after a successful pass is a no-op: + created=0, skipped=N. Tested at the CLI shell level so the + user-visible output also pins this invariant. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + s.commit() + + runner = CliRunner() + first = runner.invoke(main, ["ack-orphans", "reconcile"]) + second = runner.invoke(main, ["ack-orphans", "reconcile"]) + assert first.exit_code == 0 + assert second.exit_code == 0 + + with db.SessionLocal()() as s: + n = ( + s.query(Batch) + .filter(Batch.input_filename == "") + .count() + ) + assert n == 1 # still exactly one — second call was a no-op + + +def test_reconcile_handles_no_orphans_gracefully(): + """With zero orphans, reconcile exits 0 and prints an informative + message (no synthetic rows created, no error).""" + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "reconcile"]) + assert result.exit_code == 0, result.output + assert "0" in result.output # created=0, skipped=0 \ No newline at end of file