feat(sp41): visit-to-835 reconcile on (member_id, procedure, DOS) with best-of-N
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""Visit-to-835 reconciliation on (member_id, procedure, DOS).
|
||||
|
||||
Authoritative match key. The 5% tolerance on PAID handles the legitimate
|
||||
charge-amount differences between AxisCare's per-unit CSV and the 835's
|
||||
per-SVC breakdown.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Iterable
|
||||
|
||||
# SvcRow is owned by parse_835_svc (Task 1) — re-exported here so callers
|
||||
# of `cyclone.rebill.reconcile` only need one import.
|
||||
from cyclone.rebill.parse_835_svc import SvcRow # noqa: F401 (re-exported)
|
||||
|
||||
PAID_TOLERANCE = Decimal("0.05") # charge match tolerance
|
||||
PARTIAL_RATIO = Decimal("0.95") # PAID if total_paid >= 95% of billed
|
||||
|
||||
|
||||
class OutcomeCategory(str, Enum):
|
||||
PAID = "PAID"
|
||||
PARTIAL = "PARTIAL"
|
||||
DENIED = "DENIED"
|
||||
NOT_IN_835 = "NOT_IN_835"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VisitRow:
|
||||
date: date
|
||||
member_id: str
|
||||
procedure: str
|
||||
billed: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileOutcome:
|
||||
visit: VisitRow
|
||||
category: OutcomeCategory
|
||||
unpaid: Decimal
|
||||
matched_svc_count: int
|
||||
|
||||
|
||||
def reconcile_visits_to_835(
|
||||
visits: Iterable[VisitRow],
|
||||
svcs: Iterable[SvcRow],
|
||||
) -> list[ReconcileOutcome]:
|
||||
"""Index SVCs by (member_id, procedure, DOS), best-of-N outcome per visit."""
|
||||
by_key: dict[tuple[str, str, date], list[SvcRow]] = defaultdict(list)
|
||||
for s in svcs:
|
||||
by_key[(s.member_id, s.procedure, s.svc_date)].append(s)
|
||||
|
||||
out: list[ReconcileOutcome] = []
|
||||
for v in visits:
|
||||
cands = by_key.get((v.member_id, v.procedure, v.date), [])
|
||||
if not cands:
|
||||
out.append(ReconcileOutcome(v, OutcomeCategory.NOT_IN_835, v.billed, 0))
|
||||
continue
|
||||
total_paid = sum((c.paid for c in cands), Decimal("0"))
|
||||
any_paid = any(c.paid > 0 for c in cands)
|
||||
if any_paid and total_paid >= v.billed * PARTIAL_RATIO:
|
||||
cat, unpaid = OutcomeCategory.PAID, Decimal("0")
|
||||
elif any_paid:
|
||||
cat, unpaid = OutcomeCategory.PARTIAL, max(Decimal("0"), v.billed - total_paid)
|
||||
else:
|
||||
cat, unpaid = OutcomeCategory.DENIED, v.billed
|
||||
out.append(ReconcileOutcome(v, cat, unpaid, len(cands)))
|
||||
return out
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Visit-to-835 reconciliation on (member_id, procedure, DOS)."""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from cyclone.rebill.reconcile import (
|
||||
VisitRow,
|
||||
SvcRow,
|
||||
ReconcileOutcome,
|
||||
reconcile_visits_to_835,
|
||||
OutcomeCategory,
|
||||
)
|
||||
|
||||
|
||||
def _v(date, member, proc, amt):
|
||||
return VisitRow(date=date, member_id=member, procedure=proc, billed=Decimal(amt))
|
||||
|
||||
|
||||
def _s(date, member, proc, chg, paid, status="1"):
|
||||
return SvcRow(
|
||||
src_file="x.835", claim_id="c1", member_id=member, status=status,
|
||||
procedure=proc, modifiers="", charge=Decimal(chg), paid=Decimal(paid),
|
||||
units=Decimal("0"), svc_date=date, cas_reasons=(), pay_date=None,
|
||||
)
|
||||
|
||||
|
||||
def test_visit_paid_when_any_svc_paid_within_tolerance():
|
||||
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
|
||||
svcs = [_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "2.30")]
|
||||
out = reconcile_visits_to_835(visits, svcs)
|
||||
assert out[0].category == OutcomeCategory.PAID
|
||||
assert out[0].unpaid == Decimal("0")
|
||||
|
||||
|
||||
def test_visit_paid_when_one_svc_paid_others_denied_duplicate():
|
||||
"""OA-18 on duplicates is noise; paid SVC wins."""
|
||||
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
|
||||
svcs = [
|
||||
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "2.30", "1"),
|
||||
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
|
||||
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
|
||||
]
|
||||
out = reconcile_visits_to_835(visits, svcs)
|
||||
assert out[0].category == OutcomeCategory.PAID
|
||||
|
||||
|
||||
def test_visit_denied_when_all_svc_denied():
|
||||
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
|
||||
svcs = [
|
||||
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
|
||||
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
|
||||
]
|
||||
out = reconcile_visits_to_835(visits, svcs)
|
||||
assert out[0].category == OutcomeCategory.DENIED
|
||||
assert out[0].unpaid == Decimal("2.32")
|
||||
|
||||
|
||||
def test_visit_not_in_835_when_no_svc_match():
|
||||
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
|
||||
svcs = [_s(date(2026, 1, 19), "J813715", "T1019", "2.32", "2.30")]
|
||||
out = reconcile_visits_to_835(visits, svcs)
|
||||
assert out[0].category == OutcomeCategory.NOT_IN_835
|
||||
assert out[0].unpaid == Decimal("2.32")
|
||||
|
||||
|
||||
def test_partial_when_total_paid_below_95pct_of_billed():
|
||||
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "10.00")]
|
||||
svcs = [_s(date(2026, 1, 18), "J813715", "T1019", "10.00", "4.00")]
|
||||
out = reconcile_visits_to_835(visits, svcs)
|
||||
assert out[0].category == OutcomeCategory.PARTIAL
|
||||
assert out[0].unpaid == Decimal("6.00")
|
||||
|
||||
|
||||
def test_different_member_does_not_match():
|
||||
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
|
||||
svcs = [_s(date(2026, 1, 18), "OTHERMEMBER", "T1019", "2.32", "2.30")]
|
||||
out = reconcile_visits_to_835(visits, svcs)
|
||||
assert out[0].category == OutcomeCategory.NOT_IN_835
|
||||
Reference in New Issue
Block a user