feat(backend): add reconcile.match with date-window + multi-claim fallback

This commit is contained in:
Tyler
2026-06-19 21:59:23 -06:00
parent 05a49be964
commit c30416e68c
2 changed files with 226 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Pure reconciliation functions.
These functions take lists of objects (ORM or stand-in dataclasses) and
return intent objects describing what should change. They do not own a
database session; the caller (cyclone.store) persists the intents.
Match algorithm:
- Group claims by patient_control_number.
- For each remit, find a candidate claim with the same PCN.
- Prefer the claim whose service_date_from is closest to the remit's
service_date, within +/- window_days.
- If the remit has no service_date, fall back to the first claim for
that PCN (in input order).
- Skip claims that already have matched_remittance_id set.
- is_reversal flag is propagated to the Match.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Iterable, Optional, Protocol
WINDOW_DAYS_DEFAULT = 7
class _ClaimLike(Protocol):
id: str
patient_control_number: str
service_date_from: Optional[date]
matched_remittance_id: Optional[str]
class _RemitLike(Protocol):
id: str
payer_claim_control_number: str
service_date: Optional[date]
is_reversal: bool
@dataclass
class Match:
claim: _ClaimLike
remittance: _RemitLike
strategy: str
is_reversal: bool
def match(
claims: Iterable[_ClaimLike],
remittances: Iterable[_RemitLike],
*,
window_days: int = WINDOW_DAYS_DEFAULT,
) -> list[Match]:
"""Pair each remit with the closest eligible claim. Returns Match intents."""
claims_list = list(claims)
remits_list = list(remittances)
# Eligible = no current match, has a PCN.
eligible = [c for c in claims_list if c.patient_control_number and not c.matched_remittance_id]
by_pcn: dict[str, list[_ClaimLike]] = {}
for c in eligible:
by_pcn.setdefault(c.patient_control_number, []).append(c)
matches: list[Match] = []
used_claim_ids: set[str] = set()
for r in remits_list:
candidates = by_pcn.get(r.payer_claim_control_number, [])
candidates = [c for c in candidates if c.id not in used_claim_ids]
if not candidates:
continue
chosen = _pick_claim(candidates, r.service_date, window_days)
if chosen is None:
continue
matches.append(Match(
claim=chosen,
remittance=r,
strategy="auto",
is_reversal=r.is_reversal,
))
used_claim_ids.add(chosen.id)
return matches
def _pick_claim(
candidates: list[_ClaimLike],
remit_service_date: Optional[date],
window_days: int,
) -> Optional[_ClaimLike]:
"""Choose the claim with the closest service_date_from within the window."""
if remit_service_date is None:
return candidates[0] # no date info → first claim for that PCN
best: tuple[int, _ClaimLike] | None = None
for c in candidates:
if c.service_date_from is None:
continue
delta = abs((c.service_date_from - remit_service_date).days)
if delta > window_days:
continue
if best is None or delta < best[0]:
best = (delta, c)
if best is not None:
return best[1]
# Remit has a date but no candidate was within the window → no match.
return None
+114
View File
@@ -0,0 +1,114 @@
"""Tests for the pure reconciliation functions.
These tests use lightweight stand-in objects (namedtuples) rather than
SQLAlchemy ORM instances, so the reconcile module has zero DB dependency.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Optional
import pytest
from cyclone import reconcile
# --- Stand-in types -----------------------------------------------------------
@dataclass
class FakeClaim:
id: str
patient_control_number: str
service_date_from: Optional[date]
state: str = "submitted"
matched_remittance_id: Optional[str] = None
@dataclass
class FakeRemit:
id: str
payer_claim_control_number: str
service_date: Optional[date]
is_reversal: bool = False
# --- match() ------------------------------------------------------------------
def test_match_same_pcn_within_window_matches():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
matches = reconcile.match(claims, remits, window_days=7)
assert len(matches) == 1
assert matches[0].claim.id == "CLM-1"
assert matches[0].remittance.id == "CLP-1"
assert matches[0].strategy == "auto"
def test_match_dates_outside_window_unmatched():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 7, 1))] # 30d apart
matches = reconcile.match(claims, remits, window_days=7)
assert matches == []
def test_match_different_pcn_unmatched():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-B", date(2026, 6, 1))]
matches = reconcile.match(claims, remits)
assert matches == []
def test_match_multiple_claims_same_pcn_picks_closest_date():
claims = [
FakeClaim("CLM-1", "PCN-A", date(2026, 5, 1)),
FakeClaim("CLM-2", "PCN-A", date(2026, 6, 1)), # closer
]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
matches = reconcile.match(claims, remits)
assert len(matches) == 1
assert matches[0].claim.id == "CLM-2"
def test_match_multiple_remits_same_pcn_each_gets_own_claim():
claims = [
FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1)),
FakeClaim("CLM-2", "PCN-A", date(2026, 6, 15)),
]
remits = [
FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2)),
FakeRemit("CLP-2", "PCN-A", date(2026, 6, 16)),
]
matches = reconcile.match(claims, remits)
assert len(matches) == 2
claim_ids = {m.claim.id for m in matches}
assert claim_ids == {"CLM-1", "CLM-2"}
def test_match_skips_already_matched_claims():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1), matched_remittance_id="OLD")]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1))]
matches = reconcile.match(claims, remits)
assert matches == []
def test_match_falls_back_to_first_claim_when_no_service_date():
"""When the remit has no service_date, match the first claim for that PCN."""
claims = [
FakeClaim("CLM-1", "PCN-A", None),
FakeClaim("CLM-2", "PCN-A", None),
]
remits = [FakeRemit("CLP-1", "PCN-A", None)]
matches = reconcile.match(claims, remits)
assert len(matches) == 1
assert matches[0].claim.id == "CLM-1"
def test_match_reversal_flag_propagates():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1), is_reversal=True)]
matches = reconcile.match(claims, remits)
assert matches[0].is_reversal is True