feat(sp39): resubmit-rejected-claims instruments Resubmission rows
This commit is contained in:
@@ -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-<id>-<N>-claims -> <id>).
|
||||
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.
|
||||
|
||||
@@ -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")
|
||||
_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
|
||||
|
||||
Reference in New Issue
Block a user