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