From 0493814489417ede8ce3140c76f583c9be18c9bc Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 17:23:24 -0600 Subject: [PATCH] feat(sp39): cli resubmissions status --- backend/src/cyclone/cli.py | 62 ++++++++++++ backend/tests/test_resubmissions_cli.py | 120 ++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 backend/tests/test_resubmissions_cli.py diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 33cfcd6..795ccc0 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -1376,6 +1376,68 @@ def ack_orphans_reconcile_cmd(dry_run: bool) -> None: click.echo("(dry-run — no rows inserted)") +# --------------------------------------------------------------------------- +# SP39: `cyclone resubmissions status` — operator view onto the +# resubmissions audit table. Joins against claim_acks (via the +# existing SP28/31 auto-link) + remittances (via CLP->claim) so the +# operator can confirm post-submission outcomes of the corrected +# 837 files pushed via `cyclone resubmit-rejected-claims`. +# --------------------------------------------------------------------------- + + +@main.group("resubmissions") +def resubmissions_group() -> None: + """SP39: inspect post-submission status of corrected-file pushes.""" + + +@resubmissions_group.command("status") +@click.option("--batch-id", default=None, + help="Restrict to a single source batch_id " + "(matches ingest/corrected-v2/--claims/).") +@click.option("--limit", default=200, type=int, + help="Maximum rows to print (default 200).") +def resubmissions_status_cmd(batch_id: str | None, limit: int) -> None: + """Print per-claim resubmission status (ack + payment). + + One row per ``resubmissions`` row, optionally filtered by + ``--batch-id``. The ``ack`` column reflects the latest inbound + 999 / 277CA accept-reject code linked to the claim; the + ``payment`` column is ``paid`` once a remittance CLP links. + + 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() + rows = cycl_store.find_resubmission_status(batch_id=batch_id) + except Exception as exc: # noqa: BLE001 + click.echo(f"resubmissions status failed: {type(exc).__name__}: {exc}", err=True) + sys.exit(1) + + if not rows: + click.echo("no resubmissions recorded") + return + + click.echo( + f"{'claim_id':<22} {'batch_id':<36} {'resubmitted_at':<20} " + f"{'ack':<16} {'payment':<14}" + ) + click.echo("-" * 110) + for r in rows[:limit]: + click.echo( + f"{r.claim_id:<22} {r.batch_id:<36} " + f"{r.resubmitted_at.isoformat(timespec='seconds'):<20} " + f"{(r.ack_status or 'pending_999'):<16} " + f"{(r.payment_status or '-'):<14}" + ) + if len(rows) > limit: + click.echo( + f"... ({len(rows) - limit} more; pass --limit to see all)" + ) + + # --------------------------------------------------------------------------- # SP25: ``cyclone recover-ingest`` — offline parse + DB-write helper for # stranded source files in ingest/. Mirrors the data-writing half of diff --git a/backend/tests/test_resubmissions_cli.py b/backend/tests/test_resubmissions_cli.py new file mode 100644 index 0000000..becc2e8 --- /dev/null +++ b/backend/tests/test_resubmissions_cli.py @@ -0,0 +1,120 @@ +"""SP39: tests for the `cyclone resubmissions status` CLI.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from click.testing import CliRunner + +from cyclone import db as cycl_db +from cyclone.cli import resubmissions_status_cmd +from cyclone.db import Resubmission + + +def _seed_one(claim_id: str = "claim-1", batch_id: str = "batch-1", + icn: str = "000000001") -> None: + cycl_db.init_db() + with cycl_db.SessionLocal()() as s: + s.add(Resubmission( + claim_id=claim_id, + batch_id=batch_id, + resubmitted_at=datetime.now(timezone.utc), + source_corrected_path=f"/tmp/{claim_id}.x12", + interchange_control_number=icn, + group_control_number="1", + )) + s.commit() + + +def _cleanup(batch_id: str) -> None: + with cycl_db.SessionLocal()() as s: + s.query(Resubmission).filter(Resubmission.batch_id == batch_id).delete() + s.commit() + + +def test_no_resubmissions_prints_message_and_exits_0(): + cycl_db.init_db() + runner = CliRunner() + result = runner.invoke( + resubmissions_status_cmd, + ["--batch-id", "no-such-batch-zzz"], + ) + assert result.exit_code == 0, result.output + assert "no resubmissions recorded" in result.output + + +def test_one_resubmission_prints_status_row(): + try: + _seed_one(claim_id="claim-A", batch_id="batch-A") + runner = CliRunner() + result = runner.invoke( + resubmissions_status_cmd, + ["--batch-id", "batch-A"], + ) + assert result.exit_code == 0, result.output + assert "claim-A" in result.output + assert "pending_999" in result.output # no inbound ack yet + assert "batch-A" in result.output + finally: + _cleanup("batch-A") + + +def test_limit_truncates_output(): + try: + for i in range(5): + _seed_one( + claim_id=f"claim-L{i}", batch_id="batch-L", + icn=f"00000000{i}", + ) + runner = CliRunner() + result = runner.invoke( + resubmissions_status_cmd, + ["--batch-id", "batch-L", "--limit", "2"], + ) + assert result.exit_code == 0, result.output + assert "more" in result.output + # Only 2 rows printed in the table (plus 1 "(N more)" line). + body_lines = [ + ln for ln in result.output.splitlines() + if ln.startswith("claim-L") + ] + assert len(body_lines) == 2 + finally: + _cleanup("batch-L") + + +def test_status_reflects_paid_when_remittance_links(): + """When a remittance.claim_id links to the resubmitted claim, + the payment column shows 'paid'.""" + try: + from cyclone.db import Remittance + from decimal import Decimal + _seed_one(claim_id="claim-P", batch_id="batch-P") + with cycl_db.SessionLocal()() as s: + s.add(Remittance( + id="remit-P-1", + batch_id="batch-P", + payer_claim_control_number="claim-P", + claim_id="claim-P", + status_code="1", + status_label="Processed as Primary", + total_charge=Decimal("100.00"), + total_paid=Decimal("80.00"), + adjustment_amount=Decimal("0"), + claim_level_adjustment_amount=Decimal("0"), + received_at=datetime.now(timezone.utc), + )) + s.commit() + runner = CliRunner() + result = runner.invoke( + resubmissions_status_cmd, + ["--batch-id", "batch-P"], + ) + assert result.exit_code == 0, result.output + assert "claim-P" in result.output + assert "paid" in result.output + finally: + from cyclone.db import Remittance as _R + with cycl_db.SessionLocal()() as s: + s.query(_R).filter(_R.id == "remit-P-1").delete() + s.commit() + _cleanup("batch-P") \ No newline at end of file