diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index d500891..fe05dfa 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -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 diff --git a/backend/tests/test_line_reconciliation.py b/backend/tests/test_line_reconciliation.py new file mode 100644 index 0000000..aba16ac --- /dev/null +++ b/backend/tests/test_line_reconciliation.py @@ -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([], []) == []