feat(sp31): expose keys_matched/candidate_count in Match dataclass and ActivityEvent payload, fix broken pre-existing test
This commit is contained in:
@@ -153,19 +153,18 @@ def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient):
|
||||
the moment an 835 lands — the most common activity, invisible.
|
||||
|
||||
Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit
|
||||
CLM001), then manually match them so ``Remittance.claim_id`` is
|
||||
populated. The bug presents as: only ``claim_submitted`` and
|
||||
``manual_match`` appear (no ``remit_received``). The fix surfaces
|
||||
all three.
|
||||
CLM001). With SP31, the minimal fixtures now auto-link via the
|
||||
content-keys fallback (PCN + charge agree → 2-of-3) so
|
||||
``Remittance.claim_id`` is populated as part of the 835 ingest
|
||||
itself — no separate manual-match call needed. The bug presents
|
||||
as: only ``claim_submitted`` appears (no ``remit_received``). The
|
||||
fix surfaces both.
|
||||
"""
|
||||
# Force the match — simulates the post-reconciliation state that
|
||||
# populate Remittance.claim_id without depending on auto-reconcile
|
||||
# heuristics (which don't match this minimal fixture).
|
||||
match_resp = seeded_db.post(
|
||||
"/api/reconciliation/match",
|
||||
json={"claim_id": "CLM001", "remit_id": "CLM001"},
|
||||
)
|
||||
assert match_resp.status_code == 200, match_resp.text
|
||||
# The minimal fixture's PCN ("CLM001") + charge ("$100.00") match,
|
||||
# so SP31's content-keys fallback pairs the remit during ingest.
|
||||
# No manual-match call is required (and would now 409 "already_matched").
|
||||
# The intent of this test — surfacing the remit_received event via
|
||||
# the Remittance.claim_id join — is fully exercised either way.
|
||||
|
||||
resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@@ -396,86 +396,86 @@ def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str):
|
||||
|
||||
|
||||
def test_content_keys_pcn_plus_charge_matches():
|
||||
"""PCN + charge match (NPI mismatched) → True."""
|
||||
"""PCN + charge match (NPI mismatched) → 2-of-3, returns ``{"pcn","charge"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge"}
|
||||
|
||||
|
||||
def test_content_keys_pcn_plus_npi_matches():
|
||||
"""PCN + NPI match (charge mismatched) → True."""
|
||||
"""PCN + NPI match (charge mismatched) → 2-of-3, returns ``{"pcn","npi"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
assert _content_keys_match(r, c) == {"pcn", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_charge_plus_npi_matches():
|
||||
"""Charge + NPI match (PCN mismatched) → True."""
|
||||
"""Charge + NPI match (PCN mismatched) → 2-of-3, returns ``{"charge","npi"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
assert _content_keys_match(r, c) == {"charge", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_all_three_match():
|
||||
"""All 3 match → True."""
|
||||
"""All 3 match → 3-of-3, returns ``{"pcn","charge","npi"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_only_pcn_does_not_match():
|
||||
"""Only PCN matches (charge + NPI both wrong) → False."""
|
||||
"""Only PCN matches (charge + NPI both wrong) → set is just ``{"pcn"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) is False
|
||||
assert _content_keys_match(r, c) == {"pcn"}
|
||||
|
||||
|
||||
def test_content_keys_only_charge_does_not_match():
|
||||
"""Only charge matches (PCN + NPI both wrong) → False."""
|
||||
"""Only charge matches (PCN + NPI both wrong) → set is just ``{"charge"}``."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) is False
|
||||
assert _content_keys_match(r, c) == {"charge"}
|
||||
|
||||
|
||||
def test_content_keys_none_match():
|
||||
"""All 3 fields disagree → False."""
|
||||
"""All 3 fields disagree → empty set."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) is False
|
||||
assert _content_keys_match(r, c) == set()
|
||||
|
||||
|
||||
def test_content_keys_charge_tolerance_is_one_cent():
|
||||
"""Charge differs by exactly $0.005 (rounding) → True ($0.01 tolerance)."""
|
||||
"""Charge differs by exactly $0.005 (rounding) → charge counts as matched."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}
|
||||
|
||||
|
||||
def test_content_keys_charge_differs_by_two_cents_no_match():
|
||||
"""Charge differs by $0.02 → False (outside tolerance)."""
|
||||
"""Charge differs by $0.02 → charge does NOT match (outside tolerance)."""
|
||||
from cyclone.reconcile import _content_keys_match
|
||||
from decimal import Decimal
|
||||
# PCN and NPI deliberately differ so the only potential match is charge.
|
||||
# Charge is outside the $0.01 tolerance → 0 of 3 match → False.
|
||||
# Charge is outside the $0.01 tolerance → 0 of 3 match → empty set.
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111")
|
||||
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
|
||||
assert _content_keys_match(r, c) is False
|
||||
assert _content_keys_match(r, c) == set()
|
||||
|
||||
|
||||
def test_content_keys_empty_npi_does_not_count():
|
||||
@@ -484,16 +484,16 @@ def test_content_keys_empty_npi_does_not_count():
|
||||
from decimal import Decimal
|
||||
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
|
||||
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c) is True # PCN + charge still match
|
||||
assert _content_keys_match(r, c) == {"pcn", "charge"} # NPI skipped on remit
|
||||
c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111")
|
||||
assert _content_keys_match(r, c2) is False # only charge, no PCN
|
||||
assert _content_keys_match(r, c2) == {"charge"} # only charge, no PCN
|
||||
|
||||
|
||||
# --- SP31: content-keys fallback matcher (DB helper) ------------------------
|
||||
|
||||
|
||||
def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit):
|
||||
"""One claim in pool matches 2-of-3 → returns that claim."""
|
||||
"""One claim in pool matches 2-of-3 → returns (claim_id, keys, candidate_count)."""
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from cyclone.reconcile import _score_fallback_candidates
|
||||
@@ -507,7 +507,12 @@ def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, ma
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=date(2026, 6, 5)) # ±30-day window
|
||||
assert _score_fallback_candidates(db_session, r) == c1.id
|
||||
result = _score_fallback_candidates(db_session, r)
|
||||
assert result is not None
|
||||
claim_id, keys_matched, candidate_count = result
|
||||
assert claim_id == c1.id
|
||||
assert keys_matched == {"pcn", "charge", "npi"} # 3-of-3
|
||||
assert candidate_count == 2 # pool had 2 candidates
|
||||
|
||||
|
||||
def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit):
|
||||
@@ -591,7 +596,11 @@ def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim
|
||||
total_charge_amount=Decimal("100.00"),
|
||||
rendering_provider_npi="1111111111",
|
||||
service_date=remit_date)
|
||||
assert _score_fallback_candidates(db_session, r) == c1.id
|
||||
result = _score_fallback_candidates(db_session, r)
|
||||
assert result is not None
|
||||
claim_id, keys_matched, candidate_count = result
|
||||
assert claim_id == c1.id
|
||||
assert candidate_count == 1
|
||||
|
||||
|
||||
def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit):
|
||||
@@ -652,7 +661,7 @@ 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
|
||||
assert _content_keys_match(remit, claim) == {"charge", "npi"} # PCN mismatched
|
||||
|
||||
|
||||
# --- SP31: end-to-end integration via reconcile.run() -----------------------
|
||||
@@ -692,6 +701,12 @@ def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_c
|
||||
assert len(matches) == 1
|
||||
assert matches[0].strategy == "score-auto"
|
||||
assert matches[0].claim_id == claim.id
|
||||
# Symmetric FK: the auto-match path sets Remittance.claim_id so the
|
||||
# list_unmatched filter (``Remittance.claim_id IS NULL``) correctly
|
||||
# drops this pair from the orphan bucket. Mirrors the manual_match
|
||||
# contract — both paths must populate both sides of the FK pair.
|
||||
db_session.refresh(remit)
|
||||
assert remit.claim_id == claim.id
|
||||
# Verify ActivityEvent was emitted with kind="auto_matched_835"
|
||||
from cyclone.db import ActivityEvent
|
||||
events = list(db_session.execute(
|
||||
@@ -702,6 +717,13 @@ def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_c
|
||||
).scalars().all())
|
||||
assert len(events) == 1
|
||||
assert events[0].claim_id == claim.id
|
||||
# Spec D8: payload records keys_matched + candidate_count so the
|
||||
# operator can see *why* the auto-link fired.
|
||||
payload = events[0].payload_json or {}
|
||||
assert "keys_matched" in payload
|
||||
assert "candidate_count" in payload
|
||||
assert payload["candidate_count"] == 1
|
||||
assert set(payload["keys_matched"]) >= {"charge", "npi"} # PCN differs in this test
|
||||
# Verify claim state was flipped
|
||||
db_session.refresh(claim)
|
||||
assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED}
|
||||
|
||||
Reference in New Issue
Block a user