Files
cyclone/backend/src/cyclone/inbox_lanes.py
T
Nora ffacfd8665 feat(sp29): inbox rejected-row 999 ack chips + per-row Resubmit
- backend: _ack_summary_for_claims helper attaches claim_acks payload
  to inbox rejected-lane rows (3 tests)
- frontend: InboxRow renders newest 3 AK2 chips + '+N more' overflow
  under the rejection reason, with '999 not linked' marker when an
  ack couldn't be linked to a claim
- frontend: per-row Resubmit button downloads a single corrected 837
  via api.serializeClaim837 (no bulk modal, no zip — one click, one
  .x12 file)
- frontend: 2 new tests for the chip rendering and the per-row
  download flow
2026-07-02 13:16:38 -06:00

368 lines
14 KiB
Python

"""Compute the Inbox lanes from the DB on read.
SP6 T6.
Lanes:
- rejected: claims whose 999 AK5 set-level response was R/E
- payer_rejected: claims whose 277CA STC category is A4/A6/A7
(SP10: payer-side rejection, distinct from 999
envelope rejection)
- candidates: remits without a matched claim, with scoreable claims
- unmatched: claims still SUBMITTED with no remittance in flight
- done_today: claims that reached a terminal state in the last 24h
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Iterable
from sqlalchemy.orm import Session
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.scoring import score_pair, ScoreBreakdown
@dataclass
class Lanes:
rejected: list[dict] = field(default_factory=list)
payer_rejected: list[dict] = field(default_factory=list)
candidates: list[dict] = field(default_factory=list)
unmatched: list[dict] = field(default_factory=list)
done_today: list[dict] = field(default_factory=list)
def _isoformat(value) -> str | None:
if value is None:
return None
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def _claim_to_row(
c: Claim,
*,
kind: str,
score: ScoreBreakdown | None = None,
matched_remittance: dict | None = None,
) -> dict:
return {
"id": c.id,
"kind": kind,
"patient_control_number": c.patient_control_number,
"charge_amount": float(c.charge_amount) if c.charge_amount is not None else None,
"payer_id": c.payer_id,
"provider_npi": c.provider_npi,
"state": c.state.value if hasattr(c.state, "value") else str(c.state),
"rejection_reason": c.rejection_reason,
"rejected_at": _isoformat(c.rejected_at),
"service_date_from": _isoformat(c.service_date_from),
"score": score.total if score else None,
"score_tier": score.tier if score else None,
"score_breakdown": {
"patient": score.patient,
"date": score.date,
"amount": score.amount,
"provider": score.provider,
} if score else None,
"matched_remittance": matched_remittance,
# SP10: payer-side rejection fields. Populated when a 277CA
# acknowledged the claim with STC A4/A6/A7.
"payer_rejected_at": _isoformat(c.payer_rejected_at),
"payer_rejected_reason": c.payer_rejected_reason,
"payer_rejected_status_code": c.payer_rejected_status_code,
"payer_rejected_by_277ca_id": c.payer_rejected_by_277ca_id,
# SP14: acknowledgment tracking. Always null on the lane
# (we filter acknowledged claims out) but exposed for
# forward-compat if we later add a "Recently acknowledged"
# inspector view.
"payer_rejected_acknowledged_at": _isoformat(c.payer_rejected_acknowledged_at),
"payer_rejected_acknowledged_actor": c.payer_rejected_acknowledged_actor,
}
def _remit_to_row(r: Remittance, *, candidates: list[tuple[Claim, ScoreBreakdown]]) -> dict:
raw = r.raw_json or {}
return {
"id": r.id,
"kind": "remit",
"payer_claim_control_number": r.payer_claim_control_number,
"charge_amount": float(r.total_charge) if r.total_charge is not None else None,
"payer_id": raw.get("payer_id"),
"rendering_provider_npi": raw.get("rendering_provider_npi"),
"service_date": _isoformat(r.service_date),
"candidates": [
{
"claim_id": c.id,
"score": s.total,
"tier": s.tier,
"breakdown": {
"patient": s.patient, "date": s.date,
"amount": s.amount, "provider": s.provider,
},
}
for c, s in candidates[:5]
],
}
class _RemitScoringShim:
"""Adapt a Remittance to the duck-typed protocol score_pair expects.
The 835 parser stores service_date on the ORM column. PCN, NPI, payer_id
aren't on Remittance; we read them from raw_json.
"""
def __init__(self, r: Remittance):
raw = r.raw_json or {}
self.patient_control_number = (
raw.get("patient_control_number") or r.payer_claim_control_number
)
self.service_date = r.service_date
self.charge_amount = r.total_charge
self.rendering_provider_npi = raw.get("rendering_provider_npi")
def _matched_remittance_block(
session: Session,
claim: Claim,
matched_counts: dict,
total_lines_by_claim: dict,
) -> dict | None:
"""Build the ``matched_remittance`` block for a paired claim, or None.
SP7 §5.3: carries the matched remittance id plus per-line counts so
the ``MatchedRemitCard`` can show a ``3/4 matched lines`` badge.
"""
if claim.matched_remittance_id is None:
return None
return {
"id": claim.matched_remittance_id,
"matched_lines": int(matched_counts.get(claim.id, 0)),
"total_lines": int(total_lines_by_claim.get(claim.id, 0)),
}
def _line_count_lookup(session: Session, claims: list[Claim]) -> tuple[dict, dict]:
"""Return ({claim_id: matched_count}, {claim_id: total_lines}) for the given claims.
matched_count = number of LineReconciliation rows with status="matched"
tied to this claim.
total_lines = len(Claim.raw_json["service_lines"]) — 837 service-line
count stored inside the JSON blob.
"""
from cyclone.db import LineReconciliation
claim_ids = [c.id for c in claims]
matched_counts: dict = {}
total_lines_by_claim: dict = {}
if not claim_ids:
return matched_counts, total_lines_by_claim
matched_rows = (
session.query(LineReconciliation.claim_id)
.filter(LineReconciliation.claim_id.in_(claim_ids))
.filter(LineReconciliation.status == "matched")
.all()
)
for (cid,) in matched_rows:
matched_counts[cid] = matched_counts.get(cid, 0) + 1
# total_lines: from raw_json (837 service lines are stored there).
for c in claims:
raw = c.raw_json or {}
total_lines_by_claim[c.id] = len(raw.get("service_lines") or [])
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)
# --- Rejected ---
rejected_claims = (
session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all()
)
matched_counts, total_lines_by_claim = _line_count_lookup(session, rejected_claims)
for c in rejected_claims:
lanes.rejected.append(_claim_to_row(
c, kind="claim",
matched_remittance=_matched_remittance_block(
session, c, matched_counts, total_lines_by_claim,
),
))
# 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
# by the payer after we submitted a syntactically-valid file).
# We don't filter by Claim.state here because the claim may still
# be in SUBMITTED state — the payer just hasn't paid it yet.
#
# SP14: filter out claims the operator has already acknowledged.
# The original payer_rejected_* fields stay intact for audit;
# only the working surface (this lane) is filtered.
payer_rejected_claims = (
session.query(Claim)
.filter(Claim.payer_rejected_at.is_not(None))
.filter(Claim.payer_rejected_acknowledged_at.is_(None))
.all()
)
pr_matched, pr_total = _line_count_lookup(session, payer_rejected_claims)
matched_counts.update(pr_matched)
total_lines_by_claim.update(pr_total)
for c in payer_rejected_claims:
lanes.payer_rejected.append(_claim_to_row(
c, kind="claim",
matched_remittance=_matched_remittance_block(
session, c, matched_counts, total_lines_by_claim,
),
))
# --- Done today ---
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
terminal_states = {
ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED,
ClaimState.RECONCILED, ClaimState.REVERSED,
}
done_claims = list(
session.query(Claim).filter(
Claim.state.in_(terminal_states),
Claim.state_changed_at >= cutoff,
).all()
)
# Merge counts from the rejected query — these are different claim
# sets so the dicts can be combined.
if done_claims:
dm, dt = _line_count_lookup(session, done_claims)
matched_counts.update(dm)
total_lines_by_claim.update(dt)
for c in done_claims:
lanes.done_today.append(_claim_to_row(
c, kind="claim",
matched_remittance=_matched_remittance_block(
session, c, matched_counts, total_lines_by_claim,
),
))
# --- Unmatched (claims still submitted, no remittance in flight) ---
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all():
lanes.unmatched.append(_claim_to_row(c, kind="claim"))
# --- Candidates (remits not yet matched, with scoreable claims) ---
unmatched_remits = session.query(Remittance).filter(
Remittance.claim_id.is_(None),
).all()
submitted_claims = list(
session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all()
)
for r in unmatched_remits:
if r.claim_id:
continue
candidates: list[tuple[Claim, ScoreBreakdown]] = []
shim = _RemitScoringShim(r)
for c in submitted_claims:
if c.payer_id and r.raw_json and (r.raw_json.get("payer_id") != c.payer_id):
continue
score = score_pair(c, shim)
if score.tier == "hidden":
continue
if frozenset({c.id, r.id}) in dismissed:
continue
candidates.append((c, score))
candidates.sort(key=lambda cs: -cs[1].total)
if not candidates:
continue
lanes.candidates.append(_remit_to_row(r, candidates=candidates))
return lanes