feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event
This commit is contained in:
@@ -372,7 +372,7 @@ def run(session, batch_id: str) -> ReconcileResult:
|
|||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from cyclone.db import (
|
from cyclone.db import (
|
||||||
Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
|
Batch, Claim, Remittance, CasAdjustment, Match as MatchORM, ActivityEvent,
|
||||||
ClaimState,
|
ClaimState,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,6 +390,31 @@ def run(session, batch_id: str) -> ReconcileResult:
|
|||||||
|
|
||||||
matches = match(unmatched_claims, new_remits)
|
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
|
applied = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
for m in matches:
|
for m in matches:
|
||||||
@@ -413,7 +438,7 @@ def run(session, batch_id: str) -> ReconcileResult:
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
session.add(Match(
|
session.add(MatchORM(
|
||||||
claim_id=m.claim.id, remittance_id=m.remittance.id,
|
claim_id=m.claim.id, remittance_id=m.remittance.id,
|
||||||
strategy=m.strategy,
|
strategy=m.strategy,
|
||||||
matched_at=datetime.now(timezone.utc),
|
matched_at=datetime.now(timezone.utc),
|
||||||
@@ -431,10 +456,15 @@ def run(session, batch_id: str) -> ReconcileResult:
|
|||||||
m.remittance.claim_id = m.claim.id
|
m.remittance.claim_id = m.claim.id
|
||||||
|
|
||||||
session.add(ActivityEvent(
|
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,
|
batch_id=batch_id, claim_id=m.claim.id,
|
||||||
remittance_id=m.remittance.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
|
applied += 1
|
||||||
|
|
||||||
|
|||||||
@@ -653,3 +653,120 @@ def test_content_keys_npi_from_remit_raw_json(db_session, make_claim):
|
|||||||
db_session.add(remit)
|
db_session.add(remit)
|
||||||
db_session.flush()
|
db_session.flush()
|
||||||
assert _content_keys_match(remit, claim) is True # charge + NPI = 2 of 3
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user