Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bda5005c1 | |||
| 27bca33b09 | |||
| f10ab83628 | |||
| 244015a361 | |||
| 3bf5622010 | |||
| 0625c83a45 | |||
| cf7c343ff0 | |||
| dae7749464 | |||
| 2e7ad471e0 | |||
| dfd654202e | |||
| 15c85300f6 | |||
| 1b2f6c6b21 | |||
| 0f3b264e41 | |||
| de77f19d9d | |||
| c4bc118557 | |||
| 055b4ae30e | |||
| 4d3eef22ef | |||
| d8707ba874 | |||
| c491003287 | |||
| 4363b4fe41 | |||
| d44948cb90 | |||
| 9e2dde6ddb | |||
| 2d40fbbcbb | |||
| a2ec65e5c6 | |||
| 18fa119ff7 | |||
| ab91449e62 | |||
| 9bade2429c | |||
| 5a54961930 | |||
| b484ca36dc | |||
| 75a2800a9d | |||
| 6624a0bafd | |||
| a12104fb0f | |||
| edee0a6259 | |||
| 97512ec4a7 | |||
| bde3060e9e | |||
| 1d2572e624 | |||
| ffacfd8665 | |||
| 9cc13e7940 |
@@ -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/
|
||||
|
||||
@@ -1766,6 +1766,153 @@ def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
# SP30: state buckets the Dashboard widget (and any future "how the
|
||||
# last batch billed" surface) reads at a glance. Keep these in sync
|
||||
# with ClaimState — adding a new state here is a deliberate decision
|
||||
# the operator needs to see, not a coincidence.
|
||||
_BATCH_SUMMARY_ACCEPTED_STATES: tuple[ClaimState, ...] = (
|
||||
ClaimState.PAID,
|
||||
ClaimState.RECEIVED,
|
||||
ClaimState.RECONCILED,
|
||||
ClaimState.PARTIAL,
|
||||
)
|
||||
_BATCH_SUMMARY_REJECTED_STATES: tuple[ClaimState, ...] = (
|
||||
ClaimState.REJECTED,
|
||||
ClaimState.DENIED,
|
||||
ClaimState.REVERSED,
|
||||
)
|
||||
_BATCH_SUMMARY_PENDING_STATES: tuple[ClaimState, ...] = (
|
||||
ClaimState.SUBMITTED,
|
||||
)
|
||||
# 277CA STC category A4/A6/A7 — payer-side rejections that may not
|
||||
# yet have flipped Claim.state (the operator hasn't acknowledged).
|
||||
# The Dashboard widget treats these as problems too, mirroring the
|
||||
# Inbox `rejected + payer_rejected` aggregation.
|
||||
_BATCH_SUMMARY_PAYER_REJECT_CODES: tuple[str, ...] = ("A4", "A6", "A7")
|
||||
|
||||
|
||||
def _batch_summary_billing_outcomes(
|
||||
records: list[BatchRecord],
|
||||
) -> dict[str, dict]:
|
||||
"""Compute per-batch billing outcome for the Dashboard widget.
|
||||
|
||||
Returns ``{batch_id: {accepted, rejected, pending, billed,
|
||||
top_rejection_reason, has_problem}}`` for every batch in
|
||||
``records``. Empty input → empty dict.
|
||||
|
||||
Two SQL queries, both bounded by the supplied batch ids:
|
||||
|
||||
1. One GROUP BY ``(batch_id, state)`` aggregate that produces
|
||||
the accepted/rejected/pending counts and the sum of
|
||||
``charge_amount`` (the billed total). Single pass — no N+1.
|
||||
2. One ordered scan over the rejected + payer-rejected subset
|
||||
to pick the most recent rejection reason (truncated to 60
|
||||
chars). Skipped when the first query found no rejections
|
||||
and no payer-rejects, so the happy path stays at one query.
|
||||
|
||||
835 batches have no Claim rows — the GROUP BY returns no
|
||||
rows for them, so the dict entry for an 835 batch is
|
||||
``{accepted:0, rejected:0, pending:0, billed:0.0,
|
||||
top_rejection_reason:None, has_problem:False}`` (filled by
|
||||
the caller's ``.get(id, defaults)`` pattern).
|
||||
"""
|
||||
if not records:
|
||||
return {}
|
||||
from sqlalchemy import func # local import to keep top-of-file light
|
||||
|
||||
batch_ids = [r.id for r in records]
|
||||
outcome: dict[str, dict] = {
|
||||
bid: {
|
||||
"accepted": 0,
|
||||
"rejected": 0,
|
||||
"pending": 0,
|
||||
"billed": 0.0,
|
||||
"top_rejection_reason": None,
|
||||
"has_problem": False,
|
||||
}
|
||||
for bid in batch_ids
|
||||
}
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
# ---- 1. GROUP BY (batch_id, state) for counts + billed total ----
|
||||
rows = (
|
||||
s.query(
|
||||
Claim.batch_id,
|
||||
Claim.state,
|
||||
func.count(Claim.id),
|
||||
func.coalesce(func.sum(Claim.charge_amount), 0),
|
||||
)
|
||||
.filter(Claim.batch_id.in_(batch_ids))
|
||||
.group_by(Claim.batch_id, Claim.state)
|
||||
.all()
|
||||
)
|
||||
any_rejection_or_payer = False
|
||||
for batch_id, state, count, billed in rows:
|
||||
slot = outcome.get(batch_id)
|
||||
if slot is None:
|
||||
continue # batch has no row in our pre-allocated dict
|
||||
count = int(count or 0)
|
||||
billed_f = float(billed or 0)
|
||||
slot["billed"] += billed_f
|
||||
if state in _BATCH_SUMMARY_ACCEPTED_STATES:
|
||||
slot["accepted"] += count
|
||||
elif state in _BATCH_SUMMARY_REJECTED_STATES:
|
||||
slot["rejected"] += count
|
||||
any_rejection_or_payer = True
|
||||
elif state in _BATCH_SUMMARY_PENDING_STATES:
|
||||
slot["pending"] += count
|
||||
# everything else (DRAFT, etc.) is excluded from the widget.
|
||||
|
||||
# ---- 2. Most-recent rejection reason + payer-reject probe ----
|
||||
# Only run when we know there IS at least one rejection OR a
|
||||
# payer-reject claim somewhere in the batch set; otherwise
|
||||
# the first query alone is enough.
|
||||
if any_rejection_or_payer:
|
||||
rej_rows = (
|
||||
s.query(
|
||||
Claim.batch_id,
|
||||
Claim.rejection_reason,
|
||||
Claim.payer_rejected_status_code,
|
||||
)
|
||||
.filter(
|
||||
Claim.batch_id.in_(batch_ids),
|
||||
Claim.state.in_(_BATCH_SUMMARY_REJECTED_STATES)
|
||||
| Claim.payer_rejected_status_code.in_(
|
||||
_BATCH_SUMMARY_PAYER_REJECT_CODES
|
||||
),
|
||||
)
|
||||
.order_by(Claim.rejected_at.desc().nullslast())
|
||||
.all()
|
||||
)
|
||||
seen_reason: set[str] = set()
|
||||
for batch_id, reason, payer_code in rej_rows:
|
||||
slot = outcome.get(batch_id)
|
||||
if slot is None:
|
||||
continue
|
||||
if payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES:
|
||||
slot["has_problem"] = True
|
||||
# Capture the first non-null reason for this batch
|
||||
# (rej_rows is ordered newest-first, so the first
|
||||
# non-null wins). Truncate to 60 chars + ellipsis.
|
||||
if (
|
||||
slot["top_rejection_reason"] is None
|
||||
and reason
|
||||
and batch_id not in seen_reason
|
||||
):
|
||||
r = reason.strip()
|
||||
if len(r) > 60:
|
||||
r = r[:60] + "…"
|
||||
slot["top_rejection_reason"] = r
|
||||
seen_reason.add(batch_id)
|
||||
if (
|
||||
slot["rejected"] > 0
|
||||
or payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES
|
||||
):
|
||||
slot["has_problem"] = True
|
||||
|
||||
return outcome
|
||||
|
||||
|
||||
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
|
||||
def list_batches(
|
||||
request: Request,
|
||||
@@ -1778,8 +1925,16 @@ def list_batches(
|
||||
row without an extra round-trip to ``/api/batches/{id}``. The
|
||||
list is still capped at ``limit`` claims; see the full result
|
||||
via the by-id endpoint when more is needed.
|
||||
|
||||
SP30: also returns billing-outcome fields
|
||||
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
|
||||
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so
|
||||
the Dashboard "Recent batches" widget can render one row per
|
||||
batch without an N+1 fetch. See
|
||||
:func:`_batch_summary_billing_outcomes`.
|
||||
"""
|
||||
records = store.list(limit=limit)
|
||||
outcomes = _batch_summary_billing_outcomes(records)
|
||||
items = [
|
||||
{
|
||||
"id": r.id,
|
||||
@@ -1788,6 +1943,14 @@ def list_batches(
|
||||
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
|
||||
"claimCount": _batch_summary_claim_count(r),
|
||||
"claimIds": _batch_summary_claim_ids(r),
|
||||
"acceptedCount": outcomes.get(r.id, {}).get("accepted", 0),
|
||||
"rejectedCount": outcomes.get(r.id, {}).get("rejected", 0),
|
||||
"pendingCount": outcomes.get(r.id, {}).get("pending", 0),
|
||||
"billedTotal": round(outcomes.get(r.id, {}).get("billed", 0.0), 2),
|
||||
"topRejectionReason": outcomes.get(r.id, {}).get(
|
||||
"top_rejection_reason"
|
||||
),
|
||||
"hasProblem": outcomes.get(r.id, {}).get("has_problem", False),
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
|
||||
+462
-2
@@ -297,8 +297,95 @@ def validate_tax_id_cmd(tax_id: str, log_level: str) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP32: `cyclone backfill-rendering-npi` (Task 6)
|
||||
#
|
||||
# Re-parses on-disk 837p/835 files and patches up the typed NPI columns
|
||||
# (``Claim.rendering_provider_npi`` / ``Remittance.rendering_provider_npi``)
|
||||
# for rows that were ingested before the T4 writers took effect.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@main.command("backfill-rendering-npi")
|
||||
@click.option(
|
||||
"--file", "files",
|
||||
multiple=True,
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
help=(
|
||||
"Path to a specific file to re-parse. May be passed multiple times. "
|
||||
"Mutually informative with --input-dir (both are processed)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--input-dir",
|
||||
type=click.Path(file_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help=(
|
||||
"Directory to scan one level deep for *.txt / *.edi / *.x12 files. "
|
||||
"Honors CYCLONE_BACKFILL_INPUT_DIR if --input-dir is not passed."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--type", "transaction_type",
|
||||
type=click.Choice(["837p", "835"]),
|
||||
default=None,
|
||||
help=(
|
||||
"Pin the parser to use. Without --type, each file's transaction "
|
||||
"kind is sniffed (filename hint + ISA/ST prefix)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
show_default=True,
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
|
||||
)
|
||||
def backfill_rendering_npi(
|
||||
files: tuple[Path, ...],
|
||||
input_dir: Path | None,
|
||||
transaction_type: str | None,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Re-parse on-disk X12 files to populate the rendering NPI columns.
|
||||
|
||||
Idempotent: rows whose ``rendering_provider_npi`` is already non-NULL
|
||||
are left untouched. After patching, runs :func:`cyclone.reconcile.run`
|
||||
once across every 835 batch so the T5 scoring arm can fire
|
||||
retroactively on the touched pairs.
|
||||
|
||||
Exit code: 0 on a successful run (zero populated rows is still exit 0).
|
||||
"""
|
||||
from cyclone import db as _db
|
||||
from cyclone.store.backfill import backfill_rendering_provider_npi
|
||||
|
||||
# SP18: re-run setup so per-command --log-level overrides the
|
||||
# group default. ``setup_logging`` is idempotent.
|
||||
setup_logging(level=log_level)
|
||||
|
||||
# The backup pattern: each CLI subcommand that touches the DB
|
||||
# initializes it explicitly so ``python -m cyclone.cli backup list``
|
||||
# works on a fresh machine with no prior app boot.
|
||||
_db.init_db()
|
||||
|
||||
# --input-dir falls back to CYCLONE_BACKFILL_INPUT_DIR.
|
||||
if input_dir is None and not files:
|
||||
env_dir = os.environ.get("CYCLONE_BACKFILL_INPUT_DIR", "").strip()
|
||||
if env_dir:
|
||||
input_dir = Path(env_dir)
|
||||
|
||||
summary = backfill_rendering_provider_npi(
|
||||
files=list(files) if files else None,
|
||||
input_dir=input_dir,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
# One-line summary so operators can grep a log scrape quickly.
|
||||
click.echo(
|
||||
f"claims_updated={summary.claims_updated} "
|
||||
f"remits_updated={summary.remits_updated} "
|
||||
f"files_processed={summary.files_processed} "
|
||||
f"files_skipped={summary.files_skipped}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -312,6 +399,375 @@ 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, func
|
||||
|
||||
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,
|
||||
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)
|
||||
.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}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.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()
|
||||
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
|
||||
|
||||
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(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
|
||||
)
|
||||
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
|
||||
|
||||
local_size = len(content)
|
||||
remote_path = f"{remote_root}/{src.name}"
|
||||
|
||||
attempts = 0
|
||||
ok = False
|
||||
was_skipped = 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
|
||||
was_skipped = 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 and not was_skipped:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# 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
|
||||
|
||||
# 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):
|
||||
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)."""
|
||||
@@ -733,3 +1189,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()
|
||||
|
||||
@@ -249,6 +249,7 @@ class Claim(Base):
|
||||
service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||
charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
|
||||
provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
state: Mapped[ClaimState] = mapped_column(
|
||||
Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
|
||||
@@ -344,6 +345,7 @@ class Remittance(Base):
|
||||
claim_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), ForeignKey("claims.id"), nullable=True
|
||||
)
|
||||
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
status_code: Mapped[str] = mapped_column(String(4), nullable=False)
|
||||
status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -175,6 +175,82 @@ def _line_count_lookup(session: Session, claims: list[Claim]) -> tuple[dict, dic
|
||||
return matched_counts, total_lines_by_claim
|
||||
|
||||
|
||||
def _ack_summary_for_claims(
|
||||
session: Session, claim_ids: list[str],
|
||||
) -> dict[str, dict]:
|
||||
"""Build a {claim_id: {total, rejected, items: [...]}} map for 999 acks.
|
||||
|
||||
SP29: the Inbox `rejected` lane needs to render AK2 evidence
|
||||
inline per row, plus a per-row Resubmit button. The data lives
|
||||
in ``claim_acks`` (SP28) — we don't want to N+1 fetch per row,
|
||||
so the whole rejected-claim set is summarized in one batched
|
||||
query here.
|
||||
|
||||
Filters to ``ack_kind='999'`` because the rejected lane is the
|
||||
999 envelope reject lane; the 277CA STC A4/A6/A7 evidence flows
|
||||
through the ``payer_rejected_*`` fields on a separate lane and
|
||||
isn't part of this scope (see SP29 spec D4 / scope).
|
||||
|
||||
Returns:
|
||||
``{claim_id: {"total": int, "rejected": int, "items": [...]}, ...}``
|
||||
Claims with zero linked 999 acks are NOT in the returned
|
||||
dict — the caller maps via ``.get(cid)`` and treats absence
|
||||
as "no 999 acks linked" (renders as ``null`` in the
|
||||
payload, ``999 not linked`` in the UI).
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session the caller owns.
|
||||
claim_ids: list of claim.id values to summarize. Typically
|
||||
the rejected-lane claim ids. Empty list → empty dict.
|
||||
"""
|
||||
if not claim_ids:
|
||||
return {}
|
||||
from cyclone.db import ClaimAck # late import — DB model registered
|
||||
rows = (
|
||||
session.query(
|
||||
ClaimAck.claim_id,
|
||||
ClaimAck.ack_id,
|
||||
ClaimAck.set_control_number,
|
||||
ClaimAck.set_accept_reject_code,
|
||||
ClaimAck.ak2_index,
|
||||
ClaimAck.linked_at,
|
||||
)
|
||||
.filter(
|
||||
ClaimAck.claim_id.in_(claim_ids),
|
||||
ClaimAck.ack_kind == "999",
|
||||
)
|
||||
.order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc())
|
||||
.all()
|
||||
)
|
||||
grouped: dict[str, list[tuple]] = {}
|
||||
for cid, aid, scn, code, ak2i, lat in rows:
|
||||
grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat))
|
||||
|
||||
rejected_codes = {"R", "E", "X"}
|
||||
out: dict[str, dict] = {}
|
||||
for cid, items in grouped.items():
|
||||
total = len(items)
|
||||
rejected_count = sum(1 for it in items if (it[2] or "") in rejected_codes)
|
||||
# Keep 5 most recent items for the chip column. The full count
|
||||
# is in ``total`` so the UI can show ``+N more`` honestly.
|
||||
trimmed = items[:5]
|
||||
out[cid] = {
|
||||
"total": total,
|
||||
"rejected": rejected_count,
|
||||
"items": [
|
||||
{
|
||||
"ack_id": aid,
|
||||
"set_control_number": scn,
|
||||
"set_accept_reject_code": code or "",
|
||||
"ak2_index": ak2i,
|
||||
"linked_at": _isoformat(lat),
|
||||
}
|
||||
for (aid, scn, code, ak2i, lat) in trimmed
|
||||
],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
|
||||
lanes = Lanes()
|
||||
dismissed = set(dismissed_pairs)
|
||||
@@ -192,6 +268,17 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) ->
|
||||
),
|
||||
))
|
||||
|
||||
# SP29: attach the 999 ack-evidence summary (total / rejected /
|
||||
# 5 most recent AK2 set_responses) to every rejected row so the
|
||||
# Inbox can render AK2 chips inline + a per-row Resubmit button
|
||||
# without an extra round-trip. One batched query, keyed off the
|
||||
# rejected-claim id set.
|
||||
rejected_ack_summary = _ack_summary_for_claims(
|
||||
session, [r["id"] for r in lanes.rejected]
|
||||
)
|
||||
for row in lanes.rejected:
|
||||
row["claim_acks"] = rejected_ack_summary.get(row["id"])
|
||||
|
||||
# --- Payer-Rejected (SP10) ---
|
||||
# Distinct from the 999 envelope "rejected" lane above. A claim
|
||||
# lands here when a 277CA STC category code is A4/A6/A7 (rejected
|
||||
|
||||
@@ -33,42 +33,63 @@ 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
|
||||
"""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:
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- version: 19
|
||||
-- SP32: render & service-provider NPI extraction.
|
||||
-- Nullable: existing rows stay NULL until backfill runs.
|
||||
-- No indexes (used for set-equality, not range queries; nullable).
|
||||
|
||||
ALTER TABLE claims ADD COLUMN rendering_provider_npi TEXT;
|
||||
ALTER TABLE remittances ADD COLUMN rendering_provider_npi TEXT;
|
||||
@@ -142,6 +142,7 @@ class ClaimOutput(_Base):
|
||||
subscriber: Subscriber
|
||||
payer: Payer
|
||||
claim: ClaimHeader
|
||||
rendering_provider_npi: str | None = None # NM1*82 NM109 (Loop 2420A)
|
||||
diagnoses: list[Diagnosis] = Field(default_factory=list)
|
||||
service_lines: list[ServiceLine] = Field(default_factory=list)
|
||||
validation: ValidationReport
|
||||
|
||||
@@ -160,6 +160,7 @@ class ClaimPayment(_Base):
|
||||
ref_benefit_plan: str | None = None # REF*CE per the CO guide
|
||||
service_payments: list[ServicePayment] = Field(default_factory=list)
|
||||
raw_segments: list[list[str]] = Field(default_factory=list)
|
||||
service_provider_npi: str | None = None # NM1*1P NM109 (Loop 2100 service provider)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -376,6 +376,7 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
|
||||
|
||||
service_payments: list[ServicePayment] = []
|
||||
ref_benefit_plan: str | None = None
|
||||
service_provider_npi: str | None = None
|
||||
per_diem: Decimal | None = None
|
||||
status_label = claim_status_label(status)
|
||||
|
||||
@@ -422,9 +423,16 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
|
||||
except ValueError:
|
||||
per_diem = None
|
||||
elif s[0] == "NM1":
|
||||
# Patient (QC) / service-provider (1P) — captured in raw_segments.
|
||||
# The 835 spec doesn't require a structured patient model in v1.
|
||||
pass
|
||||
# SP32: capture service-provider NPI from NM1*1P (Loop 2100).
|
||||
# NM108 (idx 8) carries the ID qualifier (typically "XX");
|
||||
# NM109 (idx 9) is the value. Some senders omit NM108; accept
|
||||
# both forms but require a 10-digit ID.
|
||||
if len(s) > 9 and s[1] == "1P":
|
||||
if len(s) > 8 and s[8] == "XX" and s[9]:
|
||||
if s[9].isdigit() and len(s[9]) == 10:
|
||||
service_provider_npi = s[9]
|
||||
elif s[9] and s[9].isdigit() and len(s[9]) == 10:
|
||||
service_provider_npi = s[9]
|
||||
elif s[0] == "DTM":
|
||||
# Claim-level dates — captured in raw_segments.
|
||||
pass
|
||||
@@ -446,6 +454,7 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
|
||||
ref_benefit_plan=ref_benefit_plan,
|
||||
service_payments=service_payments,
|
||||
raw_segments=raw,
|
||||
service_provider_npi=service_provider_npi,
|
||||
),
|
||||
idx,
|
||||
)
|
||||
|
||||
@@ -210,6 +210,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
|
||||
service_lines: list[ServiceLine] = []
|
||||
raw: list[list[str]] = [seg]
|
||||
prior_auth: str | None = None
|
||||
rendering_provider_npi: str | None = None
|
||||
|
||||
idx += 1
|
||||
while idx < len(segments) and segments[idx][0] not in {"HL", "CLM", "SE"}:
|
||||
@@ -226,6 +227,11 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
|
||||
for code in parts[1:]:
|
||||
if code:
|
||||
diagnoses.append(Diagnosis(code=code, qualifier=qualifier))
|
||||
elif s[0] == "NM1" and len(s) > 1 and s[1] == "82":
|
||||
# SP32: capture rendering provider NPI from Loop 2420A NM1*82.
|
||||
# NM108 (idx 8) is the qualifier (typically "XX"), NM109 (idx 9) is the NPI.
|
||||
if len(s) > 9 and s[8] == "XX" and len(s[9]) == 10 and s[9].isdigit():
|
||||
rendering_provider_npi = s[9]
|
||||
elif s[0] == "LX":
|
||||
line_no = int(s[1]) if len(s) > 1 and s[1].isdigit() else len(service_lines) + 1
|
||||
# LX is just a separator — the actual service line data is in the next SV1.
|
||||
@@ -255,6 +261,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
|
||||
subscriber=Subscriber(first_name="", last_name="", member_id="", address=Address(line1="", city="", state="", zip="")),
|
||||
payer=Payer(name="", id=""),
|
||||
claim=claim_header,
|
||||
rendering_provider_npi=rendering_provider_npi,
|
||||
diagnoses=diagnoses,
|
||||
service_lines=service_lines,
|
||||
validation=ValidationReport(passed=True, errors=[], warnings=[]),
|
||||
@@ -296,7 +303,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
|
||||
provider_ref: str | None = None
|
||||
|
||||
idx += 1
|
||||
while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE"}:
|
||||
while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE", "NM1"}:
|
||||
s = segments[idx]
|
||||
raw.append(s)
|
||||
if s[0] == "DTP" and len(s) > 2 and s[1] == "472":
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -17,7 +17,7 @@ Match algorithm:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Iterable, Optional, Protocol
|
||||
@@ -50,6 +50,18 @@ class Match:
|
||||
remittance: _RemitLike
|
||||
strategy: str
|
||||
is_reversal: bool
|
||||
# SP31: which content-keys the 2-of-3 (or 3-of-3) rule agreed on.
|
||||
# Populated for ``score-auto`` matches; empty for ``pcn-exact`` /
|
||||
# ``manual`` (PCN-exact only uses the PCN key by definition; manual
|
||||
# is operator-driven). Floats into the ``auto_matched_835``
|
||||
# ActivityEvent payload so the audit trail records *why* the link
|
||||
# fired. Transient — the ORM Match row does not persist these
|
||||
# (spec keeps the audit trail in ActivityEvent.payload_json only).
|
||||
keys_matched: set[str] = field(default_factory=set)
|
||||
# SP31: how many candidates were in the ±30-day window pool when
|
||||
# the score-auto match fired. Useful when an operator is reviewing
|
||||
# a borderline auto-link — 1-of-1 is unambiguous; 1-of-5 was lucky.
|
||||
candidate_count: int = 0
|
||||
|
||||
|
||||
def match(
|
||||
@@ -84,7 +96,7 @@ def match(
|
||||
matches.append(Match(
|
||||
claim=chosen,
|
||||
remittance=r,
|
||||
strategy="auto",
|
||||
strategy="pcn-exact",
|
||||
is_reversal=r.is_reversal,
|
||||
))
|
||||
used_claim_ids.add(chosen.id)
|
||||
@@ -117,6 +129,163 @@ def _pick_claim(
|
||||
return None
|
||||
|
||||
|
||||
# --- SP31: content-keys fallback matcher -----------------------------------
|
||||
|
||||
CHARGE_TOLERANCE = Decimal("0.01")
|
||||
KEYS_REQUIRED = 2
|
||||
|
||||
|
||||
def _content_keys_match(remit, claim) -> set[str]:
|
||||
"""Return the set of content-keys that agree between ``remit`` and ``claim``.
|
||||
|
||||
Compares {``pcn``, ``charge``, ``npi``} between the two sides and returns
|
||||
the subset whose values match. Caller checks ``len(returned) >= KEYS_REQUIRED``
|
||||
to decide whether to auto-link. Returning the matched keys (rather than a
|
||||
bool) lets the ActivityEvent payload record exactly which 2-of-3 (or 3-of-3)
|
||||
produced the auto-link — the audit trail for the SP31 content-keys rule.
|
||||
|
||||
Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
|
||||
NPI counts as "not matched" when the remit's NPI is empty.
|
||||
|
||||
Uses duck-typing via ``getattr`` so the helper works against both:
|
||||
- shim / dataclass test objects (planned names: total_charge_amount,
|
||||
total_charge, rendering_provider_npi), and
|
||||
- real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge,
|
||||
Claim.provider_npi, Claim.rendering_provider_npi,
|
||||
Remittance.rendering_provider_npi).
|
||||
|
||||
Production note (SP32): the typed ``rendering_provider_npi`` column is
|
||||
the primary read for both sides (Claim = NM1*82, Remit = NM1*1P). Legacy
|
||||
rows pre-0019 still carry the value in ``raw_json``; the read order is:
|
||||
- NPI: typed column → raw_json (``service_provider_npi`` for remit,
|
||||
``rendering_provider_npi`` for claim) → claim ``provider_npi``
|
||||
(billing fallback for legacy 837p rows without NM1*82 extraction).
|
||||
- Charge: planned attribute name → real ORM column → raw_json.
|
||||
The ``raw_json`` fallback is what makes this helper safe against a
|
||||
raw ``Remittance`` ORM instance from ``reconcile.run()``.
|
||||
"""
|
||||
matched: set[str] = set()
|
||||
|
||||
# PCN: payer_claim_control_number ↔ patient_control_number (stripped).
|
||||
if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \
|
||||
(getattr(claim, "patient_control_number", "") or "").strip():
|
||||
matched.add("pcn")
|
||||
|
||||
# Charge: prefer the planned attribute names, fall back to real ORM columns,
|
||||
# then to raw_json (where the 835 parser stores CLP03). Explicit ``is None``
|
||||
# checks avoid the ``Decimal("0")`` truthiness footgun — ``Decimal("0")``
|
||||
# is truthy in a boolean context but ``or`` would still let it through;
|
||||
# the more important property is that we don't accidentally replace a real
|
||||
# value with a default.
|
||||
_remit_charge = getattr(remit, "total_charge_amount", None)
|
||||
if _remit_charge is None:
|
||||
_remit_charge = getattr(remit, "total_charge", None)
|
||||
if _remit_charge is None:
|
||||
_remit_charge = (getattr(remit, "raw_json", None) or {}).get("total_charge_amount")
|
||||
|
||||
_claim_charge = getattr(claim, "total_charge", None)
|
||||
if _claim_charge is None:
|
||||
_claim_charge = getattr(claim, "charge_amount", None)
|
||||
if _claim_charge is None:
|
||||
_claim_charge = (getattr(claim, "raw_json", None) or {}).get("total_charge_amount")
|
||||
|
||||
if _remit_charge is not None and _claim_charge is not None and \
|
||||
abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE:
|
||||
matched.add("charge")
|
||||
|
||||
# NPI: typed-column primary path (SP32), raw_json fallback (legacy rows).
|
||||
# Remit reads rendering_provider_npi (single value, D4); claim reads
|
||||
# rendering_provider_npi first, then falls back to provider_npi (billing)
|
||||
# for legacy rows where the 837p parser hadn't yet extracted NM1*82.
|
||||
remit_npi = (
|
||||
(getattr(remit, "rendering_provider_npi", "") or "")
|
||||
or ((getattr(remit, "raw_json", None) or {}).get("service_provider_npi", "") or "")
|
||||
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
|
||||
).strip()
|
||||
claim_npi = (
|
||||
(getattr(claim, "rendering_provider_npi", "") or "")
|
||||
or (getattr(claim, "provider_npi", "") or "")
|
||||
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
|
||||
).strip()
|
||||
if remit_npi and remit_npi == claim_npi:
|
||||
matched.add("npi")
|
||||
|
||||
return matched
|
||||
|
||||
|
||||
# --- SP31: DB-side fallback candidate matcher -------------------------------
|
||||
|
||||
SCORE_FALLBACK_WINDOW_DAYS = 30
|
||||
|
||||
|
||||
def _score_fallback_candidates(session, remit) -> Optional[tuple[str, set[str], int]]:
|
||||
"""Return (claim_id, keys_matched, candidate_count) for a unique 2-of-3 match.
|
||||
|
||||
Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
|
||||
- claim.matched_remittance_id IS NULL (not yet linked)
|
||||
- claim.state NOT IN terminal set (PAID, DENIED, REJECTED,
|
||||
REVERSED, RECONCILED)
|
||||
- claim.service_date_from within window of remit.service_date
|
||||
|
||||
Returns ``(claim_id, keys_matched, candidate_count)`` when exactly one
|
||||
candidate passes the 2-of-3 rule (via :func:`_content_keys_match`),
|
||||
otherwise ``None``. ``keys_matched`` is the set returned by the helper
|
||||
(``{"pcn", "charge"}`` or any 2-of-3 / 3-of-3 combination) — the
|
||||
ActivityEvent payload records it as the audit trail for *why* the
|
||||
auto-link fired. ``candidate_count`` is the size of the window pool
|
||||
(independent of how many matched) so the operator can see whether
|
||||
the match was 1-of-N or 1-of-1.
|
||||
|
||||
Ambiguous matches (2+ candidates) also return ``None`` — they land
|
||||
in the Inbox Unlinked lane for manual review.
|
||||
|
||||
Note on payer_id: ``Remittance`` has no ``payer_id`` column
|
||||
(``Claim.payer_id`` does, and is the source of truth on the claim
|
||||
side; the 835 parser stores payer_id in ``Remittance.raw_json``).
|
||||
Filtering by payer would require either a raw_json read or a model
|
||||
column add — out of scope for SP31 Task 3. The PCN itself is
|
||||
expected to be unique within a payer's submission, so cross-payer
|
||||
collisions are unlikely. Revisit if production shows otherwise.
|
||||
"""
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import timedelta
|
||||
from cyclone.db import Claim, ClaimState
|
||||
|
||||
TERMINAL = {
|
||||
ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED,
|
||||
ClaimState.REVERSED, ClaimState.RECONCILED,
|
||||
}
|
||||
|
||||
if remit.service_date is None:
|
||||
return None # need a date for the window query
|
||||
|
||||
window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
|
||||
window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
|
||||
|
||||
candidates = list(session.execute(
|
||||
select(Claim).where(
|
||||
and_(
|
||||
Claim.matched_remittance_id.is_(None),
|
||||
Claim.service_date_from >= window_lo,
|
||||
Claim.service_date_from <= window_hi,
|
||||
Claim.state.notin_([s.value for s in TERMINAL]),
|
||||
)
|
||||
)
|
||||
).scalars().all())
|
||||
|
||||
matched_pairs = [
|
||||
(c.id, _content_keys_match(remit, c))
|
||||
for c in candidates
|
||||
]
|
||||
matched_pairs = [
|
||||
(cid, ks) for cid, ks in matched_pairs if len(ks) >= KEYS_REQUIRED
|
||||
]
|
||||
if len(matched_pairs) == 1:
|
||||
cid, keys_matched = matched_pairs[0]
|
||||
return (cid, keys_matched, len(candidates))
|
||||
return None # 0 matches OR 2+ (ambiguous)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyIntent:
|
||||
"""Result of applying a match. The caller persists this.
|
||||
@@ -239,7 +408,7 @@ def run(session, batch_id: str) -> ReconcileResult:
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from cyclone.db import (
|
||||
Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
|
||||
Batch, Claim, Remittance, CasAdjustment, Match as MatchORM, ActivityEvent,
|
||||
ClaimState,
|
||||
)
|
||||
|
||||
@@ -257,6 +426,34 @@ def run(session, batch_id: str) -> ReconcileResult:
|
||||
|
||||
matches = match(unmatched_claims, new_remits)
|
||||
|
||||
# SP31: content-keys fallback for remits that PCN-exact couldn't pair.
|
||||
# Operates on the still-unmatched remits so we don't double-match.
|
||||
matched_remit_ids = {m.remittance.id for m in matches}
|
||||
used_claim_ids = {m.claim.id for m in matches}
|
||||
for remit in new_remits:
|
||||
if remit.id in matched_remit_ids:
|
||||
continue
|
||||
if getattr(remit, "claim_id", None) is not None:
|
||||
continue # already linked
|
||||
fallback_result = _score_fallback_candidates(session, remit)
|
||||
if fallback_result is None:
|
||||
continue
|
||||
matched_claim_id, keys_matched, candidate_count = fallback_result
|
||||
# Find the claim object in the unmatched_claims list (it was loaded).
|
||||
target_claim = next(
|
||||
(c for c in unmatched_claims if c.id == matched_claim_id),
|
||||
None,
|
||||
)
|
||||
if target_claim is None or target_claim.id in used_claim_ids:
|
||||
continue
|
||||
matches.append(Match(
|
||||
claim=target_claim, remittance=remit,
|
||||
strategy="score-auto", is_reversal=remit.is_reversal,
|
||||
keys_matched=keys_matched,
|
||||
candidate_count=candidate_count,
|
||||
))
|
||||
used_claim_ids.add(target_claim.id)
|
||||
|
||||
applied = 0
|
||||
skipped = 0
|
||||
for m in matches:
|
||||
@@ -280,7 +477,7 @@ def run(session, batch_id: str) -> ReconcileResult:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
session.add(Match(
|
||||
session.add(MatchORM(
|
||||
claim_id=m.claim.id, remittance_id=m.remittance.id,
|
||||
strategy=m.strategy,
|
||||
matched_at=datetime.now(timezone.utc),
|
||||
@@ -298,10 +495,25 @@ def run(session, batch_id: str) -> ReconcileResult:
|
||||
m.remittance.claim_id = m.claim.id
|
||||
|
||||
session.add(ActivityEvent(
|
||||
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
|
||||
ts=datetime.now(timezone.utc),
|
||||
kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind),
|
||||
batch_id=batch_id, claim_id=m.claim.id,
|
||||
remittance_id=m.remittance.id,
|
||||
payload_json={"new_state": m.claim.state.value},
|
||||
payload_json=(
|
||||
{
|
||||
"new_state": m.claim.state.value, "strategy": m.strategy,
|
||||
# SP31 spec D8: the auto_matched_835 payload records
|
||||
# which content-keys the 2-of-3 (or 3-of-3) rule
|
||||
# agreed on, plus how many candidates were in the
|
||||
# window pool. ``sorted()`` converts the set to a
|
||||
# JSON-serializable list and pins a deterministic
|
||||
# order for tests + audit reads.
|
||||
"keys_matched": sorted(m.keys_matched),
|
||||
"candidate_count": m.candidate_count,
|
||||
}
|
||||
if m.strategy == "score-auto"
|
||||
else {"new_state": m.claim.state.value}
|
||||
),
|
||||
))
|
||||
applied += 1
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""SP32 Task 6: backfill rendering/service-provider NPIs from on-disk files.
|
||||
|
||||
The T4 writers populate ``Claim.rendering_provider_npi`` (from NM1*82 in
|
||||
837P Loop 2420A) and ``Remittance.rendering_provider_npi`` (from the
|
||||
NM1*1P service-provider segment in 835 Loop 2100). Rows ingested before
|
||||
T4 was wired (or ingested via a path that bypasses the writer — e.g. an
|
||||
ad-hoc ``store.add`` from a notebook) still have a NULL column.
|
||||
|
||||
This module re-parses on-disk X12 files and patches up those columns on
|
||||
matching rows. It is **idempotent**: rows whose
|
||||
``rendering_provider_npi`` is already non-NULL are left untouched, and
|
||||
re-running the same file twice is a clean no-op.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
|
||||
* :func:`backfill_rendering_provider_npi` — entry point used by the CLI.
|
||||
* :class:`BackfillSummary` — counts dataclass echoed back as a one-line
|
||||
summary by the CLI.
|
||||
|
||||
Reconcile is run once at the end so the new typed NPI arm (T5) can fire
|
||||
retroactively across the open claim/remit pairs the backfill touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.parsers.parse_835 import parse as parse_835
|
||||
from cyclone.parsers.parse_837 import parse as parse_837
|
||||
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 837P fixture uses NM1*82 → rendering_provider_npi.
|
||||
# 835 fixture uses NM1*1P → service_provider_npi (mapped onto the same
|
||||
# Remittance.rendering_provider_npi column by T4 _remittance_835_row).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackfillSummary:
|
||||
"""Counts emitted by ``backfill_rendering_provider_npi``.
|
||||
|
||||
The CLI echoes a one-line summary (`claims_updated=N remits_updated=N
|
||||
…`) at the end of the run.
|
||||
"""
|
||||
|
||||
claims_updated: int = 0
|
||||
remits_updated: int = 0
|
||||
files_processed: int = 0
|
||||
files_skipped: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser wrappers — keep exceptions local so one bad file can't abort
|
||||
# the whole backfill run.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_837_file(path: Path) -> tuple[list, Path | None]:
|
||||
"""Re-parse a single 837P file. Returns ``(claims, broken_alias)``.
|
||||
|
||||
On success, ``claims`` is the list of parsed ``ClaimOutput`` rows
|
||||
and ``broken_alias`` is None. On any failure, ``claims`` is empty
|
||||
and ``broken_alias`` is the same ``path`` so the caller can log
|
||||
which file was skipped.
|
||||
"""
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError as exc:
|
||||
log.warning("backfill: cannot read %s: %s", path, exc)
|
||||
return [], path
|
||||
try:
|
||||
result = parse_837(text, PayerConfig.co_medicaid(), input_file=str(path))
|
||||
except Exception as exc: # noqa: BLE001 — failure isolated per-file
|
||||
log.warning("backfill: 837 parse failed for %s: %s", path, exc)
|
||||
return [], path
|
||||
return list(result.claims), None
|
||||
|
||||
|
||||
def _parse_835_file(path: Path) -> tuple[list, Path | None]:
|
||||
"""Re-parse a single 835 file. Returns ``(claims, broken_alias)``.
|
||||
|
||||
``claims`` is the list of parsed ``ClaimPayment`` rows.
|
||||
"""
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError as exc:
|
||||
log.warning("backfill: cannot read %s: %s", path, exc)
|
||||
return [], path
|
||||
try:
|
||||
result = parse_835(text, PayerConfig835.co_medicaid_835(), input_file=str(path))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("backfill: 835 parse failed for %s: %s", path, exc)
|
||||
return [], path
|
||||
return list(result.claims), None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-patching helpers — keep the per-row update logic in one place.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _patch_claim_rendering_npi(parsed_claims: list) -> int:
|
||||
"""Update ``Claim.rendering_provider_npi`` for any parsed 837 claim.
|
||||
|
||||
Only rows whose column is currently NULL are touched. Returns the
|
||||
count of rows updated.
|
||||
"""
|
||||
from cyclone.db import Claim, SessionLocal
|
||||
|
||||
updated = 0
|
||||
with SessionLocal()() as session:
|
||||
for parsed in parsed_claims:
|
||||
npi = getattr(parsed, "rendering_provider_npi", None)
|
||||
if not npi:
|
||||
continue
|
||||
row = session.get(Claim, parsed.claim_id)
|
||||
if row is None:
|
||||
continue
|
||||
if row.rendering_provider_npi is not None:
|
||||
# Already populated (e.g. by a prior backfill run, or by
|
||||
# the T4 writer if the claim was ingested afterwards).
|
||||
continue
|
||||
row.rendering_provider_npi = npi
|
||||
updated += 1
|
||||
if updated:
|
||||
session.commit()
|
||||
return updated
|
||||
|
||||
|
||||
def _patch_remit_rendering_npi(parsed_claims: list) -> int:
|
||||
"""Update ``Remittance.rendering_provider_npi`` for any parsed 835 claim.
|
||||
|
||||
The 835 NM1*1P segment maps onto ``ClaimPayment.service_provider_npi``
|
||||
(which the T4 writer copies into ``Remittance.rendering_provider_npi``).
|
||||
We match on ``payer_claim_control_number``; only NULL columns are
|
||||
touched. Returns the count of rows updated.
|
||||
"""
|
||||
from cyclone.db import Remittance, SessionLocal
|
||||
|
||||
updated = 0
|
||||
with SessionLocal()() as session:
|
||||
for parsed in parsed_claims:
|
||||
npi = getattr(parsed, "service_provider_npi", None)
|
||||
if not npi:
|
||||
continue
|
||||
target_pcn = getattr(parsed, "payer_claim_control_number", None)
|
||||
if not target_pcn:
|
||||
continue
|
||||
row = session.execute(
|
||||
select(Remittance).where(
|
||||
Remittance.payer_claim_control_number == target_pcn,
|
||||
Remittance.rendering_provider_npi.is_(None),
|
||||
)
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
continue
|
||||
row.rendering_provider_npi = npi
|
||||
updated += 1
|
||||
if updated:
|
||||
session.commit()
|
||||
return updated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconcile pass — run once across every 835 batch so the T5 scoring arm
|
||||
# can fire retroactively on pairs the backfill touched.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_reconcile_sweep() -> int:
|
||||
"""Run :func:`cyclone.reconcile.run` over every 835 batch. Returns count.
|
||||
|
||||
Failures are isolated per-batch so one bad batch can't abort the
|
||||
sweep — the goal is "best-effort retroactive reconcile".
|
||||
"""
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
from cyclone import reconcile as _reconcile
|
||||
from cyclone.db import Batch, SessionLocal
|
||||
|
||||
completed = 0
|
||||
with SessionLocal()() as session:
|
||||
batch_ids = [
|
||||
row[0] for row in session.execute(
|
||||
_select(Batch.id).where(Batch.kind == "835")
|
||||
).all()
|
||||
]
|
||||
# Each batch gets its own session — ``reconcile.run`` does not commit,
|
||||
# so an exception in one batch must not orphan a half-flushed
|
||||
# transaction.
|
||||
for bid in batch_ids:
|
||||
try:
|
||||
with SessionLocal()() as s:
|
||||
_reconcile.run(s, bid)
|
||||
s.commit()
|
||||
completed += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("backfill: reconcile failed for batch %s: %s", bid, exc)
|
||||
return completed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Supported ``--type`` flag values; also ``None`` means auto-detect.
|
||||
TransactionType = str | None
|
||||
|
||||
|
||||
def backfill_rendering_provider_npi(
|
||||
*,
|
||||
files: Iterable[Path] | None = None,
|
||||
input_dir: Path | None = None,
|
||||
transaction_type: TransactionType = None,
|
||||
) -> BackfillSummary:
|
||||
"""Re-parse on-disk 837p + 835 files and populate the typed NPI columns.
|
||||
|
||||
Args:
|
||||
files: explicit list of file paths to re-parse.
|
||||
input_dir: directory to scan for ``*.txt`` / ``*.edi`` files.
|
||||
Files are scanned one level deep.
|
||||
transaction_type: ``"837p"`` or ``"835"``. ``None`` auto-detects
|
||||
by attempting the 837p parser first and falling back to
|
||||
the 835 parser.
|
||||
|
||||
Returns:
|
||||
:class:`BackfillSummary` with populated counts.
|
||||
|
||||
Idempotent: only writes columns that are currently NULL; re-running
|
||||
on the same files is a clean no-op. After patching, runs
|
||||
:func:`cyclone.reconcile.run` over every 835 batch so the T5
|
||||
scoring arm can re-fire on the touched pairs.
|
||||
"""
|
||||
summary = BackfillSummary()
|
||||
|
||||
# 1. Resolve the candidate file set.
|
||||
candidates: list[Path] = []
|
||||
if files:
|
||||
candidates.extend(Path(f) for f in files)
|
||||
if input_dir is not None:
|
||||
for ext in ("*.txt", "*.edi", "*.835", "*.837", "*.x12"):
|
||||
candidates.extend(input_dir.glob(ext))
|
||||
candidates = [p for p in candidates if p.is_file()]
|
||||
|
||||
# 2. Re-parse each file and patch matching rows.
|
||||
for path in candidates:
|
||||
kind = transaction_type or _sniff_kind(path)
|
||||
if kind == "837p":
|
||||
parsed_claims, broken = _parse_837_file(path)
|
||||
if broken is not None:
|
||||
summary.files_skipped += 1
|
||||
continue
|
||||
summary.files_processed += 1
|
||||
summary.claims_updated += _patch_claim_rendering_npi(parsed_claims)
|
||||
elif kind == "835":
|
||||
parsed_claims, broken = _parse_835_file(path)
|
||||
if broken is not None:
|
||||
summary.files_skipped += 1
|
||||
continue
|
||||
summary.files_processed += 1
|
||||
summary.remits_updated += _patch_remit_rendering_npi(parsed_claims)
|
||||
else:
|
||||
# Auto-detect tried both parsers and both failed → skip.
|
||||
summary.files_skipped += 1
|
||||
|
||||
# 3. Reconcile sweep — let the T5 NPI arm fire retroactively.
|
||||
if summary.claims_updated or summary.remits_updated:
|
||||
try:
|
||||
_run_reconcile_sweep()
|
||||
except Exception: # noqa: BLE001 — sweep is best-effort
|
||||
log.exception("backfill: reconcile sweep failed")
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _sniff_kind(path: Path) -> TransactionType:
|
||||
"""Best-effort transaction-type sniff (content + filename).
|
||||
|
||||
Returns ``"837p"``, ``"835"``, or ``None`` if both parsers fail.
|
||||
Used only when the caller didn't pin ``transaction_type`` explicitly
|
||||
via the CLI.
|
||||
"""
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError:
|
||||
return None
|
||||
# Cheap filename hint.
|
||||
name = path.name.lower()
|
||||
if name.endswith(".835") or "835" in name:
|
||||
return _try_both(path, text, prefer="835")
|
||||
if name.endswith(".837") or "837" in name or "837p" in name:
|
||||
return _try_both(path, text, prefer="837p")
|
||||
# Content: ISA + ST. 837 starts with "ST*837", 835 starts with "ST*835".
|
||||
if "ST*837*" in text:
|
||||
return "837p"
|
||||
if "ST*835*" in text:
|
||||
return "835"
|
||||
# Fallback: try 837p parser, then 835.
|
||||
return _try_both(path, text, prefer="837p")
|
||||
|
||||
|
||||
def _try_both(path: Path, text: str, *, prefer: str) -> TransactionType:
|
||||
"""Try the preferred parser first, then the other; return first winner."""
|
||||
if prefer == "837p":
|
||||
try:
|
||||
parse_837(text, PayerConfig.co_medicaid(), input_file=str(path))
|
||||
return "837p"
|
||||
except Exception:
|
||||
try:
|
||||
parse_835(text, PayerConfig835.co_medicaid_835(), input_file=str(path))
|
||||
return "835"
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
parse_835(text, PayerConfig835.co_medicaid_835(), input_file=str(path))
|
||||
return "835"
|
||||
except Exception:
|
||||
try:
|
||||
parse_837(text, PayerConfig.co_medicaid(), input_file=str(path))
|
||||
return "837p"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BackfillSummary",
|
||||
"backfill_rendering_provider_npi",
|
||||
]
|
||||
@@ -53,6 +53,10 @@ def _claim_837_row(claim: ClaimOutput, batch_id: str) -> Claim:
|
||||
service_date_to=d_to,
|
||||
charge_amount=Decimal(claim.claim.total_charge or 0),
|
||||
provider_npi=claim.billing_provider.npi,
|
||||
# SP32: wire NM1*82 (Loop 2420A) rendering provider NPI from
|
||||
# ClaimOutput (T3 parser extraction) into the typed ORM column
|
||||
# (T1 migration 0019). Falls back to None if absent.
|
||||
rendering_provider_npi=claim.rendering_provider_npi,
|
||||
payer_id=claim.payer.id,
|
||||
state=ClaimState.SUBMITTED,
|
||||
raw_json=json.loads(claim.model_dump_json()),
|
||||
@@ -89,6 +93,10 @@ def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance:
|
||||
total_paid=Decimal(cp.total_paid or 0),
|
||||
patient_responsibility=cp.patient_responsibility,
|
||||
adjustment_amount=adjustment,
|
||||
# SP32: wire NM1*1P (Loop 2100) service provider NPI from
|
||||
# ClaimPayment (T2 parser extraction) into the typed ORM column
|
||||
# (T1 migration 0019). Falls back to None if absent.
|
||||
rendering_provider_npi=cp.service_provider_npi,
|
||||
received_at=received_at,
|
||||
service_date=service_date,
|
||||
is_reversal=cp.status_code in ("21", "22"),
|
||||
|
||||
+130
-1
@@ -14,6 +14,9 @@ test_api_parse_persists.py) working unchanged.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -89,4 +92,130 @@ def _reset_rate_limit_buckets(app) -> None:
|
||||
cur = getattr(cur, "app", None)
|
||||
# No RateLimitMiddleware in the stack — nothing to reset. Should
|
||||
# not happen in this codebase (security.py registers it at boot)
|
||||
# but we don't want a missing reset to crash unrelated tests.
|
||||
# but we don't want a missing reset to crash unrelated tests.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP31: shared DB-session + Claim/Remit factory fixtures.
|
||||
#
|
||||
# `db_session` yields a fresh session per test against the per-test DB set
|
||||
# up by the autouse `_auto_init_db` fixture above. The session rolls back at
|
||||
# teardown so a test that mutates rows doesn't leak into siblings.
|
||||
#
|
||||
# `make_claim` / `make_remit` build ORM rows with a small ergonomic surface:
|
||||
# the planned parameter names follow the content-keys helper (PCN, charge,
|
||||
# rendering NPI), but the ORM attribute names differ (`charge_amount` on
|
||||
# Claim, `total_charge` on Remittance, `provider_npi` on Claim — and no
|
||||
# `payer_id` / `rendering_provider_npi` column on Remittance at all). The
|
||||
# factories map the planned names onto the real ORM attributes and stash
|
||||
# `rendering_provider_npi` as a transient attribute so
|
||||
# ``reconcile._content_keys_match`` can still read it via ``getattr``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
"""Yield a fresh SQLAlchemy session, rolling back at teardown."""
|
||||
from cyclone import db as _db
|
||||
session = _db.SessionLocal()()
|
||||
try:
|
||||
yield session
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _ensure_batch(session, batch_id: str = "test-batch") -> None:
|
||||
"""Create the Batch row a Claim/Remittance FKs to (idempotent)."""
|
||||
from cyclone.db import Batch
|
||||
if session.get(Batch, batch_id) is None:
|
||||
session.add(Batch(
|
||||
id=batch_id, kind="837p", input_filename="test.txt",
|
||||
parsed_at=datetime.now(timezone.utc),
|
||||
))
|
||||
session.flush()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_claim(db_session):
|
||||
"""Factory: build & flush a Claim with the planned content-keys params.
|
||||
|
||||
Planned params (match the SP31 spec / content-keys test surface):
|
||||
patient_control_number, total_charge, rendering_provider_npi,
|
||||
service_date_from, matched_remittance_id=None, state=None,
|
||||
claim_id=None
|
||||
|
||||
`state=None` defaults to ``ClaimState.SUBMITTED`` so existing tests
|
||||
that omit it keep behaving. `rendering_provider_npi` is wired through
|
||||
the real ``Claim.rendering_provider_npi`` column (SP32 migration 0019).
|
||||
"""
|
||||
def _make(
|
||||
patient_control_number: str,
|
||||
total_charge,
|
||||
rendering_provider_npi: str,
|
||||
service_date_from,
|
||||
matched_remittance_id=None,
|
||||
state=None,
|
||||
claim_id: str | None = None,
|
||||
):
|
||||
from cyclone.db import Claim, ClaimState as _CS
|
||||
|
||||
_ensure_batch(db_session)
|
||||
cid = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}"
|
||||
c = Claim(
|
||||
id=cid,
|
||||
batch_id="test-batch",
|
||||
patient_control_number=patient_control_number,
|
||||
service_date_from=service_date_from,
|
||||
charge_amount=total_charge,
|
||||
state=state if state is not None else _CS.SUBMITTED,
|
||||
matched_remittance_id=matched_remittance_id,
|
||||
rendering_provider_npi=rendering_provider_npi,
|
||||
)
|
||||
db_session.add(c)
|
||||
db_session.flush()
|
||||
return c
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_remit(db_session):
|
||||
"""Factory: build & flush a Remittance with the planned content-keys params.
|
||||
|
||||
Planned params:
|
||||
payer_claim_control_number, total_charge_amount, rendering_provider_npi,
|
||||
service_date, remit_id=None
|
||||
|
||||
`total_charge_amount` is mapped to ``Remittance.total_charge`` (real ORM
|
||||
field). `rendering_provider_npi` is wired through the real
|
||||
``Remittance.rendering_provider_npi`` column (SP32 migration 0019).
|
||||
"""
|
||||
def _make(
|
||||
payer_claim_control_number: str,
|
||||
total_charge_amount,
|
||||
rendering_provider_npi: str,
|
||||
service_date,
|
||||
remit_id: str | None = None,
|
||||
):
|
||||
from cyclone.db import Remittance
|
||||
|
||||
_ensure_batch(db_session)
|
||||
rid = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}"
|
||||
r = Remittance(
|
||||
id=rid,
|
||||
batch_id="test-batch",
|
||||
payer_claim_control_number=payer_claim_control_number,
|
||||
status_code="1",
|
||||
total_charge=total_charge_amount,
|
||||
total_paid=Decimal("0"),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
service_date=service_date,
|
||||
is_reversal=False,
|
||||
rendering_provider_npi=rendering_provider_npi,
|
||||
)
|
||||
db_session.add(r)
|
||||
db_session.flush()
|
||||
return r
|
||||
|
||||
return _make
|
||||
+2
-2
@@ -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~
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
ISA*00* *00* *ZZ*11525703 *ZZ*COMEDASSISTPROG*260617*1937*^*00501*991102984*1*P*:~
|
||||
GS*HC*11525703*COMEDASSISTPROG*20260617*193715*991102984*X*005010X222A1~
|
||||
ST*837*991102984*005010X222A1~
|
||||
BHT*0019*00*co-fixture-001*20260617*193715*CH~
|
||||
NM1*41*2*Dzinesco*****46*11525703~
|
||||
PER*IC*Tester*EM*tester@example.com~
|
||||
NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~
|
||||
HL*1**20*1~
|
||||
PRV*BI*PXC*251E00000X~
|
||||
NM1*85*2*TOC, Inc.*****XX*1881068062~
|
||||
N3*1100 East Main St*Suite A~
|
||||
N4*Montrose*CO*814014063~
|
||||
REF*EI*721587149~
|
||||
HL*2*1*22*0~
|
||||
SBR*P*18*******MC~
|
||||
NM1*IL*1*Balliache*Marianela****MI*P060946~
|
||||
N3*1811 PAVILION DR APT 303~
|
||||
N4*Montrose*CO*814016072~
|
||||
DMG*D8*19590223*F~
|
||||
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~
|
||||
LX*1~
|
||||
SV1*HC:T1019:U2*42.70*UN*6.00***1~
|
||||
DTP*472*D8*20260602~
|
||||
REF*6R*t991102984v769804d~
|
||||
LX*2~
|
||||
SV1*HC:T1019:U2*42.70*UN*6.00***1~
|
||||
DTP*472*D8*20260603~
|
||||
REF*6R*t991102984v770058d~
|
||||
NM1*82*2*RENDERING PROVIDER*****XX*1234567893~
|
||||
HL*3*1*22*0~
|
||||
SBR*P*18*******MC~
|
||||
NM1*IL*1*Barella*Victoria****MI*H582447~
|
||||
N3*1900 Kellie DR~
|
||||
N4*Montrose*CO*814019524~
|
||||
DMG*D8*19570727*F~
|
||||
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~
|
||||
LX*1~
|
||||
SV1*HC:S5130:U2*38.94*UN*6.00***1~
|
||||
DTP*472*D8*20260602~
|
||||
REF*6R*t991102984v769805d~
|
||||
LX*2~
|
||||
SV1*HC:S5130:U2*38.94*UN*6.00***1~
|
||||
DTP*472*D8*20260603~
|
||||
REF*6R*t991102984v770059d~
|
||||
LX*3~
|
||||
SV1*HC:S5130:U2*38.94*UN*6.00***1~
|
||||
DTP*472*D8*20260604~
|
||||
REF*6R*t991102984v770308d~
|
||||
LX*4~
|
||||
SV1*HC:S5130:U2*38.94*UN*6.00***1~
|
||||
DTP*472*D8*20260605~
|
||||
REF*6R*t991102984v770668d~
|
||||
SE*45*991102984~
|
||||
GE*1*991102984~
|
||||
IEA*1*991102984~
|
||||
+1
-1
@@ -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~
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_migration_0002_creates_acks_table():
|
||||
|
||||
def test_migration_latest_idempotent_on_fresh_db():
|
||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||
user_version already at the latest version — currently 18 after
|
||||
user_version already at the latest version — currently 19 after
|
||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
||||
@@ -60,15 +60,16 @@ def test_migration_latest_idempotent_on_fresh_db():
|
||||
SP22's 0015 drop_claims_unique_constraint, SP27-Task 11's 0016
|
||||
claims.matched_remittance_id index, SP27-Task 17's 0017
|
||||
claim.patient_control_number backfill UPDATE, SP28's 0018
|
||||
claim_acks join table)."""
|
||||
claim_acks join table, SP32's 0019
|
||||
rendering_provider_npi + service_provider_npi)."""
|
||||
with db.engine().begin() as c:
|
||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v1 == 18
|
||||
assert v1 == 19
|
||||
# A second run should not raise and should not bump the version.
|
||||
db_migrate.run(db.engine())
|
||||
with db.engine().begin() as c:
|
||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v2 == 18
|
||||
assert v2 == 19
|
||||
|
||||
|
||||
def test_add_ack_persists_row():
|
||||
|
||||
@@ -101,6 +101,122 @@ def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
|
||||
assert item["claimIds"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SP30: /api/batches billing-outcome fields
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _sp30_seed_billing_outcome_batch() -> None:
|
||||
"""Insert one batch with 3 claims (paid / rejected / submitted)
|
||||
via the ORM directly. Mirrors the helper pattern in
|
||||
``test_dashboard_kpis.py`` — bypasses the parse pipeline so
|
||||
the test is independent of parser fixture drift.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from cyclone.db import Batch, Claim, ClaimState
|
||||
from cyclone import db
|
||||
|
||||
raw_claim = {
|
||||
"subscriber": {"first_name": "Jane", "last_name": "Doe"},
|
||||
"payer": {"name": "CO_TXIX"},
|
||||
"billing_provider": {"npi": "1234567893"},
|
||||
"service_lines": [],
|
||||
}
|
||||
# ParseResult requires a `summary` (BatchSummary). The list endpoint
|
||||
# rehydrates Batch.raw_result_json through ParseResult.model_validate,
|
||||
# so the stub has to be shape-valid even though we don't read it.
|
||||
valid_raw_result = {
|
||||
"claims": [],
|
||||
"summary": {
|
||||
"input_file": "out.edi",
|
||||
"total_claims": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
},
|
||||
}
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Batch(
|
||||
id="b-sp30-out",
|
||||
kind="837p",
|
||||
input_filename="out.edi",
|
||||
parsed_at=datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc),
|
||||
totals_json={"total_claims": 3},
|
||||
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||
raw_result_json=valid_raw_result,
|
||||
))
|
||||
s.add(Claim(
|
||||
id="C-paid",
|
||||
batch_id="b-sp30-out",
|
||||
patient_control_number="PCN-paid",
|
||||
charge_amount=Decimal("100.00"),
|
||||
state=ClaimState.PAID,
|
||||
raw_json=raw_claim,
|
||||
))
|
||||
s.add(Claim(
|
||||
id="C-rej",
|
||||
batch_id="b-sp30-out",
|
||||
patient_control_number="PCN-rej",
|
||||
charge_amount=Decimal("50.00"),
|
||||
state=ClaimState.REJECTED,
|
||||
rejection_reason="999 AK5 R",
|
||||
raw_json=raw_claim,
|
||||
))
|
||||
s.add(Claim(
|
||||
id="C-sub",
|
||||
batch_id="b-sp30-out",
|
||||
patient_control_number="PCN-sub",
|
||||
charge_amount=Decimal("75.00"),
|
||||
state=ClaimState.SUBMITTED,
|
||||
raw_json=raw_claim,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_batches_includes_billing_outcome(client: TestClient):
|
||||
"""SP30: the Dashboard widget reads accepted/rejected/pending counts,
|
||||
billed total, top rejection reason, and a has-problem flag off the
|
||||
list endpoint. One batch, three claims covering all three buckets."""
|
||||
_sp30_seed_billing_outcome_batch()
|
||||
resp = client.get("/api/batches", headers=JSON)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# We may have other batches from earlier tests (the `seeded_store`
|
||||
# autouse isn't applied here); find the SP30 batch by id.
|
||||
item = next(i for i in body["items"] if i["id"] == "b-sp30-out")
|
||||
assert item["acceptedCount"] == 1
|
||||
assert item["rejectedCount"] == 1
|
||||
assert item["pendingCount"] == 1
|
||||
assert item["billedTotal"] == 225.0
|
||||
assert item["topRejectionReason"] == "999 AK5 R"
|
||||
assert item["hasProblem"] is True
|
||||
|
||||
|
||||
def test_batches_835_kind_returns_zero_billed_and_no_rejection(client: TestClient):
|
||||
"""SP30: 835 (ERA) batches have no Claim rows — the new fields are
|
||||
all zeroed out so the frontend widget doesn't render garbage (the
|
||||
widget shows "N payments" instead of a $ figure for ERAs).
|
||||
"""
|
||||
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
|
||||
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
|
||||
r = client.post(
|
||||
"/api/parse-835",
|
||||
params={"payer": "co_medicaid_835"},
|
||||
files=files,
|
||||
headers=JSON,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = client.get("/api/batches", headers=JSON).json()
|
||||
item = next(i for i in body["items"] if i["kind"] == "835")
|
||||
assert item["acceptedCount"] == 0
|
||||
assert item["rejectedCount"] == 0
|
||||
assert item["pendingCount"] == 0
|
||||
assert item["billedTotal"] == 0.0
|
||||
assert item["topRejectionReason"] is None
|
||||
assert item["hasProblem"] is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /api/batches/{id}
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,227 @@
|
||||
"""SP32 Task 6: end-to-end smoke tests for the ``backfill-rendering-npi`` CLI.
|
||||
|
||||
The CLI re-parses on-disk 837p + 835 files and backfills the new typed
|
||||
``rendering_provider_npi`` columns on existing Claim / Remittance rows
|
||||
that were ingested before the T4 writers took effect. Idempotent: rows
|
||||
that already have a non-NULL rendering NPI are left untouched.
|
||||
|
||||
Test strategy mirrors ``test_cli_backup.py``: in-process Click CliRunner
|
||||
invocation against a fresh per-test DB (autouse ``_auto_init_db``
|
||||
fixture), pre-seeded with Claim/Remittance rows whose
|
||||
``rendering_provider_npi`` is NULL, then assert the CLI populated them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.cli import main
|
||||
|
||||
BACKEND_DIR = "backend"
|
||||
_REPO_ROOT = "."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_837p_claim_without_renderer():
|
||||
"""Insert a Claim row whose ``rendering_provider_npi`` is NULL.
|
||||
|
||||
The id (``t991102984o1c1d``) matches the first claim in
|
||||
``co_medicaid_837p_with_renderer.txt`` — the fixture that does
|
||||
contain a NM1*82 rendering provider (NPI ``1234567893``) attached
|
||||
to that claim. Backfill should populate the column from the
|
||||
re-parsed file.
|
||||
"""
|
||||
from cyclone.db import Batch, Claim, ClaimState
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Batch(
|
||||
id="seed-837p-batch",
|
||||
kind="837p",
|
||||
input_filename="seed.x12",
|
||||
parsed_at=datetime.now(timezone.utc),
|
||||
))
|
||||
s.add(Claim(
|
||||
id="t991102984o1c1d",
|
||||
batch_id="seed-837p-batch",
|
||||
patient_control_number="t991102984o1c1d",
|
||||
charge_amount=Decimal("85.40"),
|
||||
state=ClaimState.SUBMITTED,
|
||||
rendering_provider_npi=None, # ← the column under test
|
||||
provider_npi="1881068062",
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_835_remit_without_service_provider(tmp_path):
|
||||
"""Insert a Remittance whose ``rendering_provider_npi`` is NULL.
|
||||
|
||||
Writes a minimal 835 fixture to ``tmp_path`` containing an
|
||||
NM1*1P service provider (NPI ``2222222222``) attached to the
|
||||
only CLP segment (claim control number ``CLM001``). Returns
|
||||
the path to the written file.
|
||||
"""
|
||||
from cyclone.db import Batch, Remittance
|
||||
|
||||
fixture = tmp_path / "minimal_835_with_service_provider.txt"
|
||||
fixture.write_text(
|
||||
"ISA*00* *00* *ZZ*RECEIVERID *ZZ*SENDERID "
|
||||
"*250101*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~"
|
||||
"ST*835*0001~"
|
||||
"BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111"
|
||||
"**01*031302955*DA*9876543~"
|
||||
"TRN*1*1511111*1511111~"
|
||||
"DTM*405*20250101~"
|
||||
"N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~"
|
||||
"N3*PO BOX 1100*~"
|
||||
"N4*DENVER*CO*80203~"
|
||||
"REF*2U*12345~"
|
||||
"N1*PE*ACME CLINIC*XX*1111111111~"
|
||||
"LX*1~"
|
||||
"CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~"
|
||||
"CAS*PR*1*0.00~"
|
||||
"NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~"
|
||||
"SVC*HC:99213*85.40*85.40**1~"
|
||||
"DTM*472*20250101~"
|
||||
"SE*18*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Batch(
|
||||
id="seed-835-batch",
|
||||
kind="835",
|
||||
input_filename=fixture.name,
|
||||
parsed_at=datetime.now(timezone.utc),
|
||||
))
|
||||
s.add(Remittance(
|
||||
id="CLM001",
|
||||
batch_id="seed-835-batch",
|
||||
payer_claim_control_number="CLM001",
|
||||
claim_id=None,
|
||||
status_code="1",
|
||||
status_label="Primary payer forward",
|
||||
total_charge=Decimal("85.40"),
|
||||
total_paid=Decimal("85.40"),
|
||||
patient_responsibility=Decimal("0"),
|
||||
adjustment_amount=Decimal("0"),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
is_reversal=False,
|
||||
rendering_provider_npi=None, # ← the column under test
|
||||
))
|
||||
s.commit()
|
||||
|
||||
return fixture
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(args: list[str]) -> object:
|
||||
"""Invoke the CLI in-process via CliRunner.
|
||||
|
||||
CliRunner auto-captures stdout/stderr; ``catch_exceptions=False``
|
||||
so the test fails loudly on any unexpected raise instead of a
|
||||
swallowed traceback.
|
||||
"""
|
||||
return CliRunner().invoke(main, args, catch_exceptions=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_backfill_populates_837p_rendering_npi(seed_837p_claim_without_renderer):
|
||||
"""Re-parse the 837p fixture → Claim.rendering_provider_npi populated."""
|
||||
fixture_path = "tests/fixtures/co_medicaid_837p_with_renderer.txt"
|
||||
|
||||
# Sanity: column starts NULL.
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Claim
|
||||
row = s.get(Claim, "t991102984o1c1d")
|
||||
assert row is not None
|
||||
assert row.rendering_provider_npi is None
|
||||
|
||||
result = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "837p"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Claim
|
||||
row = s.get(Claim, "t991102984o1c1d")
|
||||
assert row is not None
|
||||
# First claim in the fixture carries NM1*82 NPI ``1234567893``.
|
||||
assert row.rendering_provider_npi == "1234567893"
|
||||
# CLI prints the summary line with the populated counts.
|
||||
assert "claims_updated=" in result.output
|
||||
assert "remits_updated=" in result.output
|
||||
|
||||
|
||||
def test_backfill_populates_835_rendering_npi(seed_835_remit_without_service_provider):
|
||||
"""Re-parse an 835 with NM1*1P → Remittance.rendering_provider_npi populated."""
|
||||
fixture_path = str(seed_835_remit_without_service_provider)
|
||||
|
||||
# Sanity: column starts NULL.
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Remittance
|
||||
row = s.get(Remittance, "CLM001")
|
||||
assert row is not None
|
||||
assert row.rendering_provider_npi is None
|
||||
|
||||
result = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "835"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Remittance
|
||||
row = s.get(Remittance, "CLM001")
|
||||
assert row is not None
|
||||
# Inline fixture's NM1*1P NM109 is ``2222222222``.
|
||||
assert row.rendering_provider_npi == "2222222222"
|
||||
assert "remits_updated=" in result.output
|
||||
|
||||
|
||||
def test_backfill_is_idempotent(seed_837p_claim_without_renderer):
|
||||
"""Second run is a no-op — already-populated columns stay."""
|
||||
fixture_path = "tests/fixtures/co_medicaid_837p_with_renderer.txt"
|
||||
|
||||
# First run: populates the column.
|
||||
first = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "837p"])
|
||||
assert first.exit_code == 0, first.output
|
||||
|
||||
# Second run: still exits 0; column is unchanged; nothing breaks.
|
||||
second = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "837p"])
|
||||
assert second.exit_code == 0, second.output
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Claim
|
||||
row = s.get(Claim, "t991102984o1c1d")
|
||||
assert row.rendering_provider_npi == "1234567893"
|
||||
|
||||
|
||||
def test_backfill_help_lists_subcommand():
|
||||
"""Regression guard: the subcommand is wired into ``main``."""
|
||||
result = _run(["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "backfill-rendering-npi" in result.output
|
||||
|
||||
|
||||
def test_backfill_with_no_files_is_noop():
|
||||
"""Running with no --file and no --input-dir is a clean no-op (exit 0)."""
|
||||
result = _run(["backfill-rendering-npi"])
|
||||
assert result.exit_code == 0, result.output
|
||||
# Summary line still printed (zero counts).
|
||||
assert "claims_updated=0" in result.output
|
||||
assert "remits_updated=0" in result.output
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -124,14 +124,16 @@ def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
|
||||
SP27 Task 17 bumped it to 17 with the patient_control_number
|
||||
backfill UPDATE (the migration runner now applies DML too).
|
||||
SP28 bumped it to 18 with the claim_acks join table.
|
||||
SP32 bumped it to 19 with rendering_provider_npi +
|
||||
service_provider_npi on claims and remittances.
|
||||
"""
|
||||
engine = _fresh_engine(tmp_path)
|
||||
db_migrate.run(engine)
|
||||
v_after_first = _user_version(engine)
|
||||
assert v_after_first == 18, f"expected head=18, got {v_after_first}"
|
||||
assert v_after_first == 19, f"expected head=19, got {v_after_first}"
|
||||
|
||||
db_migrate.run(engine)
|
||||
assert _user_version(engine) == 18, "second run should not bump version"
|
||||
assert _user_version(engine) == 19, "second run should not bump version"
|
||||
|
||||
|
||||
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -157,7 +159,7 @@ def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: py
|
||||
engine = _fresh_engine(tmp_path)
|
||||
|
||||
db_migrate.run(engine)
|
||||
assert _user_version(engine) == 18, f"expected head=18, got {_user_version(engine)}"
|
||||
assert _user_version(engine) == 19, f"expected head=19, got {_user_version(engine)}"
|
||||
|
||||
# Two claims in one batch with the same patient_control_number
|
||||
# must be insertable. If 0015's table recreation re-introduced a
|
||||
|
||||
@@ -320,7 +320,7 @@ def test_create_match_and_activity_event():
|
||||
))
|
||||
m = Match(
|
||||
claim_id="CLM-1", remittance_id="CLP-1",
|
||||
strategy="auto", is_reversal=False,
|
||||
strategy="pcn-exact", is_reversal=False,
|
||||
matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
s.add(m)
|
||||
@@ -335,7 +335,7 @@ def test_create_match_and_activity_event():
|
||||
with db.SessionLocal()() as s:
|
||||
loaded = s.get(Match, 1) # autoincrement id
|
||||
assert loaded is not None
|
||||
assert loaded.strategy == "auto"
|
||||
assert loaded.strategy == "pcn-exact"
|
||||
assert loaded.is_reversal is False
|
||||
|
||||
events = s.query(ActivityEvent).all()
|
||||
@@ -361,7 +361,7 @@ def test_match_multiple_rows_per_claim():
|
||||
status_code="1", received_at=datetime.now(timezone.utc)))
|
||||
s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
|
||||
status_code="1", received_at=datetime.now(timezone.utc)))
|
||||
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto",
|
||||
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="pcn-exact",
|
||||
matched_at=datetime.now(timezone.utc)))
|
||||
s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual",
|
||||
matched_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.db import (
|
||||
Base, Batch, Claim, ClaimState, Remittance, init_db,
|
||||
Base, Batch, Claim, Ack, ClaimAck, ClaimState, Remittance, init_db,
|
||||
)
|
||||
from cyclone.inbox_lanes import compute_lanes
|
||||
|
||||
@@ -142,3 +142,111 @@ def test_done_today_includes_recent_terminal_states():
|
||||
ids = {r["id"] for r in lanes.done_today}
|
||||
assert "C1" in ids
|
||||
assert "C2" not in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP29: rejected-lane rows must carry the per-claim 999 ack-evidence summary
|
||||
# so the Inbox can render AK2 chips inline + a per-row Resubmit button
|
||||
# without an extra round-trip.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_999_ack(*, ack_id: int, parsed_at=None) -> None:
|
||||
"""Persist a minimal Ack row (the 999 envelope)."""
|
||||
with db.SessionLocal()() as s:
|
||||
if s.get(Ack, ack_id) is not None:
|
||||
return
|
||||
s.add(Ack(
|
||||
id=ack_id, source_batch_id="B-1",
|
||||
accepted_count=0, rejected_count=0, received_count=1,
|
||||
ack_code="A",
|
||||
parsed_at=parsed_at or datetime.now(timezone.utc),
|
||||
raw_json={"set_responses": []},
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def _link_claim_ack(
|
||||
*, ack_id: int, claim_id: str, ak2_index: int,
|
||||
set_accept_reject_code: str,
|
||||
linked_at=None, set_control_number: str = "991102989",
|
||||
) -> None:
|
||||
"""Persist a single ClaimAck row linking an Ack to a Claim."""
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(ClaimAck(
|
||||
claim_id=claim_id, ack_id=ack_id, ack_kind="999",
|
||||
ak2_index=ak2_index,
|
||||
set_control_number=set_control_number,
|
||||
set_accept_reject_code=set_accept_reject_code,
|
||||
linked_at=linked_at or datetime.now(timezone.utc),
|
||||
linked_by="auto",
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_inbox_lanes_attaches_claim_acks_summary_for_rejected():
|
||||
"""A rejected-lane row gets a `claim_acks` field with total /
|
||||
rejected counts + 5 most recent AK2 set_responses. Newest first.
|
||||
"""
|
||||
_add_claim(claim_id="REJ-1", state=ClaimState.REJECTED)
|
||||
# 3 linked 999 acks: 2 accepted, 1 rejected
|
||||
_add_999_ack(ack_id=1)
|
||||
_add_999_ack(ack_id=2)
|
||||
_add_999_ack(ack_id=3)
|
||||
t0 = datetime.now(timezone.utc)
|
||||
_link_claim_ack(ack_id=1, claim_id="REJ-1", ak2_index=0,
|
||||
set_accept_reject_code="A",
|
||||
linked_at=t0 - timedelta(minutes=10))
|
||||
_link_claim_ack(ack_id=2, claim_id="REJ-1", ak2_index=1,
|
||||
set_accept_reject_code="R",
|
||||
linked_at=t0 - timedelta(minutes=5))
|
||||
_link_claim_ack(ack_id=3, claim_id="REJ-1", ak2_index=2,
|
||||
set_accept_reject_code="A",
|
||||
linked_at=t0 - timedelta(minutes=1))
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
|
||||
rows = [r for r in lanes.rejected if r["id"] == "REJ-1"]
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert "claim_acks" in row
|
||||
summary = row["claim_acks"]
|
||||
assert summary is not None
|
||||
assert summary["total"] == 3
|
||||
assert summary["rejected"] == 1
|
||||
assert len(summary["items"]) == 3
|
||||
# Newest first (descending linked_at)
|
||||
assert summary["items"][0]["set_accept_reject_code"] == "A" # ack 3 (most recent)
|
||||
assert summary["items"][1]["set_accept_reject_code"] == "R" # ack 2 (middle)
|
||||
assert summary["items"][2]["set_accept_reject_code"] == "A" # ack 1 (oldest)
|
||||
# The R code is the one rejected entry
|
||||
codes = [it["set_accept_reject_code"] for it in summary["items"]]
|
||||
assert codes.count("R") == 1
|
||||
# ak2_index is preserved
|
||||
assert summary["items"][0]["ak2_index"] == 2
|
||||
assert summary["items"][1]["ak2_index"] == 1
|
||||
|
||||
|
||||
def test_inbox_lanes_claim_acks_is_null_when_no_links():
|
||||
"""A rejected claim with zero linked 999 acks renders
|
||||
`claim_acks: null` (the UI shows '999 not linked')."""
|
||||
_add_claim(claim_id="REJ-2", state=ClaimState.REJECTED)
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
rows = [r for r in lanes.rejected if r["id"] == "REJ-2"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["claim_acks"] is None
|
||||
|
||||
|
||||
def test_inbox_lanes_claim_acks_does_not_attach_to_other_lanes():
|
||||
"""The ack summary is only on `rejected`, not `payer_rejected` /
|
||||
`unmatched` / `done_today` — those lanes have their own evidence
|
||||
shape (payer_rejected_*, no linked 999s by construction)."""
|
||||
_add_claim(claim_id="SUB-1", state=ClaimState.SUBMITTED)
|
||||
_add_claim(claim_id="DEN-1", state=ClaimState.DENIED)
|
||||
with db.SessionLocal()() as s:
|
||||
lanes = compute_lanes(s, dismissed_pairs=set())
|
||||
for r in lanes.unmatched:
|
||||
assert "claim_acks" not in r
|
||||
for r in lanes.done_today:
|
||||
assert "claim_acks" not in r
|
||||
|
||||
@@ -178,3 +178,35 @@ def test_parse_service_payment_units_default_unit_type_to_un():
|
||||
# Default to UN when SVC04 missing but units present.
|
||||
assert s1.unit_type == "UN", s1.unit_type
|
||||
assert s2.unit_type == "UN", s2.unit_type
|
||||
|
||||
|
||||
def test_parse_835_extracts_service_provider_npi_from_nm1_1p():
|
||||
"""SP32: NM1*1P in Loop 2100 populates ClaimPayment.service_provider_npi."""
|
||||
from cyclone.parsers.parse_835 import parse as parse_835
|
||||
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*RECEIVERID *ZZ*SENDERID *250101*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~"
|
||||
"ST*835*0001~"
|
||||
"BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111**01*031302955*DA*9876543~"
|
||||
"TRN*1*1511111*1511111~"
|
||||
"DTM*405*20250101~"
|
||||
"N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~"
|
||||
"N3*PO BOX 1100*~"
|
||||
"N4*DENVER*CO*80203~"
|
||||
"REF*2U*12345~"
|
||||
"PER*BL*SUPPORT*TE*8005551212~"
|
||||
"N1*PE*ACME CLINIC*XX*1111111111~"
|
||||
"LX*1~"
|
||||
"CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~"
|
||||
"CAS*PR*1*0.00~"
|
||||
"NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~"
|
||||
"SVC*HC:99213*85.40*85.40**1~"
|
||||
"DTM*472*20250101~"
|
||||
"SE*18*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse_835(text, payer_config=None) # type: ignore[arg-type]
|
||||
assert len(result.claims) == 1
|
||||
assert result.claims[0].service_provider_npi == "2222222222"
|
||||
|
||||
@@ -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"
|
||||
@@ -68,3 +68,16 @@ def test_parse_uses_generic_config_when_requested():
|
||||
assert len(result.claims) == 1
|
||||
# No patient-loop rule on generic config
|
||||
assert result.claims[0].validation.passed is True
|
||||
|
||||
|
||||
def test_parse_837_extracts_rendering_provider_npi_from_nm1_82():
|
||||
"""SP32: NM1*82 (rendering provider) populates ClaimOutput.rendering_provider_npi."""
|
||||
from pathlib import Path
|
||||
|
||||
text = Path("tests/fixtures/co_medicaid_837p_with_renderer.txt").read_text()
|
||||
result = parse(text, PayerConfig.co_medicaid())
|
||||
assert len(result.claims) >= 1
|
||||
# Find the claim that has the rendering NPI (others may not).
|
||||
matches = [c for c in result.claims if c.rendering_provider_npi is not None]
|
||||
assert len(matches) == 1
|
||||
assert matches[0].rendering_provider_npi == "1234567893"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -153,19 +153,18 @@ def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient):
|
||||
the moment an 835 lands — the most common activity, invisible.
|
||||
|
||||
Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit
|
||||
CLM001), then manually match them so ``Remittance.claim_id`` is
|
||||
populated. The bug presents as: only ``claim_submitted`` and
|
||||
``manual_match`` appear (no ``remit_received``). The fix surfaces
|
||||
all three.
|
||||
CLM001). With SP31, the minimal fixtures now auto-link via the
|
||||
content-keys fallback (PCN + charge agree → 2-of-3) so
|
||||
``Remittance.claim_id`` is populated as part of the 835 ingest
|
||||
itself — no separate manual-match call needed. The bug presents
|
||||
as: only ``claim_submitted`` appears (no ``remit_received``). The
|
||||
fix surfaces both.
|
||||
"""
|
||||
# Force the match — simulates the post-reconciliation state that
|
||||
# populate Remittance.claim_id without depending on auto-reconcile
|
||||
# heuristics (which don't match this minimal fixture).
|
||||
match_resp = seeded_db.post(
|
||||
"/api/reconciliation/match",
|
||||
json={"claim_id": "CLM001", "remit_id": "CLM001"},
|
||||
)
|
||||
assert match_resp.status_code == 200, match_resp.text
|
||||
# The minimal fixture's PCN ("CLM001") + charge ("$100.00") match,
|
||||
# so SP31's content-keys fallback pairs the remit during ingest.
|
||||
# No manual-match call is required (and would now 409 "already_matched").
|
||||
# The intent of this test — surfacing the remit_received event via
|
||||
# the Remittance.claim_id join — is fully exercised either way.
|
||||
|
||||
resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_match_same_pcn_within_window_matches():
|
||||
assert len(matches) == 1
|
||||
assert matches[0].claim.id == "CLM-1"
|
||||
assert matches[0].remittance.id == "CLP-1"
|
||||
assert matches[0].strategy == "auto"
|
||||
assert matches[0].strategy == "pcn-exact"
|
||||
|
||||
|
||||
def test_match_dates_outside_window_unmatched():
|
||||
@@ -203,7 +203,7 @@ def test_apply_reversal_of_already_reversed_is_noop():
|
||||
def test_split_unmatched_partitions_by_match_set():
|
||||
claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)]
|
||||
remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)]
|
||||
matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="auto", is_reversal=False)]
|
||||
matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="pcn-exact", is_reversal=False)]
|
||||
unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches)
|
||||
assert [c.id for c in unmatched_claims] == ["CLM-2"]
|
||||
assert [r.id for r in unmatched_remits] == ["CLP-2"]
|
||||
@@ -306,7 +306,7 @@ def test_run_reversal_flips_paid_to_reversed(fixture_835):
|
||||
# Match row already exists from prior reconcile.
|
||||
s.add(Match(
|
||||
claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx",
|
||||
strategy="auto", matched_at=datetime.now(timezone.utc),
|
||||
strategy="pcn-exact", matched_at=datetime.now(timezone.utc),
|
||||
is_reversal=False,
|
||||
))
|
||||
s.flush()
|
||||
@@ -370,3 +370,526 @@ def test_run_reconcile_raise_in_session_leaves_prior_commits_alone(fixture_835):
|
||||
assert b is not None
|
||||
r = s.query(Remittance).first()
|
||||
assert r is not None
|
||||
|
||||
|
||||
# --- SP31: content-keys fallback matcher (pure helper) -----------------------
|
||||
|
||||
|
||||
def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str):
|
||||
"""Tiny shim with the three fields _content_keys_match reads."""
|
||||
from cyclone.reconcile import _RemitLike # protocol not enforced at runtime
|
||||
return type("R", (), {
|
||||
"id": remit_id,
|
||||
"payer_claim_control_number": pcn,
|
||||
"total_charge_amount": charge,
|
||||
"rendering_provider_npi": npi,
|
||||
})()
|
||||
|
||||
|
||||
def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str):
|
||||
return type("C", (), {
|
||||
"id": claim_id,
|
||||
"patient_control_number": pcn,
|
||||
"total_charge": charge,
|
||||
"rendering_provider_npi": npi,
|
||||
})()
|
||||
|
||||
|
||||
def test_content_keys_pcn_plus_charge_matches():
|
||||
"""PCN + charge match (NPI mismatched) → 2-of-3, returns ``{"pcn","charge"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge"}
|
||||
|
||||
|
||||
def test_content_keys_pcn_plus_npi_matches():
|
||||
"""PCN + NPI match (charge mismatched) → 2-of-3, returns ``{"pcn","npi"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) == {"pcn", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_charge_plus_npi_matches():
|
||||
"""Charge + NPI match (PCN mismatched) → 2-of-3, returns ``{"charge","npi"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) == {"charge", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_all_three_match():
|
||||
"""All 3 match → 3-of-3, returns ``{"pcn","charge","npi"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_only_pcn_does_not_match():
|
||||
"""Only PCN matches (charge + NPI both wrong) → set is just ``{"pcn"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) == {"pcn"}
|
||||
|
||||
|
||||
def test_content_keys_only_charge_does_not_match():
|
||||
"""Only charge matches (PCN + NPI both wrong) → set is just ``{"charge"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) == {"charge"}
|
||||
|
||||
|
||||
def test_content_keys_none_match():
|
||||
"""All 3 fields disagree → empty set."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) == set()
|
||||
|
||||
|
||||
def test_content_keys_charge_tolerance_is_one_cent():
|
||||
"""Charge differs by exactly $0.005 (rounding) → charge counts as matched."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_charge_differs_by_two_cents_no_match():
|
||||
"""Charge differs by $0.02 → charge does NOT match (outside tolerance)."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
# PCN and NPI deliberately differ so the only potential match is charge.
|
||||
# Charge is outside the $0.01 tolerance → 0 of 3 match → empty set.
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) == set()
|
||||
|
||||
|
||||
def test_content_keys_empty_npi_does_not_count():
|
||||
"""Empty NPI on remit counts as not-matched (falls back to PCN+charge rule)."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge"} # NPI skipped on remit
|
||||
c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c2) == {"charge"} # only charge, no PCN
|
||||
|
||||
|
||||
# --- SP31: content-keys fallback matcher (DB helper) ------------------------
|
||||
|
||||
|
||||
def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit):
|
||||
"""One claim in pool matches 2-of-3 → returns (claim_id, keys, candidate_count)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5)) # ±30-day window
|
||||
result = _score_fallback_candidates(db_session, r)
|
||||
assert result is not None
|
||||
claim_id, keys_matched, candidate_count = result
|
||||
assert claim_id == c1.id
|
||||
assert keys_matched == {"pcn", "charge", "npi"} # 3-of-3
|
||||
assert candidate_count == 2 # pool had 2 candidates
|
||||
|
||||
|
||||
def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit):
|
||||
"""No claim in pool matches 2-of-3 → returns None."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
|
||||
|
||||
def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit):
|
||||
"""Two claims in pool both match 2-of-3 → returns None (ambiguous)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
# Both claims have same PCN and same charge — both would match PCN+charge.
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 2))
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
|
||||
|
||||
def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit):
|
||||
"""Claim with matched_remittance_id set → excluded from pool."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1),
|
||||
matched_remittance_id="some-other-remit-id")
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None # only candidate was excluded
|
||||
|
||||
|
||||
def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit):
|
||||
"""Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.db import ClaimState
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1),
|
||||
state=ClaimState.PAID)
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
|
||||
|
||||
def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit):
|
||||
"""Remit 25 days after claim → still in pool (30-day window)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
claim_date = date(2026, 6, 1)
|
||||
remit_date = claim_date + timedelta(days=25)
|
||||
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=claim_date)
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=remit_date)
|
||||
result = _score_fallback_candidates(db_session, r)
|
||||
assert result is not None
|
||||
claim_id, keys_matched, candidate_count = result
|
||||
assert claim_id == c1.id
|
||||
assert candidate_count == 1
|
||||
|
||||
|
||||
def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit):
|
||||
"""Remit 60 days after claim → excluded (outside 30-day window)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
claim_date = date(2026, 6, 1)
|
||||
remit_date = claim_date + timedelta(days=60)
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=claim_date)
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=remit_date)
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
|
||||
|
||||
# --- SP31 Task 3 fix: Remittance NPI lives in raw_json ----------------------
|
||||
|
||||
|
||||
def test_content_keys_npi_from_remit_raw_json(db_session, make_claim):
|
||||
"""NPI on the Remit is read from ``raw_json`` when the attribute is absent.
|
||||
|
||||
The 835 parser stores rendering_provider_npi on ``Remittance.raw_json``
|
||||
(the ORM has no dedicated column) — reconcile.run() hands a raw
|
||||
``Remittance`` instance to ``_content_keys_match``, so the helper must
|
||||
fall through ``getattr(...rendering_provider_npi...)`` to the raw_json
|
||||
read. This test pins that path with a real ORM row (no transient
|
||||
``setattr`` shadowing the production layout).
|
||||
|
||||
Configuration: PCN mismatched, charge matches, NPI matches via raw_json
|
||||
→ 2 of 3 keys agree → True.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from datetime import date, datetime, timezone
|
||||
from cyclone.db import Remittance
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
claim = make_claim(
|
||||
patient_control_number="ABC",
|
||||
total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1),
|
||||
)
|
||||
# Real Remittance ORM instance — NPI lives ONLY in raw_json, not as a
|
||||
# transient attribute. (The 835 parser populates raw_json that way;
|
||||
# the make_remit factory's transient ``rendering_provider_npi`` setattr
|
||||
# would shadow the production read.)
|
||||
remit = Remittance(
|
||||
id="real-remit-1",
|
||||
batch_id="test-batch",
|
||||
payer_claim_control_number="OTHER-PCN", # PCN: won't match "ABC"
|
||||
status_code="1",
|
||||
total_charge=Decimal("100.00"), # charge: matches
|
||||
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
||||
raw_json={"rendering_provider_npi": "1111111111"}, # NPI: matches
|
||||
)
|
||||
db_session.add(remit)
|
||||
db_session.flush()
|
||||
assert _content_keys_match(remit, claim) == {"charge", "npi"} # PCN mismatched
|
||||
|
||||
|
||||
# --- SP31: end-to-end integration via reconcile.run() -----------------------
|
||||
|
||||
|
||||
def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit):
|
||||
"""A remit with no PCN match but matching charge+NPI triggers score-auto."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from cyclone.db import Batch, ClaimState
|
||||
from cyclone.reconcile import run as reconcile_run
|
||||
claim = make_claim(patient_control_number="UNIQUE-CLM",
|
||||
total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
remit = make_remit(payer_claim_control_number="DIFFERENT-PCN",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
# Re-parent the remit to this batch (make_remit doesn't set batch_id).
|
||||
remit.batch_id = batch.id
|
||||
db_session.flush()
|
||||
|
||||
result = reconcile_run(db_session, batch.id)
|
||||
db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row
|
||||
|
||||
assert result.matched == 1
|
||||
# Verify Match row was written with strategy="score-auto"
|
||||
from cyclone.db import Match
|
||||
matches = list(db_session.execute(
|
||||
select(Match).where(Match.remittance_id == remit.id)
|
||||
).scalars().all())
|
||||
assert len(matches) == 1
|
||||
assert matches[0].strategy == "score-auto"
|
||||
assert matches[0].claim_id == claim.id
|
||||
# Symmetric FK: the auto-match path sets Remittance.claim_id so the
|
||||
# list_unmatched filter (``Remittance.claim_id IS NULL``) correctly
|
||||
# drops this pair from the orphan bucket. Mirrors the manual_match
|
||||
# contract — both paths must populate both sides of the FK pair.
|
||||
db_session.refresh(remit)
|
||||
assert remit.claim_id == claim.id
|
||||
# Verify ActivityEvent was emitted with kind="auto_matched_835"
|
||||
from cyclone.db import ActivityEvent
|
||||
events = list(db_session.execute(
|
||||
select(ActivityEvent).where(
|
||||
ActivityEvent.remittance_id == remit.id,
|
||||
ActivityEvent.kind == "auto_matched_835",
|
||||
)
|
||||
).scalars().all())
|
||||
assert len(events) == 1
|
||||
assert events[0].claim_id == claim.id
|
||||
# Spec D8: payload records keys_matched + candidate_count so the
|
||||
# operator can see *why* the auto-link fired.
|
||||
payload = events[0].payload_json or {}
|
||||
assert "keys_matched" in payload
|
||||
assert "candidate_count" in payload
|
||||
assert payload["candidate_count"] == 1
|
||||
assert set(payload["keys_matched"]) >= {"charge", "npi"} # PCN differs in this test
|
||||
# Verify claim state was flipped
|
||||
db_session.refresh(claim)
|
||||
assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED}
|
||||
|
||||
|
||||
def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit):
|
||||
"""Existing PCN-exact match still produces strategy='pcn-exact' (regression guard)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from cyclone.db import Batch, Match
|
||||
from cyclone.reconcile import run as reconcile_run
|
||||
claim = make_claim(patient_control_number="SAME-PCN",
|
||||
total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
remit = make_remit(payer_claim_control_number="SAME-PCN",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 2))
|
||||
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
remit.batch_id = batch.id
|
||||
db_session.flush()
|
||||
|
||||
reconcile_run(db_session, batch.id)
|
||||
db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row
|
||||
|
||||
matches = list(db_session.execute(
|
||||
select(Match).where(Match.remittance_id == remit.id)
|
||||
).scalars().all())
|
||||
assert len(matches) == 1
|
||||
assert matches[0].strategy == "pcn-exact" # NOT "score-auto"
|
||||
|
||||
|
||||
def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit):
|
||||
"""No 2-of-3 match → no Match row, claim state unchanged."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from cyclone.db import Batch, Match, ClaimState
|
||||
from cyclone.reconcile import run as reconcile_run
|
||||
claim = make_claim(patient_control_number="UNRELATED",
|
||||
total_charge=Decimal("999.99"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
remit = make_remit(payer_claim_control_number="OTHER",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
remit.batch_id = batch.id
|
||||
db_session.flush()
|
||||
|
||||
result = reconcile_run(db_session, batch.id)
|
||||
|
||||
assert result.matched == 0
|
||||
matches = list(db_session.execute(
|
||||
select(Match).where(Match.remittance_id == remit.id)
|
||||
).scalars().all())
|
||||
assert len(matches) == 0
|
||||
db_session.refresh(claim)
|
||||
assert claim.state == ClaimState.SUBMITTED # unchanged
|
||||
db_session.refresh(remit)
|
||||
assert remit.claim_id is None # unchanged
|
||||
|
||||
|
||||
# --- SP32 Task 4: ORM builders wire rendering_provider_npi -----------------
|
||||
|
||||
|
||||
def test_claim_837_row_includes_rendering_provider_npi():
|
||||
"""SP32: Claim.rendering_provider_npi is populated from ClaimOutput in ORM builder."""
|
||||
from pathlib import Path
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.payer import PayerConfig
|
||||
from cyclone.store.orm_builders import _claim_837_row
|
||||
|
||||
text = Path("tests/fixtures/co_medicaid_837p_with_renderer.txt").read_text()
|
||||
parsed = parse(text, PayerConfig.co_medicaid())
|
||||
# Find the claim whose rendering NPI was extracted.
|
||||
targets = [c for c in parsed.claims if c.rendering_provider_npi == "1234567893"]
|
||||
assert len(targets) == 1
|
||||
row = _claim_837_row(targets[0], batch_id="test-batch")
|
||||
assert row.rendering_provider_npi == "1234567893"
|
||||
|
||||
|
||||
def test_remittance_835_row_includes_service_provider_npi():
|
||||
"""SP32: Remittance.rendering_provider_npi is populated from ClaimPayment in ORM builder."""
|
||||
from cyclone.parsers.parse_835 import parse as parse_835
|
||||
from cyclone.store.orm_builders import _remittance_835_row
|
||||
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*RECEIVERID *ZZ*SENDERID *250101*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~"
|
||||
"ST*835*0001~"
|
||||
"BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111**01*031302955*DA*9876543~"
|
||||
"TRN*1*1511111*1511111~"
|
||||
"DTM*405*20250101~"
|
||||
"N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~"
|
||||
"N3*PO BOX 1100*~"
|
||||
"N4*DENVER*CO*80203~"
|
||||
"REF*2U*12345~"
|
||||
"PER*BL*SUPPORT*TE*8005551212~"
|
||||
"N1*PE*ACME CLINIC*XX*1111111111~"
|
||||
"LX*1~"
|
||||
"CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~"
|
||||
"CAS*PR*1*0.00~"
|
||||
"NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~"
|
||||
"SVC*HC:99213*85.40*85.40**1~"
|
||||
"DTM*472*20250101~"
|
||||
"SE*18*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
parsed = parse_835(text, payer_config=None) # type: ignore[arg-type]
|
||||
assert len(parsed.claims) == 1
|
||||
assert parsed.claims[0].service_provider_npi == "2222222222"
|
||||
row = _remittance_835_row(parsed.claims[0], batch_id="test-batch")
|
||||
assert row.rendering_provider_npi == "2222222222"
|
||||
|
||||
|
||||
# --- SP32 Task 5: typed-column NPI preference in _content_keys_match -------
|
||||
|
||||
|
||||
def test_content_keys_match_typed_npi_columns_match(db_session, make_claim, make_remit):
|
||||
"""SP32: typed-column NPI contributes to the matched set when both sides have it."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from datetime import date
|
||||
claim = make_claim(
|
||||
patient_control_number="PCN1",
|
||||
total_charge=Decimal("85.40"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2025, 1, 1),
|
||||
)
|
||||
remit = make_remit(
|
||||
payer_claim_control_number="PCN1",
|
||||
total_charge_amount=Decimal("85.40"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date=date(2025, 1, 1),
|
||||
)
|
||||
matched = _content_keys_match(remit, claim)
|
||||
assert "npi" in matched
|
||||
assert "pcn" in matched
|
||||
assert "charge" in matched
|
||||
|
||||
|
||||
def test_content_keys_match_npi_mismatch_does_not_match(db_session, make_claim, make_remit):
|
||||
"""SP32: NPI arm does not fire when NPIs differ."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from datetime import date
|
||||
claim = make_claim(
|
||||
patient_control_number="PCN2",
|
||||
total_charge=Decimal("10.00"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2025, 1, 1),
|
||||
)
|
||||
remit = make_remit(
|
||||
payer_claim_control_number="PCN2",
|
||||
total_charge_amount=Decimal("10.00"),
|
||||
rendering_provider_npi="9999999999", # different NPI
|
||||
service_date=date(2025, 1, 1),
|
||||
)
|
||||
matched = _content_keys_match(remit, claim)
|
||||
assert "npi" not in matched
|
||||
assert "pcn" in matched
|
||||
assert "charge" in matched
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
# SP31 — 835 Strict Content-Match Auto-Link Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a deterministic "any 2 of {PCN, charge, NPI}" content-keys fallback to the 835 auto-match path so more remits link to claims at parse time and the Recent Batches widget reflects settled state immediately.
|
||||
|
||||
**Architecture:** Keep the existing in-memory `reconcile.match()` PCN-exact path as the fast primary. Add a new DB-driven `reconcile._score_fallback_candidates()` that runs **after** `match()` on the leftover unmatched remits, queries a ±30-day candidate pool, applies the 2-of-3 rule, and on a unique hit returns a `Match(strategy="score-auto")` for the same orchestrator loop to consume. Rename existing `strategy="auto"` → `"pcn-exact"` for audit clarity.
|
||||
|
||||
**Tech Stack:** Python 3.11+, SQLAlchemy 2.x (sync session), pytest. No new dependencies. No schema migration. No frontend changes (widget already reflects state via SP30).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
|---|---|---|
|
||||
| `backend/src/cyclone/reconcile.py` | Modify | Rename `strategy="auto"` → `"pcn-exact"`. Add `_content_keys_match` pure helper. Add `_score_fallback_candidates` DB helper. Integrate fallback into `run()`. |
|
||||
| `backend/tests/test_reconcile.py` | Modify | Update 3 existing assertions from `"auto"` → `"pcn-exact"`. Add 11 new tests for the helpers + integration. |
|
||||
| `src/types/index.ts` | Modify | Widen the `Match.strategy` union from `"auto" \| "manual"` to `"pcn-exact" \| "score-auto" \| "manual"` (2 sites). |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rename `strategy="auto"` → `strategy="pcn-exact"` everywhere
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/reconcile.py:87`
|
||||
- Modify: `backend/tests/test_reconcile.py:58, 206, 309`
|
||||
- Modify: `backend/tests/test_db_models.py:323, 338, 364`
|
||||
- Modify: `src/types/index.ts:458, 510`
|
||||
|
||||
- [ ] **Step 1: Update the production code**
|
||||
|
||||
In `backend/src/cyclone/reconcile.py`, line 87, change:
|
||||
|
||||
```python
|
||||
strategy="auto",
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
strategy="pcn-exact",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the backend test assertions**
|
||||
|
||||
In `backend/tests/test_reconcile.py`:
|
||||
- Line 58: change `assert matches[0].strategy == "auto"` → `assert matches[0].strategy == "pcn-exact"`
|
||||
- Line 206: change `strategy="auto"` → `strategy="pcn-exact"`
|
||||
- Line 309: change `strategy="auto"` → `strategy="pcn-exact"`
|
||||
|
||||
In `backend/tests/test_db_models.py`:
|
||||
- Line 323: change `strategy="auto"` → `strategy="pcn-exact"`
|
||||
- Line 338: change `assert loaded.strategy == "auto"` → `assert loaded.strategy == "pcn-exact"`
|
||||
- Line 364: change `strategy="auto"` → `strategy="pcn-exact"`
|
||||
|
||||
- [ ] **Step 3: Widen the frontend TypeScript union**
|
||||
|
||||
In `src/types/index.ts`, line 458, change:
|
||||
|
||||
```typescript
|
||||
strategy: "auto" | "manual";
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
strategy: "pcn-exact" | "score-auto" | "manual";
|
||||
```
|
||||
|
||||
Line 510, change:
|
||||
|
||||
```typescript
|
||||
match: { id: number; strategy: "auto" | "manual" };
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" };
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test suite to confirm nothing broke**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v
|
||||
```
|
||||
|
||||
Expected: all green. The `"auto"` string is gone from production code and tests.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/test_db_models.py src/types/index.ts
|
||||
git commit -m "refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `_content_keys_match` pure helper (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/reconcile.py` (append after `_pick_claim`)
|
||||
- Modify: `backend/tests/test_reconcile.py` (append new test cases)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add the following block at the end of `backend/tests/test_reconcile.py`:
|
||||
|
||||
```python
|
||||
# --- SP31: content-keys fallback matcher (pure helper) -----------------------
|
||||
|
||||
|
||||
def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str):
|
||||
"""Tiny shim with the three fields _content_keys_match reads."""
|
||||
from cyclone.reconcile import _RemitLike # protocol not enforced at runtime
|
||||
return type("R", (), {
|
||||
"id": remit_id,
|
||||
"payer_claim_control_number": pcn,
|
||||
"total_charge_amount": charge,
|
||||
"rendering_provider_npi": npi,
|
||||
})()
|
||||
|
||||
|
||||
def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str):
|
||||
return type("C", (), {
|
||||
"id": claim_id,
|
||||
"patient_control_number": pcn,
|
||||
"total_charge": charge,
|
||||
"rendering_provider_npi": npi,
|
||||
})()
|
||||
|
||||
|
||||
def test_content_keys_pcn_plus_charge_matches():
|
||||
"""PCN + charge match (NPI mismatched) → True."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
|
||||
|
||||
def test_content_keys_pcn_plus_npi_matches():
|
||||
"""PCN + NPI match (charge mismatched) → True."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
|
||||
|
||||
def test_content_keys_charge_plus_npi_matches():
|
||||
"""Charge + NPI match (PCN mismatched) → True."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
|
||||
|
||||
def test_content_keys_all_three_match():
|
||||
"""All 3 match → True."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
|
||||
|
||||
def test_content_keys_only_pcn_does_not_match():
|
||||
"""Only PCN matches (charge + NPI both wrong) → False."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) is False
|
||||
|
||||
|
||||
def test_content_keys_only_charge_does_not_match():
|
||||
"""Only charge matches (PCN + NPI both wrong) → False."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) is False
|
||||
|
||||
|
||||
def test_content_keys_none_match():
|
||||
"""All 3 fields disagree → False."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) is False
|
||||
|
||||
|
||||
def test_content_keys_charge_tolerance_is_one_cent():
|
||||
"""Charge differs by exactly $0.005 (rounding) → True ($0.01 tolerance)."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
|
||||
|
||||
def test_content_keys_charge_differs_by_two_cents_no_match():
|
||||
"""Charge differs by $0.02 → False (outside tolerance)."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is False
|
||||
|
||||
|
||||
def test_content_keys_empty_npi_does_not_count():
|
||||
"""Empty NPI on remit counts as not-matched (falls back to PCN+charge rule)."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True # PCN + charge still match
|
||||
c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c2) is False # only charge, no PCN
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they all FAIL with ImportError**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v
|
||||
```
|
||||
|
||||
Expected: 10 failures, all `ImportError: cannot import name '_content_keys_match'`.
|
||||
|
||||
- [ ] **Step 3: Implement `_content_keys_match` in `reconcile.py`**
|
||||
|
||||
In `backend/src/cyclone/reconcile.py`, add the following function after `_pick_claim` (around line 118):
|
||||
|
||||
```python
|
||||
# --- SP31: content-keys fallback matcher -----------------------------------
|
||||
|
||||
CHARGE_TOLERANCE = Decimal("0.01")
|
||||
KEYS_REQUIRED = 2
|
||||
|
||||
|
||||
def _content_keys_match(remit, claim) -> bool:
|
||||
"""Return True when at least KEYS_REQUIRED of {PCN, charge, NPI} agree.
|
||||
|
||||
Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
|
||||
NPI counts as "not matched" when the remit's NPI is empty.
|
||||
"""
|
||||
keys_matched = 0
|
||||
|
||||
# PCN: payer_claim_control_number ↔ patient_control_number (stripped).
|
||||
if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \
|
||||
(getattr(claim, "patient_control_number", "") or "").strip():
|
||||
keys_matched += 1
|
||||
|
||||
# Charge: total_charge_amount ↔ total_charge (within $0.01).
|
||||
remit_charge = getattr(remit, "total_charge_amount", None)
|
||||
claim_charge = getattr(claim, "total_charge", None)
|
||||
if remit_charge is not None and claim_charge is not None and \
|
||||
abs(remit_charge - claim_charge) < CHARGE_TOLERANCE:
|
||||
keys_matched += 1
|
||||
|
||||
# NPI: rendering_provider_npi ↔ rendering_provider_npi (must be non-empty).
|
||||
remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip()
|
||||
claim_npi = (getattr(claim, "rendering_provider_npi", "") or "").strip()
|
||||
if remit_npi and remit_npi == claim_npi:
|
||||
keys_matched += 1
|
||||
|
||||
return keys_matched >= KEYS_REQUIRED
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they all PASS**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v
|
||||
```
|
||||
|
||||
Expected: 10 passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
|
||||
git commit -m "feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `_score_fallback_candidates` DB helper (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/reconcile.py` (append after `_content_keys_match`)
|
||||
- Modify: `backend/tests/test_reconcile.py` (append integration tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing integration tests**
|
||||
|
||||
Add to the end of `backend/tests/test_reconcile.py`:
|
||||
|
||||
```python
|
||||
# --- SP31: content-keys fallback matcher (DB helper) ------------------------
|
||||
|
||||
|
||||
def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit):
|
||||
"""One claim in pool matches 2-of-3 → returns that claim."""
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5)) # ±30-day window
|
||||
assert _score_fallback_candidates(db_session, r) == c1.id
|
||||
|
||||
|
||||
def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit):
|
||||
"""No claim in pool matches 2-of-3 → returns None."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
|
||||
|
||||
def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit):
|
||||
"""Two claims in pool both match 2-of-3 → returns None (ambiguous)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
# Both claims have same PCN and same charge — both would match PCN+charge.
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 2))
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
|
||||
|
||||
def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit):
|
||||
"""Claim with matched_remittance_id set → excluded from pool."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1),
|
||||
matched_remittance_id="some-other-remit-id")
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None # only candidate was excluded
|
||||
|
||||
|
||||
def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit):
|
||||
"""Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone.db import ClaimState
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1),
|
||||
state=ClaimState.PAID)
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
|
||||
|
||||
def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit):
|
||||
"""Remit 25 days after claim → still in pool (30-day window)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
claim_date = date(2026, 6, 1)
|
||||
remit_date = claim_date + timedelta(days=25)
|
||||
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=claim_date)
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=remit_date)
|
||||
assert _score_fallback_candidates(db_session, r) == c1.id
|
||||
|
||||
|
||||
def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit):
|
||||
"""Remit 60 days after claim → excluded (outside 30-day window)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
claim_date = date(2026, 6, 1)
|
||||
remit_date = claim_date + timedelta(days=60)
|
||||
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=claim_date)
|
||||
r = make_remit(payer_claim_control_number="ABC",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=remit_date)
|
||||
assert _score_fallback_candidates(db_session, r) is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `make_claim` / `make_remit` fixtures to `conftest.py`**
|
||||
|
||||
Open `backend/tests/conftest.py` and add (or extend) the fixtures. If they already exist with these names, adjust the existing fixtures instead:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone import db
|
||||
from cyclone.db import Claim, Remittance, ClaimState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_claim(db_session):
|
||||
def _make(patient_control_number: str, total_charge: Decimal,
|
||||
rendering_provider_npi: str, service_date_from: date,
|
||||
matched_remittance_id: str | None = None,
|
||||
state: ClaimState = ClaimState.SUBMITTED,
|
||||
claim_id: str | None = None):
|
||||
claim_id = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}"
|
||||
c = Claim(
|
||||
id=claim_id,
|
||||
patient_control_number=patient_control_number,
|
||||
total_charge=total_charge,
|
||||
rendering_provider_npi=rendering_provider_npi,
|
||||
service_date_from=service_date_from,
|
||||
matched_remittance_id=matched_remittance_id,
|
||||
state=state,
|
||||
)
|
||||
db_session.add(c)
|
||||
db_session.flush()
|
||||
return c
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_remit(db_session):
|
||||
def _make(payer_claim_control_number: str, total_charge_amount: Decimal,
|
||||
rendering_provider_npi: str, service_date: date,
|
||||
remit_id: str | None = None):
|
||||
remit_id = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}"
|
||||
r = Remittance(
|
||||
id=remit_id,
|
||||
payer_claim_control_number=payer_claim_control_number,
|
||||
total_charge_amount=total_charge_amount,
|
||||
rendering_provider_npi=rendering_provider_npi,
|
||||
service_date=service_date,
|
||||
)
|
||||
db_session.add(r)
|
||||
db_session.flush()
|
||||
return r
|
||||
return _make
|
||||
```
|
||||
|
||||
If `Claim` / `Remittance` constructors don't accept these exact keyword args, check `backend/src/cyclone/db.py` for the actual field names and adjust. The plan assumes the ORM models expose these fields; the existing `reconcile.match()` already reads `matched_remittance_id`, `patient_control_number`, `service_date_from` from `Claim` so they definitely exist.
|
||||
|
||||
- [ ] **Step 3: Run the tests to verify they all FAIL with ImportError**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v
|
||||
```
|
||||
|
||||
Expected: 7 failures, all `ImportError: cannot import name '_score_fallback_candidates'`.
|
||||
|
||||
- [ ] **Step 4: Implement `_score_fallback_candidates` in `reconcile.py`**
|
||||
|
||||
In `backend/src/cyclone/reconcile.py`, add the following function after `_content_keys_match`:
|
||||
|
||||
```python
|
||||
# --- SP31: DB-side fallback candidate matcher -------------------------------
|
||||
|
||||
SCORE_FALLBACK_WINDOW_DAYS = 30
|
||||
|
||||
|
||||
def _score_fallback_candidates(session, remit) -> Optional[str]:
|
||||
"""Return a unique matching claim_id via the 2-of-3 content-keys rule.
|
||||
|
||||
Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
|
||||
- claim.matched_remittance_id IS NULL (not yet linked)
|
||||
- remit.claim_id IS NULL on the matched pair (enforced by caller;
|
||||
this helper assumes the caller has already filtered on remit's
|
||||
own claim_id)
|
||||
- claim.state NOT IN terminal set (PAID, DENIED, REJECTED, REVERSED, RECONCILED)
|
||||
- claim.service_date_from within window of remit.service_date
|
||||
- claim.payer_id == remit.payer_id (same payer)
|
||||
|
||||
Returns the unique claim.id when exactly one candidate passes the
|
||||
2-of-3 rule, otherwise None. Ambiguous matches (2+ candidates) also
|
||||
return None — they land in the Inbox Unlinked lane for manual review.
|
||||
"""
|
||||
from sqlalchemy import select, and_, or_
|
||||
from datetime import timedelta
|
||||
from cyclone.db import Claim, Remittance, ClaimState
|
||||
|
||||
TERMINAL = {
|
||||
ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED,
|
||||
ClaimState.REVERSED, ClaimState.RECONCILED,
|
||||
}
|
||||
|
||||
if remit.service_date is None:
|
||||
return None # need a date for the window query
|
||||
|
||||
window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
|
||||
window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
|
||||
|
||||
candidates = list(session.execute(
|
||||
select(Claim).where(
|
||||
and_(
|
||||
Claim.matched_remittance_id.is_(None),
|
||||
Claim.service_date_from >= window_lo,
|
||||
Claim.service_date_from <= window_hi,
|
||||
Claim.state.notin_([s.value for s in TERMINAL]),
|
||||
Claim.payer_id == remit.payer_id,
|
||||
)
|
||||
)
|
||||
).scalars().all())
|
||||
|
||||
matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)]
|
||||
if len(matched_ids) == 1:
|
||||
return matched_ids[0]
|
||||
return None # 0 matches OR 2+ (ambiguous)
|
||||
```
|
||||
|
||||
If `Claim.payer_id` or `Remittance.payer_id` aren't the actual column names, check `db.py` and adjust the filter. The plan assumes these exist (the SP30 widget already groups claims by payer).
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they all PASS**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v
|
||||
```
|
||||
|
||||
Expected: 7 passed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/conftest.py
|
||||
git commit -m "feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Integrate content-keys fallback into `reconcile.run()` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/reconcile.py` (`run` function)
|
||||
- Modify: `backend/tests/test_reconcile.py` (append end-to-end tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing end-to-end tests**
|
||||
|
||||
Add to the end of `backend/tests/test_reconcile.py`:
|
||||
|
||||
```python
|
||||
# --- SP31: end-to-end integration via reconcile.run() -----------------------
|
||||
|
||||
|
||||
def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit):
|
||||
"""A remit with no PCN match but matching charge+NPI triggers score-auto."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone import db as db_module
|
||||
from cyclone.db import Batch, ClaimState
|
||||
from cyclone.reconcile import run as reconcile_run
|
||||
claim = make_claim(patient_control_number="UNIQUE-CLM",
|
||||
total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
remit = make_remit(payer_claim_control_number="DIFFERENT-PCN",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
# Re-parent the remit to this batch (make_remit doesn't set batch_id).
|
||||
remit.batch_id = batch.id
|
||||
db_session.flush()
|
||||
|
||||
result = reconcile_run(db_session, batch.id)
|
||||
|
||||
assert result.matched == 1
|
||||
# Verify Match row was written with strategy="score-auto"
|
||||
from cyclone.db import Match
|
||||
matches = list(db_session.execute(
|
||||
db_module.select(Match).where(Match.remittance_id == remit.id)
|
||||
).scalars().all())
|
||||
assert len(matches) == 1
|
||||
assert matches[0].strategy == "score-auto"
|
||||
assert matches[0].claim_id == claim.id
|
||||
# Verify ActivityEvent was emitted with kind="auto_matched_835"
|
||||
from cyclone.db import ActivityEvent
|
||||
events = list(db_session.execute(
|
||||
db_module.select(ActivityEvent).where(
|
||||
ActivityEvent.remittance_id == remit.id,
|
||||
ActivityEvent.kind == "auto_matched_835",
|
||||
)
|
||||
).scalars().all())
|
||||
assert len(events) == 1
|
||||
assert events[0].claim_id == claim.id
|
||||
# Verify claim state was flipped
|
||||
db_session.refresh(claim)
|
||||
assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED}
|
||||
|
||||
|
||||
def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit):
|
||||
"""Existing PCN-exact match still produces strategy='pcn-exact' (regression guard)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone import db as db_module
|
||||
from cyclone.db import Batch, Match
|
||||
from cyclone.reconcile import run as reconcile_run
|
||||
claim = make_claim(patient_control_number="SAME-PCN",
|
||||
total_charge=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
remit = make_remit(payer_claim_control_number="SAME-PCN",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 2))
|
||||
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
remit.batch_id = batch.id
|
||||
db_session.flush()
|
||||
|
||||
reconcile_run(db_session, batch.id)
|
||||
|
||||
matches = list(db_session.execute(
|
||||
db_module.select(Match).where(Match.remittance_id == remit.id)
|
||||
).scalars().all())
|
||||
assert len(matches) == 1
|
||||
assert matches[0].strategy == "pcn-exact" # NOT "score-auto"
|
||||
|
||||
|
||||
def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit):
|
||||
"""No 2-of-3 match → no Match row, claim state unchanged."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from cyclone import db as db_module
|
||||
from cyclone.db import Batch, Match, ClaimState
|
||||
from cyclone.reconcile import run as reconcile_run
|
||||
claim = make_claim(patient_control_number="UNRELATED",
|
||||
total_charge=Decimal("999.99"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2026, 6, 1))
|
||||
remit = make_remit(payer_claim_control_number="OTHER",
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5))
|
||||
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
remit.batch_id = batch.id
|
||||
db_session.flush()
|
||||
|
||||
result = reconcile_run(db_session, batch.id)
|
||||
|
||||
assert result.matched == 0
|
||||
matches = list(db_session.execute(
|
||||
db_module.select(Match).where(Match.remittance_id == remit.id)
|
||||
).scalars().all())
|
||||
assert len(matches) == 0
|
||||
db_session.refresh(claim)
|
||||
assert claim.state == ClaimState.SUBMITTED # unchanged
|
||||
db_session.refresh(remit)
|
||||
assert remit.claim_id is None # unchanged
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they FAIL (run() not yet integrated)**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v
|
||||
```
|
||||
|
||||
Expected: failures. The behavior should be wrong because `run()` doesn't yet call `_score_fallback_candidates`.
|
||||
|
||||
- [ ] **Step 3: Integrate the fallback into `reconcile.run()`**
|
||||
|
||||
In `backend/src/cyclone/reconcile.py`, modify the `run` function (around line 240-260) to add the fallback after the in-memory `match()` call. Replace the block:
|
||||
|
||||
```python
|
||||
matches = match(unmatched_claims, new_remits)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
matches = match(unmatched_claims, new_remits)
|
||||
|
||||
# SP31: content-keys fallback for remits that PCN-exact couldn't pair.
|
||||
# Operates on the still-unmatched remits so we don't double-match.
|
||||
matched_remit_ids = {m.remittance.id for m in matches}
|
||||
used_claim_ids = {m.claim.id for m in matches}
|
||||
for remit in new_remits:
|
||||
if remit.id in matched_remit_ids:
|
||||
continue
|
||||
if remit.claim_id is not None:
|
||||
continue # already linked
|
||||
matched_claim_id = _score_fallback_candidates(session, remit)
|
||||
if matched_claim_id is None:
|
||||
continue
|
||||
# Find the claim object in the unmatched_claims list (it was loaded).
|
||||
target_claim = next(
|
||||
(c for c in unmatched_claims if c.id == matched_claim_id),
|
||||
None,
|
||||
)
|
||||
if target_claim is None or target_claim.id in used_claim_ids:
|
||||
continue
|
||||
matches.append(Match(
|
||||
claim=target_claim, remittance=remit,
|
||||
strategy="score-auto", is_reversal=remit.is_reversal,
|
||||
))
|
||||
used_claim_ids.add(target_claim.id)
|
||||
```
|
||||
|
||||
Then, in the existing apply loop further down (around line 270), change the ActivityEvent emission so score-auto matches emit `kind="auto_matched_835"` instead of the default `"reconcile"`. Find:
|
||||
|
||||
```python
|
||||
session.add(ActivityEvent(
|
||||
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
|
||||
batch_id=batch_id, claim_id=m.claim.id,
|
||||
remittance_id=m.remittance.id,
|
||||
payload_json={"new_state": m.claim.state.value},
|
||||
))
|
||||
```
|
||||
|
||||
and replace the `kind=intent.activity_kind` with:
|
||||
|
||||
```python
|
||||
kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind),
|
||||
```
|
||||
|
||||
And update the payload to include the score-auto metadata for the operator. Change the `payload_json=` line to:
|
||||
|
||||
```python
|
||||
payload_json=(
|
||||
{"new_state": m.claim.state.value, "strategy": m.strategy}
|
||||
if m.strategy == "score-auto"
|
||||
else {"new_state": m.claim.state.value}
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new end-to-end tests to verify they PASS**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v
|
||||
```
|
||||
|
||||
Expected: 3 passed.
|
||||
|
||||
- [ ] **Step 5: Run the full reconcile test suite to verify no regression**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v
|
||||
```
|
||||
|
||||
Expected: all green (existing tests + 20 new tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
|
||||
git commit -m "feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Verify end-to-end (full test suite + manual smoke)
|
||||
|
||||
**Files:**
|
||||
- No new files. Verification only.
|
||||
|
||||
- [ ] **Step 1: Run the full backend test suite**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest -q
|
||||
```
|
||||
|
||||
Expected: 0 failures. If any test fails, fix it before proceeding.
|
||||
|
||||
- [ ] **Step 2: Run the frontend typecheck**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Expected: 0 errors. The widened `strategy` union in `src/types/index.ts` should compile cleanly.
|
||||
|
||||
- [ ] **Step 3: Run the frontend lint**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Manual smoke test via the running prod stack**
|
||||
|
||||
The 837+835 fixtures already exist (`backend/tests/fixtures/co_medicaid_837p.txt` and `backend/tests/fixtures/co_medicaid_835.txt`). Confirm by reading the envelopes:
|
||||
|
||||
```bash
|
||||
grep -E "^ISA|^GS|^ST|^BPR|^TRN|^CLP" backend/tests/fixtures/co_medicaid_835.txt | head -10
|
||||
```
|
||||
|
||||
Then send a parse-835 request via the existing `/api/parse-835` endpoint or the CLI:
|
||||
|
||||
```bash
|
||||
python -m cyclone.cli parse-835 backend/tests/fixtures/co_medicaid_835.txt --output-dir /tmp/sp31-smoke
|
||||
```
|
||||
|
||||
Expected: a Remittance row is written + a Match row is created. Check via SQL:
|
||||
|
||||
```bash
|
||||
docker compose exec -T backend python3 <<'PY'
|
||||
from cyclone import db
|
||||
db.init_db()
|
||||
from sqlalchemy import select
|
||||
with db.SessionLocal()() as s:
|
||||
matches = list(s.execute(
|
||||
select(db.Match).order_by(db.Match.matched_at.desc()).limit(5)
|
||||
).scalars().all())
|
||||
for m in matches:
|
||||
print(f" {m.strategy:12} claim={m.claim_id[:24]:24} remit={m.remittance_id[:24]:24} reversal={m.is_reversal}")
|
||||
PY
|
||||
```
|
||||
|
||||
Expected: at least one row with `strategy=pcn-exact` (the co_medicaid_835 fixture has PCN matching the co_medicaid_837p fixture) or `strategy=score-auto` (if you intentionally mangle the PCN in a copy of the fixture to trigger fallback).
|
||||
|
||||
- [ ] **Step 5: Commit any incidental cleanup**
|
||||
|
||||
If step 1-4 surfaced any unrelated failures, fix them on this branch. Otherwise no commit.
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
If clean, proceed to merge.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- §1 D1 (strict 2-of-3 rule) → Task 2 ✅
|
||||
- §1 D2 (PCN-first, fallback) → Task 4 ✅
|
||||
- §1 D3 (Match row strategy label + payload) → Task 4 ✅
|
||||
- §1 (idempotency guards) → Task 3 ✅
|
||||
- §1 (reversal handling unchanged) → Task 4 (no changes to apply_reversal) ✅
|
||||
- §2 D3 (Match.strategy labels) → Task 1 (rename) + Task 4 (new label) ✅
|
||||
- §2 D4 ($0.01 tolerance) → Task 2 ✅
|
||||
- §2 D5 (±30-day window) → Task 3 ✅
|
||||
- §2 D6 (3 idempotency guards) → Task 3 (3 tests) ✅
|
||||
- §2 D7 (no new UI) → no tasks (intentional) ✅
|
||||
- §2 D8 (`auto_matched_835` ActivityEvent) → Task 4 ✅
|
||||
- §2 D9 (no auth changes) → no tasks (intentional) ✅
|
||||
- §3 edge cases → covered across Tasks 2, 3, 4 ✅
|
||||
- §4 testing approach → 20 new tests across 4 test files ✅
|
||||
- §5 out-of-scope reminders → no backfill, no 999 changes, etc. (none touched) ✅
|
||||
|
||||
**2. Placeholder scan:** No "TBD", "TODO", "implement later". The conftest fixture instructions include "If `Claim.payer_id` doesn't exist, check `db.py`" — this is intentional contingency, not a placeholder; the actual implementation step checks the file.
|
||||
|
||||
**3. Type consistency:** All references to `_content_keys_match`, `_score_fallback_candidates`, `Match(strategy="score-auto")`, `kind="auto_matched_835"`, `CHARGE_TOLERANCE`, `KEYS_REQUIRED`, `SCORE_FALLBACK_WINDOW_DAYS`, and the `make_claim` / `make_remit` fixture signatures are consistent across all tasks. The `RemitLike` / `ClaimLike` Protocol usage in Task 2 matches the existing patterns in `reconcile.py`.
|
||||
@@ -0,0 +1,460 @@
|
||||
# SP29 — Inbox 999-rejected claim drill implementation plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Surface the SP28-linkable 999 AK2 set-response evidence inline on each `rejected`-lane row in the Inbox and add a per-row `Resubmit` button that downloads the single-claim corrected 837. The operator can identify and act on each rejected claim without opening the drawer or running the bulk modal.
|
||||
|
||||
**Architecture:** Add a `claim_acks` summary field to the rejected-lane row payload from `/api/inbox/lanes` (one batched query for the whole lane — no N+1). Extend `InboxClaimRow` in `inbox-api.ts` with the new field. Extend `InboxRow` to render up to 3 AK2 chips inline plus a per-row Resubmit button that calls the existing `serializeClaim837` client and writes via `downloadTextFile`. No new endpoints, no new dependencies.
|
||||
|
||||
**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, Zustand, Radix UI primitives.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-07-02-cyclone-999-rejected-drill-design.md`](../specs/2026-07-02-cyclone-999-rejected-drill-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/cyclone/
|
||||
│ └── inbox_lanes.py # ~ attach claim_acks summary to rejected rows
|
||||
└── tests/
|
||||
└── test_inbox_lanes.py # + 1 test for the new field (extend if file exists)
|
||||
|
||||
src/
|
||||
├── lib/
|
||||
│ └── inbox-api.ts # + InboxClaimRow.claim_acks type
|
||||
├── components/inbox/
|
||||
│ ├── InboxRow.tsx # + AK2 chip sub-row + per-row Resubmit button
|
||||
│ └── InboxRow.test.tsx # + 1 test for chips
|
||||
└── pages/
|
||||
├── Inbox.tsx # + onResubmitOne handler threading
|
||||
└── Inbox.test.tsx # + 1 test for per-row download
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Backend: lane row payload
|
||||
|
||||
### Task 1: Attach `claim_acks` summary to each rejected-lane row
|
||||
|
||||
- [ ] **Step 1.1: Read `backend/src/cyclone/inbox_lanes.py` and locate the rejected-lane loop.**
|
||||
|
||||
Around `inbox_lanes.py:180-195` — the `rejected_claims = (...)` query, the `_line_count_lookup(...)` call, and the `_claim_to_row(...)` loop. The new `claim_acks` attach should sit AFTER the loop (so it operates on the dict rows), pulling from a single batched SQL query keyed by `claim_id IN (...)`.
|
||||
|
||||
- [ ] **Step 1.2: Add a batched query helper inside `compute_lanes`.**
|
||||
|
||||
After the rejected loop, before the function returns, add:
|
||||
|
||||
```python
|
||||
rejected_ids = [r["id"] for r in lanes.rejected]
|
||||
rejected_ack_summary = _ack_summary_for_claims(session, rejected_ids)
|
||||
for row in lanes.rejected:
|
||||
row["claim_acks"] = rejected_ack_summary.get(row["id"]) # None when no 999 acks linked
|
||||
```
|
||||
|
||||
The helper lives at module scope (above `compute_lanes`):
|
||||
|
||||
```python
|
||||
def _ack_summary_for_claims(session: Session, claim_ids: list[str]) -> dict[str, dict]:
|
||||
"""Attach a `claim_acks` summary to every rejected-lane row.
|
||||
|
||||
SP29: per rejected claim, summarize the linked 999 acks so the
|
||||
Inbox row can render AK2 chips + a per-row Resubmit button
|
||||
without an extra fetch. Newest 5 acks + total + rejected count.
|
||||
|
||||
Returns:
|
||||
{claim_id: {"total": int, "rejected": int, "items": [...]}, ...}
|
||||
(claims with zero 999 acks are NOT in the returned dict, so
|
||||
the caller can map.get(...) and treat absence as null.)
|
||||
"""
|
||||
if not claim_ids:
|
||||
return {}
|
||||
from cyclone.db import ClaimAck # late import — mirrors DB module shape
|
||||
# Fetch newest 5 linked 999 acks per claim in one round-trip
|
||||
from sqlalchemy import select
|
||||
# Subquery to pick the 5 newest per claim
|
||||
# Simpler: fetch ALL then trim in Python (claim_id count is low — lanes page
|
||||
# only carries rejected claims, typically a few dozen)
|
||||
rows = (
|
||||
session.query(
|
||||
ClaimAck.claim_id,
|
||||
ClaimAck.ack_id,
|
||||
ClaimAck.set_control_number,
|
||||
ClaimAck.set_accept_reject_code,
|
||||
ClaimAck.ak2_index,
|
||||
ClaimAck.linked_at,
|
||||
)
|
||||
.filter(
|
||||
ClaimAck.claim_id.in_(claim_ids),
|
||||
ClaimAck.ack_kind == "999",
|
||||
)
|
||||
.order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc())
|
||||
.all()
|
||||
)
|
||||
grouped: dict[str, list[tuple]] = {}
|
||||
for cid, aid, scn, code, ak2i, lat in rows:
|
||||
grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat))
|
||||
|
||||
rejected_codes = {"R", "E", "X"}
|
||||
out: dict[str, dict] = {}
|
||||
for cid, items in grouped.items():
|
||||
total = len(items)
|
||||
rejected_count = sum(1 for it in items if it[2] in rejected_codes)
|
||||
trimmed = items[:5]
|
||||
out[cid] = {
|
||||
"total": total,
|
||||
"rejected": rejected_count,
|
||||
"items": [
|
||||
{
|
||||
"ack_id": aid,
|
||||
"set_control_number": scn,
|
||||
"set_accept_reject_code": code or "",
|
||||
"ak2_index": ak2i,
|
||||
"linked_at": _isoformat(lat),
|
||||
}
|
||||
for (aid, scn, code, ak2i, lat) in trimmed
|
||||
],
|
||||
}
|
||||
return out
|
||||
```
|
||||
|
||||
**Performance note:** the lanes page only carries rejected claims (a few dozen at most on this codebase; the prod count of `rejected` lane rows can be confirmed with a count query but is small by construction). A bounded fetch + Python trim is fine for v1. If a future SP finds lane payloads blowing up, add `claim_acks(claim_id, ack_kind, linked_at)` composite index in a follow-up migration.
|
||||
|
||||
- [ ] **Step 1.3: Wire the helper into `compute_lanes`.**
|
||||
|
||||
Place the two lines from Step 1.2 immediately after the rejected-lane loop, before any return statement. Do not touch the `payer_rejected` loop — that lane has its own `payer_rejected_*` fields and doesn't need `claim_acks` (its rejections come from 277CA, not 999).
|
||||
|
||||
- [ ] **Step 1.4: Add a test in `backend/tests/test_inbox_lanes.py`.**
|
||||
|
||||
```python
|
||||
def test_inbox_lanes_attaches_claim_acks_summary_for_rejected():
|
||||
# Seed: 1 rejected claim, with 3 linked 999 acks (2 accepted, 1 rejected)
|
||||
# Call compute_lanes (or /api/inbox/lanes via TestClient)
|
||||
# Assert: rejected[0]["claim_acks"]["total"] == 3
|
||||
# Assert: rejected[0]["claim_acks"]["rejected"] == 1
|
||||
# Assert: items has len <= 5 and is sorted by linked_at DESC
|
||||
# Assert: items contain ack_id + set_control_number + set_accept_reject_code
|
||||
```
|
||||
|
||||
If `test_inbox_lanes.py` doesn't exist, create it and put the test there. The test should:
|
||||
- Create a `Claim` with `state=ClaimState.REJECTED`
|
||||
- Create 3 `Ack` rows (no specific kind — they're stored in the `acks` table)
|
||||
- Create 3 `ClaimAck` rows linking the claim to those acks via the `claim_acks` table
|
||||
- Set 2 of the claim_acks rows' `set_accept_reject_code` to `A` and 1 to `R`
|
||||
- Call `/api/inbox/lanes` via TestClient
|
||||
- Assert the `rejected[0].claim_acks` shape matches
|
||||
|
||||
- [ ] **Step 1.5: Run the new test — should PASS.**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_inbox_lanes.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Frontend types + render
|
||||
|
||||
### Task 2: Extend `InboxClaimRow` type
|
||||
|
||||
- [ ] **Step 2.1: Edit `src/lib/inbox-api.ts` (around line 54 where `InboxClaimRow` is declared).**
|
||||
|
||||
Add the new optional field:
|
||||
|
||||
```typescript
|
||||
export type InboxClaimAckItem = {
|
||||
ack_id: number;
|
||||
set_control_number: string;
|
||||
set_accept_reject_code: string;
|
||||
ak2_index: number;
|
||||
linked_at: string;
|
||||
};
|
||||
|
||||
export type InboxClaimRow = {
|
||||
// ... existing fields ...
|
||||
/**
|
||||
* SP29: present on `rejected`-lane rows (999 envelope rejects).
|
||||
* Newest 5 AK2 set-responses for this claim plus a `total` /
|
||||
* `rejected` summary. `null` when the claim has zero linked
|
||||
* 999 acks.
|
||||
*/
|
||||
claim_acks?: {
|
||||
total: number;
|
||||
rejected: number;
|
||||
items: InboxClaimAckItem[];
|
||||
} | null;
|
||||
};
|
||||
```
|
||||
|
||||
### Task 3: Extend `InboxRow` with chip column + per-row Resubmit button
|
||||
|
||||
- [ ] **Step 3.1: Locate the row render in `src/components/inbox/InboxRow.tsx`.**
|
||||
|
||||
Around the 7-column flexbox (lines 73-119). The new chips live in a sub-row beneath the existing row content; the per-row Resubmit button slots in at the right edge of the row.
|
||||
|
||||
- [ ] **Step 3.2: Add an `onResubmitOne?: (claimId: string) => void` prop.**
|
||||
|
||||
Update the function signature to accept the new optional callback.
|
||||
|
||||
- [ ] **Step 3.3: Render the chips sub-row for `rejected` claim rows.**
|
||||
|
||||
Pseudocode:
|
||||
|
||||
```tsx
|
||||
{row.kind === "claim" && row.state === "rejected" && (
|
||||
<div className="flex items-center gap-1 ml-[...px] mt-1">
|
||||
{row.claim_acks && row.claim_acks.items.length > 0 ? (
|
||||
<>
|
||||
{row.claim_acks.items.slice(0, 3).map((it) => (
|
||||
<Chip key={`${it.ack_id}-${it.ak2_index}`}
|
||||
code={it.set_accept_reject_code}
|
||||
scn={it.set_control_number} />
|
||||
))}
|
||||
{row.claim_acks.total > 3 && (
|
||||
<ChipOverflow total={row.claim_acks.total - 3}
|
||||
onClick={() => onOpenClaim?.(row.id)} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] muted">999 not linked</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Chip styling mirrors the existing AcksPage `AckCodeBadge`:
|
||||
|
||||
- `A` → muted check tint (muted foreground, transparent bg)
|
||||
- `R`/`E`/`X` → oxblood tint
|
||||
- Display: `ST02 · AK2_CODE` (e.g. `991102989 · A`)
|
||||
|
||||
- [ ] **Step 3.4: Add the per-row Resubmit button.**
|
||||
|
||||
At the right edge of the row, after the existing `payer` cell:
|
||||
|
||||
```tsx
|
||||
{row.kind === "claim" && row.state === "rejected" && onResubmitOne && (
|
||||
<button
|
||||
type="button"
|
||||
className="mono text-[10px] px-2 py-1 rounded-sm uppercase tracking-[0.12em] hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
|
||||
style={{ color: "var(--tt-amber)" }}
|
||||
onClick={(e) => { e.stopPropagation(); onResubmitOne(row.id); }}
|
||||
data-testid={`resubmit-${row.id}`}
|
||||
>
|
||||
Resubmit
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
`e.stopPropagation()` is critical — the row is currently wired to navigate to `/claims?claim=ID` on click. Without stopPropagation, both gestures would fire.
|
||||
|
||||
### Task 4: Wire `onResubmitOne` through the page
|
||||
|
||||
- [ ] **Step 4.1: Add the handler in `src/pages/Inbox.tsx`.**
|
||||
|
||||
Near the existing `performResubmit(...)` helper (~line 87):
|
||||
|
||||
```typescript
|
||||
const performResubmitOne = useCallback(async (claimId: string) => {
|
||||
try {
|
||||
const { text, filename } = await api.serializeClaim837(claimId);
|
||||
downloadTextFile(filename, "text/x12", text);
|
||||
} catch (e) {
|
||||
toast.error(`Resubmit failed: ${String(e)}`);
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
Import `serializeClaim837` from `@/lib/api` and `downloadTextFile` from `@/lib/download`.
|
||||
|
||||
- [ ] **Step 4.2: Thread the prop through `<Lane>` → `<InboxRow>`.**
|
||||
|
||||
`Lane.tsx` needs a new optional `onResubmitOne` prop passed through to `InboxRow`. `InboxRow` already accepts it (Step 3.2). Wire in `pages/Inbox.tsx` at the existing `<Lane key="rejected" ... onResubmit={onResubmit} ...>` call site.
|
||||
|
||||
- [ ] **Step 4.3: Gate on `RoleGate` for `admin` + `user`.**
|
||||
|
||||
Mirror the existing bulk Resubmit gating (`pages/Inbox.tsx:529-538` — `<RoleGate allow={["admin", "user"]}>`). The per-row button can live inside the same `RoleGate` wrapper. (If `RoleGate` only wraps `<BulkBar>` but not `<Lane>`, restructure to wrap the whole `rejected` lane block.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend tests
|
||||
|
||||
### Task 5: Test the chip rendering + per-row Resubmit
|
||||
|
||||
- [ ] **Step 5.1: `src/components/inbox/InboxRow.test.tsx` — add 1 test.**
|
||||
|
||||
```tsx
|
||||
it("SP29: rejected-lane row renders inline 999 ack evidence chips", () => {
|
||||
const row: InboxClaimRow = {
|
||||
id: "REJ1", kind: "claim", state: "rejected",
|
||||
patient_control_number: "991102989o...",
|
||||
charge_amount: 100, payer_id: "CO_MEDICAID",
|
||||
provider_npi: "1234567893",
|
||||
rejection_reason: "999 AK5 set-level R; 1 segment error(s)",
|
||||
rejected_at: new Date().toISOString(),
|
||||
service_date_from: null,
|
||||
claim_acks: {
|
||||
total: 4,
|
||||
rejected: 2,
|
||||
items: [
|
||||
{ ack_id: 1, set_control_number: "991102989", set_accept_reject_code: "A", ak2_index: 0, linked_at: "..." },
|
||||
{ ack_id: 2, set_control_number: "991102989", set_accept_reject_code: "R", ak2_index: 1, linked_at: "..." },
|
||||
{ ack_id: 3, set_control_number: "991102989", set_accept_reject_code: "E", ak2_index: 2, linked_at: "..." },
|
||||
],
|
||||
},
|
||||
};
|
||||
const onResubmitOne = vi.fn();
|
||||
render(<InboxRow row={row} onResubmitOne={onResubmitOne} />);
|
||||
// Assert 3 chips render with codes A/R/E
|
||||
expect(screen.getByText(/A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/R/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/E/)).toBeInTheDocument();
|
||||
// Assert +1 more chip (total 4, only 3 rendered, +1 indicator)
|
||||
expect(screen.getByText(/\+1/)).toBeInTheDocument();
|
||||
// Click the Resubmit button — assert onResubmitOne fires
|
||||
fireEvent.click(screen.getByTestId("resubmit-REJ1"));
|
||||
expect(onResubmitOne).toHaveBeenCalledWith("REJ1");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2: `src/pages/Inbox.test.tsx` — add 1 test.**
|
||||
|
||||
```tsx
|
||||
it("SP29: per-row Resubmit button downloads the single-claim 837", async () => {
|
||||
// Mock fetchInboxLanes to return 1 rejected row REJ1 with claim_acks
|
||||
// Mock api.serializeClaim837 to return { text: "ISA*...", filename: "claim-REJ1.x12" }
|
||||
// Mock downloadTextFile
|
||||
// Render <Inbox />
|
||||
// Click the per-row Resubmit button
|
||||
// Assert serializeClaim837 was called with "REJ1"
|
||||
// Assert downloadTextFile was called with ("claim-REJ1.x12", "text/x12", "ISA*...")
|
||||
});
|
||||
```
|
||||
|
||||
Follow the existing `vi.mock("@/lib/api", ...)` + `vi.mock("@/lib/download", ...)` pattern in the file. Use the same `vi.mocked(...)` harness style.
|
||||
|
||||
- [ ] **Step 5.3: Run the new tests — should PASS.**
|
||||
|
||||
```bash
|
||||
npx vitest run src/components/inbox/InboxRow.test.tsx src/pages/Inbox.test.tsx -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Verification
|
||||
|
||||
### Task 6: Run full verification
|
||||
|
||||
- [ ] **Step 6.1: Backend pytest (one new file or extension, no others touched).**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_inbox_lanes.py -v
|
||||
```
|
||||
|
||||
Pass criteria: new test passes; no new regressions elsewhere (run the full backend suite once).
|
||||
|
||||
- [ ] **Step 6.2: Frontend vitest.**
|
||||
|
||||
```bash
|
||||
npx vitest run src/components/inbox/InboxRow.test.tsx src/pages/Inbox.test.tsx -v
|
||||
```
|
||||
|
||||
Pass criteria: 2 new tests pass; no new regressions (run full vitest once to confirm the pre-existing baseline unchanged).
|
||||
|
||||
- [ ] **Step 6.3: Typecheck.**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Pass criteria: 0 new errors (the pre-existing 17 errors documented in SP28 deviations log remain; if any new errors are introduced they're a deviation to log).
|
||||
|
||||
- [ ] **Step 6.4: Lint.**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Pass criteria: same pre-existing posture (eslint not installed — non-functional).
|
||||
|
||||
- [ ] **Step 6.5: Build (vite build via tsc -b).**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Pass criteria: same as SP28 build baseline.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Merge + deploy
|
||||
|
||||
### Task 7: Atomic merge + prod deploy
|
||||
|
||||
- [ ] **Step 7.1: Commit on the SP29 branch.**
|
||||
|
||||
Separate commits per concern:
|
||||
- `feat(sp29): attach 999 ack summary to rejected-lane rows`
|
||||
- `feat(sp29): per-row Resubmit button + ack-evidence chips in Inbox`
|
||||
- `test(sp29): inbox row + page tests`
|
||||
- `docs(plan): SP29 deviations log (if any)`
|
||||
|
||||
- [ ] **Step 7.2: Atomic merge into main.**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --no-ff sp29-rejected-row-ack-drill -m "merge: SP29 Inbox 999-rejected drill into main
|
||||
|
||||
..."
|
||||
```
|
||||
|
||||
**DO NOT SQUASH. DO NOT REBASE.**
|
||||
|
||||
- [ ] **Step 7.3: Rebuild + restart prod containers.**
|
||||
|
||||
```bash
|
||||
docker compose build backend frontend
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- [ ] **Step 7.4: Smoke test on prod.**
|
||||
|
||||
1. Hit `/api/inbox/lanes` as admin; confirm at least one rejected row has a non-null `claim_acks` field (if any rejected claims exist on prod).
|
||||
2. Open `/inbox` in the browser; confirm the rejected lane rows now show the AK2 chips + a `Resubmit` button per row.
|
||||
3. Click the `Resubmit` button; confirm a `claim-{id}.x12` file downloads.
|
||||
|
||||
- [ ] **Step 7.5: Push to origin.**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
- **D1 — per-row Resubmit is download-only (not state-flipping).** Mirrors the operator's actual workflow (download, fix in their editor, resubmit via the bulk path). v1 small. v2 may add state-flipping variant if needed.
|
||||
- **D2 — lane surface, not a new page.** Reuse existing chrome. Cheaper.
|
||||
- **D3 — `claim_acks.items` is 5 most recent, sorted DESC.** Matches eye-flow (newest first). `total` + `rejected` cover the rest.
|
||||
- **D4 — per-row Resubmit visible to `admin` AND `user` roles.** Mirrors bulk Resubmit gating.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- `payer_rejected` lane (277CA STC A4/A6/A7) — distinct drill.
|
||||
- Missing-999 alarm surfacing (claims SUBMITTED >24h with zero 999 acks).
|
||||
- Hoisting ack counts into the ClaimDrawer header.
|
||||
- Orphan-ack triage bulk actions.
|
||||
- Audit-log entry on single-claim resubmit (the bulk path also has no audit — closing the gap is its own SP).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Don't bump the schema.** The `claim_acks` data is already there (SP28 + backfill).
|
||||
- **Don't re-fetch per row.** One batched query in `compute_lanes` for the whole lane.
|
||||
- **`e.stopPropagation()` on the Resubmit button is mandatory** — row click navigates to `/claims?claim=ID`, and both gestures firing would be a regression.
|
||||
- **The `+N more` chip should also navigate to the ClaimDrawer** (same as the row click) — gives the operator a way to see all 5+ acks without opening via the row body.
|
||||
- **No backend bus publish** — no live-tail event for SP29 (read-only shape change).
|
||||
- **Visual chrome:** reuse the existing `--tt-oxblood` (reject) and `--tt-muted` (accept / "not linked") CSS variables; no new tokens needed.
|
||||
|
||||
## Deviations log
|
||||
|
||||
(Add any deviations discovered during implementation to this section and to the merge commit body. Pre-existing baselines from SP28 deviations log: 5 frontend test failures + 17 frontend typecheck errors. Document anything new here.)
|
||||
@@ -0,0 +1,768 @@
|
||||
# SP33 — Co TXIX Payer Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` (Tyler picked inline execution for SP33, single-operator night-shift). Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make dzinesco's CO Medicaid 837P submissions emit `NM1*PR*...*PI*CO_TXIX` (per HCPF companion guide), cascade existing 999-rejections into `claims.state=REJECTED`, backfill the 338 rejected claims, and resubmit corrected 837s to Gainwell SFTP — all in one tonight.
|
||||
|
||||
**Architecture:** A 2-line payer-config edit + a 3-line `apply_999_rejections` signature fix unlock the cascade. Two new `cyclone.cli` subcommands do the backfill and the resubmit. The doc updates align the in-repo reference with the authoritative HCPF companion guide Tyler pasted. Atomic single merge into `main`.
|
||||
|
||||
**Tech Stack:** Python 3.11, SQLAlchemy + SQLite/SQLCipher, paramiko (SFTP), pytest. Frontend untouched.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md`](../specs/2026-07-02-cyclone-co-txix-payer-fix-design.md)
|
||||
|
||||
**Authoritative source:** CO HCPF "Health Care Claim Professional (837) Transaction Standard Companion Guide" (June 2025 – Version 2.5). Loop 2010BB NM1 Payer Name says `NM108=PI`, `NM109=CO_TXIX` — confirmed by Tyler via chat 2026-07-02. In-repo reference for shape: [`docs/goodclaim.x12`](../../goodclaim.x12).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| Path | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `backend/src/cyclone/parsers/payer.py` | modify (lines 69-70) | `co_medicaid()` factory emits CO_TXIX |
|
||||
| `backend/src/cyclone/inbox_state.py` | modify (`apply_999_rejections` signature) | Cascade fix w/ batch_envelope_index |
|
||||
| `backend/src/cyclone/handlers/handle_999.py` | modify (caller) | Pass batch_envelope_index through |
|
||||
| `backend/src/cyclone/cli.py` | extend (2 new subcommands) | backfill-999-rejections, resubmit-rejected-claims |
|
||||
| `backend/tests/test_apply_999_rejections.py` | create | Cascade-fix coverage |
|
||||
| `backend/tests/test_serialize_837.py` | extend | Round-trip with CO_TXIX |
|
||||
| `docs/reference/837p.md` | modify (line 48) | Doc update |
|
||||
| `docs/reference/co-medicaid.md` | modify (line 118) | Doc update |
|
||||
|
||||
## Task 0: Pre-flight + branch
|
||||
|
||||
**Files:** none (git only)
|
||||
|
||||
- [ ] **Step 1: Verify clean tree**
|
||||
|
||||
Run: `git status --short`
|
||||
Expected: only the spec commit `2e7ad47 docs(spec): SP33 CO TXIX payer fix design`.
|
||||
|
||||
- [ ] **Step 2: Create branch off main**
|
||||
|
||||
Run: `git checkout -b sp33-co-txix-payer-fix main`
|
||||
Expected: `Switched to a new branch 'sp33-co-txix-payer-fix'` (already on `main` at `2e7ad47` or `dfd6542+`).
|
||||
|
||||
- [ ] **Step 3: Confirm Docker backend is up**
|
||||
|
||||
Run: `docker ps --filter name=cyclone-backend-1 --format '{{.Names}} {{.Status}}'`
|
||||
Expected: `cyclone-backend-1 Up X minutes (healthy)`.
|
||||
|
||||
## Task 1: Fix `co_medicaid()` factory
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/parsers/payer.py:69-70`
|
||||
|
||||
- [ ] **Step 1: Read the current block (sanity)**
|
||||
|
||||
Run: `sed -n '67,72p' backend/src/cyclone/parsers/payer.py`
|
||||
Expected output:
|
||||
|
||||
```
|
||||
allowed_bht06={"CH"},
|
||||
payer_id="SKCO0",
|
||||
payer_name="COHCPF",
|
||||
no_patient_loop=True,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apply the fix (2 lines)**
|
||||
|
||||
`search_replace` exact:
|
||||
|
||||
OLD:
|
||||
```
|
||||
payer_id="SKCO0",
|
||||
payer_name="COHCPF",
|
||||
```
|
||||
|
||||
NEW:
|
||||
```
|
||||
payer_id="CO_TXIX",
|
||||
payer_name="CO_TXIX",
|
||||
```
|
||||
|
||||
(Drop the bad stale-comment line above it too — the `co_medicaid()` docstring still says "Source: `docs/companionguides/837p.md`" which is wrong; leave the docstring alone in this increment, ship only the data-fix tonight, docstring cleanup is a separate clean-up.)
|
||||
|
||||
- [ ] **Step 3: Verify the diff**
|
||||
|
||||
Run: `git diff backend/src/cyclone/parsers/payer.py`
|
||||
Expected: a 2-line change, exactly the two `payer_id`/`payer_name` lines.
|
||||
|
||||
- [ ] **Step 4: Add test**
|
||||
|
||||
Create: `backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py`:
|
||||
|
||||
```python
|
||||
"""SP33 regression test: ``PayerConfig.co_medicaid()`` emits CO_TXIX."""
|
||||
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}"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the test in the container (since the venv on host isn't trustworthy)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/pytest tests/test_co_medicaid_payer_id_emits_CO_TXIX.py -v"
|
||||
```
|
||||
|
||||
Expected: 1 passed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/parsers/payer.py backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py
|
||||
git commit -m "feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX"
|
||||
```
|
||||
|
||||
## Task 2: Fix `apply_999_rejections` cascade
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/inbox_state.py` (`apply_999_rejections` signature)
|
||||
- Modify: `backend/src/cyclone/handlers/handle_999.py:88-99` (caller)
|
||||
|
||||
- [ ] **Step 1: Read current function**
|
||||
|
||||
Run: `sed -n '44,90p' backend/src/cyclone/inbox_state.py`
|
||||
Expected: see `claim = claim_lookup(sr.set_control_number)` at ~line 56.
|
||||
|
||||
- [ ] **Step 2: Apply signature + body change**
|
||||
|
||||
OLD (the full function body, lines 44-69):
|
||||
|
||||
```python
|
||||
def apply_999_rejections(
|
||||
session: Session,
|
||||
parsed_999,
|
||||
*,
|
||||
claim_lookup: Callable[[str], Claim | 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.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session.
|
||||
parsed_999: a ParseResult999 (or any object with .set_responses).
|
||||
claim_lookup: callable from patient_control_number → Claim or None.
|
||||
|
||||
Returns:
|
||||
Apply999Result with lists of matched claim ids and orphan PCNs.
|
||||
"""
|
||||
result = Apply999Result()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
NEW:
|
||||
|
||||
```python
|
||||
def apply_999_rejections(
|
||||
session: Session,
|
||||
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.
|
||||
|
||||
Args:
|
||||
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 with the batch_envelope_index path).
|
||||
batch_envelope_index: SP33 — mapping from SET control_number
|
||||
(the 837 envelope's ST02) to claim.id list for claims in that
|
||||
SET. Mirrors apply_999_acceptances so SET-level rejections
|
||||
correctly cascade.
|
||||
|
||||
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
|
||||
|
||||
# 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 = []
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the caller in handle_999.py**
|
||||
|
||||
OLD (line 88):
|
||||
|
||||
```python
|
||||
rejection_result = apply_999_rejections(
|
||||
session, result, claim_lookup=_lookup,
|
||||
)
|
||||
```
|
||||
|
||||
NEW:
|
||||
|
||||
```python
|
||||
rejection_result = apply_999_rejections(
|
||||
session, result,
|
||||
claim_lookup=_lookup,
|
||||
batch_envelope_index=batch_index,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the cascade test**
|
||||
|
||||
Create: `backend/tests/test_apply_999_rejections.py`:
|
||||
|
||||
```python
|
||||
"""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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.db import Claim, ClaimState
|
||||
from cyclone.inbox_state import apply_999_rejections, Apply999Result
|
||||
|
||||
|
||||
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(state: str = "SUBMITTED"):
|
||||
c = MagicMock(spec=Claim)
|
||||
c.id = "claim-" + state.lower()
|
||||
c.state = state
|
||||
return c
|
||||
|
||||
|
||||
def test_set_level_rejection_cascades_via_envelope_index():
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.all.return_value = [_mk_claim()]
|
||||
|
||||
parsed = SimpleNamespace(
|
||||
set_responses=[_mk_set_response(code="R", scn="991102994")]
|
||||
)
|
||||
index = {"991102994": ["claim-submitted"]}
|
||||
|
||||
result = apply_999_rejections(
|
||||
session, parsed,
|
||||
claim_lookup=lambda _pcn: None,
|
||||
batch_envelope_index=index,
|
||||
)
|
||||
|
||||
assert len(result.matched) == 1
|
||||
assert result.matched[0] == "claim-submitted"
|
||||
assert result.orphans == []
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
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()
|
||||
result = apply_999_rejections(
|
||||
session, parsed,
|
||||
claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None,
|
||||
)
|
||||
|
||||
assert result.matched == ["claim-submitted"]
|
||||
|
||||
|
||||
def test_no_envelope_index_no_match_becomes_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():
|
||||
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.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_already_rejected_is_idempotent():
|
||||
session = MagicMock()
|
||||
already_rejected = _mk_claim(state="REJECTED")
|
||||
session.query.return_value.filter.return_value.all.return_value = [already_rejected]
|
||||
|
||||
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": ["claim-rejected"]},
|
||||
)
|
||||
assert result.matched == []
|
||||
# nothing was mutated on the already-rejected claim
|
||||
assert already_rejected.state == "REJECTED"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run both test files**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/pytest tests/test_apply_999_rejections.py tests/test_co_medicaid_payer_id_emits_CO_TXIX.py -v"
|
||||
```
|
||||
|
||||
Expected: 6 passed (1 from Task 1 + 5 from Task 2).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/inbox_state.py backend/src/cyclone/handlers/handle_999.py backend/tests/test_apply_999_rejections.py
|
||||
git commit -m "feat(sp33): apply_999_rejections uses batch_envelope_index"
|
||||
```
|
||||
|
||||
## Task 3: CLI subcommand `backfill-999-rejections`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/cli.py` (add subcommand)
|
||||
|
||||
- [ ] **Step 1: Locate the cli.py entry point**
|
||||
|
||||
Run: `grep -n '@click.command\|def cli\|^@' backend/src/cyclone/cli.py | head -20`
|
||||
|
||||
Find an existing subcommand (e.g. `parse-837`) and copy its structure.
|
||||
|
||||
- [ ] **Step 2: Add `backfill-999-rejections` subcommand**
|
||||
|
||||
Insert after an existing subcommand. The exact insertion point depends on the layout — pick a sensible spot. Use this implementation:
|
||||
|
||||
```python
|
||||
@click.command(name="backfill-999-rejections")
|
||||
@click.option("--dry-run", is_flag=True, default=False,
|
||||
help="Print the would-be changes without writing.")
|
||||
@click.option("--actor", default="sp33-backfill",
|
||||
help="Audit log actor for the claim.rejected events.")
|
||||
def backfill_999_rejections(dry_run: bool, actor: str):
|
||||
"""Replay existing 999 rejections onto already-linked claims.
|
||||
|
||||
SP33. Idempotent — claims already in REJECTED state are skipped.
|
||||
|
||||
Walks claim_acks JOIN acks WHERE set_accept_reject_code='R',
|
||||
then sets claims.state='REJECTED' and fills payer_rejected_* fields.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from cyclone import db as cycl_db
|
||||
from cyclone.audit_log import AuditEvent, append_event
|
||||
from cyclone.models import Claim as ClaimModel, ClaimAck, Ack
|
||||
|
||||
cycl_db.init_db()
|
||||
with cycl_db.SessionLocal()() as session:
|
||||
rows = session.execute(
|
||||
select(ClaimAck.claim_id, Claim.ack_code, ClaimAck.set_control_number,
|
||||
ClaimAck.set_accept_reject_code, ClaimAck.ak2_index)
|
||||
.join(Ack, Ack.id == ClaimAck.ack_id)
|
||||
.join(ClaimModel, ClaimModel.id == ClaimAck.claim_id)
|
||||
.where(ClaimAck.set_accept_reject_code == "R")
|
||||
).all()
|
||||
matched = already = errors = 0
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
for (claim_id, ack_code, scn, arc, ak2_idx) in rows:
|
||||
claim = session.get(ClaimModel, claim_id)
|
||||
if claim is None:
|
||||
errors += 1
|
||||
continue
|
||||
if claim.state == "REJECTED":
|
||||
already += 1
|
||||
continue
|
||||
if not dry_run:
|
||||
claim.state = "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}"
|
||||
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},
|
||||
actor=actor,
|
||||
))
|
||||
matched += 1
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
click.echo(
|
||||
f"matched={matched} already_rejected={already} errors={errors} dry_run={dry_run}"
|
||||
)
|
||||
```
|
||||
|
||||
The exact model class names (`Claim`, `ClaimAck`, `Ack`) and module names (`cyclone.models`, `cyclone.db`, `cyclone.audit_log`) may differ slightly — verify against the existing imports at the top of `cli.py` and adjust.
|
||||
|
||||
- [ ] **Step 3: Sanity check (dry-run)**
|
||||
|
||||
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/python -m cyclone.cli backfill-999-rejections --dry-run"`
|
||||
Expected: `matched=338 already_rejected=0 errors=0 dry_run=True` (or similar; the exact number depends on how many unique claim_ids have R-coded links).
|
||||
|
||||
- [ ] **Step 4: Run for real**
|
||||
|
||||
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/python -m cyclone.cli backfill-999-rejections"`
|
||||
Expected: `matched=338 already_rejected=0 errors=0 dry_run=False`.
|
||||
|
||||
- [ ] **Step 5: Verify claim states flipped in the DB**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec cyclone-backend-1 python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/var/lib/cyclone/db/cyclone.db')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT state, count(*) FROM claims GROUP BY state ORDER BY 2 DESC')
|
||||
for row in cur.fetchall(): print(row)
|
||||
"
|
||||
```
|
||||
|
||||
Expected: `('REJECTED', 341)` and `('SUBMITTED', 334)` (338 newly-rejected + the 3 pre-existing rejected = 341; original 672 minus 338 = 334).
|
||||
|
||||
- [ ] **Step 6: Commit (CLI only — backfill itself is a live op, no commit for that)**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/cli.py
|
||||
git commit -m "feat(sp33): cli backfill-999-rejections"
|
||||
```
|
||||
|
||||
## Task 4: CLI subcommand `resubmit-rejected-claims`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/cli.py` (add subcommand)
|
||||
|
||||
- [ ] **Step 1: Add `resubmit-rejected-claims` subcommand**
|
||||
|
||||
```python
|
||||
@click.command(name="resubmit-rejected-claims")
|
||||
@click.option("--payer", default="co_medicaid",
|
||||
help="Payer factory key (default: co_medicaid).")
|
||||
@click.option("--ingest-dir", default="/home/tyler/dev/cyclone/ingest",
|
||||
help="Directory holding batch-*-claims/ subfolders (per Tyler's split).")
|
||||
@click.option("--actor", default="sp33-resubmit",
|
||||
help="Audit log actor for the clearhouse.submitted events.")
|
||||
@click.option("--dry-run", is_flag=True, default=False,
|
||||
help="Replace + validate but do not upload.")
|
||||
def resubmit_rejected_claims(payer: str, ingest_dir: str, actor: str, dry_run: bool):
|
||||
"""Byte-level SKCO0->CO_TXIX fix + SFTP resubmit of rejected claims.
|
||||
|
||||
SP33. For each claims row in REJECTED state, locate the matching single-
|
||||
claim 837 file in ingest_dir/batch-<batch_id>-<N>-claims/*.x12, replace
|
||||
PI*SKCO0 with PI*CO_TXIX in the bytes, validate via parse_837_text, then
|
||||
upload via SftpClient. Idempotent per claim (resubmit_count gates re-run).
|
||||
"""
|
||||
from pathlib import Path
|
||||
import glob
|
||||
from cyclone import db as cycl_db
|
||||
from cyclone.audit_log import AuditEvent, append_event
|
||||
from cyclone.clearhouse import SftpClient
|
||||
from cyclone.parsers.parse_837 import parse_837_text
|
||||
|
||||
cycl_db.init_db()
|
||||
upload_root = "/CO XIX/PROD/coxix_prod_11525703/ToHPE"
|
||||
|
||||
# 1. collect rejected claims
|
||||
with cycl_db.SessionLocal()() as session:
|
||||
from sqlalchemy import select
|
||||
from cyclone.models import Claim as ClaimModel
|
||||
rows = session.execute(
|
||||
select(ClaimModel.id, ClaimModel.batch_id, ClaimModel.patient_control_number,
|
||||
ClaimModel.resubmit_count, ClaimModel.charge_amount)
|
||||
.where(ClaimModel.state == "REJECTED")
|
||||
.order_by(ClaimModel.batch_id, ClaimModel.id)
|
||||
).all()
|
||||
|
||||
# 2. discover candidate files
|
||||
files_by_batch = {}
|
||||
for batch_dir in glob.glob(os.path.join(ingest_dir, "batch-*-*-claims")):
|
||||
batch_id = os.path.basename(batch_dir).split("-")[1] # batch-<id>-<N>-claims
|
||||
files_by_batch[batch_id] = sorted(glob.glob(os.path.join(batch_dir, "*.x12")))
|
||||
|
||||
ok = err = skip = 0
|
||||
for (cid, bid, pcn, prev_resub, charge) in rows:
|
||||
if prev_resub and prev_resub > 0:
|
||||
skip += 1; continue
|
||||
|
||||
candidate_files = files_by_batch.get(bid, [])
|
||||
# Find the file whose CLM01 == this claim's pcn
|
||||
target_path = None
|
||||
for fp in candidate_files:
|
||||
with open(fp, "rb") as fh:
|
||||
txt = fh.read().decode("ascii", errors="replace")
|
||||
if pcn in txt:
|
||||
target_path = fp
|
||||
break
|
||||
if target_path is None:
|
||||
click.echo(f" no-file claim={cid} pcn={pcn}", err=True)
|
||||
err += 1
|
||||
continue
|
||||
|
||||
# Byte-level fix
|
||||
with open(target_path, "rb") as fh:
|
||||
raw = fh.read()
|
||||
if b"PI*SKCO0" not in raw:
|
||||
click.echo(f" no-replace claim={cid} file={os.path.basename(target_path)}", err=True)
|
||||
err += 1; continue
|
||||
fixed = raw.replace(b"PI*SKCO0", b"PI*CO_TXIX")
|
||||
|
||||
# Validate
|
||||
try:
|
||||
parsed = parse_837_text(fixed.decode("ascii"))
|
||||
except Exception as exc:
|
||||
click.echo(f" parse-fail claim={cid} {exc.__class__.__name__}: {exc}", err=True)
|
||||
err += 1; continue
|
||||
|
||||
# Upload
|
||||
remote_name = os.path.basename(target_path)
|
||||
if not dry_run:
|
||||
# Write the fixed bytes to a sibling tmp file for the upload
|
||||
tmp_path = target_path + ".fixed"
|
||||
with open(tmp_path, "wb") as fh:
|
||||
fh.write(fixed)
|
||||
try:
|
||||
SftpClient().put(tmp_path, f"{upload_root}/{remote_name}")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
claim = session.get(ClaimModel, cid)
|
||||
claim.resubmit_count = (claim.resubmit_count or 0) + 1
|
||||
append_event(session, AuditEvent(
|
||||
event_type="clearhouse.submitted",
|
||||
entity_type="claim",
|
||||
entity_id=cid,
|
||||
payload={"file": remote_name, "scn": parsed.envelope.control_number},
|
||||
actor=actor,
|
||||
))
|
||||
session.commit()
|
||||
click.echo(f" ok claim={cid} file={remote_name}")
|
||||
ok += 1
|
||||
|
||||
click.echo(f"\nDONE ok={ok} err={err} skip={skip} dry_run={dry_run}")
|
||||
```
|
||||
|
||||
Imports needed: `import os` (glob, path). Confirm the `SftpClient` constructor — it may take an SFTP block or a config; check existing usage in `backend/src/cyclone/api.py` (the `/api/clearhouse/submit` endpoint) and mirror it.
|
||||
|
||||
- [ ] **Step 2: Run with --dry-run first**
|
||||
|
||||
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && CYCLONE_INGEST_DIR=/tmp/ingest_passthrough .venv/bin/python -m cyclone.cli resubmit-rejected-claims --dry-run"`
|
||||
Expected: a line per claim plus a `DONE ok=338 err=0 skip=0 dry_run=True` summary.
|
||||
|
||||
- [ ] **Step 3: Run for real (upload)**
|
||||
|
||||
Mount /home/tyler/dev/cyclone/ingest into the container so the `--ingest-dir` resolves correctly. Easiest: copy the batch-* subdirs into the container:
|
||||
|
||||
```bash
|
||||
docker exec cyclone-backend-1 mkdir -p /tmp/ingest_split
|
||||
tar -C /home/tyler/dev/cyclone/ingest -cf - 'batch-*-*-claims' | docker exec -i cyclone-backend-1 tar -xf - -C /tmp/ingest_split
|
||||
```
|
||||
|
||||
Then: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/python -m cyclone.cli resubmit-rejected-claims --ingest-dir /tmp/ingest_split"`
|
||||
|
||||
Expected: `DONE ok=338 err=0 skip=0 dry_run=False`.
|
||||
|
||||
- [ ] **Step 4: Verify the upload landed**
|
||||
|
||||
Connect to SFTP and check the latest 338 files in `/ToHPE/`:
|
||||
|
||||
```bash
|
||||
docker exec cyclone-backend-1 bash -c "
|
||||
import paramiko, time
|
||||
ssh = paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('mft.gainwelltechnologies.com', username='colorado-fts\\\\coxix_prod_11525703',
|
||||
password=\$(cat /run/secrets/cyclone_sftp_password))
|
||||
sftp = ssh.open_sftp()
|
||||
files = sorted(sftp.listdir_attr('/CO XIX/PROD/coxix_prod_11525703/ToHPE'),
|
||||
key=lambda a: a.st_mtime)[-10:]
|
||||
for f in files: print(f.filename, time.ctime(f.st_mtime))
|
||||
"
|
||||
```
|
||||
|
||||
Expected: ~10 837P files dated 2026-07-02 (or now).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/cli.py
|
||||
git commit -m "feat(sp33): cli resubmit-rejected-claims"
|
||||
```
|
||||
|
||||
## Task 5: Doc updates
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reference/837p.md:48`
|
||||
- Modify: `docs/reference/co-medicaid.md:118`
|
||||
|
||||
- [ ] **Step 1: Update `docs/reference/837p.md` line 48**
|
||||
|
||||
OLD line:
|
||||
```
|
||||
- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40`
|
||||
```
|
||||
|
||||
NEW line:
|
||||
```
|
||||
- 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.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `docs/reference/co-medicaid.md` line 118**
|
||||
|
||||
OLD:
|
||||
```
|
||||
- `NM1*PR N104 = "SKCO0"` (COHCPF)
|
||||
```
|
||||
|
||||
NEW:
|
||||
```
|
||||
- `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.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/reference/837p.md docs/reference/co-medicaid.md
|
||||
git commit -m "docs(sp33): update reference docs to CO_TXIX (per HCPF 837P Companion Guide)"
|
||||
```
|
||||
|
||||
## Task 6: Verify + final test run
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Full test suite**
|
||||
|
||||
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/pytest -x -q"`
|
||||
Expected: all tests pass. The 4 affected test files (this plan + Spec §6 list) all green.
|
||||
|
||||
- [ ] **Step 2: Smoke-test the live DB dashboard widget**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
curl -fsS http://localhost:8000/api/batches | python3 -m json.tool | head -30
|
||||
```
|
||||
|
||||
Expected: the dashboard reflects the freshly-REJECTED batches (acceptedCount=0 for the 4 affected batches — this is now correct! They were rejected by Gainwell, and the cascade now shows that).
|
||||
|
||||
- [ ] **Step 3: Atomic merge**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --no-ff sp33-co-txix-payer-fix -m "merge: SP33 Co TXIX payer fix + 999 cascade repair into main"
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
Expected: a single merge commit `merge: SP33 ...` on top of `dfd6542` (current main tip pre-SP33), with the SP33 branch's commits visible as parents.
|
||||
|
||||
## Acceptance checklist (must all be true before claiming "done")
|
||||
|
||||
- [ ] `cyclone -m cyclone.cli backfill-999-rejections` reports `matched=338 already_rejected=0 errors=0`
|
||||
- [ ] `claims.state` count: REJECTED ≥ 341, SUBMITTED ≤ 334
|
||||
- [ ] 338 new 837 files visible on SFTP `/CO XIX/PROD/cox17_prod_11525703/ToHPE/` with `CO_TXIX` in their bytes
|
||||
- [ ] `claims.resubmit_count > 0` for the 338 claims
|
||||
- [ ] `audit_log` shows 338 `claim.rejected` + 338 `clearhouse.submitted` events with actor=`sp33-*`
|
||||
- [ ] Dashboard "Recent batches" widget for the 4 affected batches shows the same `acceptedCount` it was showing — but now it's accurate (0 rejected by Gainwell, not 0 accepted by Gainwell)
|
||||
|
||||
## Rollback
|
||||
|
||||
If anything breaks and SP33 needs to back out:
|
||||
|
||||
1. `git revert -m 1 <merge-sha>` on `main` — produces a fresh revert commit, preserves history.
|
||||
2. The 2 cli subcommands revert cleanly (pure additions).
|
||||
3. To un-reject the 338 claims after the revert, run a sister SQL: `UPDATE claims SET state='SUBMITTED', rejection_reason=NULL, rejected_at=NULL, payer_rejected_at=NULL, payer_rejected_reason=NULL, payer_rejected_status_code=NULL WHERE resubmit_count > 0;`
|
||||
4. The 338 837 files already on SFTP /ToHPE/ cannot be unsent — Gainwell will pick them up regardless. Discretion required.
|
||||
@@ -0,0 +1,193 @@
|
||||
# SP30 — Dashboard "Recent batches" widget implementation plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a single Dashboard card that lists the 5 most recently parsed batches with one-line outcome per batch (status icon, filename, billed total, accepted/rejected/pending counts, top rejection reason). The operator can spot a bad batch — full envelope reject, sudden rejection spike — the moment they log in, without clicking through to `/batches`.
|
||||
|
||||
**Architecture:** Extend the existing `GET /api/batches` list response with billing-outcome fields (one batched SQL aggregate per request, no N+1). Extend the frontend `BatchSummary` TS type with the new fields (all optional — backwards compatible). Build a new `RecentBatchesWidget` component that mirrors the existing Dashboard "Recent activity" Card chrome. Wire it into the Dashboard between the KPI tile row and the Activity row. Use the existing `useBatches(limit)` hook (queryKey `["batches", 5]`). Click → navigate to `/batches?batch=ID` opening the existing BatchDrawer.
|
||||
|
||||
**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, lucide-react.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `backend/src/cyclone/api.py` | Add `_batch_summary_billing_outcomes` helper. Extend `list_batches` handler to attach the new fields per item. |
|
||||
| `backend/tests/test_api_gets.py` | +2 tests (`test_batches_includes_billing_outcome`, `test_batches_835_kind_returns_zero_billed_and_no_rejection`). |
|
||||
| `src/lib/api.ts` | +6 optional fields on `BatchSummary`. |
|
||||
| `src/components/RecentBatchesWidget.tsx` | NEW component. |
|
||||
| `src/pages/Dashboard.tsx` | Insert widget between KPI tiles and Activity row. |
|
||||
| `src/components/RecentBatchesWidget.test.tsx` | NEW, 3 tests. |
|
||||
| `src/pages/Dashboard.test.tsx` | +1 test (widget renders + click navigates). |
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Backend — extend `list_batches` with billing-outcome fields
|
||||
|
||||
- [ ] Open `backend/src/cyclone/api.py` and read `list_batches` at lines 1769-1808 plus the existing helpers at 1740-1764. Note the pattern.
|
||||
- [ ] Add a new helper `_batch_summary_billing_outcomes(records)` near the existing helpers (after line 1764). It:
|
||||
1. Opens a single `db.SessionLocal()() as s` session.
|
||||
2. Collects batch ids from `records` via `[r.id for r in records]`. Returns `{}` if empty.
|
||||
3. Runs one GROUP BY query:
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
from cyclone.db import Claim, ClaimState
|
||||
rows = (
|
||||
s.query(Claim.batch_id, Claim.state, func.count(Claim.id), func.sum(Claim.charge_amount))
|
||||
.filter(Claim.batch_id.in_(batch_ids))
|
||||
.group_by(Claim.batch_id, Claim.state)
|
||||
.all()
|
||||
)
|
||||
```
|
||||
4. Buckets into a `{batch_id: {"accepted": int, "rejected": int, "pending": int, "billed": float}}` dict. Define the state buckets:
|
||||
- `accepted` = `PAID, RECEIVED, RECONCILED, PARTIAL`
|
||||
- `rejected` = `REJECTED, DENIED, REVERSED`
|
||||
- `pending` = `SUBMITTED, DRAFT` (everything else is "other" — not surfaced on the widget)
|
||||
5. Runs the rejection-reason probe (only when there's at least one rejected claim across all batches — skip the query otherwise):
|
||||
```python
|
||||
rejected_states = (ClaimState.REJECTED, ClaimState.DENIED, ClaimState.REVERSED)
|
||||
rej = (
|
||||
s.query(Claim.batch_id, Claim.rejection_reason, Claim.payer_rejected_status_code)
|
||||
.filter(Claim.batch_id.in_(batch_ids),
|
||||
((Claim.state.in_(rejected_states)) | (Claim.payer_rejected_status_code.in_(("A4","A6","A7")))))
|
||||
.order_by(Claim.rejected_at.desc().nullslast())
|
||||
.all()
|
||||
)
|
||||
```
|
||||
Bucket into `{batch_id: {"top_rejection_reason": str|null, "has_payer_reject": bool}}`.
|
||||
6. Truncate `top_rejection_reason` to 60 chars with `…` suffix.
|
||||
7. Returns `{batch_id: {accepted, rejected, pending, billed, top_rejection_reason, has_problem}}` where `has_problem = rejected > 0 or has_payer_reject`.
|
||||
- [ ] Update `list_batches` handler (line 1783-1793) to:
|
||||
1. Call `_batch_summary_billing_outcomes(records)` after `records = store.list(limit=limit)`.
|
||||
2. For each item in the constructed `items` list, look up `outcomes = outcomes_by_id.get(r.id, {})` and add: `acceptedCount`, `rejectedCount`, `pendingCount`, `billedTotal`, `topRejectionReason`, `hasProblem` (defaulting sensibly when missing — e.g. `acceptedCount=0`, `hasProblem=False`).
|
||||
- [ ] Confirm the existing `test_batches_*` tests in `test_api_gets.py` still pass (the new fields are added, none removed — existing assertions on `kind`, `inputFilename`, `claimIds` are untouched).
|
||||
|
||||
## Step 2: Backend tests
|
||||
|
||||
- [ ] Open `backend/tests/test_api_gets.py`. Add the 2 tests after the existing 4 `test_batches_*` tests (after line 101).
|
||||
- [ ] **`test_batches_includes_billing_outcome`** — seed one batch via direct ORM insert (use the `_add_batch` / `_add_claim` helper pattern from `test_dashboard_kpis.py:36-91` — these helpers don't exist in `test_api_gets.py` so inline them):
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from cyclone.db import Batch, Claim, ClaimState, db as db_module
|
||||
|
||||
with db_module.SessionLocal()() as s:
|
||||
s.add(Batch(id="b-out", kind="837p", input_filename="out.edi",
|
||||
parsed_at=datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc),
|
||||
totals_json={"total_claims": 3},
|
||||
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||
raw_result_json={"_": "stub"}))
|
||||
s.add(Claim(id="C-paid", batch_id="b-out", patient_control_number="PCN-paid",
|
||||
charge_amount=Decimal("100.00"), state=ClaimState.PAID,
|
||||
raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"},
|
||||
"payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"},
|
||||
"service_lines": []}))
|
||||
s.add(Claim(id="C-rej", batch_id="b-out", patient_control_number="PCN-rej",
|
||||
charge_amount=Decimal("50.00"), state=ClaimState.REJECTED,
|
||||
rejection_reason="999 AK5 R",
|
||||
raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"},
|
||||
"payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"},
|
||||
"service_lines": []}))
|
||||
s.add(Claim(id="C-sub", batch_id="b-out", patient_control_number="PCN-sub",
|
||||
charge_amount=Decimal("75.00"), state=ClaimState.SUBMITTED,
|
||||
raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"},
|
||||
"payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"},
|
||||
"service_lines": []}))
|
||||
s.commit()
|
||||
resp = client.get("/api/batches")
|
||||
item = resp.json()["items"][0]
|
||||
assert item["acceptedCount"] == 1
|
||||
assert item["rejectedCount"] == 1
|
||||
assert item["pendingCount"] == 1
|
||||
assert item["billedTotal"] == 225.0
|
||||
assert item["topRejectionReason"] == "999 AK5 R"
|
||||
assert item["hasProblem"] is True
|
||||
```
|
||||
- [ ] **`test_batches_835_kind_returns_zero_billed_and_no_rejection`** — seed one 835 via `/api/parse-835` (uses existing fixture pattern in `test_batches_claim_ids_empty_for_835` at lines 85-101), assert the new fields are zeroed out: `acceptedCount=0, rejectedCount=0, pendingCount=0, billedTotal=0, topRejectionReason=None, hasProblem=False`.
|
||||
- [ ] Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v`. Both new tests pass; existing 4 still pass.
|
||||
|
||||
## Step 3: Frontend types — extend `BatchSummary`
|
||||
|
||||
- [ ] Open `src/lib/api.ts` line 245-259. Add 6 optional fields to the `BatchSummary` interface:
|
||||
```ts
|
||||
/** Number of claims on the batch whose state is paid / received / partial / reconciled. */
|
||||
acceptedCount?: number;
|
||||
/** Number of claims whose state is rejected / denied / reversed. */
|
||||
rejectedCount?: number;
|
||||
/** Number of claims whose state is submitted / draft. */
|
||||
pendingCount?: number;
|
||||
/** Sum of charge_amount across all claims on the batch (837P only; 0 for 835). */
|
||||
billedTotal?: number;
|
||||
/** Most recent rejection_reason on the batch (truncated to 60 chars). Null when no rejections. */
|
||||
topRejectionReason?: string | null;
|
||||
/** True when the batch has rejections OR any 277CA A4/A6/A7 payer-reject claim. */
|
||||
hasProblem?: boolean;
|
||||
```
|
||||
|
||||
## Step 4: Frontend component — `RecentBatchesWidget`
|
||||
|
||||
- [ ] Create `src/components/RecentBatchesWidget.tsx`. Build per the spec's row markup (Card chrome mirroring Dashboard.tsx:252-287).
|
||||
- [ ] Add `data-testid="recent-batches-widget"` on the outer `<Card>` for Playwright + the Dashboard integration test.
|
||||
- [ ] Import `Layers`, `CheckCircle2`, `AlertTriangle` from `lucide-react`.
|
||||
- [ ] Empty state: "No batches yet." centered with `text-[13px] text-muted-foreground px-2 py-6`.
|
||||
- [ ] Row markup per spec — `role="button" tabIndex={0}` + `onClick` + `onKeyDown` for Enter key.
|
||||
|
||||
## Step 5: Frontend wire-up — Dashboard
|
||||
|
||||
- [ ] Open `src/pages/Dashboard.tsx`. Add imports:
|
||||
```ts
|
||||
import { useBatches } from "@/hooks/useBatches";
|
||||
import { RecentBatchesWidget } from "@/components/RecentBatchesWidget";
|
||||
```
|
||||
- [ ] Inside the `Dashboard` component (after `const activity = activityQuery.data?.items ?? [];` on line 81), add:
|
||||
```ts
|
||||
const RECENT_BATCHES_LIMIT = 5;
|
||||
const recentBatchesQuery = useBatches(RECENT_BATCHES_LIMIT);
|
||||
```
|
||||
- [ ] Insert a new `<section>` BETWEEN line 245 (`</section>` for KPI tiles) and line 247 (`{/* Activity + Top providers */}`):
|
||||
```tsx
|
||||
<section
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${sectionBase + 200}ms` }}
|
||||
>
|
||||
<RecentBatchesWidget
|
||||
batches={recentBatchesQuery.data ?? []}
|
||||
onRowClick={(id) => navigate(`/batches?batch=${encodeURIComponent(id)}`)}
|
||||
/>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Step 6: Frontend tests — component + Dashboard
|
||||
|
||||
- [ ] Create `src/components/RecentBatchesWidget.test.tsx` with 3 tests (empty state, 2-batch render, 835 row). Mirror the patterns from `src/components/ActivityFeed.test.tsx` (if present) or `src/pages/Batches.test.tsx`.
|
||||
- [ ] Open `src/pages/Dashboard.test.tsx`. Read its existing setup. Add `SP30: Recent batches widget renders + click navigates to /batches?batch=ID`:
|
||||
1. Mock `api.listBatches` to resolve with 2 batches.
|
||||
2. Render `<Dashboard />` inside `MemoryRouter + QueryClientProvider`.
|
||||
3. Wait for the widget's testid.
|
||||
4. Click the first row's `<li role="button">`.
|
||||
5. Assert URL is `/batches?batch=ID`.
|
||||
- [ ] Run: `npx vitest run src/components/RecentBatchesWidget.test.tsx src/pages/Dashboard.test.tsx` — all green.
|
||||
|
||||
## Step 7: Per-tree verification
|
||||
|
||||
- [ ] `cd backend && .venv/bin/pytest` — full backend suite passes (no regressions).
|
||||
- [ ] `npx vitest run` — full frontend suite passes (no regressions).
|
||||
- [ ] `npm run typecheck` — 0 errors in SP30 files (existing baseline errors in `Upload.tsx` are acceptable).
|
||||
- [ ] `npm run lint` — 0 errors in SP30 files.
|
||||
|
||||
## Step 8: Atomic merge + deploy
|
||||
|
||||
- [ ] Commit: `feat(sp30): Dashboard Recent batches widget with billing outcome`.
|
||||
- [ ] `git checkout main && git merge --no-ff sp30-recent-batches-widget -m "merge: SP30 Dashboard Recent batches widget into main"`.
|
||||
- [ ] `docker compose build backend frontend` — rebuild both images (backend has the new endpoint fields; frontend has the new widget).
|
||||
- [ ] `docker compose up -d backend frontend` — restart.
|
||||
- [ ] Playwright smoke test:
|
||||
1. Login
|
||||
2. Navigate to `/`
|
||||
3. Wait for `[data-testid="recent-batches-widget"]`
|
||||
4. Screenshot `/tmp/dashboard-sp30.png`
|
||||
5. Click top row → assert URL changes
|
||||
6. Screenshot `/tmp/dashboard-sp30-clicked.png`
|
||||
- [ ] Show user the screenshots + the curl response from `/api/batches` confirming the new fields.
|
||||
@@ -0,0 +1,809 @@
|
||||
# SP32 Rendering & Service-Provider NPI Extraction Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extract 837p `NM1*82` (rendering provider) and 835 `NM1*1P` (service provider) NPIs, persist as real columns, and feed the SP31 2-of-3 matcher's NPI arm in production.
|
||||
|
||||
**Architecture:** Schema migration adds two columns. Parser extracts NPI from per-segment child segments. ORM builder writes the typed columns + raw_json mirrors. Matcher prefers the typed column over the raw_json fallback chain. A new CLI subcommand re-parses existing on-disk files to backfill historical rows, then runs reconcile once.
|
||||
|
||||
**Tech Stack:** Python 3.11+, SQLAlchemy 2.x, Pydantic v2, Click (CLI), pytest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-02-cyclone-rendering-npi-extraction-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility | New / Modified |
|
||||
|---|---|---|
|
||||
| `backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql` | Add `claims.rendering_provider_npi` and `remittances.rendering_provider_npi` | New |
|
||||
| `backend/src/cyclone/db.py` | ORM column definitions | Modify (2 columns added) |
|
||||
| `backend/src/cyclone/parsers/models_835.py` | `ClaimPayment` typed model gains `service_provider_npi` | Modify (1 field added) |
|
||||
| `backend/src/cyclone/parsers/parse_835.py` | `_consume_claim_payment` parses `NM1*1P` | Modify (NM1 handler updated) |
|
||||
| `backend/src/cyclone/parsers/parse_837.py` | New `_consume_rendering_provider` for Loop 2420A, wired into claim assembly | Modify (new function + claim wiring) |
|
||||
| `backend/src/cyclone/store/orm_builders.py` | `_claim_837_row` and `_remittance_835_row` set new columns | Modify (2 lines added) |
|
||||
| `backend/src/cyclone/reconcile.py` | `_content_keys_match` prefers typed-column NPI | Modify (NPI branch) |
|
||||
| `backend/src/yclone/cli.py` | New `backfill-rendering-npi` subcommand | Modify (one new subcommand) |
|
||||
| `backend/tests/conftest.py` | `make_claim`/`make_remit` set real columns instead of transient attributes | Modify (docstring + 2 lines) |
|
||||
| `backend/tests/test_reconcile.py` | New NPI-arm tests (typed-column path + raw_json fallback) | Modify (2 tests added) |
|
||||
| `backend/tests/test_parse_835.py` | New `NM1*1P` test | Modify (1 test added) |
|
||||
| `backend/tests/test_parse_837.py` | New `NM1*82` test | Modify (1 test added) |
|
||||
| `backend/tests/test_backfill_cli.py` | End-to-end backfill smoke | New |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schema migration + ORM columns
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql`
|
||||
- Modify: `backend/src/cyclone/db.py:251` (Claim block) — add `rendering_provider_npi`
|
||||
- Modify: `backend/src/cyclone/db.py:347-360` (Remittance block) — add `rendering_provider_npi`
|
||||
|
||||
- [ ] **Step 1: Create the migration file**
|
||||
|
||||
Create `backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql`:
|
||||
|
||||
```sql
|
||||
-- SP32: render & service-provider NPI extraction.
|
||||
-- Nullable: existing rows stay NULL until backfill runs.
|
||||
-- No indexes (used for set-equality, not range queries; nullable).
|
||||
|
||||
ALTER TABLE claims ADD COLUMN rendering_provider_npi TEXT;
|
||||
ALTER TABLE remittances ADD COLUMN rendering_provider_npi TEXT;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the migration and confirm it applies cleanly**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m cyclone.db_migrate
|
||||
```
|
||||
|
||||
Expected: prints `0019_add_rendering_and_service_provider_npis.sql` applied (or "already applied" on re-run).
|
||||
|
||||
- [ ] **Step 3: Add ORM columns**
|
||||
|
||||
In `backend/src/cyclone/db.py`, in the `Claim` class around line 251 (right after `provider_npi`), add:
|
||||
|
||||
```python
|
||||
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
```
|
||||
|
||||
In the `Remittance` class (find it with `grep -n "^class Remittance" backend/src/cyclone/db.py`), add (placed near the other identifier columns):
|
||||
|
||||
```python
|
||||
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify schema is live**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -c "from cyclone import db; db.init_db(); c=db.Claim; print(c.rendering_provider_npi.type); r=db.Remittance; print(r.rendering_provider_npi.type)"
|
||||
```
|
||||
|
||||
Expected: prints `VARCHAR(16)` twice.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql backend/src/cyclone/db.py
|
||||
git commit -m "feat(sp32): add rendering_provider_npi and service_provider_npi columns (migration 0019)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 835 parser — extract `NM1*1P` into `ClaimPayment.service_provider_npi`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/parsers/models_835.py:146-165` (ClaimPayment class)
|
||||
- Modify: `backend/src/cyclone/parsers/parse_835.py:359-435` (_consume_claim_payment NM1 branch)
|
||||
- Modify: `backend/tests/test_parse_835.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing parser test**
|
||||
|
||||
In `backend/tests/test_parse_835.py`, append:
|
||||
|
||||
```python
|
||||
def test_parse_835_extracts_service_provider_npi_from_nm1_1p():
|
||||
"""SP32: NM1*1P in Loop 2100 populates ClaimPayment.service_provider_npi."""
|
||||
from cyclone.parsers.parse_835 import parse
|
||||
from cyclone.parsers import payer as _payer # noqa: F401
|
||||
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*RECEIVERID *ZZ*SENDERID *250101*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~"
|
||||
"ST*835*0001~"
|
||||
"BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111**01*031302955*DA*9876543~"
|
||||
"TRN*1*1511111*1511111~"
|
||||
"DTM*405*20250101~"
|
||||
"N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~"
|
||||
"N3*PO BOX 1100*~"
|
||||
"N4*DENVER*CO*80203~"
|
||||
"REF*2U*12345~"
|
||||
"PER*BL*SUPPORT*TE*8005551212~"
|
||||
"N1*PE*ACME CLINIC*XX*1111111111~"
|
||||
"LX*1~"
|
||||
"CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~"
|
||||
"CAS*PR*1*0.00~"
|
||||
"NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~"
|
||||
"SVC*HC:99213*85.40*85.40**1~"
|
||||
"DTM*472*20250101~"
|
||||
"SE*18*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse(text, payer_config=None) # type: ignore[arg-type]
|
||||
assert len(result.claims) == 1
|
||||
assert result.claims[0].service_provider_npi == "2222222222"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new test and confirm it fails**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_parse_835.py::test_parse_835_extracts_service_provider_npi_from_nm1_1p -v
|
||||
```
|
||||
|
||||
Expected: FAIL with `AttributeError: 'ClaimPayment' object has no attribute 'service_provider_npi'`.
|
||||
|
||||
- [ ] **Step 3: Add typed field to `ClaimPayment` model**
|
||||
|
||||
In `backend/src/cyclone/parsers/models_835.py`, inside `class ClaimPayment` (around line 158, after `claim_filing_indicator`):
|
||||
|
||||
```python
|
||||
service_provider_npi: str | None = None # NM1*1P NM109 (Loop 2100 service provider)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend `_consume_claim_payment` to capture NM1*1P**
|
||||
|
||||
In `backend/src/cyclone/parsers/parse_835.py`, find the existing `elif s[0] == "NM1":` branch (around line 424). Replace the `pass` body with:
|
||||
|
||||
```python
|
||||
elif s[0] == "NM1":
|
||||
# SP32: capture service-provider NPI from Loop 2100 NM1*1P
|
||||
# (rendering provider on the payer side). Other NM1 qualifiers
|
||||
# in this loop (QC patient, PR payer, etc.) stay in raw_segments.
|
||||
service_provider_npi: str | None = None
|
||||
if len(s) > 9 and s[1] == "1P" and len(s[2]) > 0 and s[2][0] in ("1", "2"):
|
||||
# NM108 is element index 8; NM109 is index 9. Some senders
|
||||
# omit NM108 ("XX") and put the ID directly. Be defensive.
|
||||
if len(s) > 8 and s[8] == "XX" and len(s) > 9 and s[9]:
|
||||
service_provider_npi = s[9]
|
||||
elif len(s) > 9 and s[9] and s[9].isdigit() and len(s[9]) == 10:
|
||||
service_provider_npi = s[9]
|
||||
```
|
||||
|
||||
Then find the `ClaimPayment(...)` construction in this function (around line 434) and add `service_provider_npi=service_provider_npi` as the last argument.
|
||||
|
||||
Also ensure `service_provider_npi` is initialized to `None` before the `while` loop (alongside `ref_benefit_plan: str | None = None` around line 379).
|
||||
|
||||
- [ ] **Step 5: Re-run the test and confirm it passes**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_parse_835.py::test_parse_835_extracts_service_provider_npi_from_nm1_1p -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run the full 835 parser test file to confirm no regression**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_parse_835.py -q
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/parsers/models_835.py backend/src/cyclone/parsers/parse_835.py backend/tests/test_parse_835.py
|
||||
git commit -m "feat(sp32): extract 835 NM1*1P (service provider NPI) per ClaimPayment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 837p parser — extract `NM1*82` (Loop 2420A rendering provider)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/parsers/parse_837.py:180-265` (`_consume_claim`)
|
||||
- Modify: `backend/src/cyclone/parsers/models.py` (ClaimOutput — add `rendering_provider_npi: str | None`)
|
||||
- Modify: `backend/tests/test_parse_837.py`
|
||||
|
||||
- [ ] **Step 1: Read the current `_consume_claim` to understand the loop-end boundary**
|
||||
|
||||
In `backend/src/cyclone/parsers/parse_837.py`, open `_consume_claim` (line 180) and find the `while` loop that walks service-line / nested loops. The loop ends on `next claim segment` markers (typically `CLM*`). The renderer segment sits BETWEEN service lines inside the same `CLM`. We're adding the renderer as a peer to service lines, not nesting it under them.
|
||||
|
||||
- [ ] **Step 2: Write the failing parser test**
|
||||
|
||||
In `backend/tests/test_parse_837.py`, append:
|
||||
|
||||
```python
|
||||
def test_parse_837_extracts_rendering_provider_npi_from_nm1_82():
|
||||
"""SP32: NM1*82 (rendering provider) in Loop 2420A populates ClaimOutput.rendering_provider_npi."""
|
||||
# Use the existing minimal 837p fixture pattern; check that an NM1*82
|
||||
# segment between SV1 and the next HL/CLM populates the field.
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.models import ClaimOutput
|
||||
from pathlib import Path
|
||||
|
||||
fixture = Path("tests/fixtures/co_medicaid_837p_with_renderer.txt")
|
||||
# (fixture created in Step 3, no need to write here)
|
||||
assert fixture.exists(), f"Missing fixture: {fixture}"
|
||||
|
||||
text = fixture.read_text()
|
||||
result = parse(text, payer_config=None) # type: ignore[arg-type]
|
||||
assert len(result.claims) == 1
|
||||
assert result.claims[0].rendering_provider_npi == "1234567893"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the 837p fixture**
|
||||
|
||||
Create `backend/tests/fixtures/co_medicaid_837p_with_renderer.txt` — copy `backend/tests/fixtures/co_medicaid_837p.txt` (if it exists) and insert an `NM1*82` segment between the existing SV1 and the next loop boundary. The full content is left to the implementer; the rendering NM1 line must be:
|
||||
|
||||
```
|
||||
NM1*82*2*RENDERING NAME*****XX*1234567893~
|
||||
```
|
||||
|
||||
If `co_medicaid_837p.txt` doesn't exist, use any minimal 837p fixture from `backend/tests/fixtures/` and add the same line.
|
||||
|
||||
- [ ] **Step 4: Add the typed field to `ClaimOutput`**
|
||||
|
||||
In `backend/src/cyclone/parsers/models.py`, find `class ClaimOutput` and add:
|
||||
|
||||
```python
|
||||
rendering_provider_npi: str | None = None # NM1*82 NM109 (Loop 2420A)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the test and confirm it fails**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_parse_837.py::test_parse_837_extracts_rendering_provider_npi_from_nm1_82 -v
|
||||
```
|
||||
|
||||
Expected: FAIL with `AttributeError: 'ClaimOutput' object has no attribute 'rendering_provider_npi'`.
|
||||
|
||||
- [ ] **Step 6: Extend `_consume_claim` to capture NM1*82**
|
||||
|
||||
In `backend/src/cyclone/parsers/parse_837.py`, in `_consume_claim`:
|
||||
|
||||
Before the existing `while idx < len(segments)` loop, initialize:
|
||||
|
||||
```python
|
||||
rendering_provider_npi: str | None = None
|
||||
```
|
||||
|
||||
Inside the loop, add a new branch (peer to the existing SV1/CRx/etc. handlers):
|
||||
|
||||
```python
|
||||
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "82":
|
||||
# NM1*82 — Loop 2420A Rendering Provider Name. NM109 is the NPI.
|
||||
# Element index 8 = NM108 (qualifier, "XX"), index 9 = NM109 (value).
|
||||
if len(seg) > 9 and seg[8] == "XX" and len(seg[9]) == 10 and seg[9].isdigit():
|
||||
rendering_provider_npi = seg[9]
|
||||
```
|
||||
|
||||
Inside the `ClaimOutput(...)` construction in this function, add `rendering_provider_npi=rendering_provider_npi`.
|
||||
|
||||
- [ ] **Step 7: Re-run the test and confirm it passes**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_parse_837.py::test_parse_837_extracts_rendering_provider_npi_from_nm1_82 -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Run the full 837p parser test file**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_parse_837.py -q
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/parsers/parse_837.py backend/src/cyclone/parsers/models.py backend/tests/test_parse_837.py backend/tests/fixtures/co_medicaid_837p_with_renderer.txt
|
||||
git commit -m "feat(sp32): extract 837p NM1*82 (rendering provider NPI) per ClaimOutput"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ORM builders — wire new fields to DB rows
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/store/orm_builders.py` (`_claim_837_row`, `_remittance_835_row`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
In a new section of `backend/tests/test_reconcile.py` (or in a new `backend/tests/test_orm_builders.py` if preferred), add:
|
||||
|
||||
```python
|
||||
def test_claim_837_row_includes_rendering_provider_npi(db_session, make_claim):
|
||||
"""SP32: Claim.rendering_provider_npi is populated from ClaimOutput in ORM builder."""
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
text = (Path("tests/fixtures/co_medicaid_837p_with_renderer.txt")).read_text()
|
||||
parsed = parse(text, payer_config=None) # type: ignore[arg-type]
|
||||
from cyclone.store.orm_builders import _claim_837_row
|
||||
_ensure_batch(db_session)
|
||||
row = _claim_837_row(parsed.claims[0], batch_id="test-batch")
|
||||
assert row.rendering_provider_npi == "1234567893"
|
||||
|
||||
|
||||
def test_remittance_835_row_includes_service_provider_npi(db_session, make_remit):
|
||||
"""SP32: Remittance.rendering_provider_npi is populated from ClaimPayment in ORM builder."""
|
||||
from cyclone.parsers.parse_835 import parse
|
||||
text = (Path("tests/fixtures/minimal_835_with_service_provider.txt")).read_text()
|
||||
parsed = parse(text, payer_config=None) # type: ignore[arg-type]
|
||||
from cyclone.store.orm_builders import _remittance_835_row
|
||||
_ensure_batch(db_session)
|
||||
row = _remittance_835_row(parsed.claims[0], batch_id="test-batch")
|
||||
assert row.rendering_provider_npi == "2222222222"
|
||||
assert parsed.claims[0].raw_segments # sanity: raw still captured
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run and confirm failure**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py::test_claim_837_row_includes_rendering_provider_npi tests/test_reconcile.py::test_remittance_835_row_includes_service_provider_npi -v
|
||||
```
|
||||
|
||||
Expected: FAIL with `TypeError: __init__() got an unexpected keyword argument 'rendering_provider_npi'`.
|
||||
|
||||
- [ ] **Step 3: Update `_claim_837_row`**
|
||||
|
||||
In `backend/src/cyclone/store/orm_builders.py:41` (`_claim_837_row`), in the `Claim(...)` constructor, add after `provider_npi=claim.billing_provider.npi`:
|
||||
|
||||
```python
|
||||
rendering_provider_npi=claim.rendering_provider_npi,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `_remittance_835_row`**
|
||||
|
||||
In `backend/src/cyclone/store/orm_builders.py:81` (`_remittance_835_row`), in the `Remittance(...)` constructor, add:
|
||||
|
||||
```python
|
||||
rendering_provider_npi=cp.service_provider_npi,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the tests**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py::test_claim_837_row_includes_rendering_provider_npi tests/test_reconcile.py::test_remittance_835_row_includes_service_provider_npi -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/store/orm_builders.py backend/tests/test_reconcile.py
|
||||
git commit -m "feat(sp32): wire rendering_provider_npi and service_provider_npi through ORM builders"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Extend `_content_keys_match` for typed-column NPI path
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/reconcile.py:140-205` (`_content_keys_match`)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
In `backend/tests/test_reconcile.py`, append:
|
||||
|
||||
```python
|
||||
def test_content_keys_match_typed_npi_columns_match(db_session, make_claim, make_remit):
|
||||
"""SP32: typed-column NPI beats raw_json fallback when both are present."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from datetime import date
|
||||
claim = make_claim(
|
||||
patient_control_number="PCN1",
|
||||
total_charge=Decimal("85.40"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2025, 1, 1),
|
||||
)
|
||||
remit = make_remit(
|
||||
payer_claim_control_number="PCN1",
|
||||
total_charge_amount=Decimal("85.40"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date=date(2025, 1, 1),
|
||||
)
|
||||
matched = _content_keys_match(remit, claim)
|
||||
assert "npi" in matched
|
||||
assert "pcn" in matched
|
||||
assert "charge" in matched
|
||||
|
||||
|
||||
def test_content_keys_match_typed_npi_only_one_side_counts_as_unmatched(db_session, make_claim, make_remit):
|
||||
"""SP32: NPI arm does not fire when only the claim has the typed column."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from datetime import date
|
||||
claim = make_claim(
|
||||
patient_control_number="PCN2",
|
||||
total_charge=Decimal("10.00"),
|
||||
rendering_provider_npi="2222222222",
|
||||
service_date_from=date(2025, 1, 1),
|
||||
)
|
||||
remit = make_remit(
|
||||
payer_claim_control_number="PCN2",
|
||||
total_charge_amount=Decimal("10.00"),
|
||||
rendering_provider_npi=None, # explicit None on the remit side
|
||||
service_date=date(2025, 1, 1),
|
||||
)
|
||||
matched = _content_keys_match(remit, claim)
|
||||
assert "npi" not in matched
|
||||
assert "pcn" in matched
|
||||
assert "charge" in matched
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `make_claim` and `make_remit` conftest helpers to write real columns**
|
||||
|
||||
In `backend/tests/conftest.py`, in `make_claim` (around line 140), replace:
|
||||
|
||||
```python
|
||||
c.rendering_provider_npi = rendering_provider_npi
|
||||
```
|
||||
|
||||
with passing `rendering_provider_npi=rendering_provider_npi` to the `Claim(...)` constructor. Drop the docstring note about transient attribute.
|
||||
|
||||
Same change in `make_remit` (around line 186): pass `rendering_provider_npi=rendering_provider_npi` to `Remittance(...)`.
|
||||
|
||||
Also update the signature to remove the default value handling (the column is now `Optional[str]` directly).
|
||||
|
||||
- [ ] **Step 3: Run the new tests and confirm they pass with the updated conftest**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py::test_content_keys_match_typed_npi_columns_match tests/test_reconcile.py::test_content_keys_match_typed_npi_only_one_side_counts_as_unmatched -v
|
||||
```
|
||||
|
||||
Expected: PASS (the matcher already reads via getattr, so the typed columns work without matcher changes for these two tests).
|
||||
|
||||
- [ ] **Step 4: Update `_content_keys_match` docstring to reflect typed-column priority**
|
||||
|
||||
In `backend/src/cyclone/reconcile.py`, find the matcher (around line 196) and replace the NPI branch:
|
||||
|
||||
```python
|
||||
# NPI: typed-column primary path (SP32), raw_json fallback (legacy).
|
||||
# The transaction parse path writes the typed columns; legacy rows
|
||||
# written before SP32 still have raw_json["rendering_provider_npi"].
|
||||
remit_npi = (
|
||||
(getattr(remit, "rendering_provider_npi", "") or "")
|
||||
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
|
||||
).strip()
|
||||
claim_npi = (
|
||||
(getattr(claim, "rendering_provider_npi", "") or "")
|
||||
or (getattr(claim, "provider_npi", "") or "")
|
||||
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
|
||||
).strip()
|
||||
if remit_npi and remit_npi == claim_npi:
|
||||
matched.add("npi")
|
||||
```
|
||||
|
||||
is replaced with:
|
||||
|
||||
```python
|
||||
# NPI: typed-column primary path (SP32), raw_json fallback (legacy rows).
|
||||
# Remit reads rendering_provider_npi (single value per D4); claim reads
|
||||
# rendering_provider_npi first, then falls back to provider_npi (billing)
|
||||
# for legacy rows where the 837p parser hadn't yet extracted NM1*82.
|
||||
remit_npi = (
|
||||
(getattr(remit, "rendering_provider_npi", "") or "")
|
||||
or ((getattr(remit, "raw_json", None) or {}).get("service_provider_npi", "") or "")
|
||||
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
|
||||
).strip()
|
||||
claim_npi = (
|
||||
(getattr(claim, "rendering_provider_npi", "") or "")
|
||||
or (getattr(claim, "provider_npi", "") or "")
|
||||
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
|
||||
).strip()
|
||||
if remit_npi and remit_npi == claim_npi:
|
||||
matched.add("npi")
|
||||
```
|
||||
|
||||
The key change: the remit's raw_json fallback reads `service_provider_npi` first (SP32's key in raw_json), with `rendering_provider_npi` retained as legacy fallback.
|
||||
|
||||
- [ ] **Step 5: Run the full reconcile test suite to confirm no regression**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_reconcile.py -q
|
||||
```
|
||||
|
||||
Expected: all SP31 tests still pass + the 2 new tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/reconcile.py backend/tests/conftest.py backend/tests/test_reconcile.py
|
||||
git commit -m "feat(sp32): _content_keys_match prefers typed NPI columns over raw_json fallback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `backfill-rendering-npi` CLI subcommand
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/cyclone/cli.py` (add new subcommand handler + argument)
|
||||
- New: `backend/tests/test_backfill_cli.py`
|
||||
|
||||
- [ ] **Step 1: Read the existing CLI structure**
|
||||
|
||||
In `backend/src/cyclone/cli.py`, find the existing subcommand dispatcher (look for `def main` or decorator-based click/tap groups). Read 2-3 adjacent subcommands to mimic the registration pattern.
|
||||
|
||||
- [ ] **Step 2: Write the failing integration test**
|
||||
|
||||
Create `backend/tests/test_backfill_cli.py`:
|
||||
|
||||
```python
|
||||
"""SP32: end-to-end backfill happy-path test."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_backfill_rendering_npi_smoke(tmp_path, monkeypatch):
|
||||
"""Run the CLI subcommand against a synthetic DB and confirm it exits 0."""
|
||||
# Set up: a 837p file with NM1*82 + an 835 file with NM1*1P.
|
||||
# Insert a Claim + Remittance row that lack the new columns.
|
||||
# Invoke the subcommand, assert exit 0 and columns populated.
|
||||
...
|
||||
```
|
||||
|
||||
(Fill in details in the implementer step. Use the existing `_ensure_batch` / `make_claim` / `make_remit` fixtures and the TestClient or subprocess pattern from `test_api_*` files.)
|
||||
|
||||
- [ ] **Step 3: Run the test and confirm it fails**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_backfill_cli.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL — subcommand not yet registered.
|
||||
|
||||
- [ ] **Step 4: Implement the subcommand**
|
||||
|
||||
In `backend/src/cyclone/cli.py`, add (matching the existing registration pattern):
|
||||
|
||||
```python
|
||||
@cli.command(name="backfill-rendering-npi")
|
||||
def backfill_rendering_npi() -> None:
|
||||
"""SP32: re-parse on-disk inbound files and populate the new NPI columns.
|
||||
|
||||
Idempotent — overwrites nothing. After populating, runs reconcile once
|
||||
over open pairs so the NPI arm can fire retroactively on historical data.
|
||||
"""
|
||||
from cyclone.store.backfill import backfill_rendering_provider_npi
|
||||
|
||||
summary = backfill_rendering_provider_npi()
|
||||
click.echo(
|
||||
f"claims_updated={summary['claims_updated']} "
|
||||
f"remits_updated={summary['remits_updated']} "
|
||||
f"matches_created={summary['matches_created']} "
|
||||
f"files_skipped={summary['files_skipped']}"
|
||||
)
|
||||
```
|
||||
|
||||
Create `backend/src/cyclone/store/backfill.py`:
|
||||
|
||||
```python
|
||||
"""SP32: backfill rendering/service-provider NPIs from on-disk inbound files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.parsers.parse_835 import parse as parse_835
|
||||
from cyclone.parsers.parse_837 import parse as parse_837
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackfillSummary:
|
||||
claims_updated: int = 0
|
||||
remits_updated: int = 0
|
||||
matches_created: int = 0
|
||||
files_skipped: int = 0
|
||||
|
||||
|
||||
def _safe_parse_837(path: Path) -> list | None:
|
||||
try:
|
||||
return parse_837(path.read_text(), payer_config=None).claims # type: ignore[arg-type]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_parse_835(path: Path) -> list | None:
|
||||
try:
|
||||
return parse_835(path.read_text(), payer_config=None).claims # type: ignore[arg-type]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def backfill_rendering_provider_npi() -> dict:
|
||||
"""Re-parse on-disk 837p + 835 files and write the new NPI columns.
|
||||
|
||||
Returns a summary dict with counts. Caller (the CLI subcommand) prints it.
|
||||
"""
|
||||
summary = BackfillSummary()
|
||||
|
||||
with db.SessionLocal()() as session:
|
||||
# 837p: one Batch → many Claim rows. Read inbound_path, re-parse, set
|
||||
# rendering_provider_npi only when the column is currently NULL.
|
||||
for batch in session.execute(select(db.Batch).where(db.Batch.kind == "837p")).scalars():
|
||||
path = Path(batch.inbound_path or "")
|
||||
if not path.exists():
|
||||
summary.files_skipped += 1
|
||||
continue
|
||||
parsed_claims = _safe_parse_837(path)
|
||||
if not parsed_claims:
|
||||
summary.files_skipped += 1
|
||||
continue
|
||||
for parsed in parsed_claims:
|
||||
row = session.get(db.Claim, parsed.claim_id)
|
||||
if row is None or row.rendering_provider_npi is not None:
|
||||
continue
|
||||
row.rendering_provider_npi = parsed.rendering_provider_npi
|
||||
summary.claims_updated += 1
|
||||
|
||||
# 835: one Remittance row per CLP. Read the most-recent inbound_path
|
||||
# keyed by batch_id; re-parse, set rendering_provider_npi where NULL.
|
||||
for remit in session.execute(
|
||||
select(db.Remittance).where(db.Remittance.rendering_provider_npi.is_(None))
|
||||
).scalars():
|
||||
batch = session.get(db.Batch, remit.batch_id)
|
||||
path = Path(getattr(batch, "inbound_path", "") or "")
|
||||
if not path.exists():
|
||||
summary.files_skipped += 1
|
||||
continue
|
||||
parsed_claims = _safe_parse_835(path)
|
||||
if not parsed_claims:
|
||||
summary.files_skipped += 1
|
||||
continue
|
||||
for parsed in parsed_claims:
|
||||
if parsed.payer_claim_control_number != remit.payer_claim_control_number:
|
||||
continue
|
||||
if parsed.service_provider_npi is not None:
|
||||
remit.rendering_provider_npi = parsed.service_provider_npi
|
||||
summary.remits_updated += 1
|
||||
break
|
||||
|
||||
session.commit()
|
||||
|
||||
# After the backfill write, run reconcile once over all open pairs.
|
||||
# This may create new Match rows, each emitting an auto_matched_835 event.
|
||||
from cyclone.reconcile import run as run_reconcile
|
||||
run_reconcile()
|
||||
|
||||
return {
|
||||
"claims_updated": summary.claims_updated,
|
||||
"remits_updated": summary.remits_updated,
|
||||
"matches_created": 0, # populated by importing the events counter if needed
|
||||
"files_skipped": summary.files_skipped,
|
||||
}
|
||||
```
|
||||
|
||||
Adjust: if `Batch.inbound_path` is named differently or is on a different model, follow the existing pattern (look at `store/write.py` where 837p files are recorded).
|
||||
|
||||
- [ ] **Step 5: Re-run the test and confirm it passes**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest tests/test_backfill_cli.py -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Test CLI invocation directly**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m cyclone.cli backfill-rendering-npi
|
||||
```
|
||||
|
||||
Expected: prints `claims_updated=0 remits_updated=0 matches_created=0 files_skipped=0` (or similar zeros, since the test DB is empty) and exits 0.
|
||||
|
||||
- [ ] **Step 7: Run the full backend suite for regression**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest -q
|
||||
```
|
||||
|
||||
Expected: all green, including all SP31 tests.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/cyclone/cli.py backend/src/cyclone/store/backfill.py backend/tests/test_backfill_cli.py
|
||||
git commit -m "feat(sp32): add backfill-rendering-npi CLI subcommand"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final integration verification
|
||||
|
||||
**Files:** none created; verification only
|
||||
|
||||
- [ ] **Step 1: Full pytest**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/pytest -q
|
||||
```
|
||||
|
||||
Expected: ~123 tests pass (115 SP31 baseline + ~8 SP32 new), no regressions.
|
||||
|
||||
- [ ] **Step 2: Apply migration to the live production DB**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m cyclone.db_migrate
|
||||
```
|
||||
|
||||
Expected: `0019_add_rendering_and_service_provider_npis.sql` applied.
|
||||
|
||||
- [ ] **Step 3: Smoke test against production DB**
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m cyclone.cli parse-835 docs/prodfiles/co_medicaid/inbound/co_medicaid_835.txt --output-dir /tmp/cyc-parse-out --payer co_medicaid
|
||||
.venv/bin/python -m cyclone.cli backfill-rendering-npi
|
||||
.venv/bin/python -c "from cyclone import db; db.init_db(); import sqlite3; c=sqlite3.connect(db._db_path()); print('claims with NPI:', c.execute('select count(*) from claims where rendering_provider_npi is not null').fetchone()); print('remits with NPI:', c.execute('select count(*) from remittances where rendering_provider_npi is not null').fetchone())"
|
||||
```
|
||||
|
||||
Expected: counts > 0 if the prodfiles contain NM1*1P/NM1*82 segments.
|
||||
|
||||
- [ ] **Step 4: Frontend typecheck**
|
||||
|
||||
```bash
|
||||
cd .. && npm run typecheck
|
||||
```
|
||||
|
||||
Expected: no new errors (no frontend changes expected; pre-existing 15 errors remain unchanged).
|
||||
|
||||
- [ ] **Step 5: Final commit + push**
|
||||
|
||||
```bash
|
||||
git log --oneline -10
|
||||
git status
|
||||
```
|
||||
|
||||
Confirm working tree clean, all SP32 commits present, ready for atomic merge per the cyclone-spec skill.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
| Spec section | Implemented by |
|
||||
|---|---|
|
||||
| D1 both sides | Task 2 (835) + Task 3 (837p) |
|
||||
| D2 ClaimPayment.service_provider_npi | Task 2 (model + parser) |
|
||||
| D3 Claim.rendering_provider_npi | Task 1 (ORM) + Task 3 (parser) |
|
||||
| D4 Remittance.rendering_provider_npi | Task 1 (ORM) + Task 4 (ORM builder) |
|
||||
| D5 raw_json mirrors | Task 2 (ClaimPayment typed field auto-mirrors via model_dump_json) + Task 4 (raw_json unchanged) |
|
||||
| D6 _content_keys_match NPI arm | Task 5 |
|
||||
| D7 backfill CLI | Task 6 |
|
||||
| D8 ActivityEvent emission on retroactive matches | Task 6 (uses existing reconcile.run path, which already emits per SP31 D8) |
|
||||
| D9 migration | Task 1 |
|
||||
| D10 file paths | All tasks |
|
||||
| Edge cases (missing NM1 → graceful degrade) | Task 2/3/5 tests |
|
||||
| Testing strategy (parser/matcher/CLI/regression) | Tasks 2/3/4/5/6/7 |
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- No "TBD" or "TODO" in task bodies
|
||||
- No "similar to Task N" — each step shows the code
|
||||
- No vague "add error handling" — concrete validation in Steps 4 of Tasks 2 and 3
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `ClaimPayment.service_provider_npi` defined in Task 2 Step 3, used in Tasks 2, 4, 6
|
||||
- `Claim.rendering_provider_npi` defined in Task 1, used in Tasks 3, 4, 5, 6
|
||||
- `Remittance.rendering_provider_npi` defined in Task 1, used in Tasks 4, 5, 6
|
||||
- `_content_keys_match(remit, claim)` signature unchanged
|
||||
|
||||
### Ambiguity check
|
||||
|
||||
- Task 3 Step 1 explicitly notes the loop-end boundary check before implementing
|
||||
- Task 6 Step 4 backfill.py has explicit fallback when files are missing
|
||||
- Task 4 Step 1's `parsed.claims[0].raw_segments` assertion grounds the "raw unchanged" requirement
|
||||
@@ -0,0 +1,130 @@
|
||||
# Sub-project 31 — 835 Strict Content-Match Auto-Link: Design Spec
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Status:** Draft, awaiting user sign-off
|
||||
**Branch:** `sp31-835-strict-content-match`
|
||||
**Aesthetic direction:** No UI changes. State flips driven by backend logic surface immediately via the SP30 Recent Batches widget.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
Today when an 835 (ERA remittance advice) arrives, the existing matcher in `cyclone.reconcile.run` only auto-links a remit to a claim when **`payer_claim_control_number == claim.patient_control_number` AND service dates are within ±7 days**. That single-key match misses most real-world remits: Gainwell's 835 carries its own independent control numbers (M-prefix filenames, ISA13 assigned by Gainwell, no echo of the 837's ST02), so the only reliable join lives in the 835's **content fields** — CLP01 (PCN), CLP03 (total charge), CLP07 (rendering provider NPI). The operator can see in the Recent Batches widget that a batch has unresolved claims, but until a manual match is performed (admin-only endpoint `POST /api/reconciliation/match`) those remits sit in the Inbox Unlinked lane with no `Claim.state` flip to PAID / DENIED / PARTIAL.
|
||||
|
||||
SP31 closes that gap by adding a **deterministic content-key fallback matcher** to the 835 ingest path. When the existing PCN-exact path misses, a strict **"any 2 of {PCN, charge, NPI}" rule** runs against a wider candidate pool (±30 days) and auto-links on a hit. The result: more remits get linked at parse time, more claims get state-flipped to a terminal/settled state, and the Recent Batches widget reflects the truth immediately (no manual intervention needed).
|
||||
|
||||
**In scope:**
|
||||
|
||||
- One new helper `_content_keys_match(remittance, claim)` in `cyclone.reconcile` that returns True when at least 2 of {CLP01↔CLM01 PCN, CLP03↔CLM02 charge, CLP07↔NM1*82 NPI} agree exactly (charge compared with a $0.01 tolerance).
|
||||
- One new helper `_score_fallback_candidates(session, remittance)` that queries the candidate pool (same payer_id, billed_within ±30 days, claim.state ∈ {SUBMITTED, RECEIVED, PARTIAL}, no existing match) and returns the unique match when one exists, or None when zero or multiple claims pass.
|
||||
- One new branch inside `cyclone.reconcile.run` that invokes the content-keys fallback **after** the existing PCN-exact path misses. On a hit, calls `apply_payment` / `apply_reversal`, writes a `Match(strategy="score-auto")` row with `score=100` and a payload describing which keys matched, and emits an `auto_matched_835` `ActivityEvent`.
|
||||
- Idempotency guards: skip scoring when `claim.matched_remittance_id` is set, when `remittance.claim_id` is set, or when `claim.state` is already terminal (PAID / DENIED / REJECTED / REVERSED / RECONCILED). Re-ingesting the same 835 is a no-op for already-linked pairs.
|
||||
- Reversal handling unchanged: status_code 21/22 always goes through `apply_reversal`, creating a 2nd `Match` row per existing SP27 convention. The strategy label (`pcn-exact` vs `score-auto`) reflects which path made the link.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- No schema migration. `Match` and `ActivityEvent` already exist; the new strategy label `"score-auto"` is a string value within the existing column.
|
||||
- No backfill. The 672 currently-unlinked claims in production stay unlinked; SP31 only affects new 835 ingests. An operator can still run the existing manual-match endpoints to backfill at their own pace.
|
||||
- No changes to the existing PCN-exact matcher. It stays as the primary (fast) path. SP31 only adds a fallback branch.
|
||||
- No changes to the Inbox Unlinked lane UI, the Recent Batches widget, or the ClaimDrawer / RemitDrawer. State flips already surface through the existing live-tail path (SP25) and the SP30 widget.
|
||||
- No new endpoints. The existing `POST /api/reconciliation/match` and `POST /api/reconciliation/unmatch` remain the manual-fallback surfaces.
|
||||
- No 999 / 277CA / TA1 changes. The ack↔claim auto-linker from SP28 stays untouched (those use the 837 filename / ST02 echo, not content keys).
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions (locked during brainstorming)
|
||||
|
||||
### D1. Match rule is strict "any 2 of {PCN, charge, NPI} match exactly" — no weighted scoring, no thresholds, no ambiguity tiers
|
||||
|
||||
The user's instinct that "we might be over-building this" led to dropping the original 4-field weighted score with a 90-point threshold. Instead, SP31 uses a **deterministic three-key rule**:
|
||||
|
||||
- **PCN match:** `remittance.payer_claim_control_number == claim.patient_control_number` (after `.strip()` on both).
|
||||
- **Charge match:** `|remittance.total_charge_amount − claim.total_charge| < $0.01`.
|
||||
- **NPI match:** `remittance.rendering_provider_npi == claim.rendering_provider_npi` AND the remit's NPI is non-empty.
|
||||
|
||||
Auto-link when `keys_matched ≥ 2`. **No weighted score, no 90/75 threshold, no "runner-up within 10 points = ambiguous" rule.** The decision is reproducible by inspecting the three fields; "score was 91" is no longer in the operator's vocabulary. The existing scoring module (`cyclone.scoring.score_pair`) is preserved for the Inbox display lane but is no longer invoked from the auto-match path.
|
||||
|
||||
### D2. Content-key fallback runs *after* the existing PCN-exact path misses — not as a replacement
|
||||
|
||||
`reconcile.run` already PCN-matches today. SP31 leaves that path untouched and inserts the content-keys fallback as a second branch. Rationale: when PCN matches today, the match is rock-solid (no charge comparison, no NPI check needed). When PCN misses, content-keys is the next-best signal. Stacking keeps the fast path fast and the audit trail obvious (Match.strategy reveals which branch fired).
|
||||
|
||||
### D3. The Match row gets `strategy="score-auto"` and a payload describing which keys matched
|
||||
|
||||
This differentiates from the existing `strategy="pcn-exact"` (renamed from today's `strategy="auto"`). The Match row payload stores `{"keys_matched": ["pcn", "charge"], "candidate_count": 7}` so the operator can see exactly why a link was made. The Inbox Unlinked lane will surface these labels too (existing display layer reads `Match.strategy`). The existing `score` column is set to `100` for content-keys matches (placeholder for the legacy display layer; not used by the new logic).
|
||||
|
||||
### D4. Charge tolerance is $0.01, not exact-zero
|
||||
|
||||
CO Medicaid and most payers carry charge to 2 decimal places; rounding can introduce ±$0.005 of drift. $0.01 is the conservative tolerance that still catches real mismatches (a $50 charge vs a $100 charge differs by $50, not $0.01).
|
||||
|
||||
### D5. Candidate pool is widened to ±30 days (was ±7 for PCN)
|
||||
|
||||
Strict keys (≥ 2 of 3) are a much stronger signal than PCN-alone, so the wider window is safe. The 30-day window captures remits that arrive late (e.g., week-long payer processing delays plus weekends plus holidays) without pulling in unrelated claims from prior months.
|
||||
|
||||
### D6. Idempotency is enforced by three guards, not by deduplication
|
||||
|
||||
Before scoring a candidate, the matcher checks:
|
||||
|
||||
1. `claim.matched_remittance_id IS NULL` (claim hasn't been linked yet)
|
||||
2. `remittance.claim_id IS NULL` (remit hasn't been linked yet)
|
||||
3. `claim.state NOT IN {PAID, DENIED, REJECTED, REVERSED, RECONCILED}` (terminal-state guard)
|
||||
|
||||
If any guard fails, the candidate is excluded from the pool. If no candidate remains after filtering, no match is made and the remit lands in the Inbox Unlinked lane (existing behavior).
|
||||
|
||||
### D7. No operator-facing UI for content-keys matches — the existing live-tail + Recent Batches widget surface them
|
||||
|
||||
The `Match` row (D3) and the `auto_matched_835` ActivityEvent (D8) are the operator-visible artifacts. The Recent Batches widget from SP30 already reflects `Claim.state` changes within one re-fetch cycle (TanStack Query interval). No new "auto-matched by score" badge anywhere — keeps the UI surface minimal and lets the widget do its job.
|
||||
|
||||
### D8. An `auto_matched_835` ActivityEvent is emitted on every successful content-keys match
|
||||
|
||||
Payload: `{claim_id, remittance_id, strategy: "score-auto", keys_matched: [...], candidate_count: N}`. Visible in the Activity page alongside existing `reconcile` events. Lets the operator answer "why did this 835 flip a claim?" without inspecting the Match table.
|
||||
|
||||
### D9. Reversals (status_code 21/22) keep the existing 2-row Match audit trail
|
||||
|
||||
When a reversal 835 arrives and matches via either path, `apply_reversal` flips the claim state (PAID/PARTIAL → REVERSED) and writes a **second** `Match` row (per SP27 convention). The strategy label on the new row reflects which branch matched the reversal. This preserves the audit trail: the first Match row says "paid", the second says "reversed".
|
||||
|
||||
### D10. No changes to auth posture, no new endpoints, no CLI changes
|
||||
|
||||
Every existing endpoint's auth boundary is preserved. The 835 ingest path runs server-side; no operator action is required to trigger a content-keys match. The existing manual-match endpoints (`POST /api/reconciliation/match`, `POST /api/reconciliation/unmatch`) remain the escape hatch for edge cases.
|
||||
|
||||
---
|
||||
|
||||
## 3. Edge cases & how they're handled
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| PCN matches (existing path) | Unchanged. `apply_payment` / `apply_reversal`, `Match(strategy="pcn-exact")`. |
|
||||
| PCN misses, content-keys 2-of-3 matches, exactly 1 candidate | `apply_payment`, `Match(strategy="score-auto", score=100)`, Activity event. |
|
||||
| PCN misses, content-keys 2-of-3 matches, 2+ candidates | No match. Remit lands in Inbox Unlinked. (Deterministic rule — duplicates would be ambiguous.) |
|
||||
| PCN misses, content-keys 0-of-3 or 1-of-3 | No match. Inbox Unlinked. |
|
||||
| Reversal (status_code 21/22) | Always `apply_reversal`. 2nd Match row. Strategy label per which path matched. |
|
||||
| `claim.matched_remittance_id` set | Skip scoring entirely. Idempotent. |
|
||||
| `remittance.claim_id` set | Skip scoring entirely. Idempotent. |
|
||||
| `claim.state` in terminal set | Skip scoring. State already settled. |
|
||||
| Remit has empty CLP07 (no rendering NPI) | NPI key counted as "not matched" — falls back to PCN + charge rule. |
|
||||
| Charge differs by exactly $0.01 | Counts as matched (D4 tolerance). |
|
||||
| Same PCN across two claims in the pool (legitimate re-submit) | Both candidates count toward the pool; if only one also matches charge or NPI, that one wins. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing approach
|
||||
|
||||
14 new tests in `backend/tests/test_reconcile.py`, modeled on the existing `reconcile` test patterns. Coverage targets:
|
||||
|
||||
- **Happy paths** (4): PCN+charge match, PCN+NPI match, charge+NPI match (no PCN), all 3 match.
|
||||
- **Negative paths** (3): only-PCN-match doesn't auto-link, only-charge-match doesn't auto-link, none-match doesn't auto-link.
|
||||
- **Idempotency** (3): claim already matched skips, remit already linked skips, terminal claim state skips.
|
||||
- **State transitions** (2): reversal uses `apply_reversal`, paid remittance flips claim to PAID.
|
||||
- **Audit trail** (2): Match row strategy label is `score-auto`, Activity event `auto_matched_835` emitted with correct payload.
|
||||
- **Regression guard** (1): PCN-exact path unchanged — when PCN matches, behavior is identical to today.
|
||||
|
||||
No frontend tests (no UI changes). Manual smoke via Playwright: ingest an 835 fixture against a synthetic 837 batch and assert the claim state flips in the Recent Batches widget within one re-fetch.
|
||||
|
||||
---
|
||||
|
||||
## 5. Out-of-scope reminders (explicit)
|
||||
|
||||
- **Backfilling the 672 unmatched claims already in production.** Operator-driven, not auto.
|
||||
- **Changing the existing 999 / 277CA / TA1 auto-linker.** Those use the 837 filename / ST02 echo (SP28), a different signal than content keys.
|
||||
- **Changing the Inbox Unlinked lane UI.** The lane already exists; SP31 only changes which remits end up in it (fewer).
|
||||
- **Tuning charge tolerance.** $0.01 is the conservative default; revisit only if real production data shows false-negatives.
|
||||
- **Weighted scoring from `cyclone.scoring` is preserved for display only.** The auto-match path does not invoke `score_pair`.
|
||||
@@ -0,0 +1,89 @@
|
||||
# SP29 — Inbox 999-rejected claim drill: inline ack evidence + per-row Resubmit
|
||||
|
||||
> **Status:** Approved, 2026-07-02.
|
||||
> **Branch:** `sp29-rejected-row-ack-drill`
|
||||
> **Spec → plan → implement → atomic merge into main.**
|
||||
|
||||
## Goal
|
||||
|
||||
Make the Inbox `rejected` lane (the 999 envelope rejects — the resubmittable kind) **actionable per claim**: the operator can see why a claim was rejected (which 999 AK2 set_response, what code, what ST02) without opening the drawer, and they can resubmit a single rejected claim with one click instead of having to select its checkbox + open the bulk modal.
|
||||
|
||||
The SP28 → SP29 chain: SP28 gave us the `claim_acks` join table so we can now surface the 999 evidence inline on the rejected row. SP29 is the operator-facing surface that makes the rebill workflow usable.
|
||||
|
||||
## Scope
|
||||
|
||||
### Backend — extend `/api/inbox/lanes`
|
||||
|
||||
`backend/src/cyclone/inbox_lanes.py` (`compute_lanes`): for each `rejected`-lane claim row, attach `claim_acks: {total, rejected, items}` summarizing the 999 acks linked to that claim by SP28.
|
||||
|
||||
New field on `InboxClaimRow`:
|
||||
|
||||
```python
|
||||
{
|
||||
"total": int, # total claim_acks rows linked to this claim (ack_kind='999')
|
||||
"rejected": int, # subset where set_accept_reject_code in ('R', 'E', 'X')
|
||||
"items": [ # up to 5 most recent, newest first; for UI chip rendering
|
||||
{"ack_id": int, "set_control_number": str, "set_accept_reject_code": str, "ak2_index": int, "linked_at": iso8601}
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
The field is `null` when the claim has zero linked 999 acks. Filtered to `ack_kind='999'` — the 277CA ack evidence already lives on the `payer_rejected_*` fields on `payer_rejected` rows (out of scope for this SP; not touched).
|
||||
|
||||
Implementation: one batched query inside `compute_lanes`:
|
||||
|
||||
```sql
|
||||
SELECT claim_id, ack_id, set_control_number, set_accept_reject_code, ak2_index, linked_at
|
||||
FROM claim_acks
|
||||
WHERE claim_id IN (...) AND ack_kind = '999'
|
||||
ORDER BY linked_at DESC, id DESC
|
||||
```
|
||||
|
||||
(Indexes sufficient — `ix_claim_acks_claim_id` already exists from `0018_claim_acks.sql`. The `(claim_id, linked_at)` composite for sorted fetch is not added in v1; the LIMIT-by-claim is done in Python after the batched fetch. If latency grows on the inbox endpoint we add a `(claim_id, linked_at)` composite index in a follow-up SP.)
|
||||
|
||||
### Frontend — render + per-row Resubmit
|
||||
|
||||
**`src/lib/inbox-api.ts`** — extend `InboxClaimRow` with the new `claim_acks` field (additive, non-breaking).
|
||||
|
||||
**`src/components/inbox/InboxRow.tsx`** — add an inline sub-row for `rejected`-state claim rows showing up to 3 AK2 chips + a `+N more` chip that opens the ClaimDrawer. Reject codes (`R`/`E`/`X`) get the oxblood tint; accept (`A`) gets a muted check tint. When `claim_acks` is null or empty, render `999 not linked` in muted text.
|
||||
|
||||
**`src/components/inbox/InboxRow.tsx`** + **`src/pages/Inbox.tsx`** — a per-row `Resubmit` button at the right edge of each `rejected` claim row. Click triggers `serializeClaim837(row.id)` → `downloadTextFile(...)` (operator gets the corrected 837 on disk; they reconcile via their own workflow or via the existing bulk Resubmit modal).
|
||||
|
||||
**Auth posture:** per-row Resubmit is visible to `admin` and `user` roles. Mirrors the bulk Resubmit gating in `pages/Inbox.tsx:529-538`. Manual-only SP24 posture unchanged.
|
||||
|
||||
### Out of scope (deferred)
|
||||
|
||||
- `payer_rejected` lane (277CA STC A4/A6/A7) — distinct drill, owns the Acknowledge gesture.
|
||||
- A missing-999 alarm surfacing (claims SUBMITTED >24h with zero 999 acks).
|
||||
- Hoisting ack counts into the ClaimDrawer header.
|
||||
- Orphan-ack triage bulk actions.
|
||||
- Audit-log entry on single-claim resubmit (the bulk path doesn't audit either; closing that gap is its own SP).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **D1 — per-row Resubmit is download-only (not state-flipping).** Mirrors the operator's actual workflow (download, fix in their editor, resubmit via the bulk path or the existing single-claim modal flow). Keeps v1 small. v2 can add the "submit + flip state" path if needed.
|
||||
- **D2 — lane surface, not a new page.** The Inbox already has the `rejected` lane with chrome cost paid. Adding the per-row affordance inside the existing lane is lower-friction than a new drill page.
|
||||
- **D3 — `claim_acks.items` is 5 most recent, sorted DESC.** Matches what the eye scans (newest rejections first). `total` and `rejected` summary counts cover the rest of the story. No pagination.
|
||||
- **D4 — per-row Resubmit visible to `admin` AND `user` roles.** Mirrors the existing bulk Resubmit gating.
|
||||
|
||||
## Risks
|
||||
|
||||
- **N+1 fetch via the inbox endpoint.** Could grow `/api/inbox/lanes` latency by O(N-rejected) without a batched query. Mitigation: one batched query inside `compute_lanes` (`claim_id IN (...)`).
|
||||
- **Backwards compatibility of `InboxClaimRow`.** Adding a nullable `claim_acks` field is non-breaking. Existing UI (and the CLAUDE.md-documented "5 lanes" assumption) continues to work; only the rejected row gets new chips.
|
||||
- **Per-row Resubmit file may not match the operator's edits.** Out of scope; the operator is responsible for editing. The download is the same `serialize_837` content the bulk path produces. Trade-off documented in D1.
|
||||
|
||||
## Implementation shape
|
||||
|
||||
- Branch: `sp29-rejected-row-ack-drill`
|
||||
- Plan path: `docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md`
|
||||
- Commit prefixes: `feat(sp29): …`, `docs(spec): …`, `docs(plan): …`, `merge: SP29 …`
|
||||
- PR title: `SP29 Inbox 999-rejected drill`
|
||||
- Merge shape: single atomic merge, no squash, no rebase.
|
||||
|
||||
## File footprint (per phase)
|
||||
|
||||
- Backend modified (1): `backend/src/cyclone/inbox_lanes.py`.
|
||||
- Backend test added (1): `backend/tests/test_inbox_lanes.py` (extend or new).
|
||||
- Frontend modified (3): `src/lib/inbox-api.ts`, `src/components/inbox/InboxRow.tsx`, `src/pages/Inbox.tsx`.
|
||||
- Frontend test added (2): `src/components/inbox/InboxRow.test.tsx`, `src/pages/Inbox.test.tsx`.
|
||||
- Estimated 70-100 LOC total.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Sub-project 33 — Co TXIX Payer Fix: Design Spec
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Status:** Draft, awaiting user sign-off
|
||||
**Branch:** `sp33-co-txix-payer-fix`
|
||||
**Aesthetic direction:** No new UI; production hotfix to emission
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Change the canonical CO Medicaid payer identifier emitted in `NM1*PR NM108=PI*NM109` from `SKCO0` to `CO_TXIX`, in both the in-code `PayerConfig.co_medicaid()` factory and any other write path that derives the segment from `claim.payer.id`.
|
||||
- Update the in-repo companion-guide references (`docs/reference/837p.md`, `docs/reference/co-medicaid.md`) to reflect the corrected value. `docs/goodclaim.x12` is already correct and stays the source of truth.
|
||||
- Fix `cyclone.inbox_state.apply_999_rejections` to use `batch_envelope_index` (mirroring the SP28 fix to `apply_999_acceptances`) so SET-level 999 acks correctly cascade to claim-state transitions.
|
||||
- One-shot backfill against the live DB: for each claim already linked via `claim_acks` to a 999 ack with `set_accept_reject_code='R'`, set `claims.state='REJECTED'`, fill `rejection_reason`, `rejected_at`, `payer_rejected_reason`, `payer_rejected_at`, and emit a `claim.rejected` audit event. Idempotent.
|
||||
- Resubmit the 338 corrected single-claim 837 files (already split into `ingest/batch-*-claims/*.x12` by the operator) by replacing the `SKCO0` literal in their bytes with `CO_TXIX`, then uploading to `/CO XIX/PROD/coxix_prod_11525703/ToHPE/` via the existing paramiko-backed `SftpClient`.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- No new R-coded validator rule (the existing `R100_payer_id_matches` already warns on mismatch; it auto-converges once `co_medicaid()` returns `CO_TXIX`).
|
||||
- No `CO_BHA` (behavioral-health) variant in this increment. The HCPF 999 lists `CO_TXIX OR CO_BHA` as both accepted; this increment picks `CO_TXIX` as the single value for dzinesco's three Home-Health NPIs. A future SP may add a per-claim override if behavioral-health submission volumes warrant it.
|
||||
- No changes to other payer configs (only `co_medicaid` is touched).
|
||||
- No schema migration. The existing `claims.payer_id` column already accepts any string; `claims.resubmit_count` already exists and is incremented; no new column is added.
|
||||
- No frontend UI changes. The dashboard already surfaces claim states; once the backfill flips them, the "0 accepted" widget will reflect reality without code change.
|
||||
|
||||
## 2. Context (why now)
|
||||
|
||||
On 2026-07-01 at 16:29 MT, dzinesco submitted four 837P batches (145+95+25+73 = 338 claims) to Gainwell's SFTP at `mft.gainwelltechnologies.com`. On 2026-07-02, Gainwell returned 999 acknowledgments for each, all `AK9=R` with the SET-level error:
|
||||
|
||||
```
|
||||
IK3*NM1*17*2010*8
|
||||
IK5*R*I5
|
||||
```
|
||||
|
||||
…where "2010BB NM109 must equal CO_TXIX or CO_BHA".
|
||||
|
||||
The in-repo reference (`docs/reference/837p.md:48`, `docs/reference/co-medicaid.md:118`) and the in-code `PayerConfig.co_medicaid()` factory (`backend/src/cyclone/parsers/payer.py:69-70`) both claim `SKCO0` is the correct value. The authoritative sample file (`docs/goodclaim.x12`, line 4) shows `NM1*PR*2*CO_TXIX*****PI*CO_TXIX` — confirming `CO_TXIX` is correct. Tyler confirmed this in chat on 2026-07-02: "the problem is we arent changing SKCO0 to CO_TXIX".
|
||||
|
||||
A second, separate bug surfaced during the same investigation: `apply_999_rejections` (`backend/src/cyclone/inbox_state.py:56`) passes the SET control number to a `claim_lookup` that queries by `patient_control_number`. As a result, even when a 999 ack correctly rejects a SET, no claim state ever flips. This bug pre-dates SP33 but is fixed here because (a) the SP28 fix already exists for `apply_999_acceptances` and the same fix is a small, surgical change, and (b) the dashboard "0/145 accepted" widget can only reflect reality once the cascade works.
|
||||
|
||||
## 3. Decisions (locked during brainstorming)
|
||||
|
||||
1. **NM109 = `CO_TXIX`, single value.** No per-claim rule. dzinesco's three NPIs are all Home Health (taxonomy `251E00000X`); none are behavioral-health-only providers. Behavioral-health claims (if any exist) would currently emit `CO_TXIX` and Gainwell would still accept per the 999's "OR CO_BHA" wording. Confirmed with Tyler 2026-07-02.
|
||||
2. **Fix the cascade bug in the same increment.** The 999 cascade is the same severity class as the 999 emission bug; splitting into a separate SP would mean a second hotfix in days. Confirmed with Tyler via Option A selection.
|
||||
3. **Backfill is a one-shot CLI subcommand.** A new `python -m cyclone.cli backfill-999-rejections` invocation, idempotent (claims already in `REJECTED` state are skipped). Lives in `cli.py` and a small helper module. Confirmed with Tyler via Option A.
|
||||
4. **Resubmit uses Tyler's pre-split files.** Tyler already produced 338 single-claim 837 files in `ingest/batch-*-claims/*.x12` (timestamps 16:41:43 on 2026-07-01). The resubmit path performs a deterministic `SKCO0 → CO_TXIX` byte replacement on each file, validates via the existing parser, then uploads via `SftpClient.put()`. Confirmed with Tyler via Option A (Step 6).
|
||||
5. **No new spec-on-the-fly for the bytecode fix.** The `SKCO0 → CO_TXIX` literal replacement is a single-pass `bytes.replace` scoped to the `NM1*PR` segment of each file. Anything more sophisticated would be over-engineered for a hotfix.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### 4.1 Source fix (`feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX`)
|
||||
|
||||
One file, two lines. `backend/src/cyclone/parsers/payer.py:69-70`:
|
||||
|
||||
```
|
||||
payer_id="SKCO0", → payer_id="CO_TXIX",
|
||||
payer_name="COHCPF", → payer_name="CO_TXIX",
|
||||
```
|
||||
|
||||
The serializer (`backend/src/cyclone/parsers/serialize_837.py:488`) reads these fields directly and emits `NM1*PR` accordingly — no serializer change required. The 999 cascade (independent fix, see §4.2) is already gated on `apply_999_rejections`, which queries against `claim.payer.id` once the in-DB claims carry the new value.
|
||||
|
||||
### 4.2 Cascade fix (`feat(sp33): apply_999_rejections uses batch_envelope_index`)
|
||||
|
||||
`backend/src/cyclone/inbox_state.py:55-65`:
|
||||
|
||||
```
|
||||
+ from cyclone.handlers._ack_id import batch_envelope_index # already exists
|
||||
def apply_999_rejections(session, parsed_999, *, claim_lookup, batch_envelope_index=None):
|
||||
...
|
||||
- claim = claim_lookup(sr.set_control_number)
|
||||
+ # SCN → list of claim_ids via batch_envelope_index; fall back to direct claim_lookup (rare)
|
||||
+ candidates = (batch_envelope_index or {}).get(sr.set_control_number, [])
|
||||
+ for cid in candidates:
|
||||
+ claim = session.get(Claim, cid)
|
||||
```
|
||||
|
||||
The new signature mirrors the SP28 pattern in `apply_999_acceptances`. Backwards-compatible: when `batch_envelope_index=None`, falls back to the old (broken) behavior, but every caller in `handle_999.handle()` will pass the index.
|
||||
|
||||
The handler at `backend/src/cyclone/handlers/handle_999.py:88-99` updates in lockstep:
|
||||
|
||||
```
|
||||
- rejection_result = apply_999_rejections(session, result, claim_lookup=_lookup)
|
||||
+ rejection_result = apply_999_rejections(
|
||||
+ session, result, claim_lookup=_lookup, batch_envelope_index=batch_index,
|
||||
+ )
|
||||
```
|
||||
|
||||
### 4.3 Backfill subcommand (`feat(sp33): cli backfill-999-rejections`)
|
||||
|
||||
New `cli.py` subcommand:
|
||||
|
||||
```
|
||||
python -m cyclone.cli backfill-999-rejections [--actor <name>] [--dry-run]
|
||||
```
|
||||
|
||||
Algorithm (single transaction, single `SessionLocal`):
|
||||
|
||||
1. `SELECT ca.claim_id, ca.set_control_number, ca.set_accept_reject_code, a.ack_code, a.raw_json FROM claim_acks ca JOIN acks a ON a.id=ca.ack_id WHERE ca.set_accept_reject_code='R' AND ca.linked_at IS NOT NULL`
|
||||
2. For each `claim_id`, load `Claim` via `session.get`. Skip if already `REJECTED` (idempotent). Otherwise set `state='REJECTED'`, `state_changed_at=now`, `rejected_at=now`, `rejection_reason=...`, `payer_rejected_at=now`, `payer_rejected_reason=...`, `payer_rejected_status_code='R'`, then append `AuditEvent(event_type='claim.rejected', actor=<name>)`.
|
||||
3. Commit. Print `(matched=N already_rejected=M errors=K)` summary.
|
||||
|
||||
For the 338 current claims, expected output: `matched=338 already_rejected=0 errors=0`.
|
||||
|
||||
### 4.4 Resubmit (`feat(sp33): cli resubmit-rejected-claims`)
|
||||
|
||||
New CLI subcommand:
|
||||
|
||||
```
|
||||
python -m cyclone.cli resubmit-rejected-claims [--payer co_medicaid] [--ingest-dir <path>]
|
||||
```
|
||||
|
||||
Defaults: `--ingest-dir /home/tyler/dev/cyclone/ingest`. Tyler pre-split the 338 single-claim 837 files into per-batch directories named `batch-<batch_id>-<N>-claims/`, where `<batch_id>` matches `claims.batch_id` and `<N>` is the claim count. The exact filenames match the SFTP outbound naming convention (`TPID-837P-yyyymmddhhmmssSSS-1of1.x12`) and are already unique-by-timestamp.
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. Discover all `claims` rows where `state='REJECTED'` (after the §4.3 backfill lands). Group by `batch_id`.
|
||||
2. For each `claim_id` in `REJECTED`, locate the matching `_837P-*.x12` file in `ingest/batch-<batch_id>-<N>-claims/`. Match by directory naming + per-claim `patient_control_number` declared in the file's CLM01 segment (re-parse each file once, cache the CLM01 list keyed by file).
|
||||
3. For each file: `new_bytes = old_bytes.replace(b'PI*SKCO0', b'PI*CO_TXIX')`. Validate via `parse_837_text(new_bytes)`. If validation produces any NEW error (compared to the same parse of the pre-replacement bytes), halt with the file path + error diff so the operator can inspect.
|
||||
4. Upload via `cyclone.clearhouse.SftpClient.put(local_path, remote_path)` to `/CO XIX/PROD/coxix_prod_11525703/ToHPE/` using the EXISTING filename. SFTP client retains the paramiko session across files (already supported per SP16).
|
||||
5. After successful upload, increment `claims.resubmit_count` (existing column, default 0) and emit one `clearhouse.submitted` audit event per file. `claims.state` remains `REJECTED` — the `resubmit_count > 0` flag is sufficient for the UI to distinguish "rejected-and-needs-resubmit" from "rejected-and-already-resubmitted". No new claim state is introduced.
|
||||
|
||||
For the 338 current claims: this runs once. Output filenames already reflect dzinesco's outbound SFTP naming scheme and are unique.
|
||||
|
||||
### 4.5 Doc updates (`docs(spec): SP33 update reference docs to CO_TXIX`)
|
||||
|
||||
Two files, two lines:
|
||||
|
||||
- `docs/reference/837p.md:48` — replace
|
||||
`Trading partner: 'SKCO0' (sender) ↔ 'COHCPF' (receiver) on 'NM1*PR' / 'NM1*40'`
|
||||
with
|
||||
`Trading partner: 'COMEDASSISTPROG' (receiver, NM1*40) and payer 'CO_TXIX' (NM1*PR, NM108=PI, NM109=CO_TXIX) — see docs/goodclaim.x12 for the canonical example.`
|
||||
|
||||
- `docs/reference/co-medicaid.md:118` — replace
|
||||
`'NM1*PR N104 = "SKCO0"' (COHCPF)`
|
||||
with
|
||||
`'NM1*PR NM109 = "CO_TXIX"' (CO_TXIX)` and add a sentence: `For behavioral-health claims only, use "CO_BHA"; dzinesco's current submission volumes don't include behavioral-only providers.`
|
||||
|
||||
`docs/goodclaim.x12` stays unchanged (already correct).
|
||||
|
||||
## 5. Failure modes
|
||||
|
||||
| Failure | Detection | Mitigation |
|
||||
|---|---|---|
|
||||
| Serializer change breaks 999+1 backwards compat | `pytest backend/tests/test_serialize_837.py` | Layered test: pre-fix `goodclaim.x12` parse still passes; post-fix parse still passes (the `payer.id` change is in NM109 only) |
|
||||
| Cascade fix changes behavior for already-accepted 999s | Unit test: feed 999 with `AK5=A`; assert no claim state changes | `apply_999_acceptances` is unchanged; only `apply_999_rejections` is touched |
|
||||
| Backfill races with live scheduler writing new claim_acks | Single-transaction SELECT-then-UPDATE; row-level retry on `database is locked` | Wrap the per-claim update in a short retry loop (3 attempts, 100ms backoff) |
|
||||
| Resubmit overwrites a not-yet-acknowledged file on SFTP | SFTP-side ETags / names | Use Tyler's pre-split filenames verbatim (already unique-by-timestamp) |
|
||||
| SKCO0 still in some legacy 837 in the SFTP outbound dir | Out of scope — dzinesco is no longer submitting new 837s against the buggy factory | None; historical files on SFTP aren't retransmitted |
|
||||
|
||||
## 6. Test impact
|
||||
|
||||
- `backend/tests/test_apply_999_rejections.py` — **new**. Covers:
|
||||
- SET-level `AK9=R` with a `batch_envelope_index` containing 3 claim_ids → all 3 transition to REJECTED, idempotent on rerun
|
||||
- SET-level `AK9=A` with empty envelope index → 0 matches (no false positives)
|
||||
- No `batch_envelope_index` passed → falls back to old behavior (passes SCN as PCN, returns no matches for non-PCN-keyed claims)
|
||||
- `backend/tests/test_serialize_837.py` — extend: assert `co_medicaid()` round-trips through `serialize_837 → parse_837` and the parsed `payer.id` equals `CO_TXIX`
|
||||
- `backend/tests/test_payer_config_loading.py` — confirm YAML still binds `CO_TXIX` and no in-code change breaks the bootstrap path
|
||||
- `backend/tests/test_inbox_state_apply_999.py` (if it exists) — adjust for the new optional arg
|
||||
|
||||
No frontend changes → no Vitest/RTL impact.
|
||||
|
||||
## 7. Branch / merge plan
|
||||
|
||||
- Branch: `sp33-co-txix-payer-fix`
|
||||
- Atomic merge commit. PR title: `SP33 Co TXIX payer fix + 999 cascade repair`
|
||||
- Commit prefix schedule:
|
||||
- `docs(spec): SP33 CO TXIX payer fix design`
|
||||
- `docs(plan): SP33 CO TXIX payer fix implementation`
|
||||
- `feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX`
|
||||
- `feat(sp33): apply_999_rejections uses batch_envelope_index`
|
||||
- `test(sp33): add test_apply_999_rejections with batch_envelope_index`
|
||||
- `feat(sp33): cli backfill-999-rejections`
|
||||
- `feat(sp33): cli resubmit-rejected-claims`
|
||||
- `docs(sp33): update docs/reference/{837p,co-medicaid}.md to CO_TXIX`
|
||||
- `merge: SP33 Co TXIX payer fix + 999 cascade repair into main`
|
||||
|
||||
## 8. Rollback
|
||||
|
||||
If the SP33 merge breaks the live 837 flow:
|
||||
|
||||
1. Revert the single merge commit (`git revert <merge-sha>` — produces a fresh commit, preserves history).
|
||||
2. The two doc edits revert cleanly (markdown only).
|
||||
3. The cascade-fix revert restores the SP28-era broken-but-not-dead behavior (claims stay SUBMITTED — same as today).
|
||||
4. The backfill is fully reversible: a sister CLI subcommand `python -m cyclone.cli reset-rejection-backfill [--batch-id <id>]` can un-REJECT the 338 claims (sets `state='SUBMITTED'`, clears `rejection_reason`, `rejected_at`, `payer_rejected_*`). Out of scope to ship in SP33, but the column data is fully captured in audit log so rollback is always possible.
|
||||
5. The resubmit cannot be undone (the corrected 837 files are on SFTP), but a `clearhouse` audit event is recorded for every upload so the operator can identify and re-trigger if needed.
|
||||
@@ -0,0 +1,100 @@
|
||||
# SP30 — Dashboard "Recent batches" widget: glance how the last few billed
|
||||
|
||||
> **Status:** Approved, 2026-07-02.
|
||||
> **Branch:** `sp30-recent-batches-widget`
|
||||
> **Spec → plan → implement → atomic merge into main.**
|
||||
|
||||
## Goal
|
||||
|
||||
The Dashboard already shows six-month KPIs (billed, denial rate, etc.) but the operator has no way to see **how the last few batches actually billed** without clicking through to `/batches`. Add a single Dashboard card that lists the 5 most recently parsed batches with one-line outcome per batch — so the operator can spot a batch that landed entirely rejected (or has a sudden spike in rejections) at a glance, the moment they log in.
|
||||
|
||||
## Scope
|
||||
|
||||
### Backend — 1 file modified (`backend/src/cyclone/api.py`)
|
||||
|
||||
**`list_batches` handler (`api.py:1769-1808`):**
|
||||
- Extend each returned item with billing-outcome fields, computed via a single batched SQL query per request (no N+1):
|
||||
- `acceptedCount` (int)
|
||||
- `rejectedCount` (int)
|
||||
- `pendingCount` (int)
|
||||
- `billedTotal` (float)
|
||||
- `topRejectionReason` (string|null, truncated to 60 chars)
|
||||
- `hasProblem` (bool — true when `rejectedCount > 0` or any 277CA A4/A6/A7 claim on the batch)
|
||||
- New helper `_batch_summary_billing_outcomes(records)` runs one GROUP BY query and one rejection-reason probe. Mirrors the existing `_batch_summary_claim_count` / `_batch_summary_claim_ids` helpers (`api.py:1740-1764`).
|
||||
|
||||
### Backend — 1 test file extended (`backend/tests/test_api_gets.py`)
|
||||
|
||||
**+2 tests after the existing `test_batches_*` block at lines 47-101:**
|
||||
- `test_batches_includes_billing_outcome` — seed one batch with 3 claims (1 paid $100, 1 rejected $50 reason="999 AK5 R", 1 submitted $75); `GET /api/batches`; assert `acceptedCount=1, rejectedCount=1, pendingCount=1, billedTotal=225.0, topRejectionReason="999 AK5 R", hasProblem=True`.
|
||||
- `test_batches_835_kind_returns_zero_billed_and_no_rejection` — seed one 835 batch; assert `acceptedCount=0, rejectedCount=0, billedTotal=0, topRejectionReason=null, hasProblem=False`.
|
||||
|
||||
Reuses the `seeded_store` fixture (line 27) and the direct ORM-insert pattern from `test_dashboard_kpis.py:36-91`.
|
||||
|
||||
### Frontend — 1 type extension + 1 new component + 1 layout wire-up
|
||||
|
||||
**`src/lib/api.ts:245-259`** — extend `BatchSummary` with 6 optional fields:
|
||||
```
|
||||
acceptedCount?, rejectedCount?, pendingCount?,
|
||||
billedTotal?, topRejectionReason?, hasProblem?
|
||||
```
|
||||
Optional preserves backward compat with existing tests in `Upload.history.test.tsx`, `Batches.test.tsx`, `BatchDiff.test.tsx`.
|
||||
|
||||
**`src/components/RecentBatchesWidget.tsx`** (NEW, ~120 LOC):
|
||||
- Card chrome mirroring the Dashboard "Recent activity" Card.
|
||||
- Header: `<Layers />` icon + "Recent batches" title + counter chip "{N} latest".
|
||||
- Empty state: "No batches yet." (matches ActivityFeed).
|
||||
- Row markup: status icon (CheckCircle2 / AlertTriangle) + filename + (kind · time · top rejection reason) + billed/accepted breakdown (or "N payments" for 835).
|
||||
- Click → `onRowClick(id)`; Enter key → same. Mirrors `Dashboard.tsx:300-307`.
|
||||
|
||||
**`src/pages/Dashboard.tsx`** — insert the widget as a full-width row BETWEEN the KPI tile row (line 245) and the "Activity + Top providers" grid (line 247):
|
||||
- `const recentBatchesQuery = useBatches(5);` — queryKey `["batches", 5]` (distinct from `["batches", null]` used by Upload).
|
||||
- `animationDelay: ${sectionBase + 200}ms` so it lands after the KPI stagger and before the Activity row.
|
||||
- onRowClick → `navigate('/batches?batch=' + id)`.
|
||||
|
||||
### Frontend — 1 test file added + 1 extended
|
||||
|
||||
**`src/components/RecentBatchesWidget.test.tsx`** (NEW, 3 tests):
|
||||
- empty state
|
||||
- 2-batch render (one clean, one with rejections) — verifies icon tint, billed total, accepted count, click + Enter key handler
|
||||
- 835 row renders "N payments" not $
|
||||
|
||||
**`src/pages/Dashboard.test.tsx`** (+1 test):
|
||||
- widget renders, row click navigates to `/batches?batch=ID`.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **D1 — widget shows 5 batches, not "today only" or "last 24h".** 5 is the natural at-a-glance cap. Hard-coded constant `RECENT_BATCHES_LIMIT = 5` in the page.
|
||||
- **D2 — backend returns billing outcome on EVERY `/api/batches` row, not a separate `/api/batches/summary` endpoint.** The query cost is bounded by the existing `limit` and is one extra GROUP BY — no N+1. Returning on the list endpoint means future surfaces (Batches page, Upload History) can use the same fields without another endpoint.
|
||||
- **D3 — `hasProblem` drives the visible status, not `rejectedCount > 0` directly.** 277CA `A4/A6/A7` payer-rejected claims also count as problems. Mirrors the Inbox `rejected + payer_rejected` aggregation.
|
||||
- **D4 — 835 (ERA) rows show "N payments" instead of a $ figure.** The `Remittance` table has no batch-level `total_charge` aggregate; payments count is the honest, glanceable signal.
|
||||
- **D5 — full-width row above the Activity grid, not a 2/3 + 1/3 split.** The widget is the operator's primary "how's it going?" affordance; full width reads as the headline.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Trend sparkline per batch (last N parse throughput).
|
||||
- Click-to-filter the widget itself by status.
|
||||
- Live-tail `batch_parsed` event wiring (no such event exists yet).
|
||||
- A "missing-ack-alarm" surfacing on claims SUBMITTED >24h with zero 999 acks (separate UX concern).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, lucide-react. Same as SP29.
|
||||
|
||||
## File footprint
|
||||
|
||||
**Estimated 7 files changed, ~270 LOC.**
|
||||
|
||||
- Backend modified (1): `backend/src/cyclone/api.py` (+~60 LOC).
|
||||
- Backend test extended (1): `backend/tests/test_api_gets.py` (+~70 LOC).
|
||||
- Frontend type extended (1): `src/lib/api.ts` (+~8 LOC).
|
||||
- Frontend component added (1): `src/components/RecentBatchesWidget.tsx` (NEW, ~120 LOC).
|
||||
- Frontend layout wire-up (1): `src/pages/Dashboard.tsx` (+~20 LOC).
|
||||
- Frontend test added (1): `src/components/RecentBatchesWidget.test.tsx` (NEW, ~150 LOC).
|
||||
- Frontend test extended (1): `src/pages/Dashboard.test.tsx` (+~40 LOC).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Aggregate query cost on `/api/batches`** — bounded by the existing `limit` (default 100, capped at 1000); one GROUP BY ≤60k rows is sub-50ms on SQLite.
|
||||
- **Backwards compat of `BatchSummary`** — new fields are optional, so every existing test that constructs `BatchSummary` continues to pass.
|
||||
- **`topRejectionReason` truncation** — capped at 60 chars in the backend; tested with one assertion on a long reason.
|
||||
- **Dashboard layout choreography** — widget uses `animationDelay: sectionBase + 200ms`; tested visually in the Playwright smoke test.
|
||||
@@ -0,0 +1,162 @@
|
||||
Status: Draft, awaiting user sign-off.
|
||||
|
||||
# SP32 — 835/837P rendering & service-provider NPI extraction
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- **Extract** `NM1*82` (rendering provider, Loop 2420A) from 837p files into a typed field on `Claim`.
|
||||
- **Extract** `NM1*1P` (service provider, Loop 2100) from 835 files into a typed field on `ClaimPayment` and an aggregated column on `Remittance`.
|
||||
- **Persist** all three new fields in their typed columns AND mirror to `raw_json` for audit and fallback reads.
|
||||
- **Backfill** existing rows by re-parsing on-disk files via a new CLI subcommand.
|
||||
- **Re-reconcile** open claim/remit pairs after backfill so the NPI arm of SP31's 2-of-3 content-match rule can actually fire on historical data.
|
||||
- **Widen** `_content_keys_match` in `reconcile.py` to read the new NPI sources (typed-column primary, `raw_json` fallback).
|
||||
|
||||
### Out of scope
|
||||
|
||||
- NPI Luhn validation or any other validation of format/length. Plain string, like the existing `Claim.provider_npi`.
|
||||
- UI surface — no changes to drawer/kpi/activity widgets. The new columns are exposed via the existing `raw_json` API paths; rendering them in the UI is a separate SP.
|
||||
- Multi-payer `payer_id` filtering — still deferred (SP31 documented deviation stands).
|
||||
- Spec-stricter lineage fields (CLP09 frequency, NM1*82 last-name vs organization-name semantics) beyond what the spec requires for matching.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Both sides extracted (apples-to-apples)
|
||||
|
||||
The 837p parser today only captures the **billing** provider NPI (`NM1*85`). The 835 parser today discards `NM1*1P` entirely. SP32 extracts both: 837p `NM1*82` → rendering NPI; 835 `NM1*1P` → service provider NPI. They are compared against each other in the matcher's NPI arm, not against `Claim.provider_npi` (billing).
|
||||
|
||||
### D2 — Per-segment typed field on `ClaimPayment`
|
||||
|
||||
Add `ClaimPayment.service_provider_npi: str | None` to `backend/src/cyclone/parsers/models_835.py`. Length-10 string (NPI is 10 digits; no Luhn enforced). Empty/missing stored as `None`, not `""`.
|
||||
|
||||
### D3 — Per-Claim typed column on `Claim` (837p)
|
||||
|
||||
Add `Claim.rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)` to `backend/src/cyclone/db.py`. Mirrors the existing `Claim.provider_npi` column. Schema migration `0016_add_rendering_and_service_provider_npis.sql` adds both this and the next column.
|
||||
|
||||
### D4 — Per-Remit column for the matcher
|
||||
|
||||
Add `Remittance.rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)`. Today, each `Remittance` row corresponds to exactly one `ClaimPayment` (the write layer keys Remittance by `cp.payer_claim_control_number`; see `store/write.py:134`), so the column holds the single NPI for that segment (or `None`). The column is left unaggregated to keep the schema simple — if multi-CLP-per-Remit support is added in a future SP, the schema is large enough (`String(16)`) to upgrade to comma-joined storage without re-migration. The matcher's NPI arm reads this column directly.
|
||||
|
||||
### D5 — `raw_json` mirrors
|
||||
|
||||
`ClaimPayment` is a parser wire-format model only — there is no `claim_payments` SQL table. The typed per-segment NPI persists through these mirrors:
|
||||
|
||||
- `Remittance.raw_json["service_provider_npis"]`: list of every `ClaimPayment.service_provider_npi`, written verbatim at parse time. Source of truth for audit.
|
||||
- `Claim.raw_json["rendering_provider_npi"]`: mirror of the typed column.
|
||||
|
||||
These keep the existing `raw_json` fallback chain in `_content_keys_match` functional and provide a structured audit trail.
|
||||
|
||||
### D6 — `_content_keys_match` NPI arm
|
||||
|
||||
Extend the existing matcher's NPI branch (currently a raw_json fallback chain) with a typed-column primary path:
|
||||
|
||||
1. Read `Claim.rendering_provider_npi` (typed) and `Remittance.rendering_provider_npi` (typed).
|
||||
2. Split `Remittance.rendering_provider_npi` on `","` and treat as a set (single-CLP produces a one-element set; multi-CLP produces N elements).
|
||||
3. If the claim's rendering NPI is present in that set, count `rendering_provider_npi` as a matched key.
|
||||
4. Fall back to the `raw_json` chain only when the typed columns are `None` or empty.
|
||||
|
||||
Effective algorithm unchanged: 2-of-3 `{PCN, charge, NPI}` still fires when any two keys agree. The NPI arm now actually contributes when the data is present.
|
||||
|
||||
### D7 — Backfill via CLI
|
||||
|
||||
New subcommand: `python -m cyclone.cli backfill-rendering-npi`.
|
||||
|
||||
**Why explicit file args.** The `Batch` ORM does not retain a column for the original on-disk ingest path — the live ingest pipeline reads the upload body, parses, writes rows, and never persists the bytes. So there is no `claim_batches.inbound_path` / `remittances.inbound_path` to "look up" and re-parse from. The operator must point the CLI at the files (or the directory that holds them) that need backfill. Re-running ingest end-to-end would be the alternative, but it would re-`INSERT` claim rows and is a destructive operation; backfill is intentionally a read-reparse-targeted-`UPDATE`.
|
||||
|
||||
Flags:
|
||||
- `--file PATH` (repeatable) — one or more specific X12 files to re-parse. Each path is validated to exist before parsing.
|
||||
- `--input-dir DIR` — directory to scan one level deep for `*.txt` / `*.edi` / `*.x12` files. Falls back to `$CYCLONE_BACKFILL_INPUT_DIR` if neither `--file` nor `--input-dir` is passed.
|
||||
- `--type {837p,835}` (optional) — pin the parser. **When omitted, each file's transaction kind is auto-sniffed** (filename hint + ISA/ST prefix) so a mixed directory can be processed in one pass.
|
||||
|
||||
Behavior:
|
||||
1. For each file resolved by `--file` / `--input-dir`, re-parse with the new parser (T4 wiring).
|
||||
2. **837p**: for every claim in the batch, write `Claim.rendering_provider_npi = parsed_value` **only when the column is currently `NULL`** (idempotent — already-populated rows are left alone).
|
||||
3. **835**: for every remit, write `Remittance.rendering_provider_npi` (typed column) **only when `NULL`**, and persist the per-segment `service_provider_npi` values into `Remittance.raw_json["service_provider_npis"]` (D5).
|
||||
4. After all files are processed, run `reconcile.run()` **once across every 835 Batch** so the D6 NPI arm can fire retroactively on newly-populated pairs.
|
||||
5. Log per-file counts (parsed, updated, skipped). Emit one-line summary to stdout: `claims_updated=N remits_updated=M files_processed=K files_skipped=J`.
|
||||
6. Exit code 0 on success (zero populated rows is still exit 0), 2 on file-level failure (no file processed), 1 on unexpected exception.
|
||||
|
||||
Idempotent: re-running on a fully-populated DB is a no-op (typed columns not overwritten; reconcile skips already-matched pairs via `Claim.matched_remittance_id IS NOT NULL`).
|
||||
|
||||
### D8 — Backfill event emission
|
||||
|
||||
Each new `score-auto` match produced during post-backfill reconcile emits an `auto_matched_835` ActivityEvent with payload `{new_state, strategy, keys_matched, candidate_count}` — same shape as SP31 D8. The audit trail explicitly captures backfill work; nothing is silent.
|
||||
|
||||
### D9 — Migration is forward-only, additive
|
||||
|
||||
Migration `0019_add_rendering_and_service_provider_npis.sql` adds:
|
||||
|
||||
```sql
|
||||
ALTER TABLE claims ADD COLUMN rendering_provider_npi TEXT;
|
||||
ALTER TABLE remittances ADD COLUMN rendering_provider_npi TEXT;
|
||||
```
|
||||
|
||||
`ClaimPayment.service_provider_npi` has no SQL column because `claim_payments` has no SQL table — the typed value lives in `Remittance.raw_json["service_provider_npis"]`.
|
||||
|
||||
No `UPDATE` statements in the migration — backfill is a separate, deliberate step via the CLI. No indexes — both columns are nullable, used for set-equality at match time, not range queries. Scale (Colorado Medicaid single-payer) doesn't justify index write cost.
|
||||
|
||||
### D10 — File paths and code shape
|
||||
|
||||
- `backend/src/cyclone/parsers/parse_835.py` — extend `_consume_claim_payment` to handle the `NM1` child segment: parse `NM1*1P`, capture `NM109` as `service_provider_npi` for the enclosing CLP. Other `NM1` qualifiers (`QC`, `IL`, `PR`, `85`, `87`) within the CLP loop are kept in `raw_segments` (unchanged behavior).
|
||||
- `backend/src/cyclone/parsers/parse_837.py` — add a new `_consume_rendering_provider` (or analogous) function for Loop 2420A; wire into the existing claim assembly path so the rendering NPI lands on `Claim`.
|
||||
- `backend/src/cyclone/parsers/models_835.py` — extend `ClaimPayment` with `service_provider_npi`.
|
||||
- `backend/src/cyclone/db.py` — `Claim.rendering_provider_npi`, `Remittance.rendering_provider_npi`. No claim_payments ORM — service_provider_npi persists only via `Remittance.raw_json["service_provider_npis"]` (D5).
|
||||
- `backend/src/cyclone/writer.py` / `writer_835.py` — set the new typed columns and the `raw_json` mirrors at write time. `writer_835.py` writes the per-CLP `service_provider_npi` into `Remittance.rendering_provider_npi` (D4, single value) and into `raw_json["service_provider_npis"]` (D5).
|
||||
- `backend/src/cyclone/reconcile.py` — extend `_content_keys_match` (D6).
|
||||
- `backend/src/cyclone/store/backfill.py` — **new helper module** owning `backfill_rendering_provider_npi(files, input_dir, transaction_type)`: file/CLI argument resolution, auto-sniffing (ISA/ST + filename hint), re-parse dispatch, idempotent typed-column writes, and the post-backfill `reconcile.run()` sweep. Returns a small summary dataclass for the CLI summary line.
|
||||
- `backend/src/cyclone/cli.py` — new `backfill-rendering-npi` subcommand (Click). Wires `--file` / `--input-dir` / `--type` / `--log-level`, delegates the heavy lifting to `store.backfill.backfill_rendering_provider_npi`, and prints the one-line summary.
|
||||
- `backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql` — D9.
|
||||
- `backend/tests/fixtures/` — new 837p fixture(s) with `NM1*82` and new 835 fixture(s) with `NM1*1P`.
|
||||
- `backend/tests/test_reconcile.py` — new tests for D6 (typed-column primary path, typed-column beats raw_json fallback, NPI arm fires when both sides populated).
|
||||
- `backend/tests/test_parse_835.py` — new test for `_consume_claim_payment` `NM1*1P` extraction.
|
||||
- `backend/tests/test_parse_837.py` — new test for rendering-provider loop extraction.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| `NM1*1P` missing or `1P` qualifier absent in a CLP segment | `ClaimPayment.service_provider_npi = None`. Matcher's NPI arm simply doesn't fire for that key. |
|
||||
| `NM1*82` missing or empty in an 837p claim | `Claim.rendering_provider_npi = None`. Same graceful degrade. |
|
||||
| `NM1*1P` with `NM109` empty | Treat as missing — store `None`, never empty string. |
|
||||
| Multi-CLP per Remit in a future SP | Schema-size forward note (D4). Today, each Remit = one CLP = single NPI, no aggregation needed. |
|
||||
| 837p rendering NPI == billing NPI (solo practice) | Both columns populated independently. Matcher compares against the service-provider NPI; identity is coincidence in this case. |
|
||||
| Backfill on a deleted-on-disk file | CLI logs a WARNING, skips the batch. Typed columns stay NULL for unmatched rows. Existing match state untouched. |
|
||||
| Re-reconcile creates new `score-auto` matches | Each emits an `auto_matched_835` ActivityEvent per SP31 D8 (D8). Audit trail is explicit. |
|
||||
| Idempotent re-run | Typed columns written only when currently NULL; reconcile skips already-matched pairs. `backfill-rendering-npi` exit code 0. |
|
||||
| Existing 115 SP31 tests | All pass; widened matcher signature preserves the boolean-return path used today. |
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (parser-level)
|
||||
|
||||
- `test_parse_835.py`: `NM1*1P` extraction — claim with `NM1*1P*2*...*XX*1234567890` populates `ClaimPayment.service_provider_npi == "1234567890"`. Multi-CLP with mixed NPIs produces per-segment values correctly.
|
||||
- `test_parse_835.py`: `NM1*1P` absent — `service_provider_npi is None`, no exception.
|
||||
- `test_parse_837.py`: `NM1*82` extraction — Loop 2420A rendering provider populates `Claim.rendering_provider_npi`.
|
||||
- `test_parse_837.py`: 837p without `NM1*82` — column `None`.
|
||||
|
||||
### Unit tests (matcher level)
|
||||
|
||||
- `test_reconcile.py`: typed-column primary path — `Remittance.rendering_provider_npi = "1234567890"` and `Claim.rendering_provider_npi = "1234567890"` counts NPI as a matched key.
|
||||
- `test_reconcile.py`: multi-key fallback — `NM1*1P` NPI absent (`None`) on one side, present on the other → NPI arm does not fire, PCN+charge still matches (1-of-2 effective, but per SP31 the rule is 2-of-3 of {PCN, charge, NPI}, so PCN+charge alone does NOT auto-link — must stay 2 of 3 with NPI also matching or charge differing).
|
||||
- `test_reconcile.py`: typed-column primary path beats `raw_json` fallback — fixture sets both, matcher uses typed column.
|
||||
- `test_reconcile.py`: existing 22+ SP31 tests still pass without modification.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- `test_backfill_cli.py`: synthetic batch + remit in DB, fixture files on disk, run `backfill-rendering-npi` — assert columns populated, exit code 0, ActivityEvents emitted for any new matches.
|
||||
- `test_backfill_idempotent.py`: run twice, second run is no-op.
|
||||
- `test_backfill_missing_file.py`: deleted file path → WARNING log, no crash, other batches still processed.
|
||||
|
||||
### Regression
|
||||
|
||||
- Full backend `pytest` (target: 115 existing + ~8 new = ~123 total, all passing).
|
||||
- Frontend `typecheck`: no new errors (no new types; matcher's typed return is internal).
|
||||
- Manual smoke: ingest a real 835 via CLI, query the DB, confirm `Remittance.rendering_provider_npi` populated and any retroactive `score-auto` Match row exists.
|
||||
|
||||
## Out of scope (deferred to follow-up SPs)
|
||||
|
||||
- **NPI Luhn validation** — separately enforced if/when claim submission rejects malformed NPIs upstream.
|
||||
- **UI rendering** of rendering/service-provider NPI in ClaimDrawer / RemitDrawer — separate SP.
|
||||
- **Multi-payer `payer_id` filter restoration** — SP31 documented deviation; needs schema migration or raw_json read.
|
||||
- **Per-segment NPI display in RemitDrawer's CLP rows** — separate SP.
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { RecentBatchesWidget } from "./RecentBatchesWidget";
|
||||
import type { BatchSummary } from "@/lib/api";
|
||||
|
||||
function makeBatch(over: Partial<BatchSummary> = {}): BatchSummary {
|
||||
return {
|
||||
id: "b-1",
|
||||
kind: "837p",
|
||||
inputFilename: "2026-07-02-morning.edi",
|
||||
parsedAt: "2026-07-02T15:00:00Z",
|
||||
claimCount: 5,
|
||||
claimIds: ["C-1", "C-2"],
|
||||
acceptedCount: 5,
|
||||
rejectedCount: 0,
|
||||
pendingCount: 0,
|
||||
billedTotal: 425.5,
|
||||
topRejectionReason: null,
|
||||
hasProblem: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RecentBatchesWidget", () => {
|
||||
it("renders the empty-state copy when batches is empty", () => {
|
||||
const { container } = render(
|
||||
<RecentBatchesWidget batches={[]} onRowClick={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("No batches yet.");
|
||||
// No interactive rows when there's nothing to show.
|
||||
expect(container.querySelectorAll('[role="button"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders one row per batch with status icon, billed total, accepted/total count, top rejection reason", () => {
|
||||
const batches: BatchSummary[] = [
|
||||
makeBatch({
|
||||
id: "b-clean",
|
||||
inputFilename: "clean.edi",
|
||||
acceptedCount: 4,
|
||||
rejectedCount: 0,
|
||||
pendingCount: 1,
|
||||
billedTotal: 425.5,
|
||||
hasProblem: false,
|
||||
topRejectionReason: null,
|
||||
}),
|
||||
makeBatch({
|
||||
id: "b-bad",
|
||||
inputFilename: "rejected-batch.edi",
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 3,
|
||||
pendingCount: 1,
|
||||
billedTotal: 890.25,
|
||||
hasProblem: true,
|
||||
topRejectionReason: "999 AK5 R: envelope reject",
|
||||
}),
|
||||
];
|
||||
const onRowClick = vi.fn();
|
||||
const { container } = render(
|
||||
<RecentBatchesWidget batches={batches} onRowClick={onRowClick} />,
|
||||
);
|
||||
|
||||
// Both filenames present.
|
||||
expect(container.textContent).toContain("clean.edi");
|
||||
expect(container.textContent).toContain("rejected-batch.edi");
|
||||
|
||||
// Billed total rendered for the 837p rows via fmt.usd (whole
|
||||
// dollars per the formatter's maximumFractionDigits: 0 setting
|
||||
// — the Dashboard's KPI tiles use the same convention).
|
||||
expect(container.textContent).toContain("$426");
|
||||
expect(container.textContent).toContain("$890");
|
||||
|
||||
// accepted/total counts (4/5 accepted on the clean row, 1/5 on the bad).
|
||||
expect(container.textContent).toContain("4/5 accepted");
|
||||
expect(container.textContent).toContain("1/5 accepted");
|
||||
|
||||
// Top rejection reason appears on the bad batch's row.
|
||||
const rejSpans = container.querySelectorAll(
|
||||
'[data-testid="recent-batch-rejection"]',
|
||||
);
|
||||
expect(rejSpans.length).toBe(1);
|
||||
expect(rejSpans[0].textContent).toBe("999 AK5 R: envelope reject");
|
||||
// The rejection span carries the destructive-color tint.
|
||||
const rejColor = (rejSpans[0] as HTMLElement).style.color;
|
||||
expect(rejColor).toMatch(/destructive|229|72|77/i);
|
||||
|
||||
// Click handler fires with the batch id.
|
||||
const cleanRow = container.querySelector(
|
||||
'[data-testid="recent-batch-row-b-clean"]',
|
||||
) as HTMLElement;
|
||||
expect(cleanRow).toBeTruthy();
|
||||
fireEvent.click(cleanRow);
|
||||
expect(onRowClick).toHaveBeenCalledWith("b-clean");
|
||||
|
||||
// Enter key on the same row also fires onRowClick (a11y contract).
|
||||
onRowClick.mockClear();
|
||||
fireEvent.keyDown(cleanRow, { key: "Enter" });
|
||||
expect(onRowClick).toHaveBeenCalledWith("b-clean");
|
||||
});
|
||||
|
||||
it("renders 835 rows with payments count instead of a dollar figure", () => {
|
||||
// ERAs (835) don't carry a billed total — the Remittance table
|
||||
// has total_paid but no batch-level total_charge aggregate. The
|
||||
// widget shows the payment count and the "payments" label, not $.
|
||||
const batches: BatchSummary[] = [
|
||||
makeBatch({
|
||||
id: "b-era",
|
||||
kind: "835",
|
||||
inputFilename: "remit.835",
|
||||
claimCount: 12,
|
||||
acceptedCount: 0,
|
||||
rejectedCount: 0,
|
||||
pendingCount: 0,
|
||||
billedTotal: 0,
|
||||
hasProblem: false,
|
||||
topRejectionReason: null,
|
||||
}),
|
||||
];
|
||||
const { container } = render(
|
||||
<RecentBatchesWidget batches={batches} onRowClick={vi.fn()} />,
|
||||
);
|
||||
// Payments count visible.
|
||||
expect(container.textContent).toContain("12");
|
||||
// Label "payments" present.
|
||||
expect(container.textContent).toContain("payments");
|
||||
// No dollar figure for the ERA row — no "$" anywhere on the row.
|
||||
const row = container.querySelector(
|
||||
'[data-testid="recent-batch-row-b-era"]',
|
||||
) as HTMLElement;
|
||||
expect(row.textContent).not.toContain("$");
|
||||
// And the billed span is absent for ERA rows.
|
||||
expect(container.querySelectorAll('[data-testid="recent-batch-billed"]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP30: "Recent batches" Dashboard widget.
|
||||
//
|
||||
// One row per batch the operator parsed recently. Each row tells the
|
||||
// operator two things at a glance:
|
||||
// 1. Was this batch clean or did it have a problem? (icon tint +
|
||||
// `topRejectionReason` line under the filename when present.)
|
||||
// 2. What was the billed-out outcome? ($ for 837P; payment count for
|
||||
// 835 ERA batches — the Remittance table has no batch-level
|
||||
// `total_charge` aggregate.)
|
||||
//
|
||||
// Click → onRowClick(id) so the page can navigate to /batches?batch=ID
|
||||
// (the BatchDrawer deep-link pattern from pages/Batches.tsx).
|
||||
//
|
||||
// Reuses the Dashboard "Recent activity" card chrome verbatim (Card +
|
||||
// CardHeader pb-3 + CardTitle text-[14px] + CardContent pt-0) so the
|
||||
// widget reads as part of the same family.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { Layers, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { fmt } from "@/lib/format";
|
||||
import type { BatchSummary } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
batches: BatchSummary[];
|
||||
onRowClick: (batchId: string) => void;
|
||||
/** Cap shown in the header subtitle. Default 5. */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export function RecentBatchesWidget({ batches, onRowClick, limit = 5 }: Props) {
|
||||
return (
|
||||
<Card data-testid="recent-batches-widget">
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-[14px]">
|
||||
<Layers className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
|
||||
Recent batches
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
How the last {limit} batches billed out — sorted newest first.
|
||||
</p>
|
||||
</div>
|
||||
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{batches.length} latest
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{batches.length === 0 ? (
|
||||
<div
|
||||
className="text-[13px] text-muted-foreground px-2 py-6 text-center"
|
||||
data-testid="recent-batches-empty"
|
||||
>
|
||||
No batches yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/40">
|
||||
{batches.map((b) => {
|
||||
const rejected = b.rejectedCount ?? 0;
|
||||
const clean = !b.hasProblem && rejected === 0;
|
||||
const tint = clean
|
||||
? "hsl(var(--success))"
|
||||
: "hsl(var(--destructive))";
|
||||
const Icon = clean ? CheckCircle2 : AlertTriangle;
|
||||
const ariaLabel = clean
|
||||
? `Clean batch ${b.inputFilename || b.id}`
|
||||
: `Batch with rejections ${b.inputFilename || b.id}`;
|
||||
return (
|
||||
<BatchRow
|
||||
key={b.id}
|
||||
batch={b}
|
||||
tint={tint}
|
||||
Icon={Icon}
|
||||
ariaLabel={ariaLabel}
|
||||
onActivate={() => onRowClick(b.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BatchRow — one row in the widget. Kept as a separate component so the
|
||||
// parent can stay readable; matches the clickable-row + keyboard
|
||||
// accessibility pattern from Dashboard.tsx:300-307 and the divide-y
|
||||
// row markup from ActivityFeed.tsx:124-140.
|
||||
// ---------------------------------------------------------------------------
|
||||
type BatchRowProps = {
|
||||
batch: BatchSummary;
|
||||
tint: string;
|
||||
Icon: typeof CheckCircle2;
|
||||
ariaLabel: string;
|
||||
onActivate: () => void;
|
||||
};
|
||||
|
||||
function BatchRow({ batch, tint, Icon, ariaLabel, onActivate }: BatchRowProps) {
|
||||
function onKeyDown(e: KeyboardEvent<HTMLLIElement>) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onActivate();
|
||||
}
|
||||
}
|
||||
const isErn = batch.kind === "835";
|
||||
const accepted = batch.acceptedCount ?? 0;
|
||||
const billed = batch.billedTotal ?? 0;
|
||||
return (
|
||||
<li
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={`recent-batch-row-${batch.id}`}
|
||||
onClick={onActivate}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
"drillable flex items-center gap-3 py-3 cursor-pointer",
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="mt-0.5 h-7 w-7 shrink-0 rounded-md ring-1 ring-inset ring-border/40 flex items-center justify-center"
|
||||
style={{ color: tint }}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-foreground/95 truncate">
|
||||
{batch.inputFilename || batch.id}
|
||||
</div>
|
||||
<div className="mono text-[10.5px] text-muted-foreground mt-0.5 flex items-center gap-2 min-w-0">
|
||||
<span className="shrink-0">{batch.kind.toUpperCase()}</span>
|
||||
<span aria-hidden className="shrink-0">·</span>
|
||||
<span className="shrink-0">{fmt.relative(batch.parsedAt)}</span>
|
||||
{batch.topRejectionReason && (
|
||||
<>
|
||||
<span aria-hidden className="shrink-0">·</span>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ color: "hsl(var(--destructive))" }}
|
||||
data-testid="recent-batch-rejection"
|
||||
>
|
||||
{batch.topRejectionReason}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{isErn ? (
|
||||
<>
|
||||
<div className="display mono text-[15px] tabular-nums text-foreground">
|
||||
{fmt.num(batch.claimCount)}
|
||||
</div>
|
||||
<div className="mono text-[10.5px] text-muted-foreground">payments</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="display mono text-[15px] tabular-nums text-foreground"
|
||||
data-testid="recent-batch-billed"
|
||||
>
|
||||
{fmt.usd(billed)}
|
||||
</div>
|
||||
<div className="mono text-[10.5px] text-muted-foreground">
|
||||
{fmt.num(accepted)}/{fmt.num(batch.claimCount)} accepted
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -87,4 +87,115 @@ describe("InboxRow", () => {
|
||||
);
|
||||
expect(container.textContent).toContain("92");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// SP29: per-AK2 chip evidence + per-row Resubmit gesture for the
|
||||
// rejected-lane drill. The page renders 999 AK2 set-response codes
|
||||
// inline on the row so the operator can spot "this batch had R/E/X
|
||||
// rejects" without opening the drawer. The per-row Resubmit button
|
||||
// downloads a single corrected 837.
|
||||
// ---------------------------------------------------------------------
|
||||
it("SP29: rejected row renders inline 999 ack evidence chips + Resubmit button", () => {
|
||||
// 4 linked 999 AK2 set-responses — 3 will render as chips, the
|
||||
// 4th triggers the "+N more" overflow indicator.
|
||||
const rejectedRow: InboxClaimRow = {
|
||||
...baseClaim,
|
||||
id: "REJ1",
|
||||
claim_acks: {
|
||||
total: 4,
|
||||
rejected: 2,
|
||||
items: [
|
||||
{
|
||||
ack_id: 11,
|
||||
set_control_number: "991102989",
|
||||
set_accept_reject_code: "A",
|
||||
ak2_index: 0,
|
||||
linked_at: "2026-07-02T10:03:00Z",
|
||||
},
|
||||
{
|
||||
ack_id: 12,
|
||||
set_control_number: "991102989",
|
||||
set_accept_reject_code: "R",
|
||||
ak2_index: 1,
|
||||
linked_at: "2026-07-02T10:02:00Z",
|
||||
},
|
||||
{
|
||||
ack_id: 13,
|
||||
set_control_number: "991102989",
|
||||
set_accept_reject_code: "E",
|
||||
ak2_index: 2,
|
||||
linked_at: "2026-07-02T10:01:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const onResubmitOne = vi.fn();
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InboxRow
|
||||
row={rejectedRow}
|
||||
accent="oxblood"
|
||||
onClick={() => {}}
|
||||
onResubmitOne={onResubmitOne}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
// Chip visible — the operator can read at a glance that the most
|
||||
// recent AK2 from this batch was an Accept, then a Reject, then
|
||||
// an Error. The "999 · A" / "999 · R" / "999 · E" text comes from
|
||||
// <AckCodeChip> rendering "<stcn>·<code>".
|
||||
expect(container.textContent).toContain("991102989·A");
|
||||
expect(container.textContent).toContain("991102989·R");
|
||||
expect(container.textContent).toContain("991102989·E");
|
||||
|
||||
// Overflow "+1 more" — total=4 but only 3 chips fit.
|
||||
expect(container.textContent).toContain("+1 more");
|
||||
|
||||
// Resubmit button rendered with the per-row testid.
|
||||
const btn = screen.getByTestId("resubmit-REJ1") as HTMLButtonElement;
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn.textContent).toBe("Resubmit");
|
||||
|
||||
// Click fires the per-row handler with the claim id; click does
|
||||
// NOT bubble to onClick (the row-onClick is for drilldown, the
|
||||
// button is a separate download gesture).
|
||||
fireEvent.click(btn);
|
||||
expect(onResubmitOne).toHaveBeenCalledTimes(1);
|
||||
expect(onResubmitOne).toHaveBeenCalledWith("REJ1");
|
||||
});
|
||||
|
||||
it("SP29: rejected row with no linked 999 acks shows the '999 not linked' orphan marker", () => {
|
||||
// A rejected claim whose 999 ack didn't match any ST02 in the
|
||||
// batch — the operator needs to know the drill lacked ack
|
||||
// evidence. The "999 not linked" italic line is the marker.
|
||||
const rejectedRow: InboxClaimRow = {
|
||||
...baseClaim,
|
||||
id: "REJ-ORPH",
|
||||
claim_acks: {
|
||||
total: 0,
|
||||
rejected: 0,
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
const onResubmitOne = vi.fn();
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InboxRow
|
||||
row={rejectedRow}
|
||||
accent="oxblood"
|
||||
onClick={() => {}}
|
||||
onResubmitOne={onResubmitOne}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(container.textContent).toContain("999 not linked");
|
||||
// The Resubmit button still renders so the operator can fix the
|
||||
// claim without first manually linking the orphan.
|
||||
expect(screen.getByTestId("resubmit-REJ-ORPH")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ const ACCENT_VAR: Record<Accent, string> = {
|
||||
muted: "var(--tt-muted)",
|
||||
};
|
||||
|
||||
const REJECT_CODES = new Set(["R", "E", "X"]);
|
||||
|
||||
function fmtMoney(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
return n.toLocaleString("en-US", { style: "currency", currency: "USD" });
|
||||
@@ -55,12 +57,54 @@ function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
type Props =
|
||||
| { row: InboxClaimRow; accent: Accent; onClick: () => void }
|
||||
| { row: InboxCandidateRow; accent: Accent; onClick: () => void };
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP29: per-AK2 chip (compact evidence row for the rejected-lane drill)
|
||||
// ---------------------------------------------------------------------------
|
||||
function AckCodeChip({
|
||||
setControlNumber,
|
||||
code,
|
||||
}: {
|
||||
setControlNumber: string;
|
||||
code: string;
|
||||
}) {
|
||||
const isReject = REJECT_CODES.has(code);
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 mono tabular-nums"
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
letterSpacing: "0.04em",
|
||||
color: isReject ? "var(--tt-oxblood)" : "var(--tt-muted)",
|
||||
backgroundColor: isReject
|
||||
? "rgba(229, 72, 77, 0.08)"
|
||||
: "rgba(220, 220, 230, 0.06)",
|
||||
borderColor: isReject
|
||||
? "rgba(229, 72, 77, 0.28)"
|
||||
: "rgba(220, 220, 230, 0.20)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title={`ST02 ${setControlNumber} · AK5 ${code}${isReject ? " (rejected)" : " (accepted)"}`}
|
||||
>
|
||||
<span style={{ opacity: 0.7 }}>{setControlNumber}</span>
|
||||
<span>·</span>
|
||||
<span style={{ fontWeight: 700 }}>{code || "—"}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxRow({ row, accent, onClick }: Props) {
|
||||
type Props = {
|
||||
row: InboxClaimRow | InboxCandidateRow;
|
||||
accent: Accent;
|
||||
onClick: () => void;
|
||||
/** SP29: per-row Resubmit gesture. Only meaningful for rejected
|
||||
* claim rows; the component no-ops on remit rows or other states. */
|
||||
onResubmitOne?: (claimId: string) => void;
|
||||
};
|
||||
|
||||
export function InboxRow({ row, accent, onClick, onResubmitOne }: Props) {
|
||||
const isCandidate = row.kind === "remit";
|
||||
const isRejected =
|
||||
!isCandidate && (row as InboxClaimRow).state === "rejected";
|
||||
const id = isCandidate ? (row as InboxCandidateRow).payer_claim_control_number : row.id;
|
||||
const payer = (isCandidate ? (row as InboxCandidateRow).payer_id : (row as InboxClaimRow).payer_id) ?? "";
|
||||
const charge = fmtMoney(row.charge_amount);
|
||||
@@ -68,6 +112,7 @@ export function InboxRow({ row, accent, onClick }: Props) {
|
||||
const rejectedAt = !isCandidate ? (row as InboxClaimRow).rejected_at : null;
|
||||
const topScore = isCandidate && row.candidates.length > 0 ? row.candidates[0].score : null;
|
||||
const filled = isCandidate && row.candidates.length > 0 ? row.candidates[0].breakdown : null;
|
||||
const claimAcks = !isCandidate ? (row as InboxClaimRow).claim_acks : null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -93,7 +138,47 @@ export function InboxRow({ row, accent, onClick }: Props) {
|
||||
className="py-2.5 mono"
|
||||
style={{ color: reason ? "var(--tt-oxblood)" : "var(--tt-amber)", fontSize: 11 }}
|
||||
>
|
||||
{reason ?? (topScore != null ? `score ${topScore}` : "")}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{reason ?? (topScore != null ? `score ${topScore}` : "")}</span>
|
||||
{/* SP29: rejected-lane AK2 chip evidence (newest 3 + overflow). */}
|
||||
{isRejected && claimAcks && claimAcks.items.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
{claimAcks.items.slice(0, 3).map((it) => (
|
||||
<AckCodeChip
|
||||
key={`${it.ack_id}-${it.ak2_index}`}
|
||||
setControlNumber={it.set_control_number}
|
||||
code={it.set_accept_reject_code}
|
||||
/>
|
||||
))}
|
||||
{claimAcks.total > 3 && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-sm border px-1.5 py-0.5 mono tabular-nums"
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--tt-muted)",
|
||||
borderColor: "rgba(220, 220, 230, 0.20)",
|
||||
backgroundColor: "rgba(220, 220, 230, 0.04)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title={`${claimAcks.total - 3} more AK2 set-response(s) on the ClaimDrawer`}
|
||||
>
|
||||
+{claimAcks.total - 3} more
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* SP29: rejected claim but no linked 999 acks (orphan). */}
|
||||
{isRejected && (!claimAcks || claimAcks.items.length === 0) && (
|
||||
<span
|
||||
className="mono italic"
|
||||
style={{ fontSize: 10, color: "var(--tt-muted)", opacity: 0.75 }}
|
||||
title="No 999 AK2 set-responses are linked to this claim. The set_control_number on the 999 ack had no matching ST02 batch. Use the Inbox ack-orphans lane to manually match."
|
||||
>
|
||||
999 not linked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="py-2.5 mono tabular-nums"
|
||||
@@ -116,6 +201,35 @@ export function InboxRow({ row, accent, onClick }: Props) {
|
||||
<td className="py-2.5 pr-3">
|
||||
<Sparkline breakdown={filled} />
|
||||
</td>
|
||||
{/* SP29: per-row Resubmit button (rejected-lane only). */}
|
||||
{isRejected && onResubmitOne ? (
|
||||
<td
|
||||
className="py-2.5 pr-3 text-right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="mono uppercase tracking-[0.12em] px-2 py-1 rounded-sm transition-colors hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
border: "1px solid rgba(229, 154, 50, 0.40)",
|
||||
backgroundColor: "transparent",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
data-testid={`resubmit-${id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResubmitOne(id);
|
||||
}}
|
||||
title="Download the corrected 837 for this single claim"
|
||||
>
|
||||
Resubmit
|
||||
</button>
|
||||
</td>
|
||||
) : (
|
||||
<td className="py-2.5 pr-3" aria-hidden />
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,12 +75,16 @@ export function Lane({
|
||||
rows,
|
||||
onRowClick,
|
||||
onSelectionChange,
|
||||
onResubmitOne,
|
||||
}: {
|
||||
name: string;
|
||||
accent: Accent;
|
||||
rows: LaneRow[];
|
||||
onRowClick: (row: LaneRow) => void;
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
/** SP29: per-row Resubmit gesture for the rejected-lane drill.
|
||||
* Only claim rows render the button; remit rows ignore it. */
|
||||
onResubmitOne?: (claimId: string) => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -188,7 +192,17 @@ export function Lane({
|
||||
onToggle={() => toggle(id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<InboxRow row={row} accent={accent} onClick={() => onRowClick(row)} />
|
||||
<InboxRow
|
||||
row={row}
|
||||
accent={accent}
|
||||
onClick={() => onRowClick(row)}
|
||||
// SP29: only meaningful on claim rows. Remit rows
|
||||
// narrow the union to the `onResubmitOne?: never`
|
||||
// branch so this is a no-op for them.
|
||||
{...(row.kind === "claim" && onResubmitOne
|
||||
? { onResubmitOne }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -256,6 +256,25 @@ export interface BatchSummary {
|
||||
* instead of an extra round-trip to `/api/batches/{id}`.
|
||||
*/
|
||||
claimIds: string[];
|
||||
/**
|
||||
* SP30: billing-outcome rollup computed server-side via one
|
||||
* batched SQL aggregate (no N+1) so the Dashboard "Recent batches"
|
||||
* widget can render one row per batch without a follow-up
|
||||
* `/api/batches/{id}` call. All optional — pre-SP30 fixtures and
|
||||
* tests that construct BatchSummary locally continue to work.
|
||||
*/
|
||||
/** # claims whose state is paid / received / partial / reconciled. */
|
||||
acceptedCount?: number;
|
||||
/** # claims whose state is rejected / denied / reversed. */
|
||||
rejectedCount?: number;
|
||||
/** # claims whose state is submitted (or draft). */
|
||||
pendingCount?: number;
|
||||
/** SUM(charge_amount) across all claims (837P only; 0 for 835). */
|
||||
billedTotal?: number;
|
||||
/** Most-recent rejection_reason on the batch, truncated to 60 chars. */
|
||||
topRejectionReason?: string | null;
|
||||
/** True when rejectedCount > 0 OR any 277CA A4/A6/A7 claim. */
|
||||
hasProblem?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -54,6 +54,28 @@ export type InboxClaimRow = {
|
||||
payer_rejected_by_277ca_id?: string | null;
|
||||
payer_rejected_acknowledged_at?: string | null;
|
||||
payer_rejected_acknowledged_actor?: string | null;
|
||||
// SP29: present on `rejected`-lane rows (999 envelope rejects).
|
||||
// Newest 5 AK2 set-responses for this claim plus a `total` /
|
||||
// `rejected` summary. `null` when the claim has zero linked
|
||||
// 999 acks. Filtered to ack_kind='999' only — the
|
||||
// `payer_rejected` lane carries 277CA evidence separately.
|
||||
claim_acks?: InboxClaimAckSummary | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* SP29: per-claim 999 ack-evidence summary for the Inbox
|
||||
* `rejected`-lane drill. Newest-first; up to 5 items.
|
||||
*/
|
||||
export type InboxClaimAckSummary = {
|
||||
total: number;
|
||||
rejected: number;
|
||||
items: Array<{
|
||||
ack_id: number;
|
||||
set_control_number: string;
|
||||
set_accept_reject_code: string;
|
||||
ak2_index: number;
|
||||
linked_at: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type InboxCandidateRow = {
|
||||
|
||||
@@ -193,3 +193,18 @@ describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => {
|
||||
expect(probe.getAttribute("data-search")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP30: Dashboard integration is exercised by:
|
||||
// - The 3 component tests in src/components/RecentBatchesWidget.test.tsx
|
||||
// (which pin the click → onRowClick behavior + the empty/clean/problem
|
||||
// rendering branches + the 837p vs 835 row split).
|
||||
// - The Playwright smoke test (login → / → click row → URL changes
|
||||
// to /batches?batch=ID).
|
||||
//
|
||||
// A Dashboard-level test would need to flip api.isConfigured mid-test
|
||||
// and re-import Dashboard under a fresh module graph, which fights
|
||||
// the existing top-level mock that pins isConfigured=false for the
|
||||
// activity-routing tests. Skipping the Dashboard-level test keeps
|
||||
// coverage without the module-graph gymnastics.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -20,9 +20,15 @@ import { eventKindToUrl } from "@/lib/event-routing";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useDashboardKpis } from "@/hooks/useDashboardKpis";
|
||||
import { useActivity } from "@/hooks/useActivity";
|
||||
import { useBatches } from "@/hooks/useBatches";
|
||||
import { RecentBatchesWidget } from "@/components/RecentBatchesWidget";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const MONTHS_BACK = 6;
|
||||
// SP30: cap the "Recent batches" widget at 5 rows. Hard-coded constant
|
||||
// so changing it is one edit. Five is the at-a-glance sweet spot —
|
||||
// too few hides context, too many scrolls past the KPI fold.
|
||||
const RECENT_BATCHES_LIMIT = 5;
|
||||
|
||||
// Zero-shaped KPI totals used when the server response hasn't arrived
|
||||
// yet or the backend isn't configured. Mirrors the empty-DB shape of
|
||||
@@ -53,6 +59,10 @@ export function Dashboard() {
|
||||
// in SQL once over the full population.
|
||||
const kpisQuery = useDashboardKpis({ months: MONTHS_BACK });
|
||||
const activityQuery = useActivity({ limit: 10 });
|
||||
// SP30: "Recent batches" widget data. queryKey ["batches", 5] is
|
||||
// distinct from the ["batches", null] cache used by Upload's History
|
||||
// tab so the two don't collide.
|
||||
const recentBatchesQuery = useBatches(RECENT_BATCHES_LIMIT);
|
||||
|
||||
const kpisData = kpisQuery.data;
|
||||
// Fall back to a zero-shaped object so the KpiTiles still render a
|
||||
@@ -244,6 +254,26 @@ export function Dashboard() {
|
||||
</DrillableCell>
|
||||
</section>
|
||||
|
||||
{/* SP30: Recent batches — how the last few batches billed out.
|
||||
Sits as its own full-width row between the KPI tiles and the
|
||||
Activity + Top providers grid. animationDelay +200ms lands it
|
||||
visually after the KPI stagger (kpiBase + kpiStep*5 + 80)
|
||||
and before the Activity row (sectionBase) so the eye flows
|
||||
KPIs → Recent batches → Activity without breaking the
|
||||
fade-in choreography. */}
|
||||
<section
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${sectionBase + 200}ms` }}
|
||||
>
|
||||
<RecentBatchesWidget
|
||||
batches={recentBatchesQuery.data ?? []}
|
||||
limit={RECENT_BATCHES_LIMIT}
|
||||
onRowClick={(id) =>
|
||||
navigate(`/batches?batch=${encodeURIComponent(id)}`)
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Activity + Top providers */}
|
||||
<section
|
||||
className="grid gap-4 lg:grid-cols-3 animate-fade-in-up"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Inbox from "./Inbox";
|
||||
import * as inboxApi from "@/lib/inbox-api";
|
||||
import * as apiModule from "@/lib/api";
|
||||
import * as downloadModule from "@/lib/download";
|
||||
|
||||
// The Inbox's write-affordance BulkBars (resubmit / acknowledge /
|
||||
@@ -769,4 +770,112 @@ describe("Inbox page", () => {
|
||||
expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A");
|
||||
expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SP29: per-row Resubmit button on rejected-lane claim rows downloads
|
||||
// the single-claim corrected 837. The bulk resubmit path (multi-select)
|
||||
// is exercised above in the "SP8 multi-select resubmit" test; this
|
||||
// covers the dedicated single-claim gesture and the operator's
|
||||
// most-click flow ("I see one reject in the lane, I click Resubmit
|
||||
// next to it").
|
||||
// -------------------------------------------------------------------------
|
||||
it("SP29: per-row Resubmit button downloads the single-claim 837", async () => {
|
||||
// Lane returns one rejected claim. The page is responsible for
|
||||
// calling api.serializeClaim837(id) and handing the returned
|
||||
// text to downloadTextFile as <id>.x12.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (url: string) => {
|
||||
if (url.includes("/api/inbox/lanes")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rejected: [
|
||||
{
|
||||
id: "REJ1",
|
||||
kind: "claim",
|
||||
patient_control_number: "REJ1",
|
||||
charge_amount: 175,
|
||||
payer_id: "P1",
|
||||
provider_npi: "1234567890",
|
||||
state: "rejected",
|
||||
rejection_reason: "999 AK9 R",
|
||||
rejected_at: "2026-07-02T09:00:00Z",
|
||||
service_date_from: null,
|
||||
claim_acks: {
|
||||
total: 2,
|
||||
rejected: 1,
|
||||
items: [
|
||||
{
|
||||
ack_id: 50,
|
||||
set_control_number: "991102989",
|
||||
set_accept_reject_code: "R",
|
||||
ak2_index: 0,
|
||||
linked_at: "2026-07-02T09:00:00Z",
|
||||
},
|
||||
{
|
||||
ack_id: 51,
|
||||
set_control_number: "991102989",
|
||||
set_accept_reject_code: "A",
|
||||
ak2_index: 1,
|
||||
linked_at: "2026-07-02T08:59:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
payer_rejected: [],
|
||||
candidates: [],
|
||||
unmatched: [],
|
||||
done_today: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}),
|
||||
);
|
||||
|
||||
// Spy on the api call AND the download sink. The page calls
|
||||
// `api.serializeClaim837(id)` (namespace import from @/lib/api),
|
||||
// and downloadTextFile is on the download module.
|
||||
const serializeSpy = vi
|
||||
.spyOn(apiModule.api, "serializeClaim837")
|
||||
.mockResolvedValue({
|
||||
text: "ISA*00*~...",
|
||||
filename: "claim-REJ1.x12",
|
||||
});
|
||||
const downloadSpy = vi
|
||||
.spyOn(downloadModule, "downloadTextFile")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const view = renderInbox();
|
||||
await waitFor(() => {
|
||||
expect(view.container.textContent).toContain("REJ1");
|
||||
});
|
||||
|
||||
// Click the per-row Resubmit button — its testid includes the
|
||||
// claim id so the operator can target one row at a time.
|
||||
const btn = view.container.querySelector(
|
||||
'[data-testid="resubmit-REJ1"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn!.textContent).toBe("Resubmit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(btn!);
|
||||
});
|
||||
|
||||
// The api was called with the row's claim id, and the resulting
|
||||
// .x12 was handed to downloadTextFile with text/x12 mime so the
|
||||
// browser saves it as a download.
|
||||
await waitFor(() => {
|
||||
expect(serializeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(serializeSpy).toHaveBeenCalledWith("REJ1");
|
||||
});
|
||||
expect(downloadSpy).toHaveBeenCalledTimes(1);
|
||||
const [filename, mime, text] = downloadSpy.mock.calls[0];
|
||||
expect(filename).toBe("claim-REJ1.x12");
|
||||
expect(mime).toBe("text/x12");
|
||||
expect(text).toBe("ISA*00*~...");
|
||||
});
|
||||
});
|
||||
|
||||
+24
-1
@@ -30,7 +30,8 @@ import {
|
||||
resubmitRejectedWithDownload,
|
||||
acknowledgePayerRejected,
|
||||
} from "@/lib/inbox-api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadBlob, downloadTextFile } from "@/lib/download";
|
||||
import { RoleGate } from "@/auth/RoleGate";
|
||||
|
||||
type LaneKey =
|
||||
@@ -107,6 +108,24 @@ export default function Inbox() {
|
||||
await refetch();
|
||||
}
|
||||
|
||||
// SP29: per-row Resubmit gesture for the rejected lane. Downloads
|
||||
// the single-claim corrected 837 (read-only — see spec §D1). The
|
||||
// operator reconciles via their own workflow, then resubmits via
|
||||
// the bulk path or the existing single-claim modal flow.
|
||||
async function performResubmitOne(claimId: string) {
|
||||
try {
|
||||
const { text, filename } = await api.serializeClaim837(claimId);
|
||||
downloadTextFile(filename, "text/x12", text);
|
||||
} catch (err) {
|
||||
console.error("resubmit single failed:", claimId, err);
|
||||
// Surface the failure — the Inbox doesn't have a toast yet, so
|
||||
// we fall back to an alert. v2 swaps in a sonner toast.
|
||||
window.alert(
|
||||
`Failed to download 837 for ${claimId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onResubmit() {
|
||||
const ids = selected.rejected;
|
||||
if (ids.length === 0) return;
|
||||
@@ -298,6 +317,10 @@ export default function Inbox() {
|
||||
}
|
||||
}}
|
||||
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
||||
// SP29: per-row Resubmit button. Downloads the single-claim
|
||||
// corrected 837 (read-only). InboxRow renders the button only
|
||||
// on rejected claim rows; remit rows ignore the prop.
|
||||
onResubmitOne={(claimId) => void performResubmitOne(claimId)}
|
||||
/>
|
||||
{/*
|
||||
SP14: payer-rejected (277CA STC A4/A6/A7). Distinct accent
|
||||
|
||||
+2
-2
@@ -455,7 +455,7 @@ export interface Match {
|
||||
id: number;
|
||||
claim_id: string;
|
||||
remittance_id: string;
|
||||
strategy: "auto" | "manual";
|
||||
strategy: "pcn-exact" | "score-auto" | "manual";
|
||||
matched_at: string;
|
||||
prior_claim_state?: ClaimState | null;
|
||||
is_reversal: boolean;
|
||||
@@ -507,7 +507,7 @@ export interface UnmatchedResponse {
|
||||
|
||||
export interface MatchResponse {
|
||||
claim: UnmatchedClaim;
|
||||
match: { id: number; strategy: "auto" | "manual" };
|
||||
match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user