feat(sp7): match_service_lines() pure function + tests
This commit is contained in:
@@ -320,3 +320,100 @@ def run(session, batch_id: str) -> ReconcileResult:
|
||||
unmatched_remittances=len(unmatched_remits_after),
|
||||
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
|
||||
|
||||
@@ -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([], []) == []
|
||||
Reference in New Issue
Block a user