624 lines
22 KiB
Python
624 lines
22 KiB
Python
"""
|
|
Doctor Rating Adapter Service
|
|
|
|
Handles the transformation of Doctor Rating data from HIS/CSV to internal format.
|
|
- Parses doctor names (extracts ID prefix like '10738-')
|
|
- Matches doctors to existing Staff records
|
|
- Creates individual ratings and aggregates monthly
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from apps.organizations.models import Hospital, Patient, Staff
|
|
|
|
from .models import DoctorRatingImportJob, PhysicianIndividualRating, PhysicianMonthlyRating
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DoctorRatingAdapter:
|
|
"""
|
|
Adapter for transforming Doctor Rating data from HIS/CSV to internal format.
|
|
"""
|
|
|
|
@staticmethod
|
|
def parse_doctor_name(doctor_name_raw: str) -> Tuple[str, str]:
|
|
"""
|
|
Parse doctor name from HIS format.
|
|
|
|
HIS Format: "10738-OMAYMAH YAQOUB ELAMEIAN"
|
|
Returns: (doctor_id, doctor_name_clean)
|
|
|
|
Examples:
|
|
- "10738-OMAYMAH YAQOUB ELAMEIAN" -> ("10738", "OMAYMAH YAQOUB ELAMEIAN")
|
|
- "OMAYMAH YAQOUB ELAMEIAN" -> ("", "OMAYMAH YAQOUB ELAMEIAN")
|
|
"""
|
|
if not doctor_name_raw:
|
|
return "", ""
|
|
|
|
doctor_name_raw = doctor_name_raw.strip()
|
|
|
|
# Pattern: ID-NAME (e.g., "10738-OMAYMAH YAQOUB ELAMEIAN")
|
|
match = re.match(r"^(\d+)-(.+)$", doctor_name_raw)
|
|
if match:
|
|
doctor_id = match.group(1)
|
|
doctor_name = match.group(2).strip()
|
|
return doctor_id, doctor_name
|
|
|
|
# Pattern: ID - NAME (with spaces)
|
|
match = re.match(r"^(\d+)\s*-\s*(.+)$", doctor_name_raw)
|
|
if match:
|
|
doctor_id = match.group(1)
|
|
doctor_name = match.group(2).strip()
|
|
return doctor_id, doctor_name
|
|
|
|
# No ID prefix found
|
|
return "", doctor_name_raw
|
|
|
|
@staticmethod
|
|
def parse_date(date_str: str) -> Optional[datetime]:
|
|
"""
|
|
Parse date from various formats.
|
|
|
|
Supported formats:
|
|
- "22-Dec-2024 19:12:24" (HIS format)
|
|
- "22-Dec-2024"
|
|
- "2024-12-22 19:12:24"
|
|
- "2024-12-22"
|
|
- "22/12/2024 19:12:24"
|
|
- "22/12/2024"
|
|
"""
|
|
if not date_str:
|
|
return None
|
|
|
|
date_str = date_str.strip()
|
|
|
|
formats = [
|
|
"%d-%b-%Y %H:%M:%S",
|
|
"%d-%b-%Y",
|
|
"%d-%b-%y %H:%M:%S",
|
|
"%d-%b-%y",
|
|
"%Y-%m-%d %H:%M:%S",
|
|
"%Y-%m-%d",
|
|
"%d/%m/%Y %H:%M:%S",
|
|
"%d/%m/%Y",
|
|
"%m/%d/%Y %H:%M:%S",
|
|
"%m/%d/%Y",
|
|
]
|
|
|
|
for fmt in formats:
|
|
try:
|
|
naive_dt = datetime.strptime(date_str, fmt)
|
|
return timezone.make_aware(naive_dt)
|
|
except ValueError:
|
|
continue
|
|
|
|
logger.warning(f"Could not parse date: {date_str}")
|
|
return None
|
|
|
|
@staticmethod
|
|
def parse_age(age_str: str) -> str:
|
|
"""
|
|
Parse age string to extract just the number.
|
|
|
|
Examples:
|
|
- "36 Years" -> "36"
|
|
- "36" -> "36"
|
|
"""
|
|
if not age_str:
|
|
return ""
|
|
|
|
match = re.search(r"(\d+)", age_str)
|
|
if match:
|
|
return match.group(1)
|
|
return age_str
|
|
|
|
@staticmethod
|
|
def clean_phone(phone: str) -> str:
|
|
"""
|
|
Clean and normalize phone number to international format.
|
|
|
|
Examples:
|
|
- "0504884011" -> "+966504884011"
|
|
- "+966504884011" -> "+966504884011"
|
|
"""
|
|
if not phone:
|
|
return ""
|
|
|
|
phone = phone.strip().replace(" ", "").replace("-", "")
|
|
|
|
if phone.startswith("+"):
|
|
return phone
|
|
|
|
# Saudi numbers
|
|
if phone.startswith("05"):
|
|
return "+966" + phone[1:]
|
|
elif phone.startswith("5"):
|
|
return "+966" + phone
|
|
elif phone.startswith("0"):
|
|
return "+966" + phone[1:]
|
|
|
|
return phone
|
|
|
|
@staticmethod
|
|
def find_staff_by_doctor_id(doctor_id: str, hospital: Hospital, doctor_name: str = "") -> Optional[Staff]:
|
|
"""
|
|
Find staff record by doctor ID or name.
|
|
|
|
Search priority:
|
|
1. Match by employee_id (exact)
|
|
2. Match by license_number (exact)
|
|
3. Match by name (case-insensitive contains)
|
|
"""
|
|
if not doctor_id and not doctor_name:
|
|
return None
|
|
|
|
# Try by employee_id (exact match)
|
|
if doctor_id:
|
|
staff = Staff.objects.filter(hospital=hospital, employee_id=doctor_id).first()
|
|
if staff:
|
|
return staff
|
|
|
|
# Try by license_number
|
|
if doctor_id:
|
|
staff = Staff.objects.filter(hospital=hospital, license_number=doctor_id).first()
|
|
if staff:
|
|
return staff
|
|
|
|
# Try by name matching
|
|
if doctor_name:
|
|
# Try exact match first
|
|
staff = Staff.objects.filter(hospital=hospital, name__iexact=doctor_name).first()
|
|
if staff:
|
|
return staff
|
|
|
|
# Try contains match on name
|
|
staff = Staff.objects.filter(hospital=hospital, name__icontains=doctor_name).first()
|
|
if staff:
|
|
return staff
|
|
|
|
# Try first_name + last_name
|
|
name_parts = doctor_name.split()
|
|
if len(name_parts) >= 2:
|
|
first_name = name_parts[0]
|
|
last_name = name_parts[-1]
|
|
staff = Staff.objects.filter(
|
|
hospital=hospital, first_name__iexact=first_name, last_name__iexact=last_name
|
|
).first()
|
|
if staff:
|
|
return staff
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def process_his_rating_record(data: Dict, hospital: Hospital) -> Dict:
|
|
"""
|
|
Process a single doctor rating record from HIS API format.
|
|
|
|
HIS Format:
|
|
{
|
|
"DoctorID": "11510",
|
|
"EmpNo": "17046",
|
|
"DoctorName": "AAMIR USMAN BAIG",
|
|
"DoctorDepartment": "ORTHOPAEDIC",
|
|
"HospitalName": "SUWAIDI",
|
|
"HospitalID": "2",
|
|
"Rating": "5.00",
|
|
"RatingDate": "30-Dec-2025 14:06"
|
|
}
|
|
|
|
Args:
|
|
data: Dictionary containing HIS rating data
|
|
hospital: Hospital instance
|
|
|
|
Returns:
|
|
Dict with 'success', 'rating_id', 'message', 'staff_matched', 'is_duplicate'
|
|
"""
|
|
result = {
|
|
"success": False,
|
|
"rating_id": None,
|
|
"message": "",
|
|
"staff_matched": False,
|
|
"staff_id": None,
|
|
"is_duplicate": False,
|
|
}
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
# Extract doctor info
|
|
doctor_id = data.get("DoctorID", "").strip()
|
|
emp_no = data.get("EmpNo", "").strip()
|
|
doctor_name = data.get("DoctorName", "").strip()
|
|
department_name = data.get("DoctorDepartment", "").strip()
|
|
|
|
# Find staff by DoctorID or EmpNo
|
|
staff = None
|
|
if doctor_id:
|
|
staff = Staff.objects.filter(hospital=hospital, employee_id=doctor_id).first()
|
|
|
|
if not staff and emp_no:
|
|
staff = Staff.objects.filter(hospital=hospital, employee_id=emp_no).first()
|
|
|
|
# Try name matching as fallback
|
|
if not staff and doctor_name:
|
|
staff = DoctorRatingAdapter.find_staff_by_doctor_id(
|
|
doctor_id="", hospital=hospital, doctor_name=doctor_name
|
|
)
|
|
|
|
# Mark as physician if matched
|
|
if staff and not staff.physician:
|
|
staff.physician = True
|
|
staff.save(update_fields=["physician"])
|
|
|
|
# Parse rating date
|
|
rating_date = DoctorRatingAdapter.parse_date(data.get("RatingDate", ""))
|
|
if not rating_date:
|
|
result["message"] = f"Invalid rating date: {data.get('RatingDate')}"
|
|
return result
|
|
|
|
# Parse rating
|
|
try:
|
|
rating = int(float(data.get("Rating", 0)))
|
|
if rating < 1 or rating > 5:
|
|
result["message"] = f"Invalid rating value: {rating}"
|
|
return result
|
|
except (ValueError, TypeError):
|
|
result["message"] = f"Invalid rating format: {data.get('Rating')}"
|
|
return result
|
|
|
|
# Check for duplicates (DoctorID + RatingDate)
|
|
doctor_ref = doctor_id or emp_no
|
|
if doctor_ref:
|
|
existing = PhysicianIndividualRating.objects.filter(
|
|
doctor_id=doctor_ref, rating_date=rating_date, hospital=hospital
|
|
).first()
|
|
|
|
if existing:
|
|
result["is_duplicate"] = True
|
|
result["message"] = f"Duplicate rating for doctor {doctor_ref} on {rating_date}"
|
|
return result
|
|
|
|
# Create individual rating (no patient data for HIS ratings)
|
|
individual_rating = PhysicianIndividualRating.objects.create(
|
|
staff=staff,
|
|
hospital=hospital,
|
|
source=PhysicianIndividualRating.RatingSource.HIS_API,
|
|
source_reference=f"HIS_{data.get('DoctorID', '')}",
|
|
doctor_name_raw=doctor_name,
|
|
doctor_id=doctor_id or emp_no,
|
|
doctor_name=doctor_name,
|
|
department_name=department_name,
|
|
# Patient fields are null for HIS ratings
|
|
patient_uhid=None,
|
|
patient_name=None,
|
|
patient_gender="",
|
|
patient_age="",
|
|
patient_nationality="",
|
|
patient_phone="",
|
|
patient_type=None,
|
|
admit_date=None,
|
|
discharge_date=None,
|
|
rating=rating,
|
|
feedback="", # HIS doesn't provide feedback
|
|
rating_date=rating_date,
|
|
is_aggregated=False,
|
|
metadata={
|
|
"emp_no": emp_no,
|
|
"hospital_id_his": data.get("HospitalID", ""),
|
|
"hospital_name_his": data.get("HospitalName", ""),
|
|
"imported_at": timezone.now().isoformat(),
|
|
},
|
|
)
|
|
|
|
result["success"] = True
|
|
result["rating_id"] = str(individual_rating.id)
|
|
result["staff_matched"] = staff is not None
|
|
result["staff_id"] = str(staff.id) if staff else None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing HIS doctor rating: {str(e)}", exc_info=True)
|
|
result["message"] = str(e)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def get_or_create_patient(uhid: str, patient_name: str, hospital: Hospital, **kwargs) -> Optional[Patient]:
|
|
"""
|
|
Get or create patient by UHID.
|
|
"""
|
|
if not uhid:
|
|
return None
|
|
|
|
# Split name
|
|
name_parts = patient_name.split() if patient_name else ["Unknown", ""]
|
|
first_name = name_parts[0] if name_parts else "Unknown"
|
|
last_name = name_parts[-1] if len(name_parts) > 1 else ""
|
|
|
|
patient, created = Patient.objects.get_or_create(
|
|
mrn=uhid,
|
|
defaults={
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"primary_hospital": hospital,
|
|
},
|
|
)
|
|
|
|
# Update patient info if provided
|
|
if kwargs.get("phone"):
|
|
patient.phone = kwargs["phone"]
|
|
if kwargs.get("nationality"):
|
|
patient.nationality = kwargs["nationality"]
|
|
if kwargs.get("gender"):
|
|
patient.gender = kwargs["gender"].lower()
|
|
if kwargs.get("date_of_birth"):
|
|
patient.date_of_birth = kwargs["date_of_birth"]
|
|
|
|
patient.save()
|
|
return patient
|
|
|
|
@staticmethod
|
|
def process_single_rating(
|
|
data: Dict,
|
|
hospital: Hospital,
|
|
source: str = PhysicianIndividualRating.RatingSource.HIS_API,
|
|
source_reference: str = "",
|
|
) -> Dict:
|
|
"""
|
|
Process a single doctor rating record.
|
|
|
|
Args:
|
|
data: Dictionary containing rating data
|
|
hospital: Hospital instance
|
|
source: Source of the rating (his_api, csv_import, manual)
|
|
source_reference: Reference ID from source system
|
|
|
|
Returns:
|
|
Dict with 'success', 'rating_id', 'message', 'staff_matched'
|
|
"""
|
|
result = {"success": False, "rating_id": None, "message": "", "staff_matched": False, "staff_id": None}
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
# Extract and parse doctor info
|
|
doctor_name_raw = data.get("doctor_name", "").strip()
|
|
doctor_id, doctor_name = DoctorRatingAdapter.parse_doctor_name(doctor_name_raw)
|
|
|
|
# Find staff
|
|
staff = DoctorRatingAdapter.find_staff_by_doctor_id(doctor_id, hospital, doctor_name)
|
|
|
|
# If staff found, mark as physician
|
|
if staff and not staff.physician:
|
|
staff.physician = True
|
|
staff.save(update_fields=["physician"])
|
|
|
|
# Extract patient info
|
|
uhid = data.get("uhid", "").strip()
|
|
patient_name = data.get("patient_name", "").strip()
|
|
|
|
# Parse dates
|
|
admit_date = DoctorRatingAdapter.parse_date(data.get("admit_date", ""))
|
|
discharge_date = DoctorRatingAdapter.parse_date(data.get("discharge_date", ""))
|
|
rating_date = DoctorRatingAdapter.parse_date(data.get("rating_date", ""))
|
|
|
|
if not rating_date and admit_date:
|
|
rating_date = admit_date
|
|
|
|
if not rating_date:
|
|
rating_date = timezone.now()
|
|
|
|
# Clean phone
|
|
phone = DoctorRatingAdapter.clean_phone(data.get("mobile_no", ""))
|
|
|
|
# Parse rating
|
|
try:
|
|
rating = int(float(data.get("rating", 0)))
|
|
if rating < 1 or rating > 5:
|
|
result["message"] = f"Invalid rating value: {rating}"
|
|
return result
|
|
except (ValueError, TypeError):
|
|
result["message"] = f"Invalid rating format: {data.get('rating')}"
|
|
return result
|
|
|
|
# Get or create patient
|
|
patient = None
|
|
if uhid:
|
|
patient = DoctorRatingAdapter.get_or_create_patient(
|
|
uhid=uhid,
|
|
patient_name=patient_name,
|
|
hospital=hospital,
|
|
phone=phone,
|
|
nationality=data.get("nationality", ""),
|
|
gender=data.get("gender", ""),
|
|
)
|
|
|
|
# Determine patient type
|
|
patient_type_raw = data.get("patient_type", "").upper()
|
|
patient_type_map = {
|
|
"IP": PhysicianIndividualRating.PatientType.INPATIENT,
|
|
"OP": PhysicianIndividualRating.PatientType.OUTPATIENT,
|
|
"OPD": PhysicianIndividualRating.PatientType.OUTPATIENT,
|
|
"ER": PhysicianIndividualRating.PatientType.EMERGENCY,
|
|
"EMS": PhysicianIndividualRating.PatientType.EMERGENCY,
|
|
"DC": PhysicianIndividualRating.PatientType.DAYCASE,
|
|
"DAYCASE": PhysicianIndividualRating.PatientType.DAYCASE,
|
|
}
|
|
patient_type = patient_type_map.get(patient_type_raw, "")
|
|
|
|
# Create individual rating
|
|
individual_rating = PhysicianIndividualRating.objects.create(
|
|
staff=staff,
|
|
hospital=hospital,
|
|
source=source,
|
|
source_reference=source_reference,
|
|
doctor_name_raw=doctor_name_raw,
|
|
doctor_id=doctor_id,
|
|
doctor_name=doctor_name,
|
|
department_name=data.get("department", ""),
|
|
patient_uhid=uhid,
|
|
patient_name=patient_name,
|
|
patient_gender=data.get("gender", ""),
|
|
patient_age=DoctorRatingAdapter.parse_age(data.get("age", "")),
|
|
patient_nationality=data.get("nationality", ""),
|
|
patient_phone=phone,
|
|
patient_type=patient_type,
|
|
admit_date=admit_date,
|
|
discharge_date=discharge_date,
|
|
rating=rating,
|
|
feedback=data.get("feedback", ""),
|
|
rating_date=rating_date,
|
|
is_aggregated=False,
|
|
metadata={
|
|
"patient_type_raw": data.get("patient_type", ""),
|
|
"imported_at": timezone.now().isoformat(),
|
|
},
|
|
)
|
|
|
|
result["success"] = True
|
|
result["rating_id"] = str(individual_rating.id)
|
|
result["staff_matched"] = staff is not None
|
|
result["staff_id"] = str(staff.id) if staff else None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing doctor rating: {str(e)}", exc_info=True)
|
|
result["message"] = str(e)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def process_bulk_ratings(records: List[Dict], hospital: Hospital, job: DoctorRatingImportJob) -> Dict:
|
|
"""
|
|
Process multiple doctor rating records in bulk.
|
|
|
|
Args:
|
|
records: List of rating data dictionaries
|
|
hospital: Hospital instance
|
|
job: DoctorRatingImportJob instance for tracking
|
|
|
|
Returns:
|
|
Dict with summary statistics
|
|
"""
|
|
results = {"total": len(records), "success": 0, "failed": 0, "skipped": 0, "staff_matched": 0, "errors": []}
|
|
|
|
job.status = DoctorRatingImportJob.JobStatus.PROCESSING
|
|
job.started_at = timezone.now()
|
|
job.save()
|
|
|
|
for idx, record in enumerate(records, 1):
|
|
try:
|
|
result = DoctorRatingAdapter.process_single_rating(data=record, hospital=hospital, source=job.source)
|
|
|
|
if result["success"]:
|
|
results["success"] += 1
|
|
if result["staff_matched"]:
|
|
results["staff_matched"] += 1
|
|
else:
|
|
results["failed"] += 1
|
|
results["errors"].append({"row": idx, "message": result["message"], "data": record})
|
|
|
|
# Update progress every 10 records
|
|
if idx % 10 == 0:
|
|
job.processed_count = idx
|
|
job.success_count = results["success"]
|
|
job.failed_count = results["failed"]
|
|
job.skipped_count = results["skipped"]
|
|
job.save()
|
|
|
|
except Exception as e:
|
|
results["failed"] += 1
|
|
results["errors"].append({"row": idx, "message": str(e), "data": record})
|
|
logger.error(f"Error processing record {idx}: {str(e)}", exc_info=True)
|
|
|
|
# Final update
|
|
job.processed_count = results["total"]
|
|
job.success_count = results["success"]
|
|
job.failed_count = results["failed"]
|
|
job.skipped_count = results["skipped"]
|
|
job.results = results
|
|
job.completed_at = timezone.now()
|
|
|
|
# Determine final status
|
|
if results["failed"] == 0:
|
|
job.status = DoctorRatingImportJob.JobStatus.COMPLETED
|
|
elif results["success"] == 0:
|
|
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
|
else:
|
|
job.status = DoctorRatingImportJob.JobStatus.PARTIAL
|
|
|
|
job.save()
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def aggregate_monthly_ratings(year: int, month: int, hospital: Hospital = None) -> Dict:
|
|
"""
|
|
Aggregate individual ratings into monthly summaries.
|
|
|
|
This should be called after importing ratings to update the monthly aggregates.
|
|
|
|
Args:
|
|
year: Year to aggregate
|
|
month: Month to aggregate (1-12)
|
|
hospital: Optional hospital filter (if None, aggregates all)
|
|
|
|
Returns:
|
|
Dict with summary of aggregations
|
|
"""
|
|
from django.db.models import Avg, Count, Q
|
|
|
|
results = {"aggregated": 0, "errors": []}
|
|
|
|
# Get unaggregated ratings for the period
|
|
queryset = PhysicianIndividualRating.objects.filter(
|
|
rating_date__year=year, rating_date__month=month, is_aggregated=False
|
|
)
|
|
|
|
if hospital:
|
|
queryset = queryset.filter(hospital=hospital)
|
|
|
|
# Group by staff
|
|
staff_ratings = queryset.values("staff").annotate(
|
|
avg_rating=Avg("rating"),
|
|
total_count=Count("id"),
|
|
positive_count=Count("id", filter=Q(rating__gte=4)),
|
|
neutral_count=Count("id", filter=Q(rating__gte=3, rating__lt=4)),
|
|
negative_count=Count("id", filter=Q(rating__lt=3)),
|
|
)
|
|
|
|
for group in staff_ratings:
|
|
staff_id = group["staff"]
|
|
if not staff_id:
|
|
continue
|
|
|
|
try:
|
|
staff = Staff.objects.get(id=staff_id)
|
|
|
|
# Update or create monthly rating
|
|
monthly_rating, created = PhysicianMonthlyRating.objects.update_or_create(
|
|
staff=staff,
|
|
year=year,
|
|
month=month,
|
|
defaults={
|
|
"average_rating": round(group["avg_rating"], 2),
|
|
"total_surveys": group["total_count"],
|
|
"positive_count": group["positive_count"],
|
|
"neutral_count": group["neutral_count"],
|
|
"negative_count": group["negative_count"],
|
|
},
|
|
)
|
|
|
|
# Mark individual ratings as aggregated
|
|
queryset.filter(staff=staff).update(is_aggregated=True, aggregated_at=timezone.now())
|
|
|
|
results["aggregated"] += 1
|
|
|
|
except Exception as e:
|
|
results["errors"].append({"staff_id": str(staff_id), "error": str(e)})
|
|
|
|
return results
|