543 lines
18 KiB
Python
543 lines
18 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 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
|
|
)
|
|
|
|
# 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
|