feat(sp33): cli backfill-999-rejections

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 <sub>' — only the 'cyclone' console script
worked. Latent bug since SP17; surfaced when SP33 added another
post-block command.
This commit is contained in:
Nora
2026-07-02 21:16:11 -06:00
parent 3bf5622010
commit 244015a361
+126 -4
View File
@@ -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()