393 lines
13 KiB
Python
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}%"
|