feat(backend): add reconcile.match with date-window + multi-claim fallback
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user