From 244015a361fae65575865f7630b3642b8c56a261 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 21:16:11 -0600 Subject: [PATCH] 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()