HH/apps/analytics/kpi_models.py
2026-03-28 14:03:56 +03:00

380 lines
14 KiB
Python

"""
KPI Report Models - Monthly automated reports based on MOH requirements
This module implements KPI reports that match the Excel-style templates:
- 72H Resolution Rate (MOH-2)
- Patient Experience Score (MOH-1)
- Overall Satisfaction with Resolution (MOH-3)
- N-PAD-001 Resolution Rate
- Response Rate (Dep-KPI-4)
- Activation Within 2 Hours (KPI-6)
- Unactivated Filled Complaints Rate (KPI-7)
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.core.models import TimeStampedModel, UUIDModel
class KPIReportType(models.TextChoices):
"""KPI report types matching MOH and internal requirements"""
RESOLUTION_72H = "resolution_72h", _("72-Hour Resolution Rate (MOH-2)")
PATIENT_EXPERIENCE = "patient_experience", _("Patient Experience Score (MOH-1)")
SATISFACTION_RESOLUTION = "satisfaction_resolution", _("Overall Satisfaction with Resolution (MOH-3)")
N_PAD_001 = "n_pad_001", _("Resolution to Patient Complaints (N-PAD-001)")
RESPONSE_RATE = "response_rate", _("Department Response Rate (Dep-KPI-4)")
ACTIVATION_2H = "activation_2h", _("Complaint Activation Within 2 Hours (KPI-6)")
UNACTIVATED = "unactivated", _("Unactivated Filled Complaints Rate (KPI-7)")
class KPIReportStatus(models.TextChoices):
"""Status of KPI report generation"""
PENDING = "pending", _("Pending")
GENERATING = "generating", _("Generating")
COMPLETED = "completed", _("Completed")
FAILED = "failed", _("Failed")
class KPIReport(UUIDModel, TimeStampedModel):
"""
KPI Report - Monthly automated report for a specific KPI type
Each report represents one month of data for a specific KPI,
matching the Excel-style table format from the reference templates.
"""
report_type = models.CharField(
max_length=50, choices=KPIReportType.choices, db_index=True, help_text=_("Type of KPI report")
)
# Organization scope
hospital = models.ForeignKey(
"organizations.Hospital",
on_delete=models.CASCADE,
related_name="kpi_reports",
help_text=_("Hospital this report belongs to"),
)
# Reporting period
year = models.IntegerField(db_index=True)
month = models.IntegerField(db_index=True)
# Report metadata
report_date = models.DateField(help_text=_("Date the report was generated"))
status = models.CharField(
max_length=20, choices=KPIReportStatus.choices, default=KPIReportStatus.PENDING, db_index=True
)
generated_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="generated_kpi_reports",
help_text=_("User who generated the report (null for automated)"),
)
generated_at = models.DateTimeField(null=True, blank=True)
# Report configuration metadata
target_percentage = models.DecimalField(
max_digits=5, decimal_places=2, default=95.00, help_text=_("Target percentage for this KPI")
)
threshold_percentage = models.DecimalField(
max_digits=5,
decimal_places=2,
default=90.00,
help_text=_("Threshold (minimum acceptable) percentage for this KPI"),
)
# Report metadata (category, type, risk, etc.)
category = models.CharField(
max_length=50, default="Organizational", help_text=_("Report category (e.g., Organizational, Clinical)")
)
kpi_type = models.CharField(
max_length=50, default="Outcome", help_text=_("KPI type (e.g., Outcome, Process, Structure)")
)
risk_level = models.CharField(
max_length=20,
default="High",
choices=[
("High", "High"),
("Medium", "Medium"),
("Low", "Low"),
],
help_text=_("Risk level for this KPI"),
)
data_collection_method = models.CharField(
max_length=50, default="Retrospective", help_text=_("Data collection method")
)
data_collection_frequency = models.CharField(
max_length=50, default="Monthly", help_text=_("How often data is collected")
)
reporting_frequency = models.CharField(
max_length=50, default="Monthly", help_text=_("How often report is generated")
)
dimension = models.CharField(
max_length=50, default="Efficiency", help_text=_("KPI dimension (e.g., Efficiency, Quality, Safety)")
)
collector_name = models.CharField(max_length=200, blank=True, help_text=_("Name of data collector"))
analyzer_name = models.CharField(max_length=200, blank=True, help_text=_("Name of data analyzer"))
# Summary metrics
total_numerator = models.IntegerField(default=0, help_text=_("Total count of successful outcomes"))
total_denominator = models.IntegerField(default=0, help_text=_("Total count of all cases"))
overall_result = models.DecimalField(
max_digits=6, decimal_places=2, default=0.00, help_text=_("Overall percentage result")
)
# Error tracking
error_message = models.TextField(blank=True)
# AI-generated analysis
ai_analysis = models.JSONField(
null=True, blank=True, help_text=_("AI-generated analysis and recommendations for this report")
)
ai_analysis_generated_at = models.DateTimeField(
null=True, blank=True, help_text=_("When the AI analysis was generated")
)
class Meta:
ordering = ["-year", "-month", "report_type"]
unique_together = [["report_type", "hospital", "year", "month"]]
indexes = [
models.Index(fields=["report_type", "-year", "-month"]),
models.Index(fields=["hospital", "-year", "-month"]),
models.Index(fields=["status", "-created_at"]),
]
verbose_name = "KPI Report"
verbose_name_plural = "KPI Reports"
def __str__(self):
return f"{self.get_report_type_display()} - {self.year}/{self.month:02d} - {self.hospital.name}"
@property
def report_period_display(self):
"""Get human-readable report period"""
month_names = [
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
return f"{month_names[self.month]} {self.year}"
@property
def kpi_id(self):
"""Get KPI ID based on report type"""
mapping = {
KPIReportType.RESOLUTION_72H: "MOH-2",
KPIReportType.PATIENT_EXPERIENCE: "MOH-1",
KPIReportType.SATISFACTION_RESOLUTION: "MOH-3",
KPIReportType.N_PAD_001: "N-PAD-001",
KPIReportType.RESPONSE_RATE: "Dep-KPI-4",
KPIReportType.ACTIVATION_2H: "KPI-6",
KPIReportType.UNACTIVATED: "KPI-7",
}
return mapping.get(self.report_type, "KPI")
@property
def indicator_title(self):
"""Get indicator title based on report type"""
titles = {
KPIReportType.RESOLUTION_72H: "72-Hour Complaint Resolution Rate",
KPIReportType.PATIENT_EXPERIENCE: "Patient Experience Score",
KPIReportType.SATISFACTION_RESOLUTION: "Overall Satisfaction with Complaint Resolution",
KPIReportType.N_PAD_001: "Resolution to Patient Complaints",
KPIReportType.RESPONSE_RATE: "Department Response Rate (48h)",
KPIReportType.ACTIVATION_2H: "Complaint Activation Within 2 Hours",
KPIReportType.UNACTIVATED: "Unactivated Filled Complaints Rate",
}
return titles.get(self.report_type, "KPI Report")
@property
def numerator_label(self):
"""Get label for numerator based on report type"""
labels = {
KPIReportType.RESOLUTION_72H: "Resolved ≤72h",
KPIReportType.PATIENT_EXPERIENCE: "Positive Responses",
KPIReportType.SATISFACTION_RESOLUTION: "Satisfied Responses",
KPIReportType.N_PAD_001: "Resolved Complaints",
KPIReportType.RESPONSE_RATE: "Responded Within 48h",
KPIReportType.ACTIVATION_2H: "Activated Within 2h",
KPIReportType.UNACTIVATED: "Unactivated Complaints",
}
return labels.get(self.report_type, "Numerator")
@property
def denominator_label(self):
"""Get label for denominator based on report type"""
labels = {
KPIReportType.RESOLUTION_72H: "Total complaints",
KPIReportType.PATIENT_EXPERIENCE: "Total responses",
KPIReportType.SATISFACTION_RESOLUTION: "Total responses",
KPIReportType.N_PAD_001: "Total complaints",
KPIReportType.RESPONSE_RATE: "Total complaints",
KPIReportType.ACTIVATION_2H: "Total complaints",
KPIReportType.UNACTIVATED: "Total filled complaints",
}
return labels.get(self.report_type, "Denominator")
class KPIReportMonthlyData(UUIDModel, TimeStampedModel):
"""
Monthly breakdown data for a KPI report
Stores the Jan-Dec + TOTAL values shown in the Excel-style table.
This allows for trend analysis and historical comparison.
"""
kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="monthly_data")
# Month (1-12) - 0 represents the TOTAL row
month = models.IntegerField(db_index=True, help_text=_("Month number (1-12), 0 for TOTAL"))
# Values for this month
numerator = models.IntegerField(default=0, help_text=_("Count of successful outcomes"))
denominator = models.IntegerField(default=0, help_text=_("Count of all cases"))
percentage = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, help_text=_("Calculated percentage"))
# Status indicators
is_below_target = models.BooleanField(default=False, help_text=_("Whether this month is below target"))
# Additional metadata for this month
details = models.JSONField(
default=dict, blank=True, help_text=_("Additional breakdown data (e.g., by source, department)")
)
class Meta:
ordering = ["month"]
unique_together = [["kpi_report", "month"]]
indexes = [
models.Index(fields=["kpi_report", "month"]),
]
verbose_name = "KPI Monthly Data"
verbose_name_plural = "KPI Monthly Data"
def __str__(self):
month_name = (
"TOTAL"
if self.month == 0
else ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][self.month - 1]
)
return f"{self.kpi_report} - {month_name}: {self.percentage}%"
def calculate_percentage(self):
"""Calculate percentage from numerator and denominator"""
if self.denominator > 0:
self.percentage = (self.numerator / self.denominator) * 100
else:
self.percentage = 0
return self.percentage
class KPIReportDepartmentBreakdown(UUIDModel, TimeStampedModel):
"""
Department-level breakdown for KPI reports
Stores metrics for each department to show in the department grid
section of the report (Medical, Nursing, Admin, Support Services).
"""
kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="department_breakdowns")
department_category = models.CharField(
max_length=50,
choices=[
("medical", "Medical Department"),
("nursing", "Nursing Department"),
("admin", "Non-Medical / Admin"),
("support", "Support Services"),
],
help_text=_("Category of department"),
)
# Department-specific metrics
complaint_count = models.IntegerField(default=0)
resolved_count = models.IntegerField(default=0)
avg_resolution_days = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
# Top complaints/areas (stored as text for display)
top_areas = models.TextField(blank=True, help_text=_("Top complaint areas or notes (newline-separated)"))
# JSON field for flexible department-specific data
details = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["department_category"]
unique_together = [["kpi_report", "department_category"]]
verbose_name = "KPI Department Breakdown"
verbose_name_plural = "KPI Department Breakdowns"
def __str__(self):
return f"{self.kpi_report} - {self.get_department_category_display()}"
class KPIReportLocationBreakdown(UUIDModel, TimeStampedModel):
"""
Location-level breakdown for KPI reports
Stores complaint distribution by location type
(In-Patient, Out-Patient, ER).
"""
kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="location_breakdowns")
location_type = models.CharField(
max_length=50,
choices=[
("In-Patient", "In-Patient"),
("Out-Patient", "Out-Patient"),
("ER", "ER"),
],
help_text=_("Location type category"),
)
complaint_count = models.IntegerField(default=0)
percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0.00)
class Meta:
ordering = ["location_type"]
unique_together = [["kpi_report", "location_type"]]
verbose_name = "KPI Location Breakdown"
verbose_name_plural = "KPI Location Breakdowns"
def __str__(self):
return f"{self.kpi_report} - {self.location_type}: {self.percentage}%"
class KPIReportSourceBreakdown(UUIDModel, TimeStampedModel):
"""
Complaint source breakdown for KPI reports
Stores percentage distribution of complaints by source
(Patient, Family, Staff, MOH, CHI, etc.)
"""
kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="source_breakdowns")
source_name = models.CharField(max_length=100)
complaint_count = models.IntegerField(default=0)
percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0.00)
class Meta:
ordering = ["-complaint_count"]
verbose_name = "KPI Source Breakdown"
verbose_name_plural = "KPI Source Breakdowns"
def __str__(self):
return f"{self.kpi_report} - {self.source_name}: {self.percentage}%"