feat: implement 4-point demographic synchronization & patient validation
This commit is contained in:
@@ -269,3 +269,170 @@ async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session
|
||||
logger.error(f"Push-vitals error: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
|
||||
|
||||
|
||||
# === Patient-to-Monitor Matching Endpoint ===
|
||||
def _normalize_name(s: str) -> str:
|
||||
"""Lowercase, strip punctuation and extra spaces for fuzzy name comparison."""
|
||||
import re
|
||||
if not s:
|
||||
return ""
|
||||
return re.sub(r"[^a-z0-9\s]", "", s.lower()).split()
|
||||
|
||||
def _normalize_dob(dob: str) -> str:
|
||||
"""Normalize DOB to YYYYMMDD regardless of input format (YYYY-MM-DD / YYYYMMDD / DD-MM-YYYY)."""
|
||||
if not dob:
|
||||
return ""
|
||||
dob = dob.strip().replace("/", "-")
|
||||
if len(dob) == 8 and dob.isdigit():
|
||||
return dob # already YYYYMMDD
|
||||
parts = dob.split("-")
|
||||
if len(parts) == 3:
|
||||
if len(parts[0]) == 4: # YYYY-MM-DD
|
||||
return "".join(parts)
|
||||
elif len(parts[2]) == 4: # DD-MM-YYYY
|
||||
return parts[2] + parts[1] + parts[0]
|
||||
return dob.replace("-", "")
|
||||
|
||||
def _gender_code(g: str) -> str:
|
||||
"""Return single-letter gender code: M / F / U."""
|
||||
if not g:
|
||||
return ""
|
||||
g = g.strip().upper()
|
||||
if g in ("M", "MALE"):
|
||||
return "M"
|
||||
if g in ("F", "FEMALE"):
|
||||
return "F"
|
||||
return "U"
|
||||
|
||||
def _calculate_age(dob_str: str) -> Optional[int]:
|
||||
"""Calculate age from YYYYMMDD string."""
|
||||
if not dob_str:
|
||||
return None
|
||||
normalized = _normalize_dob(dob_str)
|
||||
if len(normalized) != 8 or not normalized.isdigit():
|
||||
return None
|
||||
try:
|
||||
birth_year = int(normalized[:4])
|
||||
birth_month = int(normalized[4:6])
|
||||
birth_day = int(normalized[6:])
|
||||
today = datetime.now()
|
||||
return today.year - birth_year - ((today.month, today.day) < (birth_month, birth_day))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@router.get("/match-patient")
|
||||
def match_patient(
|
||||
mrn: str = None,
|
||||
name: str = None,
|
||||
gender: str = None,
|
||||
dob: str = None,
|
||||
age: int = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Match incoming patient demographics to an active Contec monitor.
|
||||
|
||||
Call this from the dashboard iframe URL:
|
||||
/api/dashboard?minimal=true&mrn=MRN001&name=John+Doe&gender=M&dob=19900115&age=36
|
||||
|
||||
Returns:
|
||||
{ matched: true/false, device_id, patient_id, bed_id, device_status, ... }
|
||||
"""
|
||||
if not mrn and not name:
|
||||
return {"matched": False, "reason": "No patient identifiers supplied"}
|
||||
|
||||
# Fetch all patients
|
||||
all_patients = db.query(Patient).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Normalize incoming params
|
||||
in_mrn = (mrn or "").strip().upper()
|
||||
in_name = _normalize_name(name or "")
|
||||
in_gender = _gender_code(gender or "")
|
||||
in_dob = _normalize_dob(dob or "")
|
||||
in_age = age
|
||||
|
||||
matched_patient = None
|
||||
match_score = 0
|
||||
|
||||
for p in all_patients:
|
||||
score = 0
|
||||
|
||||
# --- MRN check (primary) ---
|
||||
p_mrn = (p.mrn or p.patient_id or "").strip().upper()
|
||||
if in_mrn and p_mrn == in_mrn:
|
||||
score += 10
|
||||
elif in_mrn:
|
||||
continue # MRN supplied but doesn't match → skip
|
||||
|
||||
# --- Name check ---
|
||||
if in_name:
|
||||
p_name = _normalize_name(p.name or "")
|
||||
if p_name and any(w in p_name for w in in_name):
|
||||
score += 3
|
||||
|
||||
# --- Gender check ---
|
||||
if in_gender and p.gender:
|
||||
if _gender_code(p.gender) == in_gender:
|
||||
score += 2
|
||||
|
||||
# --- DOB check ---
|
||||
if in_dob and p.dob:
|
||||
if _normalize_dob(p.dob) == in_dob:
|
||||
score += 3
|
||||
|
||||
# --- Age check ---
|
||||
if in_age is not None and p.dob:
|
||||
calc_p_age = _calculate_age(p.dob)
|
||||
if calc_p_age is not None and calc_p_age == in_age:
|
||||
score += 2
|
||||
|
||||
if score > match_score:
|
||||
match_score = score
|
||||
matched_patient = p
|
||||
|
||||
# To be matched, MRN must be found, and name/gender/dob must also be validated
|
||||
# (Require at least a score of 12: MRN + Name, or MRN + Gender, etc.)
|
||||
if not matched_patient or match_score < 12:
|
||||
return {"matched": False, "reason": "No matching patient found in monitor database with 4-point verification"}
|
||||
|
||||
# Find the most recent vital reading for this patient
|
||||
latest = (
|
||||
db.query(VitalReading)
|
||||
.filter(VitalReading.patient_id == matched_patient.patient_id)
|
||||
.order_by(VitalReading.timestamp.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not latest:
|
||||
return {
|
||||
"matched": True,
|
||||
"patient_id": matched_patient.patient_id,
|
||||
"device_id": None,
|
||||
"bed_id": matched_patient.bed_id,
|
||||
"reason": "Patient found but no readings yet"
|
||||
}
|
||||
|
||||
# Get device status
|
||||
device = db.query(Device).filter(Device.device_id == latest.device_id).first()
|
||||
device_status = "unknown"
|
||||
if device:
|
||||
last_seen = device.last_seen
|
||||
if last_seen.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||
device_status = "active" if (now - last_seen).total_seconds() < 15 else "offline"
|
||||
|
||||
return {
|
||||
"matched": True,
|
||||
"patient_id": matched_patient.patient_id,
|
||||
"patient_name": matched_patient.name,
|
||||
"patient_gender": matched_patient.gender,
|
||||
"patient_dob": matched_patient.dob,
|
||||
"device_id": latest.device_id,
|
||||
"bed_id": matched_patient.bed_id,
|
||||
"device_status": device_status,
|
||||
"last_reading_at": latest.timestamp.isoformat() if latest.timestamp else None,
|
||||
"match_score": match_score,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user