""" 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