feat(sp6): compute_lanes — 4 lanes on read with scoring
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Compute the four Inbox lanes from the DB on read.
|
||||
|
||||
SP6 T6.
|
||||
|
||||
Lanes:
|
||||
- rejected: claims whose 999 AK5 set-level response was R/E
|
||||
- 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)
|
||||
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) -> 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,
|
||||
}
|
||||
|
||||
|
||||
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 compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
|
||||
lanes = Lanes()
|
||||
dismissed = set(dismissed_pairs)
|
||||
|
||||
# --- Rejected ---
|
||||
for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all():
|
||||
lanes.rejected.append(_claim_to_row(c, kind="claim"))
|
||||
|
||||
# --- Done today ---
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
terminal_states = {
|
||||
ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED,
|
||||
ClaimState.RECONCILED, ClaimState.REVERSED,
|
||||
}
|
||||
for c in session.query(Claim).filter(
|
||||
Claim.state.in_(terminal_states),
|
||||
Claim.state_changed_at >= cutoff,
|
||||
).all():
|
||||
lanes.done_today.append(_claim_to_row(c, kind="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
|
||||
Reference in New Issue
Block a user