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

393 lines
13 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")
)
# 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)
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 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}%"