ffacfd8665
- 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
368 lines
14 KiB
Python
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
|