diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index d90f6f0..1fed5d7 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -372,7 +372,7 @@ def run(session, batch_id: str) -> ReconcileResult: """ from sqlalchemy import select from cyclone.db import ( - Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent, + Batch, Claim, Remittance, CasAdjustment, Match as MatchORM, ActivityEvent, ClaimState, ) @@ -390,6 +390,31 @@ def run(session, batch_id: str) -> ReconcileResult: matches = match(unmatched_claims, new_remits) + # SP31: content-keys fallback for remits that PCN-exact couldn't pair. + # Operates on the still-unmatched remits so we don't double-match. + matched_remit_ids = {m.remittance.id for m in matches} + used_claim_ids = {m.claim.id for m in matches} + for remit in new_remits: + if remit.id in matched_remit_ids: + continue + if getattr(remit, "claim_id", None) is not None: + continue # already linked + matched_claim_id = _score_fallback_candidates(session, remit) + if matched_claim_id is None: + continue + # Find the claim object in the unmatched_claims list (it was loaded). + target_claim = next( + (c for c in unmatched_claims if c.id == matched_claim_id), + None, + ) + if target_claim is None or target_claim.id in used_claim_ids: + continue + matches.append(Match( + claim=target_claim, remittance=remit, + strategy="score-auto", is_reversal=remit.is_reversal, + )) + used_claim_ids.add(target_claim.id) + applied = 0 skipped = 0 for m in matches: @@ -413,7 +438,7 @@ def run(session, batch_id: str) -> ReconcileResult: skipped += 1 continue - session.add(Match( + session.add(MatchORM( claim_id=m.claim.id, remittance_id=m.remittance.id, strategy=m.strategy, matched_at=datetime.now(timezone.utc), @@ -431,10 +456,15 @@ def run(session, batch_id: str) -> ReconcileResult: m.remittance.claim_id = m.claim.id session.add(ActivityEvent( - ts=datetime.now(timezone.utc), kind=intent.activity_kind, + ts=datetime.now(timezone.utc), + kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind), batch_id=batch_id, claim_id=m.claim.id, remittance_id=m.remittance.id, - payload_json={"new_state": m.claim.state.value}, + payload_json=( + {"new_state": m.claim.state.value, "strategy": m.strategy} + if m.strategy == "score-auto" + else {"new_state": m.claim.state.value} + ), )) applied += 1 diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index 890d03a..a2dc8ab 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -653,3 +653,120 @@ def test_content_keys_npi_from_remit_raw_json(db_session, make_claim): db_session.add(remit) db_session.flush() assert _content_keys_match(remit, claim) is True # charge + NPI = 2 of 3 + + +# --- SP31: end-to-end integration via reconcile.run() ----------------------- + + +def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit): + """A remit with no PCN match but matching charge+NPI triggers score-auto.""" + from decimal import Decimal + from datetime import date + from sqlalchemy import select + from cyclone.db import Batch, ClaimState + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="UNIQUE-CLM", + total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="DIFFERENT-PCN", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + # Re-parent the remit to this batch (make_remit doesn't set batch_id). + remit.batch_id = batch.id + db_session.flush() + + result = reconcile_run(db_session, batch.id) + db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row + + assert result.matched == 1 + # Verify Match row was written with strategy="score-auto" + from cyclone.db import Match + matches = list(db_session.execute( + select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 1 + assert matches[0].strategy == "score-auto" + assert matches[0].claim_id == claim.id + # Verify ActivityEvent was emitted with kind="auto_matched_835" + from cyclone.db import ActivityEvent + events = list(db_session.execute( + select(ActivityEvent).where( + ActivityEvent.remittance_id == remit.id, + ActivityEvent.kind == "auto_matched_835", + ) + ).scalars().all()) + assert len(events) == 1 + assert events[0].claim_id == claim.id + # Verify claim state was flipped + db_session.refresh(claim) + assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED} + + +def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit): + """Existing PCN-exact match still produces strategy='pcn-exact' (regression guard).""" + from decimal import Decimal + from datetime import date + from sqlalchemy import select + from cyclone.db import Batch, Match + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="SAME-PCN", + total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="SAME-PCN", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 2)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + remit.batch_id = batch.id + db_session.flush() + + reconcile_run(db_session, batch.id) + db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row + + matches = list(db_session.execute( + select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 1 + assert matches[0].strategy == "pcn-exact" # NOT "score-auto" + + +def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit): + """No 2-of-3 match → no Match row, claim state unchanged.""" + from decimal import Decimal + from datetime import date + from sqlalchemy import select + from cyclone.db import Batch, Match, ClaimState + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="UNRELATED", + total_charge=Decimal("999.99"), + rendering_provider_npi="2222222222", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="OTHER", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + remit.batch_id = batch.id + db_session.flush() + + result = reconcile_run(db_session, batch.id) + + assert result.matched == 0 + matches = list(db_session.execute( + select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 0 + db_session.refresh(claim) + assert claim.state == ClaimState.SUBMITTED # unchanged + db_session.refresh(remit) + assert remit.claim_id is None # unchanged