From 3400ed5ec2a994a7394aba9d1f413ea4a7e78f9d Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 17:34:20 -0600 Subject: [PATCH] feat(sp39): resubmit-rejected-claims instruments Resubmission rows --- backend/src/cyclone/cli.py | 57 ++++++-- backend/tests/test_resubmissions_cli.py | 167 +++++++++++++++++++++++- 2 files changed, 215 insertions(+), 9 deletions(-) diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 795ccc0..fa400cb 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -648,15 +648,22 @@ def resubmit_rejected_claims( content = src.read_bytes() - # Validate: parse must succeed AND payer_id must match the - # companion-guide CO_TXIX (catches a bad byte-fix early). + # SP39: always parse once so the instrumentation step below can + # reach `parsed.claims` and `parsed.envelope` for the + # Resubmission row (which needs claim_id + ICN/GCN). With + # ``--no-validate`` the parse is still cheap for single-claim + # files and avoids a re-parse just for instrumentation. + try: + parsed = parse_837_text(content.decode(), payer_cfg) + except Exception as exc: # noqa: BLE001 + failed += 1 + click.echo(f"PARSE FAIL {src.name}: {exc.__class__.__name__}: {exc}", err=True) + continue + + # Validate (when enabled): parse must succeed AND payer_id must + # match the companion-guide CO_TXIX (catches a bad byte-fix + # early). if validate: - try: - parsed = parse_837_text(content.decode(), payer_cfg) - except Exception as exc: # noqa: BLE001 - failed += 1 - click.echo(f"PARSE FAIL {src.name}: {exc.__class__.__name__}: {exc}", err=True) - continue mismatch = next( (c for c in parsed.claims if c.payer.id != "CO_TXIX"), None ) @@ -737,6 +744,40 @@ def resubmit_rejected_claims( f"{exc.__class__.__name__}: {exc}", err=True, ) + # SP39: record one Resubmission row per claim per push. + # The corrected-v2 regen preserves the original claim_id + # from raw_json, so inbound 999 acks auto-link back to the + # original claim row and the resubmissions status CLI can + # surface the result via the existing SP28/31 auto-link. + # Idempotent on (claim_id, interchange_control_number). + try: + # Derive batch_id from the parent dir name + # (batch---claims -> ). + dir_name = src.parent.name + if dir_name.startswith("batch-") and "-" in dir_name[6:]: + push_batch_id = dir_name.split("-")[1] + else: + push_batch_id = dir_name + env = getattr(parsed, "envelope", None) + icn = getattr(env, "control_number", None) or "000000000" + gcn = getattr(env, "group_control_number", None) or "0" + for claim in parsed.claims: + claim_id = getattr(claim, "claim_id", None) + if not claim_id: + continue + cycl_store.record_resubmission( + claim_id=claim_id, + batch_id=push_batch_id, + source_corrected_path=str(src), + interchange_control_number=icn, + group_control_number=gcn, + ) + except Exception as exc: # noqa: BLE001 + click.echo( + f"resubmissions row write failed for {src.name}: " + f"{exc.__class__.__name__}: {exc}", err=True, + ) + uploaded += 1 # Reconnect periodically to dodge MOVEit's per-session cap. diff --git a/backend/tests/test_resubmissions_cli.py b/backend/tests/test_resubmissions_cli.py index becc2e8..5860a8f 100644 --- a/backend/tests/test_resubmissions_cli.py +++ b/backend/tests/test_resubmissions_cli.py @@ -117,4 +117,169 @@ def test_status_reflects_paid_when_remittance_links(): 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 + _cleanup("batch-P") + +# --------------------------------------------------------------------------- +# SP39: `cyclone resubmit-rejected-claims` instruments Resubmission rows. +# --------------------------------------------------------------------------- + + +def _seed_clearhouse_stub_false(tmp_path) -> None: + """Seed the clearhouse singleton and flip ``sftp_block.stub=False`` + with a non-routable host so the CLI proceeds past the guard and + the paramiko stub below intercepts the connection.""" + import json + from cyclone import db as db_mod + from cyclone.db import ClearhouseORM + from cyclone.store import store as cycl_store + cycl_store.ensure_clearhouse_seeded() + with db_mod.SessionLocal()() as s: + ch = s.get(ClearhouseORM, 1) + assert ch is not None, "ensure_clearhouse_seeded should have created it" + block = json.loads(json.dumps(ch.sftp_block_json)) + block["stub"] = False + block["host"] = "stub.invalid" + ch.sftp_block_json = block + s.commit() + + +def test_resubmit_rejected_claims_inserts_resubmission_row(tmp_path, monkeypatch): + """SP39: after a successful upload, one Resubmission row per claim + is inserted with the parent dir's batch_id and the parsed envelope + control numbers.""" + import sys + import types + from cyclone.cli import resubmit_rejected_claims + from cyclone.parsers.parse_837 import parse as parse_837_text + from cyclone.parsers.payer import PayerConfig + from cyclone.store import store as cycl_store + + _seed_clearhouse_stub_false(tmp_path) + + # Build a parseable 837P file (CO_TXIX payer, single claim). + cfg = PayerConfig.co_medicaid() + sample = tmp_path / "batch-deadbeef-1-claims" + sample.mkdir() + file_path = sample / "tp1-837P-test-1of1.x12" + src_text = open("tests/fixtures/co_medicaid_837p.txt").read() + file_path.write_text(src_text) + + parsed = parse_837_text(src_text, cfg) + assert parsed.claims, "fixture must produce at least one claim" + first_claim_id = parsed.claims[0].claim_id + + # Replace paramiko with a fake whose stat() raises (file not on + # remote yet, so the CLI takes the upload branch). + fake_paramiko = types.ModuleType("paramiko") + + class _FakeSFTP: + def stat(self, path): + raise IOError("not found") + def put(self, local, remote): + return None + + class _FakeSSH: + def open_sftp(self): + return _FakeSFTP() + def set_missing_host_key_policy(self, policy): + return None + def connect(self, *a, **kw): + return None + def close(self): + return None + + fake_paramiko.SSHClient = lambda: _FakeSSH() + fake_paramiko.AutoAddPolicy = lambda: None + monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko) + + captured: list[dict] = [] + monkeypatch.setattr( + cycl_store, "record_resubmission", + lambda **kw: captured.append(kw) or True, + ) + + runner = CliRunner() + result = runner.invoke(resubmit_rejected_claims, [ + "--ingest-dir", str(tmp_path), + "--limit", "1", + "--no-validate", + ]) + assert result.exit_code == 0, ( + f"CLI exited {result.exit_code}, output={result.output!r}, " + f"exception={result.exception!r}" + ) + assert len(captured) >= 1, ( + f"expected record_resubmission to be called, got {captured!r}" + ) + call = captured[0] + assert call["claim_id"] == first_claim_id, call + assert call["batch_id"] == "deadbeef", call + assert "deadbeef-1-claims" in call["source_corrected_path"], call + assert call["interchange_control_number"], call + assert call["group_control_number"], call + + +def test_resubmit_idempotency_does_not_create_duplicate_rows(tmp_path, monkeypatch): + """A second run against the same file (now on the SFTP) does NOT + insert a second Resubmission row — only the original upload branch + writes, matching the audit-log behavior.""" + from cyclone.cli import resubmit_rejected_claims + from cyclone.store import store as cycl_store + import sys + import types + + _seed_clearhouse_stub_false(tmp_path) + + # Set up: one corrected file. + sample = tmp_path / "batch-cafebabe-1-claims" + sample.mkdir() + file_path = sample / "tp1-837P-test-1of1.x12" + src_text = open("tests/fixtures/co_medicaid_837p.txt").read() + file_path.write_text(src_text) + on_remote_size = len(src_text.encode()) + + # Build a paramiko fake where stat() returns matching size so the + # CLI takes the skip branch. The CLI only reads ``st_size`` off the + # return value, so a simple duck-typed object suffices. + fake_paramiko = types.ModuleType("paramiko") + + class _FakeAttr: + def __init__(self, size: int) -> None: + self.st_size = size + + class _FakeSFTPAlready: + def stat(self, path): + return _FakeAttr(on_remote_size) + + def put(self, local, remote): + raise AssertionError("should not put when file already on remote") + + class _FakeSSHAlready: + def open_sftp(self): + return _FakeSFTPAlready() + def set_missing_host_key_policy(self, policy): + return None + def connect(self, *a, **kw): + return None + def close(self): + return None + + fake_paramiko.SSHClient = lambda: _FakeSSHAlready() + fake_paramiko.AutoAddPolicy = lambda: None + monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko) + + captured: list[dict] = [] + monkeypatch.setattr( + cycl_store, "record_resubmission", + lambda **kw: captured.append(kw) or True, + ) + + runner = CliRunner() + result = runner.invoke(resubmit_rejected_claims, [ + "--ingest-dir", str(tmp_path), + "--limit", "1", + "--no-validate", + ]) + # On the skip path, record_resubmission should NOT be called. + assert captured == [], f"expected no record_resubmission on skip, got {captured!r}" + assert "skipped" in result.output, result.output