HH/apps/physicians/adapter.py
2026-02-22 08:35:53 +03:00

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