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),
|
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
|
||||||
|
|||||||
@@ -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