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
|
||||
|
||||
Reference in New Issue
Block a user