From cf7c343ff087e1c07343eb886db81c4a931be0f6 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 20:23:56 -0600 Subject: [PATCH 1/7] feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX --- backend/src/cyclone/parsers/payer.py | 4 +-- ...test_co_medicaid_payer_id_emits_CO_TXIX.py | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py diff --git a/backend/src/cyclone/parsers/payer.py b/backend/src/cyclone/parsers/payer.py index 4851b22..1a3799d 100644 --- a/backend/src/cyclone/parsers/payer.py +++ b/backend/src/cyclone/parsers/payer.py @@ -66,8 +66,8 @@ class PayerConfig(BaseModel): # Lenient in v1 — see spec §9 R031. require_ref_g1_for_adjustments=False, allowed_bht06={"CH"}, - payer_id="SKCO0", - payer_name="COHCPF", + payer_id="CO_TXIX", + payer_name="CO_TXIX", no_patient_loop=True, encounter_claim_in_same_batch=False, allowed_facility_qualifiers={"B"}, diff --git a/backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py b/backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py new file mode 100644 index 0000000..bf88cfc --- /dev/null +++ b/backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py @@ -0,0 +1,33 @@ +"""SP33 regression test: ``PayerConfig.co_medicaid()`` emits CO_TXIX. + +The HCPF 837P Companion Guide (June 2025 - Version 2.5) requires +``NM1*PR NM108=PI, NM109=CO_TXIX`` for the Payer Name loop (2010BB). +The in-code factory historically emitted ``SKCO0`` which Gainwell +rejects with ``IK3*NM1*17*2010*8``. This test pins the corrected value. +See ``docs/superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md``. +""" +from __future__ import annotations + + +def test_co_medicaid_factory_emits_CO_TXIX(): + """Per HCPF 837P Companion Guide, NM1*PR NM109 must equal CO_TXIX.""" + from cyclone.parsers.payer import PayerConfig + cfg = PayerConfig.co_medicaid() + assert cfg.payer_id == "CO_TXIX", ( + f"co_medicaid().payer_id must be 'CO_TXIX' (HCPF guide), got {cfg.payer_id!r}" + ) + assert cfg.payer_name == "CO_TXIX", ( + f"co_medicaid().payer_name must be 'CO_TXIX' (matches docs/goodclaim.x12), got {cfg.payer_name!r}" + ) + + +def test_skco0_not_present_in_factory(): + """Hard guard: SKCO0 must never be emitted by the factory again.""" + from cyclone.parsers.payer import PayerConfig + cfg = PayerConfig.co_medicaid() + assert "SKCO0" not in cfg.payer_id, ( + f"SKCO0 must not appear in payer_id (HCPF guide says CO_TXIX), got {cfg.payer_id!r}" + ) + assert "SKCO0" not in cfg.payer_name, ( + f"SKCO0 must not appear in payer_name, got {cfg.payer_name!r}" + ) From 0625c83a45b758ac9455575a22294bb48d899fcd Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 21:07:07 -0600 Subject: [PATCH 2/7] feat(sp33): apply_999_rejections uses batch_envelope_index The 999 handler's rejection pass was looking up claims by patient control number, but Gainwell rejects at the SET level (ST envelope) when the whole batch fails the NM109=CO_TXIX rule. That meant a SET rejection was treated as a no-op even though every claim in the SET was actually rejected by the payer. Add a batch_envelope_index param (mirrors apply_999_acceptances from SP28) so SET-level rejections cascade to every claim in the SET. Falls back to the legacy PCN lookup when the index has no entry. Also tightens test_payer.py: PayerConfig.co_medicaid() now returns payer_id='CO_TXIX' and payer_name='CO_TXIX' per HCPF 837P Companion Guide (June 2025 - Version 2.5). --- backend/src/cyclone/handlers/handle_999.py | 4 +- backend/src/cyclone/inbox_state.py | 51 ++++++--- backend/tests/test_apply_999_rejections.py | 120 +++++++++++++++++++++ backend/tests/test_payer.py | 4 +- 4 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_apply_999_rejections.py diff --git a/backend/src/cyclone/handlers/handle_999.py b/backend/src/cyclone/handlers/handle_999.py index 6a9dbc0..920fe3e 100644 --- a/backend/src/cyclone/handlers/handle_999.py +++ b/backend/src/cyclone/handlers/handle_999.py @@ -90,7 +90,9 @@ def handle( .first() ) rejection_result = apply_999_rejections( - session, result, claim_lookup=_lookup, + session, result, + claim_lookup=_lookup, + batch_envelope_index=batch_index, ) if rejection_result.matched: for cid in rejection_result.matched: diff --git a/backend/src/cyclone/inbox_state.py b/backend/src/cyclone/inbox_state.py index 4a807d1..c5a6cfd 100644 --- a/backend/src/cyclone/inbox_state.py +++ b/backend/src/cyclone/inbox_state.py @@ -33,6 +33,7 @@ def apply_999_rejections( parsed_999, *, claim_lookup: Callable[[str], Claim | None], + batch_envelope_index: dict[str, list[str]] | None = None, ) -> Apply999Result: """For each set response with code R or E, look up the matching claim and move it to REJECTED. Idempotent on already-rejected claims. @@ -41,34 +42,54 @@ def apply_999_rejections( session: SQLAlchemy session. parsed_999: a ParseResult999 (or any object with .set_responses). claim_lookup: callable from patient_control_number → Claim or None. + Legacy fallback; rarely hits when batch_envelope_index is present. + batch_envelope_index: SP33 — mapping from SET control_number (the 837 + envelope's ST02) to list of Claim.id for the claims in that SET. + Mirrors the SP28 fix in apply_999_acceptances so SET-level + rejections correctly cascade across every claim under the SET. Returns: Apply999Result with lists of matched claim ids and orphan PCNs. """ result = Apply999Result() now = datetime.now(timezone.utc) + index = batch_envelope_index or {} for sr in parsed_999.set_responses: code = sr.set_accept_reject.code if code not in ("R", "E", "X"): continue - claim = claim_lookup(sr.set_control_number) - if claim is None: - result.orphans.append(sr.set_control_number) - continue + # SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level + # rejection correctly flips every claim in the SET. Fall back to + # the legacy claim_lookup when the index is empty for this SCN. + candidate_ids = index.get(sr.set_control_number, []) or [] + claims_to_reject: list[Claim] = [] + if candidate_ids: + claims_to_reject = ( + session.query(Claim) + .filter(Claim.id.in_(candidate_ids)) + .all() + ) + else: + legacy = claim_lookup(sr.set_control_number) + if legacy is not None: + claims_to_reject = [legacy] + else: + result.orphans.append(sr.set_control_number) + continue - if claim.state == ClaimState.REJECTED: - # Idempotent: don't double-mutate. - continue - - claim.state = ClaimState.REJECTED - claim.state_changed_at = now - claim.rejected_at = now - claim.rejection_reason = _build_reason( - code, len(sr.segment_errors or []) - ) - result.matched.append(claim.id) + for claim in claims_to_reject: + if claim.state == ClaimState.REJECTED: + # Idempotent: don't double-mutate. + continue + claim.state = ClaimState.REJECTED + claim.state_changed_at = now + claim.rejected_at = now + claim.rejection_reason = _build_reason( + code, len(sr.segment_errors or []) + ) + result.matched.append(claim.id) if result.matched or result.orphans: session.commit() diff --git a/backend/tests/test_apply_999_rejections.py b/backend/tests/test_apply_999_rejections.py new file mode 100644 index 0000000..2b0310d --- /dev/null +++ b/backend/tests/test_apply_999_rejections.py @@ -0,0 +1,120 @@ +"""SP33: apply_999_rejections must use batch_envelope_index (mirrors +SP28's apply_999_acceptances fix) so SET-level 999 rejections cascade +to claim state transitions. + +The pre-SP33 bug: ``claim_lookup(sr.set_control_number)`` passed the +SET control_number to a PCN-based lookup, so no claim was ever matched +and rejections silently died. SP33 threads the same +``batch_envelope_index`` that ``apply_999_acceptances`` already has. +""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from cyclone.db import ClaimState +from cyclone.inbox_state import apply_999_rejections + + +def _mk_set_response(code: str = "R", scn: str = "991102994"): + """Build a stand-in for ``ParseResult999.set_responses[0]``.""" + return SimpleNamespace( + set_accept_reject=SimpleNamespace(code=code), + set_control_number=scn, + segment_errors=[], + ) + + +def _mk_claim(cid: str = "claim-x", state=ClaimState.SUBMITTED): + c = MagicMock() + c.id = cid + c.state = state + return c + + +def test_set_level_rejection_cascades_via_envelope_index(): + """When the SET-level 999 has set_control_number=991102994 and the + batch_envelope_index maps it to a list of 3 claim_ids, all 3 should + transition to REJECTED. + """ + session = MagicMock() + claims = [_mk_claim(f"c-{i}") for i in range(3)] + session.query.return_value.filter.return_value.all.return_value = claims + + parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")]) + index = {"991102994": [c.id for c in claims]} + + result = apply_999_rejections( + session, parsed, + claim_lookup=lambda _pcn: None, + batch_envelope_index=index, + ) + + assert sorted(result.matched) == sorted(c.id for c in claims) + assert result.orphans == [] + session.commit.assert_called_once() + for c in claims: + # ClaimState.REJECTED is the lowercase 'rejected' string (the enum + # value stored in the DB column via SQLAlchemy Enum type). + assert c.state == ClaimState.REJECTED + + +def test_set_level_rejection_without_index_still_uses_legacy_lookup(): + """Backwards compat: when batch_envelope_index is None, the function + must fall back to claim_lookup (existing behavior preserved). + """ + session = MagicMock() + parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")]) + legacy_claim = _mk_claim("legacy-claim") + + result = apply_999_rejections( + session, parsed, + claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None, + ) + + assert result.matched == ["legacy-claim"] + + +def test_no_envelope_index_no_match_becomes_orphan(): + """Empty index + empty legacy lookup => the SET becomes an orphan.""" + session = MagicMock() + parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102999")]) + + result = apply_999_rejections( + session, parsed, + claim_lookup=lambda _pcn: None, + batch_envelope_index={}, # empty index + ) + assert result.matched == [] + assert "991102999" in result.orphans + + +def test_accepted_999_does_not_reject(): + """An 'A' code must not transition any claim to REJECTED.""" + session = MagicMock() + parsed = SimpleNamespace(set_responses=[_mk_set_response(code="A", scn="991102994")]) + + result = apply_999_rejections( + session, parsed, + claim_lookup=lambda _pcn: None, + batch_envelope_index={"991102994": ["claim-x"]}, + ) + assert result.matched == [] + session.query.assert_not_called() + session.commit.assert_not_called() + + +def test_already_rejected_is_idempotent(): + """Pre-REJECTED claims in the index are left alone (no second commit).""" + session = MagicMock() + already = _mk_claim("already-rejected", state=ClaimState.REJECTED) + session.query.return_value.filter.return_value.all.return_value = [already] + + parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")]) + result = apply_999_rejections( + session, parsed, + claim_lookup=lambda _pcn: None, + batch_envelope_index={"991102994": ["already-rejected"]}, + ) + assert result.matched == [] + assert already.state == ClaimState.REJECTED # untouched diff --git a/backend/tests/test_payer.py b/backend/tests/test_payer.py index 814eff2..bf95d70 100644 --- a/backend/tests/test_payer.py +++ b/backend/tests/test_payer.py @@ -8,8 +8,8 @@ def test_co_medicaid_defaults(): assert cfg.allowed_claim_frequencies == {1, 7, 8} assert cfg.require_ref_g1_for_adjustments is False # lenient in v1 assert cfg.allowed_bht06 == {"CH"} - assert cfg.payer_id == "SKCO0" - assert cfg.payer_name == "COHCPF" + assert cfg.payer_id == "CO_TXIX" + assert cfg.payer_name == "CO_TXIX" assert cfg.no_patient_loop is True assert cfg.encounter_claim_in_same_batch is False From 3bf56220103f63d07bdbef464055260aae54549b Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 21:07:12 -0600 Subject: [PATCH 3/7] docs(sp33): update reference docs to CO_TXIX (per HCPF 837P Companion Guide) docs/reference/837p.md line 48: the CO Medicaid trading-partner line was still saying 'SKCO0 (sender) <-> COHCPF (receiver)'. The HCPF 837P Companion Guide (June 2025 - Version 2.5) requires NM1*PR NM109 = CO_TXIX; SKCO0 causes Gainwell to reject the SET ('2010BB NM109 must equal CO_TXIX or CO_BHA'). docs/reference/co-medicaid.md line 118 + the Trading Partner table at the top: same fix, with a pointer to the SP33 spec for the root-cause story. No code changes. Pure doc fix to match what the source now emits (PayerConfig.co_medicaid().payer_id = 'CO_TXIX'). --- docs/reference/837p.md | 2 +- docs/reference/co-medicaid.md | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/reference/837p.md b/docs/reference/837p.md index 6bd0a99..b813648 100644 --- a/docs/reference/837p.md +++ b/docs/reference/837p.md @@ -45,7 +45,7 @@ full walker. ## CO Medicaid specifics -- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40` +- Trading partners: `COMEDASSISTPROG` (NM1*40 NM109, 1000B receiver) and `CO_TXIX` (NM1*PR NM109, 2010BB payer). See `docs/goodclaim.x12` for a canonical example and the HCPF 837P Companion Guide for the full segment table. - `CLM05` is a composite of three components: place of service, facility code qualifier, and frequency code (in that order) - `CLM05-1` = place of service (any valid CMS POS code) diff --git a/docs/reference/co-medicaid.md b/docs/reference/co-medicaid.md index fc8dd26..4d77b69 100644 --- a/docs/reference/co-medicaid.md +++ b/docs/reference/co-medicaid.md @@ -9,11 +9,14 @@ Financing (HCPF) requires. | Role | 837P | 835 | |---|---|---| -| Sender | `SKCO0` | (varies; usually the clearinghouse) | -| Receiver | `COHCPF` | (varies) | +| Sender (submitter, `NM1*41`) | `Dzinesco` (TPID `11525703`) | (varies; usually the clearinghouse) | +| Receiver (`NM1*40`) | `COMEDASSISTPROG` | (varies) | +| Payer (`NM1*PR`) | `CO_TXIX` (per HCPF 837P Companion Guide) | (varies) | These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of -the 837P file. +the 837P file. The legacy codes `SKCO0` / `COHCPF` are no longer +accepted by Gainwell (HCPF error: "2010BB NM109 must equal CO_TXIX or +CO_BHA"); see [SP33](../superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md). ## dzinesco's TPID (clearinghouse identity) @@ -115,7 +118,7 @@ ls -la "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/ToHPE/" ### 837P (claims) -- `NM1*PR N104 = "SKCO0"` (COHCPF) +- `NM1*PR NM108 = "PI"`, `NM1*PR NM109 = "CO_TXIX"` (per HCPF 837P Companion Guide, June 2025 — Version 2.5). dzinesco submits against this code under dzinesco TPID `11525703`. The legacy trading-partner ID `SKCO0` is no longer accepted as the payer identifier. ### 835 (remittance) From 244015a361fae65575865f7630b3642b8c56a261 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 21:16:11 -0600 Subject: [PATCH 4/7] feat(sp33): cli backfill-999-rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the 'cyclone backfill-999-rejections' subcommand to replay the cascade fix in apply_999_rejections for any 999 acks already in the DB. Used on 2026-07-02 after Gainwell rejected the four dzinesco batches at the SET level — the 999s were ingested but the pre-SP33 cascade bug didn't flip claim states, so the dashboard's '0/145 accepted' widget was lying. The command walks claim_acks joined with claims where the link row's set_accept_reject_code='R', groups by claim_id so each unique claim fires exactly one audit event (the 36777 R-coded rows collapse to 339 unique claims), and flips each still-SUBMITTED claim to REJECTED with rejection_reason + payer_rejected_* fields populated. Claims already in REJECTED are skipped. Also moves 'if __name__ == "__main__": main()' to the bottom of the file. The old mid-file placement meant commands defined after it (backup, pull-inbound, backfill-999-rejections) weren't accessible via 'python -m cyclone.cli ' — only the 'cyclone' console script worked. Latent bug since SP17; surfaced when SP33 added another post-block command. --- backend/src/cyclone/cli.py | 130 +++++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 4 deletions(-) diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 0ac07c1..14b0738 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -388,10 +388,6 @@ def backfill_rendering_npi( ) -if __name__ == "__main__": - main() - - # --------------------------------------------------------------------------- # SP17: `cyclone backup` subcommands # @@ -403,6 +399,128 @@ if __name__ == "__main__": # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# SP33: `cyclone backfill-999-rejections` +# +# One-shot replay of the cascade fix in `apply_999_rejections` for any +# 999 acks already in the DB. Used on the night of 2026-07-02 after +# Gainwell rejected the four dzinesco batches at the SET level +# ("2010BB NM109 must equal CO_TXIX") — the 999s were ingested but the +# pre-SP33 cascade bug didn't flip claim states, so the dashboard's +# "0/145 accepted" widget was lying. +# +# Idempotent: claims already in REJECTED are skipped (counted in +# `already_rejected`). Safe to re-run. +# --------------------------------------------------------------------------- + +@main.command("backfill-999-rejections") +@click.option("--dry-run", is_flag=True, default=False, + help="Print the would-be state transitions without writing.") +@click.option("--actor", default="sp33-backfill", + show_default=True, + help="Audit-log actor tag for the claim.rejected events.") +def backfill_999_rejections(dry_run: bool, actor: str) -> None: + """Replay existing 999 rejections onto already-linked claims (SP33). + + Walks ``claim_acks`` joined with ``claims`` where the link row's + ``set_accept_reject_code='R'`` (a SET-level rejection from the 999 + envelope) and flips the matching claim to ``REJECTED`` — mirroring + what the (now-fixed) ``apply_999_rejections`` would have done at + ingest time. + + Each flipped claim gets: + - ``state`` set to ``REJECTED`` + - ``state_changed_at`` and ``rejected_at`` set to now + - ``rejection_reason`` filled with the 999 AK5 code + SCN + - ``payer_rejected_at`` / ``payer_rejected_reason`` / + ``payer_rejected_status_code`` filled (the 999 SET-level reject + is also a payer-side reject for Inbox-lanes purposes) + - one ``claim.rejected`` audit-log event + + Claims already in REJECTED are skipped (counted in the summary). + """ + from datetime import datetime, timezone + from sqlalchemy import select + + from cyclone import db as db_mod + from cyclone.audit_log import AuditEvent, append_event + + db_mod.init_db() + now = datetime.now(timezone.utc) + + with db_mod.SessionLocal()() as session: + # Pull one representative R-coded link per claim_id (a single + # 999 may stamp many AK2 rows against the same claim, so we + # collapse via MIN(set_control_number) / MIN(ak2_index) to avoid + # firing 1 audit event per duplicate ack row). + # + # 36777 R-coded rows resolve to 339 unique claims (338 still in + # SUBMITTED + 1 already in REJECTED). Grouping in SQL keeps the + # in-Python loop small AND emits exactly 1 audit event per claim. + rows = session.execute( + select( + db_mod.Claim.id, + db_mod.Claim.state, + db_mod.func.min(db_mod.ClaimAck.set_control_number).label("scn"), + db_mod.func.min(db_mod.ClaimAck.ak2_index).label("ak2"), + db_mod.func.min(db_mod.Ack.ack_code).label("ack_code"), + ) + .join(db_mod.ClaimAck, db_mod.ClaimAck.claim_id == db_mod.Claim.id) + .join(db_mod.Ack, db_mod.Ack.id == db_mod.ClaimAck.ack_id) + .where(db_mod.ClaimAck.set_accept_reject_code == "R") + .where(db_mod.ClaimAck.claim_id.is_not(None)) + .group_by(db_mod.Claim.id, db_mod.Claim.state) + ).all() + + matched = 0 + already = 0 + errors = 0 + for (claim_id, current_state, scn, ak2_idx, ack_code) in rows: + if current_state == db_mod.ClaimState.REJECTED: + already += 1 + continue + + if dry_run: + matched += 1 + continue + + claim = session.get(db_mod.Claim, claim_id) + if claim is None: + errors += 1 + continue + + claim.state = db_mod.ClaimState.REJECTED + claim.state_changed_at = now + claim.rejected_at = now + claim.rejection_reason = ( + f"999 AK5={ack_code or 'R'} SCN={scn} ak2={ak2_idx}" + ) + # Mirror the 999 SET-level reject into the payer-rejected + # lane so the Inbox sees it as a Payer-Rejected claim too. + claim.payer_rejected_at = now + claim.payer_rejected_reason = f"999 SET-level reject at SCN={scn}" + claim.payer_rejected_status_code = "R" + + append_event(session, AuditEvent( + event_type="claim.rejected", + entity_type="claim", + entity_id=claim_id, + payload={"source": "backfill-999-rejections", + "scn": scn, "ak2_index": ak2_idx, + "ack_code": ack_code}, + actor=actor, + )) + matched += 1 + + if not dry_run: + session.commit() + + click.echo( + f"matched={matched} already_rejected={already} errors={errors} " + f"dry_run={dry_run}" + ) + + @main.group() def backup() -> None: """Encrypted DB backup management (SP17).""" @@ -824,3 +942,7 @@ def pull_inbound( click.echo("download errors:", err=True) for e in summary["download_errors"]: click.echo(f" {e}", err=True) + + +if __name__ == "__main__": + main() From f10ab8362890a192d5fcd79eb8effe237a21f3bb Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 21:50:23 -0600 Subject: [PATCH 5/7] feat(sp33): cli resubmit-rejected-claims + fixture/test payer_id refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds `cyclone resubmit-rejected-claims` to push corrected single-claim 837 files to the Gainwell ToHPE SFTP dir. Idempotent (stat-then-skip by byte size). One persistent paramiko session per batch with reconnect-every=50 to dodge MOVEit's silent per-session file cap (~200 puts/session, no exception). - Validates each file via `parse_837` before upload and rejects any whose payer_id is not `CO_TXIX` (catches a bad byte-fix early). - Refreshes test fixtures (`minimal_837p.txt`, `co_medicaid_837p.txt`, `co_medicaid_837p_with_renderer.txt`) and the corresponding test assertions (`test_payer.py`, `test_payer_summary.py`, `test_co_medicaid_fixture.py`, `test_parse_837.py`) from the old `SKCO0`/`COHCPF` payer IDs to `CO_TXIX`, matching PayerConfig.co_medicaid() and the HCPF 837P Companion Guide. - Adds `ingest/` to .gitignore — local scratch / production-data staging only. --- .gitignore | 4 + backend/src/cyclone/cli.py | 251 +++++++++++++++++- backend/tests/fixtures/co_medicaid_837p.txt | 4 +- .../co_medicaid_837p_with_renderer.txt | 4 +- backend/tests/fixtures/minimal_837p.txt | 2 +- backend/tests/test_co_medicaid_fixture.py | 4 +- backend/tests/test_parse_837.py | 2 +- backend/tests/test_payer_summary.py | 18 +- 8 files changed, 268 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 779370e..479a213 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ claims_output/ # Brainstorm session artifacts (visual companion mockups, events, server state). # Skills under .superpowers/skills/ are committed project-scoped guidance. .superpowers/brainstorm/ + +# SP33+ scratch / production-data ingest. Generated artifacts live +# here only — the source EDI sits under docs/prodfiles/. +ingest/ diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 14b0738..a7cdd20 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -440,7 +440,7 @@ def backfill_999_rejections(dry_run: bool, actor: str) -> None: Claims already in REJECTED are skipped (counted in the summary). """ from datetime import datetime, timezone - from sqlalchemy import select + from sqlalchemy import select, func from cyclone import db as db_mod from cyclone.audit_log import AuditEvent, append_event @@ -461,9 +461,9 @@ def backfill_999_rejections(dry_run: bool, actor: str) -> None: select( db_mod.Claim.id, db_mod.Claim.state, - db_mod.func.min(db_mod.ClaimAck.set_control_number).label("scn"), - db_mod.func.min(db_mod.ClaimAck.ak2_index).label("ak2"), - db_mod.func.min(db_mod.Ack.ack_code).label("ack_code"), + func.min(db_mod.ClaimAck.set_control_number).label("scn"), + func.min(db_mod.ClaimAck.ak2_index).label("ak2"), + func.min(db_mod.Ack.ack_code).label("ack_code"), ) .join(db_mod.ClaimAck, db_mod.ClaimAck.claim_id == db_mod.Claim.id) .join(db_mod.Ack, db_mod.Ack.id == db_mod.ClaimAck.ack_id) @@ -521,6 +521,249 @@ def backfill_999_rejections(dry_run: bool, actor: str) -> None: ) +# --------------------------------------------------------------------------- +# SP33: `cyclone resubmit-rejected-claims` +# +# Push the corrected single-claim 837 files to Gainwell's SFTP ToHPE +# dir so dzinesco can resubmit the batch. The byte-level +# SKCO0 -> CO_TXIX fix is assumed to have already been applied (see +# the SP33 plan §4.4 / `docs/ingest/corrected/`). This CLI just walks +# the corrected directory, validates each file via the parser, and +# uploads via the real SftpClient. +# +# Idempotent: a file already present on the remote with the same byte +# size is skipped (counted in `skipped`). Re-runnable after a partial +# failure without re-uploading files that landed. +# --------------------------------------------------------------------------- + +@main.command("resubmit-rejected-claims") +@click.option("--ingest-dir", default="ingest/corrected", + show_default=True, + help="Root dir holding batch-*-claims/ subfolders of fixed .x12 files.") +@click.option("--actor", default="sp33-resubmit", + show_default=True, + help="Audit-log actor tag for the clearhouse.submitted events.") +@click.option("--validate/--no-validate", default=True, + help="Parse each file via parse_837 before upload (catches a bad fix).") +@click.option("--limit", type=int, default=None, + help="Stop after checking this many files (smoke-tests). Counts " + "all attempts, not just successful uploads.") +@click.option("--reconnect-every", type=int, default=50, show_default=True, + help="Reconnect SFTP every N uploads to avoid MOVEit's per-session cap.") +def resubmit_rejected_claims( + ingest_dir: str, + actor: str, + validate: bool, + limit: int | None, + reconnect_every: int, +) -> None: + """Upload corrected 837 files to the Gainwell SFTP ToHPE dir (SP33). + + Walks every ``batch-*-claims/*.x12`` under ``--ingest-dir`` (default + ``./ingest/corrected``), validates each one through ``parse_837``, + and uploads via the seeded real-SFTP ``SftpClient``. + + Idempotent: a file already present on the remote with the same + byte size is skipped (counted in ``skipped``). Reconnects every + ``--reconnect-every`` uploads to avoid MOVEit's silent per-session + file cap (observed: ~200 puts/session before silent drops with no + exception — see SP33 root-cause notes). + + Doesn't mutate claim state — claims stay in REJECTED until a 999 + ACK confirms Gainwell accepted the resubmit. Emits one + ``clearhouse.submitted`` audit event per successful upload. + """ + import time + + from cyclone import db as db_mod + from cyclone.audit_log import AuditEvent, append_event + from cyclone.clearhouse import SftpClient + 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 + + db_mod.init_db() + cycl_store.ensure_clearhouse_seeded() + clearhouse = cycl_store.get_clearhouse() + if clearhouse is None: + click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True) + sys.exit(2) + sftp_block = clearhouse.sftp_block + if sftp_block.stub: + click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True) + sys.exit(2) + remote_root = sftp_block.paths["outbound"] + + root = Path(ingest_dir).resolve() + if not root.exists(): + click.echo(f"--ingest-dir does not exist: {root}", err=True) + sys.exit(2) + + files: list[Path] = [] + for batch_dir in sorted(root.glob("batch-*-claims")): + files.extend(sorted(p for p in batch_dir.glob("*.x12") + if not p.name.startswith("._"))) + if not files: + click.echo(f"No .x12 files under {root}", err=True) + sys.exit(2) + + click.echo(f"found {len(files)} files under {root}") + + uploaded = 0 + skipped = 0 + failed = 0 + validated = 0 + payer_cfg = PayerConfig.co_medicaid() + sftp_client = SftpClient(sftp_block) + start = time.monotonic() + last_progress = start + + # One persistent paramiko session per batch, with periodic + # reconnect to dodge MOVEit's silent per-session file cap + # (~200 puts/session, no exception — see SP33 root-cause notes). + import paramiko + from cyclone.secrets import get_secret + + def _open_session() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]: + pw = get_secret(sftp_block.auth.get("password_keychain_account", "")) + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + sftp_block.host, port=sftp_block.port, + username=sftp_block.username, password=pw, + timeout=15, banner_timeout=15, auth_timeout=15, + ) + return ssh, ssh.open_sftp() + + def _close_session(ssh: paramiko.SSHClient | None) -> None: + if ssh is None: + return + try: ssh.close() + except Exception: pass + + for i, src in enumerate(files, 1): + if limit is not None and i > limit: + break + + # Validate: 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(src.read_text(), payer_cfg) + except Exception as exc: # noqa: BLE001 + failed += 1 + click.echo(f"PARSE FAIL {src.name}: {exc.__class__.__name__}: {exc}", err=True) + continue + for c in parsed.claims: + if c.payer.id != "CO_TXIX": + failed += 1 + click.echo( + f"PAYER MISMATCH {src.name}: payer.id={c.payer.id!r} " + f"(expected 'CO_TXIX')", err=True, + ) + break + else: + validated += 1 + + content = src.read_bytes() + local_size = len(content) + remote_path = f"{remote_root}/{src.name}" + + ssh: paramiko.SSHClient | None = None + sftp: paramiko.SFTPClient | None = None + attempts = 0 + ok = False + while attempts < 3: + attempts += 1 + if ssh is None: + try: + ssh, sftp = _open_session() + except Exception as exc: # noqa: BLE001 + click.echo( + f"SFTP CONNECT FAIL attempt {attempts}: " + f"{exc.__class__.__name__}: {exc}", err=True, + ) + continue + try: + # Idempotency check. + try: + rs = sftp.stat(remote_path) # type: ignore[union-attr] + if rs.st_size == local_size: + skipped += 1 + ok = True + break + except IOError: + pass # not on remote yet + sftp.put(str(src), remote_path) # type: ignore[union-attr] + ok = True + break + except Exception as exc: # noqa: BLE001 + # Connection died — drop the session and let the next + # attempt reopen. + _close_session(ssh) + ssh, sftp = None, None + if attempts >= 3: + click.echo( + f"UPLOAD FAIL {src.name}: " + f"{exc.__class__.__name__}: {exc}", err=True, + ) + continue + + if not ok: + failed += 1 + continue + + # Reconnect periodically to dodge MOVEit's per-session cap. + if uploaded > 0 and uploaded % reconnect_every == 0: + click.echo(f"reconnecting (after {uploaded} uploads)", err=True) + _close_session(ssh) + ssh, sftp = None, None + + # Audit (best-effort; if the DB is unavailable we still keep + # the file on the wire). + try: + with db_mod.SessionLocal()() as session: + append_event(session, AuditEvent( + event_type="clearhouse.submitted", + entity_type="claim_file", + entity_id=src.name, + payload={"remote_path": remote_path, + "source": "resubmit-rejected-claims", + "size": local_size}, + actor=actor, + )) + session.commit() + except Exception as exc: # noqa: BLE001 + click.echo( + f"audit-log write failed for {src.name}: " + f"{exc.__class__.__name__}: {exc}", err=True, + ) + + uploaded += 1 + + # Progress every 10s of wall-clock (or at end). + now = time.monotonic() + if now - last_progress >= 10 or i == len(files): + elapsed = now - start + rate = uploaded / elapsed if elapsed else 0 + click.echo( + f"progress {i}/{len(files)} uploaded={uploaded} " + f"skipped={skipped} failed={failed} rate={rate:.2f}/s " + f"elapsed={elapsed:.1f}s", err=True, + ) + last_progress = now + + # Tear down the long-lived session if one is still open. + if ssh is not None: + _close_session(ssh) + + elapsed = time.monotonic() - start + click.echo( + f"DONE uploaded={uploaded} skipped={skipped} failed={failed} " + f"validated={validated} files_total={len(files)} elapsed={elapsed:.1f}s" + ) + + @main.group() def backup() -> None: """Encrypted DB backup management (SP17).""" diff --git a/backend/tests/fixtures/co_medicaid_837p.txt b/backend/tests/fixtures/co_medicaid_837p.txt index 54def3f..ebf374a 100644 --- a/backend/tests/fixtures/co_medicaid_837p.txt +++ b/backend/tests/fixtures/co_medicaid_837p.txt @@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~ N3*1811 PAVILION DR APT 303~ N4*Montrose*CO*814016072~ DMG*D8*19590223*F~ -NM1*PR*2*COHCPF*****PI*SKCO0~ +NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~ REF*G1*3173~ HI*ABK:R69~ @@ -35,7 +35,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~ N3*1900 Kellie DR~ N4*Montrose*CO*814019524~ DMG*D8*19570727*F~ -NM1*PR*2*COHCPF*****PI*SKCO0~ +NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~ REF*G1*3173~ HI*ABK:R69~ diff --git a/backend/tests/fixtures/co_medicaid_837p_with_renderer.txt b/backend/tests/fixtures/co_medicaid_837p_with_renderer.txt index 1132e8c..7f678b7 100644 --- a/backend/tests/fixtures/co_medicaid_837p_with_renderer.txt +++ b/backend/tests/fixtures/co_medicaid_837p_with_renderer.txt @@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~ N3*1811 PAVILION DR APT 303~ N4*Montrose*CO*814016072~ DMG*D8*19590223*F~ -NM1*PR*2*COHCPF*****PI*SKCO0~ +NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~ REF*G1*3173~ HI*ABK:R69~ @@ -36,7 +36,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~ N3*1900 Kellie DR~ N4*Montrose*CO*814019524~ DMG*D8*19570727*F~ -NM1*PR*2*COHCPF*****PI*SKCO0~ +NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~ REF*G1*3173~ HI*ABK:R69~ diff --git a/backend/tests/fixtures/minimal_837p.txt b/backend/tests/fixtures/minimal_837p.txt index 47c8040..61ee4e6 100644 --- a/backend/tests/fixtures/minimal_837p.txt +++ b/backend/tests/fixtures/minimal_837p.txt @@ -17,7 +17,7 @@ NM1*IL*1*Doe*John****MI*ABC123~ N3*456 Member St~ N4*Denver*CO*80203~ DMG*D8*19800101*M~ -NM1*PR*2*COHCPF*****PI*SKCO0~ +NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~ REF*G1*PA123~ HI*ABK:Z00~ diff --git a/backend/tests/test_co_medicaid_fixture.py b/backend/tests/test_co_medicaid_fixture.py index e15e49d..3ed54f8 100644 --- a/backend/tests/test_co_medicaid_fixture.py +++ b/backend/tests/test_co_medicaid_fixture.py @@ -36,5 +36,5 @@ def test_subscriber_payer_reused_across_claims(): result = parse(FIXTURE.read_text(), PayerConfig.co_medicaid()) assert result.claims[0].billing_provider.npi == "1881068062" assert result.claims[1].billing_provider.npi == "1881068062" - assert result.claims[0].payer.id == "SKCO0" - assert result.claims[1].payer.id == "SKCO0" + assert result.claims[0].payer.id == "CO_TXIX" + assert result.claims[1].payer.id == "CO_TXIX" diff --git a/backend/tests/test_parse_837.py b/backend/tests/test_parse_837.py index f904106..6f9d0c6 100644 --- a/backend/tests/test_parse_837.py +++ b/backend/tests/test_parse_837.py @@ -18,7 +18,7 @@ def test_parse_minimal_fixture_returns_one_claim(): assert claim.subscriber.last_name == "Doe" assert claim.subscriber.first_name == "John" assert claim.subscriber.member_id == "ABC123" - assert claim.payer.id == "SKCO0" + assert claim.payer.id == "CO_TXIX" assert claim.claim.frequency_code == "1" assert claim.claim.place_of_service == "12" assert claim.claim.prior_auth == "PA123" diff --git a/backend/tests/test_payer_summary.py b/backend/tests/test_payer_summary.py index 9abb1c7..6fb9e04 100644 --- a/backend/tests/test_payer_summary.py +++ b/backend/tests/test_payer_summary.py @@ -5,13 +5,13 @@ The endpoint is the payer-level aggregate that the drill-down UI's denial rate, and the top 5 NPIs by claim volume for one payer_id, cached in-process for 60s. -The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``, +The minimal 837P fixture ships one CLM with ``payer_id="CO_TXIX"``, charge_amount=100.00; the minimal 835 carries one CLP for the same -claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns +claim with total_paid=85.00. So ``/api/payers/CO_TXIX/summary`` returns ``claim_count >= 1`` after both files are ingested. Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier, -e.g. ``SKCO0``). It is NOT the configured payer name from +e.g. ``CO_TXIX``). It is NOT the configured payer name from ``config/payers.yaml``. The filter key in the store layer is ``Claim.payer_id`` — not the ``payer=`` substring filter used by ``/api/claims?payer=...``. @@ -54,7 +54,7 @@ def client() -> TestClient: def seeded_db(client: TestClient): """Ingest one minimal 837P + one minimal 835. - Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint + Both fixtures carry ``payer_id="CO_TXIX"`` so the summary endpoint has something to aggregate. ``client`` is yielded back so the test can hit the API on the same TestClient that ingested the fixtures (parses share the per-test SQLite from conftest). @@ -77,11 +77,11 @@ def seeded_db(client: TestClient): def test_payer_summary_happy_path(seeded_db: TestClient): - """Seeded db has at least one claim for SKCO0 → 200 with the spec shape.""" - resp = seeded_db.get("/api/payers/SKCO0/summary") + """Seeded db has at least one claim for CO_TXIX → 200 with the spec shape.""" + resp = seeded_db.get("/api/payers/CO_TXIX/summary") assert resp.status_code == 200, resp.text data = resp.json() - assert data["payer_id"] == "SKCO0" + assert data["payer_id"] == "CO_TXIX" assert "claim_count" in data assert "billed_total" in data assert "received_total" in data @@ -99,8 +99,8 @@ def test_payer_summary_unknown_payer_returns_404(client: TestClient): def test_payer_summary_caches_then_invalidates(seeded_db: TestClient): """Two back-to-back calls return identical payloads (in-process cache).""" - resp1 = seeded_db.get("/api/payers/SKCO0/summary") - resp2 = seeded_db.get("/api/payers/SKCO0/summary") + resp1 = seeded_db.get("/api/payers/CO_TXIX/summary") + resp2 = seeded_db.get("/api/payers/CO_TXIX/summary") assert resp1.status_code == 200 assert resp2.status_code == 200 assert resp1.json() == resp2.json() \ No newline at end of file From 27bca33b0921e7c3d0620239ac1826912ce1be3f Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 3 Jul 2026 12:32:30 -0600 Subject: [PATCH 6/7] feat(sp33): fix resubmit CLI session reuse + mismatch skip - payer-mismatch files are now skipped instead of uploaded - SFTP session persists across files (was reopened and leaked per file) - --reconnect-every now counts successful uploads, after increment - drop unused SftpClient instantiation; read each file once - apply_999_rejections docstring: R/E/X, not R/E Co-Authored-By: Claude Fable 5 --- backend/src/cyclone/cli.py | 45 +++++++++++++++--------------- backend/src/cyclone/inbox_state.py | 2 +- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index a7cdd20..6519d92 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -577,7 +577,6 @@ def resubmit_rejected_claims( from cyclone import db as db_mod from cyclone.audit_log import AuditEvent, append_event - from cyclone.clearhouse import SftpClient 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 @@ -614,7 +613,6 @@ def resubmit_rejected_claims( failed = 0 validated = 0 payer_cfg = PayerConfig.co_medicaid() - sftp_client = SftpClient(sftp_block) start = time.monotonic() last_progress = start @@ -641,36 +639,39 @@ def resubmit_rejected_claims( try: ssh.close() except Exception: pass + ssh: paramiko.SSHClient | None = None + sftp: paramiko.SFTPClient | None = None + for i, src in enumerate(files, 1): if limit is not None and i > limit: break + content = src.read_bytes() + # Validate: 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(src.read_text(), payer_cfg) + 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 - for c in parsed.claims: - if c.payer.id != "CO_TXIX": - failed += 1 - click.echo( - f"PAYER MISMATCH {src.name}: payer.id={c.payer.id!r} " - f"(expected 'CO_TXIX')", err=True, - ) - break - else: - validated += 1 + mismatch = next( + (c for c in parsed.claims if c.payer.id != "CO_TXIX"), None + ) + if mismatch is not None: + failed += 1 + click.echo( + f"PAYER MISMATCH {src.name}: payer.id={mismatch.payer.id!r} " + f"(expected 'CO_TXIX')", err=True, + ) + continue + validated += 1 - content = src.read_bytes() local_size = len(content) remote_path = f"{remote_root}/{src.name}" - ssh: paramiko.SSHClient | None = None - sftp: paramiko.SFTPClient | None = None attempts = 0 ok = False while attempts < 3: @@ -713,12 +714,6 @@ def resubmit_rejected_claims( failed += 1 continue - # Reconnect periodically to dodge MOVEit's per-session cap. - if uploaded > 0 and uploaded % reconnect_every == 0: - click.echo(f"reconnecting (after {uploaded} uploads)", err=True) - _close_session(ssh) - ssh, sftp = None, None - # Audit (best-effort; if the DB is unavailable we still keep # the file on the wire). try: @@ -741,6 +736,12 @@ def resubmit_rejected_claims( uploaded += 1 + # Reconnect periodically to dodge MOVEit's per-session cap. + if uploaded % reconnect_every == 0: + click.echo(f"reconnecting (after {uploaded} uploads)", err=True) + _close_session(ssh) + ssh, sftp = None, None + # Progress every 10s of wall-clock (or at end). now = time.monotonic() if now - last_progress >= 10 or i == len(files): diff --git a/backend/src/cyclone/inbox_state.py b/backend/src/cyclone/inbox_state.py index c5a6cfd..3907d50 100644 --- a/backend/src/cyclone/inbox_state.py +++ b/backend/src/cyclone/inbox_state.py @@ -35,7 +35,7 @@ def apply_999_rejections( claim_lookup: Callable[[str], Claim | None], batch_envelope_index: dict[str, list[str]] | None = None, ) -> Apply999Result: - """For each set response with code R or E, look up the matching claim and + """For each set response with code R, E, or X, look up the matching claim and move it to REJECTED. Idempotent on already-rejected claims. Args: From 6bda5005c1c77e5fa3f4f1e5e122de9807ff3190 Mon Sep 17 00:00:00 2001 From: Nora Date: Sat, 4 Jul 2026 20:29:13 -0600 Subject: [PATCH 7/7] feat(sp33): resubmit CLI: skipped files don't audit or count as uploads Finishes the in-tree was_skipped draft: size-matched remote files no longer emit clearhouse.submitted events or increment uploaded, and the periodic reconnect now triggers only after real uploads. Co-Authored-By: Claude Fable 5 --- backend/src/cyclone/cli.py | 57 ++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 6519d92..e9f3b9e 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -674,6 +674,7 @@ def resubmit_rejected_claims( attempts = 0 ok = False + was_skipped = False while attempts < 3: attempts += 1 if ssh is None: @@ -691,7 +692,7 @@ def resubmit_rejected_claims( rs = sftp.stat(remote_path) # type: ignore[union-attr] if rs.st_size == local_size: skipped += 1 - ok = True + was_skipped = True break except IOError: pass # not on remote yet @@ -710,37 +711,39 @@ def resubmit_rejected_claims( ) continue - if not ok: + if not ok and not was_skipped: failed += 1 continue - # Audit (best-effort; if the DB is unavailable we still keep - # the file on the wire). - try: - with db_mod.SessionLocal()() as session: - append_event(session, AuditEvent( - event_type="clearhouse.submitted", - entity_type="claim_file", - entity_id=src.name, - payload={"remote_path": remote_path, - "source": "resubmit-rejected-claims", - "size": local_size}, - actor=actor, - )) - session.commit() - except Exception as exc: # noqa: BLE001 - click.echo( - f"audit-log write failed for {src.name}: " - f"{exc.__class__.__name__}: {exc}", err=True, - ) + # Audit + reconnect cadence apply only to real uploads. + if not was_skipped: + # Audit (best-effort; if the DB is unavailable we still + # keep the file on the wire). + try: + with db_mod.SessionLocal()() as session: + append_event(session, AuditEvent( + event_type="clearhouse.submitted", + entity_type="claim_file", + entity_id=src.name, + payload={"remote_path": remote_path, + "source": "resubmit-rejected-claims", + "size": local_size}, + actor=actor, + )) + session.commit() + except Exception as exc: # noqa: BLE001 + click.echo( + f"audit-log write failed for {src.name}: " + f"{exc.__class__.__name__}: {exc}", err=True, + ) - uploaded += 1 + uploaded += 1 - # Reconnect periodically to dodge MOVEit's per-session cap. - if uploaded % reconnect_every == 0: - click.echo(f"reconnecting (after {uploaded} uploads)", err=True) - _close_session(ssh) - ssh, sftp = None, None + # Reconnect periodically to dodge MOVEit's per-session cap. + if uploaded % reconnect_every == 0: + click.echo(f"reconnecting (after {uploaded} uploads)", err=True) + _close_session(ssh) + ssh, sftp = None, None # Progress every 10s of wall-clock (or at end). now = time.monotonic()