feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool

This commit is contained in:
Nora
2026-07-02 15:17:26 -06:00
parent b484ca36dc
commit 5a54961930
3 changed files with 335 additions and 6 deletions
+76 -5
View File
@@ -128,6 +128,14 @@ def _content_keys_match(remit, claim) -> bool:
Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
NPI counts as "not matched" when the remit's NPI is empty.
Uses duck-typing via ``getattr`` so the helper works against both:
- shim / dataclass test objects (planned names: total_charge_amount,
total_charge, rendering_provider_npi), and
- real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge,
Claim.provider_npi). The Remittance ORM has no NPI column — callers
that need NPI on a Remit set the transient ``rendering_provider_npi``
attribute (the 835 parser does this on raw_json; tests do it directly).
"""
keys_matched = 0
@@ -136,22 +144,85 @@ def _content_keys_match(remit, claim) -> bool:
(getattr(claim, "patient_control_number", "") or "").strip():
keys_matched += 1
# Charge: total_charge_amount ↔ total_charge (within $0.01).
remit_charge = getattr(remit, "total_charge_amount", None)
claim_charge = getattr(claim, "total_charge", None)
# Charge: total_charge_amount/total_charge ↔ total_charge/charge_amount (within $0.01).
remit_charge = getattr(remit, "total_charge_amount", None) or getattr(remit, "total_charge", None)
claim_charge = getattr(claim, "total_charge", None) or getattr(claim, "charge_amount", None)
if remit_charge is not None and claim_charge is not None and \
abs(remit_charge - claim_charge) < CHARGE_TOLERANCE:
keys_matched += 1
# NPI: rendering_provider_npi ↔ rendering_provider_npi (must be non-empty).
# NPI: rendering_provider_npi (or provider_npi fallback on Claim) ↔
# rendering_provider_npi (must be non-empty on the remit side).
remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip()
claim_npi = (getattr(claim, "rendering_provider_npi", "") or "").strip()
claim_npi = (
(getattr(claim, "rendering_provider_npi", "") or "")
or (getattr(claim, "provider_npi", "") or "")
).strip()
if remit_npi and remit_npi == claim_npi:
keys_matched += 1
return keys_matched >= KEYS_REQUIRED
# --- SP31: DB-side fallback candidate matcher -------------------------------
SCORE_FALLBACK_WINDOW_DAYS = 30
def _score_fallback_candidates(session, remit) -> Optional[str]:
"""Return a unique matching claim_id via the 2-of-3 content-keys rule.
Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
- claim.matched_remittance_id IS NULL (not yet linked)
- claim.state NOT IN terminal set (PAID, DENIED, REJECTED,
REVERSED, RECONCILED)
- claim.service_date_from within window of remit.service_date
Returns the unique claim.id when exactly one candidate passes the
2-of-3 rule (via :func:`_content_keys_match`), otherwise ``None``.
Ambiguous matches (2+ candidates) also return ``None`` — they land
in the Inbox Unlinked lane for manual review.
Note on payer_id: ``Remittance`` has no ``payer_id`` column
(``Claim.payer_id`` does, and is the source of truth on the claim
side; the 835 parser stores payer_id in ``Remittance.raw_json``).
Filtering by payer would require either a raw_json read or a model
column add — out of scope for SP31 Task 3. The PCN itself is
expected to be unique within a payer's submission, so cross-payer
collisions are unlikely. Revisit if production shows otherwise.
"""
from sqlalchemy import select, and_
from datetime import timedelta
from cyclone.db import Claim, ClaimState
TERMINAL = {
ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED,
ClaimState.REVERSED, ClaimState.RECONCILED,
}
if remit.service_date is None:
return None # need a date for the window query
window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
candidates = list(session.execute(
select(Claim).where(
and_(
Claim.matched_remittance_id.is_(None),
Claim.service_date_from >= window_lo,
Claim.service_date_from <= window_hi,
Claim.state.notin_([s.value for s in TERMINAL]),
)
)
).scalars().all())
matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)]
if len(matched_ids) == 1:
return matched_ids[0]
return None # 0 matches OR 2+ (ambiguous)
@dataclass
class ApplyIntent:
"""Result of applying a match. The caller persists this.