681 lines
20 KiB
Python
681 lines
20 KiB
Python
"""
|
|
Complaints models - Complaint management with SLA tracking
|
|
|
|
This module implements the complaint management system that:
|
|
- Tracks complaints with SLA deadlines
|
|
- Manages complaint workflow (open → in progress → resolved → closed)
|
|
- Triggers resolution satisfaction surveys
|
|
- Creates PX actions for negative resolution satisfaction
|
|
- Maintains complaint timeline and attachments
|
|
"""
|
|
from datetime import timedelta
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
from apps.core.models import PriorityChoices, SeverityChoices, TimeStampedModel, UUIDModel
|
|
|
|
|
|
class ComplaintStatus(models.TextChoices):
|
|
"""Complaint status choices"""
|
|
OPEN = 'open', 'Open'
|
|
IN_PROGRESS = 'in_progress', 'In Progress'
|
|
RESOLVED = 'resolved', 'Resolved'
|
|
CLOSED = 'closed', 'Closed'
|
|
CANCELLED = 'cancelled', 'Cancelled'
|
|
|
|
|
|
class ComplaintSource(models.TextChoices):
|
|
"""Complaint source choices"""
|
|
PATIENT = 'patient', 'Patient'
|
|
FAMILY = 'family', 'Family Member'
|
|
STAFF = 'staff', 'Staff'
|
|
SURVEY = 'survey', 'Survey'
|
|
SOCIAL_MEDIA = 'social_media', 'Social Media'
|
|
CALL_CENTER = 'call_center', 'Call Center'
|
|
MOH = 'moh', 'Ministry of Health'
|
|
CHI = 'chi', 'Council of Health Insurance'
|
|
OTHER = 'other', 'Other'
|
|
|
|
|
|
class Complaint(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Complaint model with SLA tracking.
|
|
|
|
Workflow:
|
|
1. OPEN - Complaint received
|
|
2. IN_PROGRESS - Being investigated
|
|
3. RESOLVED - Solution provided
|
|
4. CLOSED - Confirmed closed (triggers resolution satisfaction survey)
|
|
|
|
SLA:
|
|
- Calculated based on severity and hospital configuration
|
|
- Reminders sent before due date
|
|
- Escalation triggered when overdue
|
|
"""
|
|
# Patient and encounter information
|
|
patient = models.ForeignKey(
|
|
'organizations.Patient',
|
|
on_delete=models.CASCADE,
|
|
related_name='complaints'
|
|
)
|
|
encounter_id = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Related encounter ID if applicable"
|
|
)
|
|
|
|
# Organization
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='complaints'
|
|
)
|
|
department = models.ForeignKey(
|
|
'organizations.Department',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='complaints'
|
|
)
|
|
physician = models.ForeignKey(
|
|
'organizations.Physician',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='complaints'
|
|
)
|
|
|
|
# Complaint details
|
|
title = models.CharField(max_length=500)
|
|
description = models.TextField()
|
|
|
|
# Classification
|
|
category = models.CharField(
|
|
max_length=100,
|
|
choices=[
|
|
('clinical_care', 'Clinical Care'),
|
|
('staff_behavior', 'Staff Behavior'),
|
|
('facility', 'Facility & Environment'),
|
|
('wait_time', 'Wait Time'),
|
|
('billing', 'Billing'),
|
|
('communication', 'Communication'),
|
|
('other', 'Other'),
|
|
],
|
|
db_index=True
|
|
)
|
|
subcategory = models.CharField(max_length=100, blank=True)
|
|
|
|
# Priority and severity
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
default=PriorityChoices.MEDIUM,
|
|
db_index=True
|
|
)
|
|
severity = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
default=SeverityChoices.MEDIUM,
|
|
db_index=True
|
|
)
|
|
|
|
# Source
|
|
source = models.CharField(
|
|
max_length=50,
|
|
choices=ComplaintSource.choices,
|
|
default=ComplaintSource.PATIENT,
|
|
db_index=True
|
|
)
|
|
|
|
# Status and workflow
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=ComplaintStatus.choices,
|
|
default=ComplaintStatus.OPEN,
|
|
db_index=True
|
|
)
|
|
|
|
# Assignment
|
|
assigned_to = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='assigned_complaints'
|
|
)
|
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# SLA tracking
|
|
due_at = models.DateTimeField(
|
|
db_index=True,
|
|
help_text="SLA deadline"
|
|
)
|
|
is_overdue = models.BooleanField(default=False, db_index=True)
|
|
reminder_sent_at = models.DateTimeField(null=True, blank=True)
|
|
escalated_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Resolution
|
|
resolution = models.TextField(blank=True)
|
|
resolved_at = models.DateTimeField(null=True, blank=True)
|
|
resolved_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='resolved_complaints'
|
|
)
|
|
|
|
# Closure
|
|
closed_at = models.DateTimeField(null=True, blank=True)
|
|
closed_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='closed_complaints'
|
|
)
|
|
|
|
# Resolution satisfaction survey
|
|
resolution_survey = models.ForeignKey(
|
|
'surveys.SurveyInstance',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='complaint_resolution'
|
|
)
|
|
resolution_survey_sent_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['status', '-created_at']),
|
|
models.Index(fields=['hospital', 'status', '-created_at']),
|
|
models.Index(fields=['is_overdue', 'status']),
|
|
models.Index(fields=['due_at', 'status']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.title} - {self.patient.get_full_name()} ({self.status})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Calculate SLA due date on creation"""
|
|
if not self.due_at:
|
|
self.due_at = self.calculate_sla_due_date()
|
|
super().save(*args, **kwargs)
|
|
|
|
def calculate_sla_due_date(self):
|
|
"""
|
|
Calculate SLA due date based on severity and hospital configuration.
|
|
|
|
First tries to use ComplaintSLAConfig from database.
|
|
Falls back to settings.SLA_DEFAULTS if no config exists.
|
|
"""
|
|
# Try to get SLA config from database
|
|
try:
|
|
sla_config = ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
sla_hours = sla_config.sla_hours
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
# Fall back to settings
|
|
sla_hours = settings.SLA_DEFAULTS['complaint'].get(
|
|
self.severity,
|
|
settings.SLA_DEFAULTS['complaint']['medium']
|
|
)
|
|
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
|
|
def check_overdue(self):
|
|
"""Check if complaint is overdue and update status"""
|
|
if self.status in [ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED]:
|
|
return False
|
|
|
|
if timezone.now() > self.due_at:
|
|
if not self.is_overdue:
|
|
self.is_overdue = True
|
|
self.save(update_fields=['is_overdue'])
|
|
return True
|
|
return False
|
|
|
|
|
|
class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
|
"""Complaint attachment (images, documents, etc.)"""
|
|
complaint = models.ForeignKey(
|
|
Complaint,
|
|
on_delete=models.CASCADE,
|
|
related_name='attachments'
|
|
)
|
|
|
|
file = models.FileField(upload_to='complaints/%Y/%m/%d/')
|
|
filename = models.CharField(max_length=500)
|
|
file_type = models.CharField(max_length=100, blank=True)
|
|
file_size = models.IntegerField(help_text="File size in bytes")
|
|
|
|
uploaded_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name='complaint_attachments'
|
|
)
|
|
|
|
description = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint} - {self.filename}"
|
|
|
|
|
|
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Complaint update/timeline entry.
|
|
|
|
Tracks all updates, status changes, and communications.
|
|
"""
|
|
complaint = models.ForeignKey(
|
|
Complaint,
|
|
on_delete=models.CASCADE,
|
|
related_name='updates'
|
|
)
|
|
|
|
# Update details
|
|
update_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
('status_change', 'Status Change'),
|
|
('assignment', 'Assignment'),
|
|
('note', 'Note'),
|
|
('resolution', 'Resolution'),
|
|
('escalation', 'Escalation'),
|
|
('communication', 'Communication'),
|
|
],
|
|
db_index=True
|
|
)
|
|
|
|
message = models.TextField()
|
|
|
|
# User who made the update
|
|
created_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name='complaint_updates'
|
|
)
|
|
|
|
# Status change tracking
|
|
old_status = models.CharField(max_length=20, blank=True)
|
|
new_status = models.CharField(max_length=20, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['complaint', '-created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
|
|
class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
|
"""
|
|
SLA configuration for complaints per hospital, severity, and priority.
|
|
|
|
Allows flexible SLA configuration instead of hardcoded values.
|
|
"""
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='complaint_sla_configs'
|
|
)
|
|
|
|
severity = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
help_text="Severity level for this SLA"
|
|
)
|
|
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
help_text="Priority level for this SLA"
|
|
)
|
|
|
|
sla_hours = models.IntegerField(
|
|
help_text="Number of hours until SLA deadline"
|
|
)
|
|
|
|
reminder_hours_before = models.IntegerField(
|
|
default=24,
|
|
help_text="Send reminder X hours before deadline"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ['hospital', 'severity', 'priority']
|
|
unique_together = [['hospital', 'severity', 'priority']]
|
|
indexes = [
|
|
models.Index(fields=['hospital', 'is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.severity}/{self.priority} - {self.sla_hours}h"
|
|
|
|
|
|
class ComplaintCategory(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Custom complaint categories per hospital.
|
|
|
|
Replaces hardcoded category choices with flexible, hospital-specific categories.
|
|
"""
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='complaint_categories',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Leave blank for system-wide categories"
|
|
)
|
|
|
|
code = models.CharField(
|
|
max_length=50,
|
|
help_text="Unique code for this category"
|
|
)
|
|
|
|
name_en = models.CharField(max_length=200)
|
|
name_ar = models.CharField(max_length=200, blank=True)
|
|
|
|
description_en = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
parent = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='subcategories',
|
|
help_text="Parent category for hierarchical structure"
|
|
)
|
|
|
|
order = models.IntegerField(
|
|
default=0,
|
|
help_text="Display order"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ['hospital', 'order', 'name_en']
|
|
verbose_name_plural = 'Complaint Categories'
|
|
indexes = [
|
|
models.Index(fields=['hospital', 'is_active']),
|
|
models.Index(fields=['code']),
|
|
]
|
|
|
|
def __str__(self):
|
|
hospital_name = self.hospital.name if self.hospital else "System-wide"
|
|
return f"{hospital_name} - {self.name_en}"
|
|
|
|
|
|
class EscalationRule(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Configurable escalation rules for complaints.
|
|
|
|
Defines who receives escalated complaints based on conditions.
|
|
"""
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='escalation_rules'
|
|
)
|
|
|
|
name = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True)
|
|
|
|
# Trigger conditions
|
|
trigger_on_overdue = models.BooleanField(
|
|
default=True,
|
|
help_text="Trigger when complaint is overdue"
|
|
)
|
|
|
|
trigger_hours_overdue = models.IntegerField(
|
|
default=0,
|
|
help_text="Trigger X hours after overdue (0 = immediately)"
|
|
)
|
|
|
|
# Escalation target
|
|
escalate_to_role = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
('department_manager', 'Department Manager'),
|
|
('hospital_admin', 'Hospital Admin'),
|
|
('px_admin', 'PX Admin'),
|
|
('specific_user', 'Specific User'),
|
|
],
|
|
help_text="Role to escalate to"
|
|
)
|
|
|
|
escalate_to_user = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='escalation_target_rules',
|
|
help_text="Specific user if escalate_to_role is 'specific_user'"
|
|
)
|
|
|
|
# Conditions
|
|
severity_filter = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
blank=True,
|
|
help_text="Only escalate complaints with this severity (blank = all)"
|
|
)
|
|
|
|
priority_filter = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
blank=True,
|
|
help_text="Only escalate complaints with this priority (blank = all)"
|
|
)
|
|
|
|
order = models.IntegerField(
|
|
default=0,
|
|
help_text="Escalation order (lower = first)"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ['hospital', 'order']
|
|
indexes = [
|
|
models.Index(fields=['hospital', 'is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.name}"
|
|
|
|
|
|
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Configurable thresholds for complaint-related triggers.
|
|
|
|
Defines when to trigger actions based on metrics (e.g., survey scores).
|
|
"""
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='complaint_thresholds'
|
|
)
|
|
|
|
threshold_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
('resolution_survey_score', 'Resolution Survey Score'),
|
|
('response_time', 'Response Time'),
|
|
('resolution_time', 'Resolution Time'),
|
|
],
|
|
help_text="Type of threshold"
|
|
)
|
|
|
|
threshold_value = models.FloatField(
|
|
help_text="Threshold value (e.g., 50 for 50% score)"
|
|
)
|
|
|
|
comparison_operator = models.CharField(
|
|
max_length=10,
|
|
choices=[
|
|
('lt', 'Less Than'),
|
|
('lte', 'Less Than or Equal'),
|
|
('gt', 'Greater Than'),
|
|
('gte', 'Greater Than or Equal'),
|
|
('eq', 'Equal'),
|
|
],
|
|
default='lt',
|
|
help_text="How to compare against threshold"
|
|
)
|
|
|
|
action_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
('create_px_action', 'Create PX Action'),
|
|
('send_notification', 'Send Notification'),
|
|
('escalate', 'Escalate'),
|
|
],
|
|
help_text="Action to take when threshold is breached"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ['hospital', 'threshold_type']
|
|
indexes = [
|
|
models.Index(fields=['hospital', 'is_active']),
|
|
models.Index(fields=['threshold_type', 'is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}"
|
|
|
|
def check_threshold(self, value):
|
|
"""Check if value breaches threshold"""
|
|
if self.comparison_operator == 'lt':
|
|
return value < self.threshold_value
|
|
elif self.comparison_operator == 'lte':
|
|
return value <= self.threshold_value
|
|
elif self.comparison_operator == 'gt':
|
|
return value > self.threshold_value
|
|
elif self.comparison_operator == 'gte':
|
|
return value >= self.threshold_value
|
|
elif self.comparison_operator == 'eq':
|
|
return value == self.threshold_value
|
|
return False
|
|
|
|
|
|
class Inquiry(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Inquiry model for general questions/requests.
|
|
|
|
Similar to complaints but for non-complaint inquiries.
|
|
"""
|
|
# Patient information
|
|
patient = models.ForeignKey(
|
|
'organizations.Patient',
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='inquiries'
|
|
)
|
|
|
|
# Contact information (if patient not in system)
|
|
contact_name = models.CharField(max_length=200, blank=True)
|
|
contact_phone = models.CharField(max_length=20, blank=True)
|
|
contact_email = models.EmailField(blank=True)
|
|
|
|
# Organization
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='inquiries'
|
|
)
|
|
department = models.ForeignKey(
|
|
'organizations.Department',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='inquiries'
|
|
)
|
|
|
|
# Inquiry details
|
|
subject = models.CharField(max_length=500)
|
|
message = models.TextField()
|
|
|
|
# Category
|
|
category = models.CharField(
|
|
max_length=100,
|
|
choices=[
|
|
('appointment', 'Appointment'),
|
|
('billing', 'Billing'),
|
|
('medical_records', 'Medical Records'),
|
|
('general', 'General Information'),
|
|
('other', 'Other'),
|
|
]
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('open', 'Open'),
|
|
('in_progress', 'In Progress'),
|
|
('resolved', 'Resolved'),
|
|
('closed', 'Closed'),
|
|
],
|
|
default='open',
|
|
db_index=True
|
|
)
|
|
|
|
# Assignment
|
|
assigned_to = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='assigned_inquiries'
|
|
)
|
|
|
|
# Response
|
|
response = models.TextField(blank=True)
|
|
responded_at = models.DateTimeField(null=True, blank=True)
|
|
responded_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='responded_inquiries'
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
verbose_name_plural = 'Inquiries'
|
|
indexes = [
|
|
models.Index(fields=['status', '-created_at']),
|
|
models.Index(fields=['hospital', 'status']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.subject} ({self.status})"
|