""" KPI Report Calculation Service This service calculates KPI metrics for monthly reports based on the complaint and survey data in the system. """ import logging from datetime import datetime, timedelta from decimal import Decimal from typing import Dict, List, Optional from django.db.models import Avg, Count, F, Q from django.utils import timezone from apps.complaints.models import Complaint, ComplaintStatus, ComplaintSource from apps.organizations.models import Department from apps.surveys.models import SurveyInstance, SurveyStatus, SurveyTemplate from .kpi_models import ( KPIReport, KPIReportDepartmentBreakdown, KPIReportMonthlyData, KPIReportSourceBreakdown, KPIReportStatus, KPIReportType, ) logger = logging.getLogger(__name__) class KPICalculationService: """ Service for calculating KPI report metrics Handles the complex calculations for each KPI type: - 72H Resolution Rate - Patient Experience Score - Satisfaction with Resolution - Response Rate - Activation Time - Unactivated Rate """ @classmethod def generate_monthly_report( cls, report_type: str, hospital, year: int, month: int, generated_by=None ) -> KPIReport: """ Generate a complete monthly KPI report Args: report_type: Type of KPI report (from KPIReportType) hospital: Hospital instance year: Report year month: Report month (1-12) generated_by: User who generated the report (optional) Returns: KPIReport instance """ # Get or create the report report, created = KPIReport.objects.get_or_create( report_type=report_type, hospital=hospital, year=year, month=month, defaults={ "report_date": timezone.now().date(), "status": KPIReportStatus.PENDING, "generated_by": generated_by, } ) if not created and report.status == KPIReportStatus.COMPLETED: # Report already exists and is complete - return it return report # Update status to generating report.status = KPIReportStatus.GENERATING report.save() try: # Calculate based on report type if report_type == KPIReportType.RESOLUTION_72H: cls._calculate_72h_resolution(report) elif report_type == KPIReportType.PATIENT_EXPERIENCE: cls._calculate_patient_experience(report) elif report_type == KPIReportType.SATISFACTION_RESOLUTION: cls._calculate_satisfaction_resolution(report) elif report_type == KPIReportType.N_PAD_001: cls._calculate_n_pad_001(report) elif report_type == KPIReportType.RESPONSE_RATE: cls._calculate_response_rate(report) elif report_type == KPIReportType.ACTIVATION_2H: cls._calculate_activation_2h(report) elif report_type == KPIReportType.UNACTIVATED: cls._calculate_unactivated(report) # Mark as completed report.status = KPIReportStatus.COMPLETED report.generated_at = timezone.now() report.save() logger.info(f"KPI Report {report.id} generated successfully") except Exception as e: logger.exception(f"Error generating KPI report {report.id}") report.status = KPIReportStatus.FAILED report.error_message = str(e) report.save() raise return report @classmethod def _calculate_72h_resolution(cls, report: KPIReport): """Calculate 72-Hour Resolution Rate (MOH-2)""" # Get date range for the report period start_date = datetime(report.year, report.month, 1) if report.month == 12: end_date = datetime(report.year + 1, 1, 1) else: end_date = datetime(report.year, report.month + 1, 1) # Get all months data for YTD (year to date) year_start = datetime(report.year, 1, 1) # Calculate for each month total_numerator = 0 total_denominator = 0 for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) # Get complaints for this month complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, complaint_type="complaint" # Only actual complaints ) # Count total complaints denominator = complaints.count() # Count resolved within 72 hours numerator = 0 for complaint in complaints: if complaint.resolved_at and complaint.created_at: resolution_time = complaint.resolved_at - complaint.created_at if resolution_time.total_seconds() <= 72 * 3600: # 72 hours numerator += 1 # Create or update monthly data monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, } ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage # Store source breakdown in details source_data = cls._get_source_breakdown(complaints) monthly_data.details = {"source_breakdown": source_data} monthly_data.save() total_numerator += numerator total_denominator += denominator # Update report totals report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() # Create source breakdown for pie chart all_complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=year_start, created_at__lt=end_date, complaint_type="complaint" ) cls._create_source_breakdowns(report, all_complaints) # Create department breakdown cls._create_department_breakdown(report, all_complaints) @classmethod def _calculate_patient_experience(cls, report: KPIReport): """Calculate Patient Experience Score (MOH-1)""" # Get date range year_start = datetime(report.year, 1, 1) start_date = datetime(report.year, report.month, 1) if report.month == 12: end_date = datetime(report.year + 1, 1, 1) else: end_date = datetime(report.year, report.month + 1, 1) total_numerator = 0 total_denominator = 0 for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) # Get completed surveys for patient experience surveys = SurveyInstance.objects.filter( survey_template__hospital=report.hospital, status=SurveyStatus.COMPLETED, completed_at__gte=month_start, completed_at__lt=month_end, survey_template__survey_type__in=["stage", "general"] ) denominator = surveys.count() # Count positive responses (score >= 4 out of 5) numerator = surveys.filter(total_score__gte=4).count() monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, } ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage # Store average score avg_score = surveys.aggregate(avg=Avg('total_score'))['avg'] or 0 monthly_data.details = {"avg_score": round(avg_score, 2)} monthly_data.save() total_numerator += numerator total_denominator += denominator report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() @classmethod def _calculate_satisfaction_resolution(cls, report: KPIReport): """Calculate Overall Satisfaction with Resolution (MOH-3)""" year_start = datetime(report.year, 1, 1) total_numerator = 0 total_denominator = 0 for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) # Get complaint resolution surveys surveys = SurveyInstance.objects.filter( survey_template__hospital=report.hospital, status=SurveyStatus.COMPLETED, completed_at__gte=month_start, completed_at__lt=month_end, survey_template__survey_type="complaint_resolution" ) denominator = surveys.count() # Satisfied = score >= 4 numerator = surveys.filter(total_score__gte=4).count() monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, } ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() total_numerator += numerator total_denominator += denominator report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() @classmethod def _calculate_n_pad_001(cls, report: KPIReport): """Calculate N-PAD-001 Resolution Rate""" year_start = datetime(report.year, 1, 1) total_numerator = 0 total_denominator = 0 for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) # Get complaints complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, complaint_type="complaint" ) denominator = complaints.count() # Resolved includes closed and resolved statuses numerator = complaints.filter( status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED] ).count() monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, } ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() total_numerator += numerator total_denominator += denominator report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() @classmethod def _calculate_response_rate(cls, report: KPIReport): """Calculate Department Response Rate (48h)""" year_start = datetime(report.year, 1, 1) total_numerator = 0 total_denominator = 0 for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) # Get complaints that received a response complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, complaint_type="complaint" ) denominator = complaints.count() # Count complaints with response within 48h numerator = 0 for complaint in complaints: first_update = complaint.updates.order_by('created_at').first() if first_update and complaint.created_at: response_time = first_update.created_at - complaint.created_at if response_time.total_seconds() <= 48 * 3600: numerator += 1 monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, } ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() total_numerator += numerator total_denominator += denominator report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() @classmethod def _calculate_activation_2h(cls, report: KPIReport): """Calculate Complaint Activation Within 2 Hours""" year_start = datetime(report.year, 1, 1) total_numerator = 0 total_denominator = 0 for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) # Get complaints with assigned_to (activated) complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, complaint_type="complaint" ) denominator = complaints.count() # Count activated within 2 hours numerator = 0 for complaint in complaints: if complaint.assigned_at and complaint.created_at: activation_time = complaint.assigned_at - complaint.created_at if activation_time.total_seconds() <= 2 * 3600: numerator += 1 monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, } ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() total_numerator += numerator total_denominator += denominator report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() @classmethod def _calculate_unactivated(cls, report: KPIReport): """Calculate Unactivated Filled Complaints Rate""" year_start = datetime(report.year, 1, 1) total_numerator = 0 total_denominator = 0 for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) # Get all complaints complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, complaint_type="complaint" ) denominator = complaints.count() # Unactivated = no assigned_to numerator = complaints.filter(assigned_to__isnull=True).count() monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, } ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() # Note: For unactivated, HIGHER is WORSE, so below target = above threshold monthly_data.is_below_target = monthly_data.percentage > (100 - report.target_percentage) monthly_data.save() total_numerator += numerator total_denominator += denominator report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() @classmethod def _get_source_breakdown(cls, complaints) -> Dict[str, int]: """Get breakdown of complaints by source""" sources = {} for complaint in complaints: source_name = complaint.source.name_en if complaint.source else "Other" sources[source_name] = sources.get(source_name, 0) + 1 return sources @classmethod def _create_source_breakdowns(cls, report: KPIReport, complaints): """Create source breakdown records for pie chart""" # Delete existing report.source_breakdowns.all().delete() # Count by source source_counts = {} total = complaints.count() for complaint in complaints: source_name = complaint.source.name_en if complaint.source else "Other" source_counts[source_name] = source_counts.get(source_name, 0) + 1 # Create records for source_name, count in source_counts.items(): percentage = (count / total * 100) if total > 0 else 0 KPIReportSourceBreakdown.objects.create( kpi_report=report, source_name=source_name, complaint_count=count, percentage=Decimal(str(percentage)) ) @classmethod def _create_department_breakdown(cls, report: KPIReport, complaints): """Create department breakdown records""" # Delete existing report.department_breakdowns.all().delete() # Categorize departments department_categories = { "medical": ["Medical", "Surgery", "Cardiology", "Orthopedics", "Pediatrics", "Obstetrics", "Gynecology"], "nursing": ["Nursing", "ICU", "ER", "OR"], "admin": ["Administration", "HR", "Finance", "IT", "Reception"], "support": ["Housekeeping", "Maintenance", "Security", "Cafeteria", "Transport"], } for category, keywords in department_categories.items(): # Find departments matching this category dept_complaints = complaints.filter( department__name__icontains=keywords[0] ) for keyword in keywords[1:]: dept_complaints = dept_complaints | complaints.filter( department__name__icontains=keyword ) complaint_count = dept_complaints.count() resolved_count = dept_complaints.filter( status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED] ).count() # Calculate average resolution days avg_days = None resolved_complaints = dept_complaints.filter(resolved_at__isnull=False) if resolved_complaints.exists(): total_days = 0 for c in resolved_complaints: days = (c.resolved_at - c.created_at).total_seconds() / (24 * 3600) total_days += days avg_days = Decimal(str(total_days / resolved_complaints.count())) # Get top areas (subcategories) top_areas_list = [] for c in dept_complaints[:10]: if c.category: top_areas_list.append(c.category.name_en) top_areas = "\n".join(list(set(top_areas_list))[:5]) if top_areas_list else "" KPIReportDepartmentBreakdown.objects.create( kpi_report=report, department_category=category, complaint_count=complaint_count, resolved_count=resolved_count, avg_resolution_days=avg_days, top_areas=top_areas )