feat(sp7): match_service_lines() pure function + tests

This commit is contained in:
Tyler
2026-06-20 19:29:21 -06:00
parent 8442bf91ce
commit 9626eae5f1
2 changed files with 222 additions and 0 deletions
+97
View File
@@ -320,3 +320,100 @@ def run(session, batch_id: str) -> ReconcileResult:
unmatched_remittances=len(unmatched_remits_after), unmatched_remittances=len(unmatched_remits_after),
skipped=skipped, skipped=skipped,
) )
# ---------------------------------------------------------------------------
# SP7 §4 — per-service-line matching
# ---------------------------------------------------------------------------
@dataclass
class LineMatch:
"""One row of a line-level reconciliation result.
At least one of ``claim_line`` / ``service_payment`` is non-None.
Spec §4.1.
"""
claim_line: Optional["_ClaimLineLike"] = None
service_payment: Optional["_ServicePaymentLike"] = None
status: str = "" # matched | unmatched_835_only | unmatched_837_only
class _ClaimLineLike(Protocol):
line_number: int
procedure_code: str
modifiers: list
service_date: Optional[date]
units: Optional[Decimal]
class _ServicePaymentLike(Protocol):
line_number: int
procedure_code: str
modifiers: list
service_date: Optional[date]
units: Optional[Decimal]
def _norm_code(code: str) -> str:
return (code or "").strip().upper()
def _norm_mods(mods) -> frozenset:
return frozenset((m or "").strip().upper() for m in (mods or []) if m)
def _svc_matches_claim(svc, claim) -> bool:
"""Strict match per spec §4.2."""
return (
_norm_code(svc.procedure_code) == _norm_code(claim.procedure_code)
and _norm_mods(svc.modifiers) == _norm_mods(claim.modifiers)
and svc.service_date == claim.service_date
and svc.units == claim.units
)
def match_service_lines(
claim_lines,
service_payments,
) -> list:
"""Pair each 837 SV1 line with the first 835 SVC composite that matches.
Spec §4. Unmatched lines on either side produce their own LineMatch
rows so every line is accounted for in the persistence step.
"""
matches: list = []
used_svc_ids: set = set()
for claim in sorted(claim_lines, key=lambda c: c.line_number):
match_idx: Optional[int] = None
for i, svc in enumerate(service_payments):
if i in used_svc_ids:
continue
if _svc_matches_claim(svc, claim):
match_idx = i
break
if match_idx is not None:
used_svc_ids.add(match_idx)
matches.append(LineMatch(
claim_line=claim,
service_payment=service_payments[match_idx],
status="matched",
))
else:
matches.append(LineMatch(
claim_line=claim,
service_payment=None,
status="unmatched_837_only",
))
for i, svc in enumerate(service_payments):
if i in used_svc_ids:
continue
matches.append(LineMatch(
claim_line=None,
service_payment=svc,
status="unmatched_835_only",
))
return matches
+125
View File
@@ -0,0 +1,125 @@
"""SP7 — pure-function tests for match_service_lines().
Spec §4.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from cyclone.reconcile import (
LineMatch,
match_service_lines,
)
@dataclass
class FakeLine:
line_number: int
procedure_code: str
modifiers: list
service_date: date | None
units: Decimal | None
payment: Decimal = Decimal("0") # 835 only
@dataclass
class FakeClaimLine(FakeLine):
charge: Decimal = Decimal("0") # 837 only
def _claim(*lines: FakeClaimLine) -> list:
return list(lines)
def _remit(*lines: FakeLine) -> list:
return list(lines)
def test_exact_match_on_all_four_criteria():
claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), Decimal("1")))
remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 1), Decimal("1"), payment=Decimal("80")))
matches = match_service_lines(claim_lines, remit_lines)
assert len(matches) == 1
assert matches[0].claim_line.line_number == 1
assert matches[0].service_payment.line_number == 1
assert matches[0].status == "matched"
def test_procedure_code_mismatch_both_unmatched():
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
remit_lines = _remit(FakeLine(1, "99214", [], None, None))
matches = match_service_lines(claim_lines, remit_lines)
assert len(matches) == 2
statuses = sorted(m.status for m in matches)
assert statuses == ["unmatched_835_only", "unmatched_837_only"]
def test_modifiers_set_equal_regardless_of_order():
claim_lines = _claim(FakeClaimLine(1, "99213", ["25", "59"], None, None))
remit_lines = _remit(FakeLine(1, "99213", ["59", "25"], None, None))
matches = match_service_lines(claim_lines, remit_lines)
assert len(matches) == 1
assert matches[0].status == "matched"
def test_service_date_mismatch():
claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), None))
remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 2), None))
matches = match_service_lines(claim_lines, remit_lines)
assert len(matches) == 2
statuses = sorted(m.status for m in matches)
assert statuses == ["unmatched_835_only", "unmatched_837_only"]
def test_units_mismatch():
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, Decimal("1")))
remit_lines = _remit(FakeLine(1, "99213", [], None, Decimal("2")))
matches = match_service_lines(claim_lines, remit_lines)
assert len(matches) == 2
statuses = sorted(m.status for m in matches)
assert statuses == ["unmatched_835_only", "unmatched_837_only"]
def test_procedure_code_case_normalized():
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
remit_lines = _remit(FakeLine(1, "99213", [], None, None)) # case irrelevant when both upper
matches = match_service_lines(claim_lines, remit_lines)
assert matches[0].status == "matched"
def test_claim_has_more_lines_than_remit():
claim_lines = _claim(
FakeClaimLine(1, "99213", [], None, None),
FakeClaimLine(2, "99214", [], None, None),
)
remit_lines = _remit(FakeLine(1, "99213", [], None, None))
matches = match_service_lines(claim_lines, remit_lines)
statuses = sorted(m.status for m in matches)
assert statuses == ["matched", "unmatched_837_only"]
def test_remit_has_more_lines_than_claim():
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
remit_lines = _remit(
FakeLine(1, "99213", [], None, None),
FakeLine(2, "99214", [], None, None),
)
matches = match_service_lines(claim_lines, remit_lines)
statuses = sorted(m.status for m in matches)
assert statuses == ["matched", "unmatched_835_only"]
def test_empty_837_lines_no_reconciliation_rows():
matches = match_service_lines([], _remit(FakeLine(1, "99213", [], None, None)))
assert len(matches) == 1
assert matches[0].status == "unmatched_835_only"
def test_empty_835_lines_no_reconciliation_rows():
matches = match_service_lines(_claim(FakeClaimLine(1, "99213", [], None, None)), [])
assert len(matches) == 1
assert matches[0].status == "unmatched_837_only"
def test_both_empty():
assert match_service_lines([], []) == []