2691 lines
99 KiB
Python
2691 lines
99 KiB
Python
"""
|
||
Complaints utility functions
|
||
|
||
Export and bulk operation utilities.
|
||
"""
|
||
|
||
import csv
|
||
import io
|
||
from datetime import datetime, timedelta
|
||
from typing import List
|
||
|
||
from django.db.models import Count, Q
|
||
from django.http import HttpResponse
|
||
from django.utils import timezone
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
|
||
def export_complaints_csv(queryset, filters=None):
|
||
"""
|
||
Export complaints to CSV format.
|
||
|
||
Args:
|
||
queryset: Complaint queryset to export
|
||
filters: Optional dict of applied filters
|
||
|
||
Returns:
|
||
HttpResponse with CSV file
|
||
"""
|
||
response = HttpResponse(content_type="text/csv")
|
||
response["Content-Disposition"] = (
|
||
f'attachment; filename="complaints_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv"'
|
||
)
|
||
|
||
writer = csv.writer(response)
|
||
|
||
# Write header
|
||
writer.writerow(
|
||
[
|
||
"ID",
|
||
"Title",
|
||
"Patient Name",
|
||
"Patient MRN",
|
||
"Hospital",
|
||
"Department",
|
||
"Category",
|
||
"Severity",
|
||
"Priority",
|
||
"Status",
|
||
"Source",
|
||
"Assigned To",
|
||
"Created At",
|
||
"Due At",
|
||
"Is Overdue",
|
||
"Resolved At",
|
||
"Closed At",
|
||
"Description",
|
||
]
|
||
)
|
||
|
||
# Write data
|
||
for complaint in queryset:
|
||
writer.writerow(
|
||
[
|
||
str(complaint.id)[:8],
|
||
complaint.title,
|
||
complaint.patient.get_full_name() if complaint.patient else "",
|
||
complaint.patient.mrn if complaint.patient else "",
|
||
complaint.hospital.name if complaint.hospital else "",
|
||
complaint.department.name if complaint.department else "",
|
||
str(complaint.category) if complaint.category else "",
|
||
complaint.get_severity_display(),
|
||
complaint.get_priority_display(),
|
||
complaint.get_status_display(),
|
||
complaint.get_complaint_source_type_display(),
|
||
complaint.assigned_to.get_full_name() if complaint.assigned_to else "",
|
||
complaint.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||
complaint.due_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.due_at else "",
|
||
"Yes" if complaint.is_overdue else "No",
|
||
complaint.resolved_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.resolved_at else "",
|
||
complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "",
|
||
complaint.description[:500] if complaint.description else "",
|
||
]
|
||
)
|
||
|
||
return response
|
||
|
||
|
||
def export_complaints_excel(queryset, filters=None):
|
||
"""
|
||
Export complaints to Excel format with formatting.
|
||
|
||
Args:
|
||
queryset: Complaint queryset to export
|
||
filters: Optional dict of applied filters
|
||
|
||
Returns:
|
||
HttpResponse with Excel file
|
||
"""
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Complaints"
|
||
|
||
# Define styles
|
||
header_font = Font(bold=True, color="FFFFFF")
|
||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||
|
||
# Write header
|
||
headers = [
|
||
"ID",
|
||
"Title",
|
||
"Patient Name",
|
||
"Patient MRN",
|
||
"Hospital",
|
||
"Department",
|
||
"Category",
|
||
"Severity",
|
||
"Priority",
|
||
"Status",
|
||
"Source",
|
||
"Assigned To",
|
||
"Created At",
|
||
"Due At",
|
||
"Is Overdue",
|
||
"Resolved At",
|
||
"Closed At",
|
||
"Description",
|
||
]
|
||
|
||
for col_num, header in enumerate(headers, 1):
|
||
cell = ws.cell(row=1, column=col_num, value=header)
|
||
cell.font = header_font
|
||
cell.fill = header_fill
|
||
cell.alignment = header_alignment
|
||
|
||
# Write data
|
||
for row_num, complaint in enumerate(queryset, 2):
|
||
ws.cell(row=row_num, column=1, value=str(complaint.id)[:8])
|
||
ws.cell(row=row_num, column=2, value=complaint.title)
|
||
ws.cell(row=row_num, column=3, value=complaint.patient.get_full_name() if complaint.patient else "")
|
||
ws.cell(row=row_num, column=4, value=complaint.patient.mrn if complaint.patient else "")
|
||
ws.cell(row=row_num, column=5, value=complaint.hospital.name if complaint.hospital else "")
|
||
ws.cell(row=row_num, column=6, value=complaint.department.name if complaint.department else "")
|
||
ws.cell(row=row_num, column=7, value=str(complaint.category) if complaint.category else "")
|
||
ws.cell(row=row_num, column=8, value=complaint.get_severity_display())
|
||
ws.cell(row=row_num, column=9, value=complaint.get_priority_display())
|
||
ws.cell(row=row_num, column=10, value=complaint.get_status_display())
|
||
ws.cell(row=row_num, column=11, value=complaint.get_complaint_source_type_display())
|
||
ws.cell(row=row_num, column=12, value=complaint.assigned_to.get_full_name() if complaint.assigned_to else "")
|
||
ws.cell(row=row_num, column=13, value=complaint.created_at.strftime("%Y-%m-%d %H:%M:%S"))
|
||
ws.cell(
|
||
row=row_num, column=14, value=complaint.due_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.due_at else ""
|
||
)
|
||
ws.cell(row=row_num, column=15, value="Yes" if complaint.is_overdue else "No")
|
||
ws.cell(
|
||
row=row_num,
|
||
column=16,
|
||
value=complaint.resolved_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.resolved_at else "",
|
||
)
|
||
ws.cell(
|
||
row=row_num,
|
||
column=17,
|
||
value=complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "",
|
||
)
|
||
ws.cell(row=row_num, column=18, value=complaint.description[:500] if complaint.description else "")
|
||
|
||
# Auto-adjust column widths
|
||
for column in ws.columns:
|
||
max_length = 0
|
||
column_letter = column[0].column_letter
|
||
for cell in column:
|
||
try:
|
||
if len(str(cell.value)) > max_length:
|
||
max_length = len(cell.value)
|
||
except:
|
||
pass
|
||
adjusted_width = min(max_length + 2, 50)
|
||
ws.column_dimensions[column_letter].width = adjusted_width
|
||
|
||
# Save to response
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = (
|
||
f'attachment; filename="complaints_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx"'
|
||
)
|
||
wb.save(response)
|
||
|
||
return response
|
||
|
||
|
||
def bulk_assign_complaints(complaint_ids: List[str], user_id: str, current_user):
|
||
"""
|
||
Bulk assign complaints to a user.
|
||
|
||
Args:
|
||
complaint_ids: List of complaint IDs
|
||
user_id: ID of user to assign to
|
||
current_user: User performing the action
|
||
|
||
Returns:
|
||
dict: Result with success count and errors
|
||
"""
|
||
from apps.complaints.models import Complaint, ComplaintUpdate
|
||
from apps.accounts.models import User
|
||
from django.utils import timezone
|
||
|
||
try:
|
||
assignee = User.objects.get(id=user_id)
|
||
except User.DoesNotExist:
|
||
return {"success": False, "error": "User not found"}
|
||
|
||
success_count = 0
|
||
errors = []
|
||
|
||
for complaint_id in complaint_ids:
|
||
try:
|
||
complaint = Complaint.objects.get(id=complaint_id)
|
||
complaint.assigned_to = assignee
|
||
complaint.assigned_at = timezone.now()
|
||
complaint.save(update_fields=["assigned_to", "assigned_at"])
|
||
|
||
# Create update
|
||
ComplaintUpdate.objects.create(
|
||
complaint=complaint,
|
||
update_type="assignment",
|
||
message=f"Bulk assigned to {assignee.get_full_name()}",
|
||
created_by=current_user,
|
||
)
|
||
|
||
success_count += 1
|
||
except Complaint.DoesNotExist:
|
||
errors.append(f"Complaint {complaint_id} not found")
|
||
except Exception as e:
|
||
errors.append(f"Error assigning complaint {complaint_id}: {str(e)}")
|
||
|
||
return {"success": True, "success_count": success_count, "total": len(complaint_ids), "errors": errors}
|
||
|
||
|
||
def bulk_change_status(complaint_ids: List[str], new_status: str, current_user, note: str = ""):
|
||
"""
|
||
Bulk change status of complaints.
|
||
|
||
Args:
|
||
complaint_ids: List of complaint IDs
|
||
new_status: New status to set
|
||
current_user: User performing the action
|
||
note: Optional note
|
||
|
||
Returns:
|
||
dict: Result with success count and errors
|
||
"""
|
||
from apps.complaints.models import Complaint, ComplaintUpdate
|
||
from django.utils import timezone
|
||
|
||
success_count = 0
|
||
errors = []
|
||
|
||
for complaint_id in complaint_ids:
|
||
try:
|
||
complaint = Complaint.objects.get(id=complaint_id)
|
||
old_status = complaint.status
|
||
complaint.status = new_status
|
||
|
||
# Handle status-specific logic
|
||
if new_status == "resolved":
|
||
complaint.resolved_at = timezone.now()
|
||
complaint.resolved_by = current_user
|
||
elif new_status == "closed":
|
||
complaint.closed_at = timezone.now()
|
||
complaint.closed_by = current_user
|
||
|
||
complaint.save()
|
||
|
||
# Create update
|
||
ComplaintUpdate.objects.create(
|
||
complaint=complaint,
|
||
update_type="status_change",
|
||
message=note or f"Bulk status change from {old_status} to {new_status}",
|
||
created_by=current_user,
|
||
old_status=old_status,
|
||
new_status=new_status,
|
||
)
|
||
|
||
success_count += 1
|
||
except Complaint.DoesNotExist:
|
||
errors.append(f"Complaint {complaint_id} not found")
|
||
except Exception as e:
|
||
errors.append(f"Error changing status for complaint {complaint_id}: {str(e)}")
|
||
|
||
return {"success": True, "success_count": success_count, "total": len(complaint_ids), "errors": errors}
|
||
|
||
|
||
def bulk_escalate_complaints(complaint_ids: List[str], current_user, reason: str = ""):
|
||
"""
|
||
Bulk escalate complaints.
|
||
|
||
Args:
|
||
complaint_ids: List of complaint IDs
|
||
current_user: User performing the action
|
||
reason: Escalation reason
|
||
|
||
Returns:
|
||
dict: Result with success count and errors
|
||
"""
|
||
from apps.complaints.models import Complaint, ComplaintUpdate
|
||
from apps.complaints.services.complaint_service import ComplaintService
|
||
from django.utils import timezone
|
||
|
||
success_count = 0
|
||
errors = []
|
||
not_assigned = 0
|
||
|
||
for complaint_id in complaint_ids:
|
||
try:
|
||
complaint = Complaint.objects.select_related("staff", "department", "hospital").get(id=complaint_id)
|
||
complaint.escalated_at = timezone.now()
|
||
|
||
target_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff)
|
||
|
||
escalation_message = f"Bulk escalation. Reason: {reason or 'No reason provided'}"
|
||
if target_user:
|
||
complaint.assigned_to = target_user
|
||
escalation_message += f" Escalated to: {target_user.get_full_name()} [via {fallback_path}]"
|
||
else:
|
||
not_assigned += 1
|
||
escalation_message += " WARNING: No escalation target found."
|
||
|
||
complaint.save(update_fields=["escalated_at", "assigned_to", "updated_at"])
|
||
|
||
ComplaintUpdate.objects.create(
|
||
complaint=complaint,
|
||
update_type="escalation",
|
||
message=escalation_message,
|
||
created_by=current_user,
|
||
metadata={
|
||
"reason": reason,
|
||
"escalated_to_user_id": str(target_user.id) if target_user else None,
|
||
"escalation_fallback_path": fallback_path,
|
||
"bulk": True,
|
||
},
|
||
)
|
||
|
||
success_count += 1
|
||
except Complaint.DoesNotExist:
|
||
errors.append(f"Complaint {complaint_id} not found")
|
||
except Exception as e:
|
||
errors.append(f"Error escalating complaint {complaint_id}: {str(e)}")
|
||
|
||
return {
|
||
"success": True,
|
||
"success_count": success_count,
|
||
"total": len(complaint_ids),
|
||
"errors": errors,
|
||
"not_assigned": not_assigned,
|
||
}
|
||
|
||
|
||
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
|
||
HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||
HEADER_ALIGNMENT = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||
SECTION_FONT = Font(bold=True, size=11)
|
||
SECTION_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid")
|
||
THIN_BORDER = Border(
|
||
left=Side(style="thin"),
|
||
right=Side(style="thin"),
|
||
top=Side(style="thin"),
|
||
bottom=Side(style="thin"),
|
||
)
|
||
|
||
|
||
def _write_header_row(ws, row, headers, font=HEADER_FONT, fill=HEADER_FILL, alignment=HEADER_ALIGNMENT):
|
||
for col_num, header in enumerate(headers, 1):
|
||
cell = ws.cell(row=row, column=col_num, value=header)
|
||
cell.font = font
|
||
cell.fill = fill
|
||
cell.alignment = alignment
|
||
cell.border = THIN_BORDER
|
||
|
||
|
||
def _auto_width(ws, max_width=40):
|
||
for column in ws.columns:
|
||
max_length = 0
|
||
col_letter = get_column_letter(column[0].column)
|
||
for cell in column:
|
||
try:
|
||
if cell.value:
|
||
max_length = max(max_length, len(str(cell.value)))
|
||
except Exception:
|
||
pass
|
||
ws.column_dimensions[col_letter].width = min(max_length + 3, max_width)
|
||
|
||
|
||
def export_requests_report(queryset, year=None, month=None):
|
||
"""
|
||
Step 0 — Requests Report Excel export.
|
||
|
||
Generates a monthly Excel report matching the Step 0 template:
|
||
- Timeline template sheet with 15 columns
|
||
- Monthly data sheet with all request rows
|
||
- Summary section with stats and staff breakdown
|
||
"""
|
||
from apps.dashboard.models import ComplaintRequest
|
||
|
||
wb = Workbook()
|
||
|
||
ws_template = wb.active
|
||
ws_template.title = "Time-Line Template"
|
||
|
||
template_headers = [
|
||
"#",
|
||
"Request Date",
|
||
"Patient Name",
|
||
"File Number",
|
||
"Complained Department",
|
||
"Incident Date",
|
||
"Entry (Staff)",
|
||
"Time",
|
||
"Form Sent Date",
|
||
"Time",
|
||
"Complaint Filed Date",
|
||
"Time",
|
||
"Time Between Send & File",
|
||
"Non-Activation Reason",
|
||
"PR Observations",
|
||
]
|
||
_write_header_row(ws_template, 1, template_headers)
|
||
for col in range(1, 16):
|
||
ws_template.column_dimensions[get_column_letter(col)].width = 18
|
||
|
||
qs = queryset.select_related("staff", "hospital", "complained_department", "complaint")
|
||
|
||
month_label = f"{year}-{month:02d}" if year and month else datetime.now().strftime("%Y-%m")
|
||
ws_data = wb.create_sheet(title=month_label)
|
||
|
||
_write_header_row(ws_data, 1, template_headers)
|
||
|
||
row_num = 2
|
||
for idx, req in enumerate(qs, 1):
|
||
filled_date = req.filled_at.date() if req.filled_at else ""
|
||
filled_time = req.filled_at.strftime("%H:%M") if req.filled_at else ""
|
||
form_sent_date = req.form_sent_at.date() if req.form_sent_at else ""
|
||
form_sent_time = req.form_sent_time.strftime("%H:%M") if req.form_sent_time else ""
|
||
|
||
ws_data.cell(row=row_num, column=1, value=idx)
|
||
ws_data.cell(row=row_num, column=2, value=req.request_date)
|
||
ws_data.cell(row=row_num, column=3, value=req.patient_name)
|
||
ws_data.cell(row=row_num, column=4, value=req.file_number)
|
||
ws_data.cell(row=row_num, column=5, value=req.complained_department.name if req.complained_department else "")
|
||
ws_data.cell(row=row_num, column=6, value=req.incident_date or "")
|
||
ws_data.cell(row=row_num, column=7, value=req.staff.get_full_name() if req.staff else "")
|
||
ws_data.cell(row=row_num, column=8, value=req.request_time.strftime("%H:%M") if req.request_time else "")
|
||
ws_data.cell(row=row_num, column=9, value=form_sent_date)
|
||
ws_data.cell(row=row_num, column=10, value=form_sent_time)
|
||
ws_data.cell(row=row_num, column=11, value=filled_date)
|
||
ws_data.cell(row=row_num, column=12, value=filled_time)
|
||
if req.form_sent_at and req.filled_at:
|
||
delta = req.filled_at - req.form_sent_at
|
||
ws_data.cell(row=row_num, column=13, value=str(delta))
|
||
ws_data.cell(row=row_num, column=14, value=req.get_reason_non_activation_display() or "")
|
||
ws_data.cell(row=row_num, column=15, value=req.pr_observations)
|
||
for col in range(1, 16):
|
||
ws_data.cell(row=row_num, column=col).border = THIN_BORDER
|
||
row_num += 1
|
||
|
||
summary_row = row_num + 2
|
||
stats = qs.aggregate(
|
||
on_hold_count=Count("pk", filter=Q(on_hold=True)),
|
||
not_filled_count=Count("pk", filter=Q(not_filled=True)),
|
||
filled_count=Count("pk", filter=Q(filled=True)),
|
||
barcode_count=Count("pk", filter=Q(from_barcode=True)),
|
||
same_time=Count("pk", filter=Q(filling_time_category="same_time")),
|
||
within_6h=Count("pk", filter=Q(filling_time_category="within_6h")),
|
||
six_to_24h=Count("pk", filter=Q(filling_time_category="6_to_24h")),
|
||
after_1_day=Count("pk", filter=Q(filling_time_category="after_1_day")),
|
||
not_mentioned=Count("pk", filter=Q(filling_time_category="not_mentioned")),
|
||
)
|
||
total = qs.count()
|
||
|
||
summary_items = [
|
||
("Total Complaints on Hold", stats["on_hold_count"]),
|
||
("Total Not Filled", stats["not_filled_count"]),
|
||
("Total Filled", stats["filled_count"]),
|
||
("Total from Barcode (SELF)", stats["barcode_count"]),
|
||
("Filled at the same time", stats["same_time"]),
|
||
("Filled within 6 hours", stats["within_6h"]),
|
||
("Filled from 6 to 24 hours", stats["six_to_24h"]),
|
||
("Filled after 1 day", stats["after_1_day"]),
|
||
("Time not mentioned", stats["not_mentioned"]),
|
||
("TOTAL", total),
|
||
]
|
||
ws_data.cell(row=summary_row, column=1, value="Summary").font = SECTION_FONT
|
||
summary_row += 1
|
||
for label, val in summary_items:
|
||
ws_data.cell(row=summary_row, column=1, value=label).font = Font(bold=(label == "TOTAL"))
|
||
ws_data.cell(row=summary_row, column=2, value=val)
|
||
summary_row += 1
|
||
|
||
summary_row += 1
|
||
ws_data.cell(row=summary_row, column=1, value="Staff Breakdown").font = SECTION_FONT
|
||
summary_row += 1
|
||
_write_header_row(ws_data, summary_row, ["Staff", "Total", "Filled", "Not Filled"])
|
||
summary_row += 1
|
||
|
||
staff_stats = (
|
||
qs.values("staff__first_name", "staff__last_name", "staff__id")
|
||
.annotate(
|
||
total=Count("pk"),
|
||
filled=Count("pk", filter=Q(filled=True)),
|
||
not_filled=Count("pk", filter=Q(not_filled=True)),
|
||
)
|
||
.order_by("-total")
|
||
)
|
||
|
||
for s in staff_stats:
|
||
name = f"{s['staff__first_name'] or ''} {s['staff__last_name'] or ''}".strip() or "Unknown"
|
||
ws_data.cell(row=summary_row, column=1, value=name)
|
||
ws_data.cell(row=summary_row, column=2, value=s["total"])
|
||
ws_data.cell(row=summary_row, column=3, value=s["filled"])
|
||
ws_data.cell(row=summary_row, column=4, value=s["not_filled"])
|
||
for col in range(1, 5):
|
||
ws_data.cell(row=summary_row, column=col).border = THIN_BORDER
|
||
summary_row += 1
|
||
|
||
summary_row += 1
|
||
ws_data.cell(row=summary_row, column=1, value="Non-Activation Reasons").font = SECTION_FONT
|
||
summary_row += 1
|
||
_write_header_row(ws_data, summary_row, ["Reason", "Count", "Percentage"])
|
||
summary_row += 1
|
||
|
||
reason_stats = (
|
||
qs.exclude(reason_non_activation="")
|
||
.values("reason_non_activation")
|
||
.annotate(count=Count("pk"))
|
||
.order_by("-count")
|
||
)
|
||
|
||
for r in reason_stats:
|
||
display = dict(ComplaintRequest.NON_ACTIVATION_REASON_CHOICES).get(
|
||
r["reason_non_activation"], r["reason_non_activation"]
|
||
)
|
||
pct = (r["count"] / total * 100) if total else 0
|
||
ws_data.cell(row=summary_row, column=1, value=display)
|
||
ws_data.cell(row=summary_row, column=2, value=r["count"])
|
||
ws_data.cell(row=summary_row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws_data.cell(row=summary_row, column=col).border = THIN_BORDER
|
||
summary_row += 1
|
||
|
||
_auto_width(ws_data, 50)
|
||
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = f'attachment; filename="requests_report_{month_label}.xlsx"'
|
||
wb.save(response)
|
||
return response
|
||
|
||
|
||
def export_monthly_calculations(queryset, year, month):
|
||
"""
|
||
Step 1 — Monthly Calculations Excel export.
|
||
|
||
Generates the full 108-column monthly calculations report
|
||
matching the Step 1 template structure, including:
|
||
- Main data sheet with all columns A-BN
|
||
- Summary sections: weekly, source, location, department,
|
||
sub-department, employee performance, activation delay
|
||
"""
|
||
from apps.complaints.models import Complaint, ComplaintInvolvedDepartment
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = month_label = f"{year}-{month:02d}"
|
||
|
||
headers = [
|
||
"Week",
|
||
"No.",
|
||
"Complaint ID",
|
||
"File Number",
|
||
"Source",
|
||
"Location",
|
||
"Main Department",
|
||
"Sub-Department",
|
||
"Date Received",
|
||
"Entered By",
|
||
"MOH Ref",
|
||
"MOH Ref Date",
|
||
"MOH Ref Time",
|
||
"Phone Number",
|
||
"Timeline SLA",
|
||
"Form Sent Date",
|
||
"Form Sent Time",
|
||
"Employee (Form Sent)",
|
||
"Complaint Filed Date",
|
||
"Complaint Filed Time",
|
||
"Activated",
|
||
"Activation Date",
|
||
"Activation Time",
|
||
"Employee (Activation)",
|
||
"Sent to Dept Date",
|
||
"Sent to Dept Time",
|
||
"Employee (Dept Send)",
|
||
"Time: Filed to Sent",
|
||
"1st Reminder Date",
|
||
"1st Reminder Time",
|
||
"Employee (1st Rem)",
|
||
"Time: 1st Rem to Sent",
|
||
"2nd Reminder Date",
|
||
"2nd Reminder Time",
|
||
"Employee (2nd Rem)",
|
||
"Time: 2nd Rem to 1st Rem",
|
||
"Escalated",
|
||
"Escalation Date",
|
||
"Escalation Time",
|
||
"Employee (Escalation)",
|
||
"Time: Escalation to Sent",
|
||
"Closed",
|
||
"Close Date",
|
||
"Close Time",
|
||
"Employee (Close)",
|
||
"Time: Close to Sent",
|
||
"Resolved",
|
||
"Resolve Date",
|
||
"Resolve Time",
|
||
"Employee (Resolve)",
|
||
"Time: Resolve to Sent",
|
||
"Response Date",
|
||
"Response Time",
|
||
"Time: Response to Sent",
|
||
"Complained Person Name",
|
||
"Main Complaint Subject",
|
||
"Summary (Arabic)",
|
||
"Summary (English)",
|
||
"Reminder Documentation",
|
||
"Reminder Date",
|
||
"Delay Reason (Dept)",
|
||
"Delay Reason (Closure 72h)",
|
||
"Person Responsible for Delay",
|
||
"Satisfaction",
|
||
"Action Taken by Dept",
|
||
"Investigation Result",
|
||
"Solutions & Suggestions",
|
||
"Recommendation/Action Plan",
|
||
"Responsible Department",
|
||
"Rightful Side",
|
||
"PR Observations",
|
||
]
|
||
|
||
_write_header_row(ws, 1, headers)
|
||
ws.freeze_panes = "D2"
|
||
|
||
qs = queryset.select_related(
|
||
"hospital",
|
||
"department",
|
||
"main_section",
|
||
"subsection",
|
||
"assigned_to",
|
||
"resolved_by",
|
||
"closed_by",
|
||
"created_by",
|
||
"source",
|
||
).prefetch_related("involved_departments__department", "updates")
|
||
|
||
dept_cat_keywords = {
|
||
"medical": [
|
||
"doctor",
|
||
"physician",
|
||
"surgeon",
|
||
"consultant",
|
||
"specialist",
|
||
"er",
|
||
"emergency",
|
||
"icu",
|
||
"nicu",
|
||
"pediatric",
|
||
"ob/gyn",
|
||
"obstetric",
|
||
"gynecolog",
|
||
"cardiology",
|
||
"orthoped",
|
||
"radiology",
|
||
"dermatolog",
|
||
"patholog",
|
||
"lab",
|
||
"laboratory",
|
||
"pharmacy",
|
||
"anesthesi",
|
||
"nephrology",
|
||
"urology",
|
||
"dental",
|
||
"dentist",
|
||
"ophthalmol",
|
||
"ent",
|
||
"otorhinolaryng",
|
||
"pulmonar",
|
||
"respirator",
|
||
"oncolog",
|
||
"hematolog",
|
||
"gastroenter",
|
||
"endocrin",
|
||
"neurolog",
|
||
"psychiatry",
|
||
"psychiatric",
|
||
"internal medicine",
|
||
"general surgery",
|
||
"pediatrics",
|
||
"neonat",
|
||
"nutrition",
|
||
"dietitian",
|
||
"physiothera",
|
||
"physical therapy",
|
||
"rehab",
|
||
"speech therap",
|
||
"occupational",
|
||
"medical report",
|
||
"blood bank",
|
||
"infection control",
|
||
],
|
||
"admin": [
|
||
"reception",
|
||
"registration",
|
||
"appointment",
|
||
"approval",
|
||
"insurance",
|
||
"finance",
|
||
"billing",
|
||
"account",
|
||
"hr",
|
||
"human resource",
|
||
"it ",
|
||
"information technology",
|
||
"medical record",
|
||
"health information",
|
||
"management",
|
||
"admin",
|
||
"security",
|
||
"parking",
|
||
"facility",
|
||
"maintenance",
|
||
"housekeep",
|
||
"clean",
|
||
"food",
|
||
"kitchen",
|
||
"cafeteria",
|
||
"transport",
|
||
"patient relation",
|
||
"pr ",
|
||
"public relation",
|
||
"complaint",
|
||
"quality",
|
||
"risk",
|
||
"credential",
|
||
"medical approval",
|
||
"pre-approval",
|
||
"preapproval",
|
||
],
|
||
"nursing": [
|
||
"nurs",
|
||
"nurse",
|
||
"iv ",
|
||
"injection",
|
||
"medication admin",
|
||
"wound care",
|
||
"triage",
|
||
],
|
||
"support": [
|
||
"kitchen",
|
||
"food service",
|
||
"clean",
|
||
"housekeep",
|
||
"laundry",
|
||
"security",
|
||
"transport",
|
||
"maintenance",
|
||
"facility",
|
||
"steriliz",
|
||
"central supply",
|
||
],
|
||
}
|
||
|
||
def classify_department(dept_name):
|
||
if not dept_name:
|
||
return ""
|
||
name_lower = dept_name.lower()
|
||
for cat, keywords in dept_cat_keywords.items():
|
||
for kw in keywords:
|
||
if kw in name_lower:
|
||
return cat.title()
|
||
return "Other"
|
||
|
||
def get_timeline_sla(complaint):
|
||
if not complaint.resolved_at or not complaint.activated_at:
|
||
return "More than 72 hours"
|
||
delta = complaint.resolved_at - complaint.activated_at
|
||
hours = delta.total_seconds() / 3600
|
||
if hours <= 24:
|
||
return "24 Hours"
|
||
elif hours <= 48:
|
||
return "48 Hours"
|
||
elif hours <= 72:
|
||
return "72 Hours"
|
||
return "More than 72 hours"
|
||
|
||
def get_dept_primary(complaint):
|
||
dept = complaint.involved_departments.filter(is_primary=True).first()
|
||
if not dept:
|
||
dept = complaint.involved_departments.first()
|
||
return dept
|
||
|
||
def fmt_date(dt):
|
||
if not dt:
|
||
return ""
|
||
return dt.date() if hasattr(dt, "date") else dt
|
||
|
||
def fmt_time(dt):
|
||
if not dt:
|
||
return ""
|
||
return dt.strftime("%H:%M") if hasattr(dt, "strftime") else ""
|
||
|
||
def time_diff(dt1, dt2):
|
||
if dt1 and dt2:
|
||
delta = dt1 - dt2
|
||
return str(delta)
|
||
return ""
|
||
|
||
row_num = 2
|
||
for idx, c in enumerate(qs, 1):
|
||
cal = c.created_at
|
||
week_of_month = (cal.day - 1) // 7 + 1
|
||
|
||
dept_name = ""
|
||
sub_dept_name = ""
|
||
dept_inv = get_dept_primary(c)
|
||
if dept_inv:
|
||
dept_name = dept_inv.department.name if dept_inv.department else ""
|
||
sub_dept_name = dept_name
|
||
|
||
main_dept = classify_department(dept_name or (c.department.name if c.department else ""))
|
||
|
||
source_display = ""
|
||
if c.source:
|
||
source_display = c.source.name if hasattr(c.source, "name") else str(c.source)
|
||
if c.complaint_source_type == "internal":
|
||
if c.source and c.source.code in ("moh", "chi"):
|
||
pass
|
||
else:
|
||
source_display = "Patient"
|
||
|
||
location_display = ""
|
||
if c.main_section:
|
||
location_display = c.main_section.name if hasattr(c.main_section, "name") else str(c.main_section)
|
||
if c.location:
|
||
loc_name = c.location.name if hasattr(c.location, "name") else str(c.location)
|
||
if loc_name:
|
||
location_display = f"{loc_name} - {location_display}" if location_display else loc_name
|
||
|
||
entry_by = c.created_by.get_full_name() if c.created_by else ""
|
||
|
||
activation_date = c.activated_at or c.assigned_at
|
||
activation_employee = ""
|
||
if c.assigned_to:
|
||
activation_employee = c.assigned_to.get_full_name()
|
||
|
||
forwarded_date = c.forwarded_to_dept_at
|
||
forwarded_employee = ""
|
||
if dept_inv and dept_inv.assigned_to:
|
||
forwarded_employee = dept_inv.assigned_to.get_full_name()
|
||
|
||
reminder1_date = c.reminder_sent_at
|
||
reminder2_date = c.second_reminder_sent_at
|
||
if dept_inv:
|
||
if dept_inv.first_reminder_sent_at:
|
||
reminder1_date = dept_inv.first_reminder_sent_at
|
||
if dept_inv.second_reminder_sent_at:
|
||
reminder2_date = dept_inv.second_reminder_sent_at
|
||
|
||
escalated_date = c.escalated_at
|
||
|
||
filed_date = cal
|
||
time_filed_to_sent = time_diff(forwarded_date, filed_date)
|
||
time_rem1_to_sent = time_diff(forwarded_date, reminder1_date) if reminder1_date and forwarded_date else ""
|
||
time_rem2_to_rem1 = time_diff(reminder2_date, reminder1_date) if reminder2_date and reminder1_date else ""
|
||
time_escal_to_sent = time_diff(forwarded_date, escalated_date) if escalated_date and forwarded_date else ""
|
||
|
||
close_date = c.closed_at
|
||
resolve_date = c.resolved_at
|
||
|
||
summary_ar = c.short_description_ar
|
||
summary_en = c.short_description_en
|
||
|
||
complained_person = c.staff_name or ""
|
||
|
||
delay_reason_dept = ""
|
||
delayed_person = ""
|
||
if dept_inv:
|
||
delay_reason_dept = dept_inv.delay_reason
|
||
delayed_person = dept_inv.delayed_person
|
||
|
||
rightful_side = c.get_resolution_outcome_display() if c.resolution_outcome else ""
|
||
|
||
row_data = [
|
||
week_of_month,
|
||
idx,
|
||
c.reference_number or "",
|
||
c.file_number or (c.patient.mrn if c.patient else ""),
|
||
source_display,
|
||
location_display,
|
||
main_dept,
|
||
sub_dept_name,
|
||
fmt_date(cal),
|
||
entry_by,
|
||
c.moh_reference,
|
||
fmt_date(c.moh_reference_date),
|
||
"",
|
||
c.contact_phone,
|
||
get_timeline_sla(c),
|
||
fmt_date(c.form_sent_at),
|
||
fmt_time(c.form_sent_at),
|
||
c.created_by.get_full_name() if c.created_by else "",
|
||
fmt_date(filed_date),
|
||
fmt_time(filed_date),
|
||
"Yes" if activation_date else "No",
|
||
fmt_date(activation_date),
|
||
fmt_time(activation_date),
|
||
activation_employee,
|
||
fmt_date(forwarded_date),
|
||
fmt_time(forwarded_date),
|
||
forwarded_employee,
|
||
time_filed_to_sent,
|
||
fmt_date(reminder1_date),
|
||
fmt_time(reminder1_date),
|
||
"",
|
||
time_rem1_to_sent,
|
||
fmt_date(reminder2_date),
|
||
fmt_time(reminder2_date),
|
||
"",
|
||
time_rem2_to_rem1,
|
||
"Yes" if escalated_date else "No",
|
||
fmt_date(escalated_date),
|
||
fmt_time(escalated_date),
|
||
"",
|
||
time_escal_to_sent,
|
||
"Yes" if close_date else "No",
|
||
fmt_date(close_date),
|
||
fmt_time(close_date),
|
||
c.closed_by.get_full_name() if c.closed_by else "",
|
||
time_diff(resolve_date, close_date) if resolve_date and close_date else "",
|
||
"Yes" if resolve_date else "No",
|
||
fmt_date(resolve_date),
|
||
fmt_time(resolve_date),
|
||
c.resolved_by.get_full_name() if c.resolved_by else "",
|
||
time_diff(c.resolution_sent_at, resolve_date) if c.resolution_sent_at and resolve_date else "",
|
||
fmt_date(c.response_date),
|
||
"",
|
||
time_diff(c.response_date, resolve_date) if c.response_date and resolve_date else "",
|
||
complained_person,
|
||
c.complaint_subject or c.title,
|
||
summary_ar,
|
||
summary_en,
|
||
"",
|
||
"",
|
||
delay_reason_dept,
|
||
c.delay_reason_closure,
|
||
delayed_person,
|
||
c.get_satisfaction_display() if c.satisfaction else "",
|
||
c.action_taken_by_dept,
|
||
c.action_result,
|
||
c.recommendation_action_plan or "",
|
||
c.recommendation_action_plan or "",
|
||
dept_name or (c.department.name if c.department else ""),
|
||
rightful_side,
|
||
"",
|
||
]
|
||
|
||
for col_num, val in enumerate(row_data, 1):
|
||
cell = ws.cell(row=row_num, column=col_num, value=val)
|
||
cell.border = THIN_BORDER
|
||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||
row_num += 1
|
||
|
||
summary_row = row_num + 2
|
||
|
||
ws.cell(row=summary_row, column=1, value="Weekly Breakdown").font = SECTION_FONT
|
||
summary_row += 1
|
||
_write_header_row(ws, summary_row, ["Metric", "Week 1", "Week 2", "Week 3", "Week 4", "Week 5", "Total"])
|
||
summary_row += 1
|
||
for week in range(1, 6):
|
||
start_day = (week - 1) * 7 + 1
|
||
end_day = week * 7
|
||
week_count = sum(
|
||
1 for c in queryset if start_day <= c.created_at.day <= end_day and c.created_at.month == month
|
||
)
|
||
ws.cell(row=summary_row, column=week + 1, value=week_count)
|
||
ws.cell(row=summary_row, column=7, value=queryset.count())
|
||
summary_row += 2
|
||
|
||
ws.cell(row=summary_row, column=1, value="Source Distribution").font = SECTION_FONT
|
||
summary_row += 1
|
||
_write_header_row(ws, summary_row, ["Source", "Count", "%"])
|
||
summary_row += 1
|
||
total = queryset.count()
|
||
source_counts = {"MOH": 0, "CCHI": 0, "Patient": 0, "Patient's Relatives": 0, "Other": 0}
|
||
for c in queryset:
|
||
if c.source and c.source.code and c.source.code.upper() == "MOH":
|
||
source_counts["MOH"] += 1
|
||
elif c.source and c.source.code and c.source.code.upper() in ("CHI", "CCHI"):
|
||
source_counts["CCHI"] += 1
|
||
elif c.complaint_source_type == "internal":
|
||
source_counts["Patient"] += 1
|
||
else:
|
||
source_counts["Other"] += 1
|
||
for src, cnt in source_counts.items():
|
||
if cnt > 0:
|
||
pct = (cnt / total * 100) if total else 0
|
||
ws.cell(row=summary_row, column=1, value=src)
|
||
ws.cell(row=summary_row, column=2, value=cnt)
|
||
ws.cell(row=summary_row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws.cell(row=summary_row, column=col).border = THIN_BORDER
|
||
summary_row += 1
|
||
ws.cell(row=summary_row, column=1, value="Total").font = Font(bold=True)
|
||
ws.cell(row=summary_row, column=2, value=total)
|
||
summary_row += 2
|
||
|
||
ws.cell(row=summary_row, column=1, value="Department Category Breakdown").font = SECTION_FONT
|
||
summary_row += 1
|
||
_write_header_row(ws, summary_row, ["Category", "Count", "%"])
|
||
summary_row += 1
|
||
dept_categories = {}
|
||
for c in queryset:
|
||
dept_name_raw = ""
|
||
dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first()
|
||
if dept_inv and dept_inv.department:
|
||
dept_name_raw = dept_inv.department.name
|
||
elif c.department:
|
||
dept_name_raw = c.department.name
|
||
cat = classify_department(dept_name_raw) or "Other"
|
||
dept_categories[cat] = dept_categories.get(cat, 0) + 1
|
||
for cat, cnt in sorted(dept_categories.items(), key=lambda x: -x[1]):
|
||
pct = (cnt / total * 100) if total else 0
|
||
ws.cell(row=summary_row, column=1, value=cat)
|
||
ws.cell(row=summary_row, column=2, value=cnt)
|
||
ws.cell(row=summary_row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws.cell(row=summary_row, column=col).border = THIN_BORDER
|
||
summary_row += 1
|
||
summary_row += 2
|
||
|
||
ws.cell(row=summary_row, column=1, value="Employee Performance").font = SECTION_FONT
|
||
summary_row += 1
|
||
_write_header_row(ws, summary_row, ["Employee", "Count", "%", "24h", "48h", "72h", ">72h", "Total", "SELF"])
|
||
summary_row += 1
|
||
|
||
emp_stats = (
|
||
queryset.values("created_by__first_name", "created_by__last_name")
|
||
.annotate(
|
||
total=Count("pk"),
|
||
)
|
||
.order_by("-total")
|
||
)
|
||
|
||
for emp in emp_stats:
|
||
name = f"{emp['created_by__first_name'] or ''} {emp['created_by__last_name'] or ''}".strip() or "Unknown"
|
||
emp_qs = queryset.filter(
|
||
created_by__first_name=emp["created_by__first_name"], created_by__last_name=emp["created_by__last_name"]
|
||
)
|
||
h24 = sum(1 for c in emp_qs if c.resolved_at and c.activated_at and (c.resolved_at - c.activated_at).total_seconds() <= 86400)
|
||
h48 = sum(
|
||
1 for c in emp_qs if c.resolved_at and c.activated_at and 86400 < (c.resolved_at - c.activated_at).total_seconds() <= 172800
|
||
)
|
||
h72 = sum(
|
||
1 for c in emp_qs if c.resolved_at and c.activated_at and 172800 < (c.resolved_at - c.activated_at).total_seconds() <= 259200
|
||
)
|
||
over72 = emp["total"] - h24 - h48 - h72
|
||
self_count = sum(1 for c in emp_qs if c.created_by == c.assigned_to)
|
||
pct = (emp["total"] / total * 100) if total else 0
|
||
row_vals = [name, emp["total"], f"{pct:.1f}%", h24, h48, h72, max(0, over72), emp["total"], self_count]
|
||
for col_num, val in enumerate(row_vals, 1):
|
||
ws.cell(row=summary_row, column=col_num, value=val)
|
||
ws.cell(row=summary_row, column=col_num).border = THIN_BORDER
|
||
summary_row += 1
|
||
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = f'attachment; filename="monthly_calculations_{month_label}.xlsx"'
|
||
wb.save(response)
|
||
return response
|
||
|
||
|
||
def _build_quarterly_yearly_report(queryset, title, months_list, year=None):
|
||
"""
|
||
Shared logic for quarterly and yearly calculations reports.
|
||
|
||
Args:
|
||
queryset: Complaint queryset (already filtered by date range)
|
||
title: Sheet/report title (e.g., "Q1 2025" or "Yearly 2025")
|
||
months_list: list of (month_num,) tuples for each month in the period
|
||
year: year number (optional, for sheet naming)
|
||
"""
|
||
from apps.complaints.models import Complaint, ComplaintInvolvedDepartment
|
||
|
||
wb = Workbook()
|
||
|
||
ws_kpi = wb.active
|
||
ws_kpi.title = "1. KPI"
|
||
|
||
dept_cat_keywords = {
|
||
"medical": [
|
||
"doctor",
|
||
"physician",
|
||
"surgeon",
|
||
"consultant",
|
||
"specialist",
|
||
"er",
|
||
"emergency",
|
||
"icu",
|
||
"nicu",
|
||
"pediatric",
|
||
"ob/gyn",
|
||
"cardiology",
|
||
"orthoped",
|
||
"radiology",
|
||
"dermatolog",
|
||
"lab",
|
||
"pharmacy",
|
||
"anesthesi",
|
||
"nephrology",
|
||
"urology",
|
||
"dental",
|
||
"oncolog",
|
||
"hematolog",
|
||
"gastroenter",
|
||
"endocrin",
|
||
"neurolog",
|
||
"psychiatry",
|
||
"internal medicine",
|
||
"general surgery",
|
||
"pediatrics",
|
||
"nutrition",
|
||
"physiothera",
|
||
"physical therapy",
|
||
"rehab",
|
||
"medical report",
|
||
"blood bank",
|
||
"infection control",
|
||
],
|
||
"admin": [
|
||
"reception",
|
||
"appointment",
|
||
"approval",
|
||
"insurance",
|
||
"finance",
|
||
"billing",
|
||
"hr",
|
||
"medical record",
|
||
"management",
|
||
"admin",
|
||
"security",
|
||
"facility",
|
||
"quality",
|
||
"risk",
|
||
"credential",
|
||
"medical approval",
|
||
"pre-approval",
|
||
"preapproval",
|
||
"it ",
|
||
],
|
||
"nursing": ["nurs", "nurse", "iv ", "injection", "medication admin", "wound care", "triage"],
|
||
"support": ["kitchen", "food service", "clean", "housekeep", "laundry", "steriliz", "central supply"],
|
||
}
|
||
|
||
def classify_department(dept_name):
|
||
if not dept_name:
|
||
return "Other"
|
||
name_lower = dept_name.lower()
|
||
for cat, keywords in dept_cat_keywords.items():
|
||
for kw in keywords:
|
||
if kw in name_lower:
|
||
return cat.title()
|
||
return "Other"
|
||
|
||
month_labels = [datetime.strptime(f"{year}-{m:02d}", "%Y-%m").strftime("%b") for _, m in months_list]
|
||
|
||
complaints_list = list(
|
||
queryset.select_related(
|
||
"hospital",
|
||
"department",
|
||
"source",
|
||
"created_by",
|
||
"assigned_to",
|
||
"resolved_by",
|
||
"closed_by",
|
||
).prefetch_related("involved_departments__department")
|
||
)
|
||
|
||
month_complaints = {}
|
||
for c in complaints_list:
|
||
m_key = c.created_at.month
|
||
if m_key not in month_complaints:
|
||
month_complaints[m_key] = []
|
||
month_complaints[m_key].append(c)
|
||
|
||
def _month_qs(m):
|
||
return month_complaints.get(m, [])
|
||
|
||
row = 1
|
||
ws_kpi.cell(row=row, column=1, value=title).font = Font(bold=True, size=14)
|
||
row += 2
|
||
|
||
def write_kpi_block(name, monthly_values, target=0.95, threshold=0.90):
|
||
nonlocal row
|
||
ws_kpi.cell(row=row, column=1, value=name).font = Font(bold=True, size=12)
|
||
row += 1
|
||
|
||
numerator_label_row = row
|
||
ws_kpi.cell(row=row, column=1, value="Numerator")
|
||
for i, (m_num, _) in enumerate(months_list):
|
||
ws_kpi.cell(row=row, column=2 + i, value=monthly_values[i][0])
|
||
total_num = sum(v[0] for v in monthly_values)
|
||
ws_kpi.cell(row=row, column=2 + len(months_list), value=total_num)
|
||
row += 1
|
||
|
||
ws_kpi.cell(row=row, column=1, value="Denominator")
|
||
for i, (m_num, _) in enumerate(months_list):
|
||
ws_kpi.cell(row=row, column=2 + i, value=monthly_values[i][1])
|
||
total_den = sum(v[1] for v in monthly_values)
|
||
ws_kpi.cell(row=row, column=2 + len(months_list), value=total_den)
|
||
row += 1
|
||
|
||
ws_kpi.cell(row=row, column=1, value="Result (%)")
|
||
for i, (m_num, _) in enumerate(months_list):
|
||
pct = (monthly_values[i][0] / monthly_values[i][1] * 100) if monthly_values[i][1] else 0
|
||
ws_kpi.cell(row=row, column=2 + i, value=f"{pct:.1f}%")
|
||
total_pct = (total_num / total_den * 100) if total_den else 0
|
||
ws_kpi.cell(row=row, column=2 + len(months_list), value=f"{total_pct:.1f}%")
|
||
row += 2
|
||
|
||
kpi1_data = []
|
||
kpi2_data = []
|
||
kpi4_data = []
|
||
for m_num, _ in months_list:
|
||
mc = _month_qs(m_num)
|
||
total_m = len(mc)
|
||
closed_m = sum(1 for c in mc if c.status in ("resolved", "closed"))
|
||
kpi1_data.append((closed_m, total_m))
|
||
|
||
resolved_72h = 0
|
||
for c in mc:
|
||
if c.resolved_at and c.activated_at:
|
||
if (c.resolved_at - c.activated_at).total_seconds() <= 259200:
|
||
resolved_72h += 1
|
||
kpi2_data.append((resolved_72h, total_m))
|
||
|
||
resolved_48h = 0
|
||
for c in mc:
|
||
if c.resolved_at and c.activated_at:
|
||
if (c.resolved_at - c.activated_at).total_seconds() <= 172800:
|
||
resolved_48h += 1
|
||
kpi4_data.append((resolved_48h, total_m))
|
||
|
||
write_kpi_block("KPI #1 — Closure Rate", kpi1_data)
|
||
write_kpi_block("KPI #2 — Resolved Within 72 Hours", kpi2_data)
|
||
write_kpi_block("KPI #4 — Responses Within 48 Hours", kpi4_data, target=0.50, threshold=0.40)
|
||
|
||
kpi3_data = []
|
||
for m_num, _ in months_list:
|
||
mc = _month_qs(m_num)
|
||
satisfied_m = sum(1 for c in mc if c.satisfaction == "satisfied")
|
||
total_surveyed_m = sum(1 for c in mc if c.satisfaction in ("satisfied", "neutral", "dissatisfied"))
|
||
kpi3_data.append((satisfied_m, total_surveyed_m))
|
||
|
||
write_kpi_block("KPI #3 — Satisfaction Rate", kpi3_data, target=0.80, threshold=0.70)
|
||
|
||
response_rate_call_data = []
|
||
response_rate_survey_data = []
|
||
for m_num, _ in months_list:
|
||
mc = _month_qs(m_num)
|
||
total_m = len(mc)
|
||
responded_call = sum(1 for c in mc if c.satisfaction in ("satisfied", "neutral", "dissatisfied"))
|
||
responded_survey = sum(1 for c in mc if c.resolution_survey_id is not None)
|
||
response_rate_call_data.append((responded_call, total_m))
|
||
response_rate_survey_data.append((responded_survey, total_m))
|
||
|
||
write_kpi_block("Response Rate — Calls", response_rate_call_data, target=0.80, threshold=0.70)
|
||
write_kpi_block("Response Rate — Survey", response_rate_survey_data, target=0.80, threshold=0.70)
|
||
|
||
reopen_data = []
|
||
for m_num, _ in months_list:
|
||
mc = _month_qs(m_num)
|
||
total_m = len(mc)
|
||
reopened_m = sum(1 for c in mc if c.reopened_from_id is not None)
|
||
reopen_data.append((reopened_m, total_m))
|
||
write_kpi_block("Reopen Rate", reopen_data, target=0.10, threshold=0.20)
|
||
|
||
reassign_data = []
|
||
ComplaintUpdate = apps.get_model("complaints", "ComplaintUpdate")
|
||
for m_num, _ in months_list:
|
||
mc_ids = list(mc.values_list("id", flat=True)) if hasattr(mc, 'values_list') else []
|
||
total_m = len(mc_ids) if mc_ids else len(_month_qs(m_num))
|
||
if mc_ids:
|
||
mc_qs = _month_qs(m_num)
|
||
else:
|
||
mc_qs = _month_qs(m_num)
|
||
reassigned_m = ComplaintUpdate.objects.filter(
|
||
complaint__in=mc_qs,
|
||
update_type="assignment",
|
||
created_at__month=m_num,
|
||
).count()
|
||
reassign_data.append((reassigned_m, total_m))
|
||
write_kpi_block("Reassign Count", reassign_data)
|
||
|
||
for col in range(1, 3 + len(months_list)):
|
||
ws_kpi.column_dimensions[get_column_letter(col)].width = 16
|
||
|
||
ws_table = wb.create_sheet("2. First Table")
|
||
row = 1
|
||
ws_table.cell(row=row, column=1, value=f"{title} — Distribution Analysis").font = Font(bold=True, size=14)
|
||
row += 2
|
||
|
||
external = 0
|
||
internal = 0
|
||
source_breakdown = {"MOH": 0, "CCHI": 0, "Insurance": 0, "Internal": 0}
|
||
location_breakdown = {"Inpatient": 0, "Outpatient": 0, "Emergency": 0}
|
||
category_breakdown = {}
|
||
|
||
for c in complaints_list:
|
||
is_external = False
|
||
if c.source and c.source.code and c.source.code.upper() == "MOH":
|
||
source_breakdown["MOH"] += 1
|
||
external += 1
|
||
is_external = True
|
||
elif c.source and c.source.code and c.source.code.upper() in ("CHI", "CCHI"):
|
||
source_breakdown["CCHI"] += 1
|
||
external += 1
|
||
is_external = True
|
||
else:
|
||
source_breakdown["Internal"] += 1
|
||
internal += 1
|
||
|
||
loc = "Other"
|
||
if c.main_section:
|
||
loc_name = c.main_section.name.lower() if hasattr(c.main_section, "name") else str(c.main_section).lower()
|
||
if "inpatient" in loc_name or "ip" in loc_name:
|
||
loc = "Inpatient"
|
||
elif "outpatient" in loc_name or "op" in loc_name or "clinic" in loc_name:
|
||
loc = "Outpatient"
|
||
elif "er" in loc_name or "emergency" in loc_name:
|
||
loc = "Emergency"
|
||
location_breakdown[loc] = location_breakdown.get(loc, 0) + 1
|
||
|
||
dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first()
|
||
dept_name = ""
|
||
if dept_inv and dept_inv.department:
|
||
dept_name = dept_inv.department.name
|
||
elif c.department:
|
||
dept_name = c.department.name
|
||
cat = classify_department(dept_name)
|
||
category_breakdown[cat] = category_breakdown.get(cat, 0) + 1
|
||
|
||
total = len(complaints_list)
|
||
|
||
ws_table.cell(row=row, column=1, value="External vs Internal").font = SECTION_FONT
|
||
row += 1
|
||
_write_header_row(ws_table, row, ["Category", "Count", "%"])
|
||
row += 1
|
||
for label, count in [("External (MOH/CCHI/Insurance)", external), ("Internal (Patients/Relatives)", internal)]:
|
||
pct = (count / total * 100) if total else 0
|
||
ws_table.cell(row=row, column=1, value=label)
|
||
ws_table.cell(row=row, column=2, value=count)
|
||
ws_table.cell(row=row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws_table.cell(row=row, column=col).border = THIN_BORDER
|
||
row += 1
|
||
ws_table.cell(row=row, column=1, value="Total").font = Font(bold=True)
|
||
ws_table.cell(row=row, column=2, value=total).font = Font(bold=True)
|
||
row += 2
|
||
|
||
ws_table.cell(row=row, column=1, value="By Source").font = SECTION_FONT
|
||
row += 1
|
||
_write_header_row(ws_table, row, ["Source", "Count", "%"])
|
||
row += 1
|
||
for src, cnt in sorted(source_breakdown.items(), key=lambda x: -x[1]):
|
||
pct = (cnt / total * 100) if total else 0
|
||
ws_table.cell(row=row, column=1, value=src)
|
||
ws_table.cell(row=row, column=2, value=cnt)
|
||
ws_table.cell(row=row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws_table.cell(row=row, column=col).border = THIN_BORDER
|
||
row += 1
|
||
row += 2
|
||
|
||
ws_table.cell(row=row, column=1, value="By Location").font = SECTION_FONT
|
||
row += 1
|
||
_write_header_row(ws_table, row, ["Location", "Count", "%"])
|
||
row += 1
|
||
for loc, cnt in sorted(location_breakdown.items(), key=lambda x: -x[1]):
|
||
pct = (cnt / total * 100) if total else 0
|
||
ws_table.cell(row=row, column=1, value=loc)
|
||
ws_table.cell(row=row, column=2, value=cnt)
|
||
ws_table.cell(row=row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws_table.cell(row=row, column=col).border = THIN_BORDER
|
||
row += 1
|
||
row += 2
|
||
|
||
ws_table.cell(row=row, column=1, value="By Department Category").font = SECTION_FONT
|
||
row += 1
|
||
_write_header_row(ws_table, row, ["Category", "Count", "%"])
|
||
row += 1
|
||
for cat, cnt in sorted(category_breakdown.items(), key=lambda x: -x[1]):
|
||
pct = (cnt / total * 100) if total else 0
|
||
ws_table.cell(row=row, column=1, value=cat)
|
||
ws_table.cell(row=row, column=2, value=cnt)
|
||
ws_table.cell(row=row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws_table.cell(row=row, column=col).border = THIN_BORDER
|
||
row += 1
|
||
|
||
for col in range(1, 5):
|
||
ws_table.column_dimensions[get_column_letter(col)].width = 35
|
||
|
||
ws_escalated = wb.create_sheet("3. Escalated Complaints")
|
||
row = 1
|
||
ws_escalated.cell(row=row, column=1, value=f"{title} — Escalated Complaints by Department").font = Font(
|
||
bold=True, size=14
|
||
)
|
||
row += 2
|
||
|
||
escalated_by_dept = {}
|
||
escalated_by_cat = {}
|
||
for c in complaints_list:
|
||
if c.escalated_at:
|
||
dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first()
|
||
dept_name = ""
|
||
if dept_inv and dept_inv.department:
|
||
dept_name = dept_inv.department.name
|
||
elif c.department:
|
||
dept_name = c.department.name
|
||
if dept_name:
|
||
escalated_by_dept[dept_name] = escalated_by_dept.get(dept_name, 0) + 1
|
||
cat = classify_department(dept_name)
|
||
if cat not in escalated_by_cat:
|
||
escalated_by_cat[cat] = {"escalated": 0, "total": 0}
|
||
escalated_by_cat[cat]["escalated"] += 1
|
||
|
||
for c in complaints_list:
|
||
dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first()
|
||
dept_name = ""
|
||
if dept_inv and dept_inv.department:
|
||
dept_name = dept_inv.department.name
|
||
elif c.department:
|
||
dept_name = c.department.name
|
||
cat = classify_department(dept_name)
|
||
if cat not in escalated_by_cat:
|
||
escalated_by_cat[cat] = {"escalated": 0, "total": 0}
|
||
escalated_by_cat[cat]["total"] += 1
|
||
|
||
_write_header_row(ws_escalated, row, ["Department Category", "Escalated", "Total", "Escalation Rate"])
|
||
row += 1
|
||
for cat, data in sorted(escalated_by_cat.items()):
|
||
rate = (data["escalated"] / data["total"] * 100) if data["total"] else 0
|
||
ws_escalated.cell(row=row, column=1, value=cat)
|
||
ws_escalated.cell(row=row, column=2, value=data["escalated"])
|
||
ws_escalated.cell(row=row, column=3, value=data["total"])
|
||
ws_escalated.cell(row=row, column=4, value=f"{rate:.1f}%")
|
||
for col in range(1, 5):
|
||
ws_escalated.cell(row=row, column=col).border = THIN_BORDER
|
||
row += 1
|
||
|
||
row += 2
|
||
ws_escalated.cell(row=row, column=1, value="Escalated by Specific Department").font = SECTION_FONT
|
||
row += 1
|
||
_write_header_row(ws_escalated, row, ["Department", "Escalated Count"])
|
||
row += 1
|
||
for dept, cnt in sorted(escalated_by_dept.items(), key=lambda x: -x[1]):
|
||
ws_escalated.cell(row=row, column=1, value=dept)
|
||
ws_escalated.cell(row=row, column=2, value=cnt)
|
||
for col in range(1, 3):
|
||
ws_escalated.cell(row=row, column=col).border = THIN_BORDER
|
||
row += 1
|
||
|
||
for col in range(1, 5):
|
||
ws_escalated.column_dimensions[get_column_letter(col)].width = 30
|
||
|
||
response_rate_sheets = [
|
||
("5. Internal Response Rate", "internal"),
|
||
("6. CHI Response Rate", "chi"),
|
||
("7. MOH Response Rate", "moh"),
|
||
]
|
||
for sheet_name, source_filter in response_rate_sheets:
|
||
ws_rr = wb.create_sheet(sheet_name)
|
||
row = 1
|
||
ws_rr.cell(row=row, column=1, value=f"{title} — {sheet_name}").font = Font(bold=True, size=14)
|
||
row += 2
|
||
|
||
if source_filter == "internal":
|
||
filtered = [c for c in complaints_list if not (c.source and c.source.code and c.source.code.upper() in ("MOH", "CHI", "CCHI"))]
|
||
elif source_filter == "chi":
|
||
filtered = [c for c in complaints_list if c.source and c.source.code and c.source.code.upper() in ("CHI", "CCHI")]
|
||
else:
|
||
filtered = [c for c in complaints_list if c.source and c.source.code and c.source.code.upper() == "MOH"]
|
||
|
||
buckets = {"24 Hours": 0, "48 Hours": 0, "72 Hours": 0, "More than 72 Hours": 0}
|
||
for c in filtered:
|
||
if c.resolved_at and c.activated_at:
|
||
hours = (c.resolved_at - c.activated_at).total_seconds() / 3600
|
||
if hours <= 24:
|
||
buckets["24 Hours"] += 1
|
||
elif hours <= 48:
|
||
buckets["48 Hours"] += 1
|
||
elif hours <= 72:
|
||
buckets["72 Hours"] += 1
|
||
else:
|
||
buckets["More than 72 Hours"] += 1
|
||
else:
|
||
buckets["More than 72 Hours"] += 1
|
||
|
||
total_filtered = len(filtered)
|
||
_write_header_row(ws_rr, row, ["Timeline", "Count", "%"])
|
||
row += 1
|
||
for label, cnt in buckets.items():
|
||
pct = (cnt / total_filtered * 100) if total_filtered else 0
|
||
ws_rr.cell(row=row, column=1, value=label)
|
||
ws_rr.cell(row=row, column=2, value=cnt)
|
||
ws_rr.cell(row=row, column=3, value=f"{pct:.1f}%")
|
||
for col in range(1, 4):
|
||
ws_rr.cell(row=row, column=col).border = THIN_BORDER
|
||
row += 1
|
||
ws_rr.cell(row=row, column=1, value="Total").font = Font(bold=True)
|
||
ws_rr.cell(row=row, column=2, value=total_filtered).font = Font(bold=True)
|
||
|
||
for col in range(1, 4):
|
||
ws_rr.column_dimensions[get_column_letter(col)].width = 25
|
||
|
||
return wb
|
||
|
||
|
||
def export_quarterly_calculations(queryset, year, quarter):
|
||
"""
|
||
Step 2 — Quarterly Calculations Excel export.
|
||
|
||
Args:
|
||
queryset: Complaint queryset filtered to the quarter
|
||
year: int
|
||
quarter: int (1-4)
|
||
"""
|
||
quarter_months = {
|
||
1: [(1, "Jan"), (2, "Feb"), (3, "Mar")],
|
||
2: [(4, "Apr"), (5, "May"), (6, "Jun")],
|
||
3: [(7, "Jul"), (8, "Aug"), (9, "Sep")],
|
||
4: [(10, "Oct"), (11, "Nov"), (12, "Dec")],
|
||
}
|
||
months = quarter_months[quarter]
|
||
title = f"Q{quarter} {year}"
|
||
|
||
wb = _build_quarterly_yearly_report(queryset, title, months, year=year)
|
||
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = f'attachment; filename="quarterly_calculations_Q{quarter}_{year}.xlsx"'
|
||
wb.save(response)
|
||
return response
|
||
|
||
|
||
def export_yearly_calculations(queryset, year):
|
||
"""
|
||
Yearly Calculations Excel export.
|
||
|
||
Args:
|
||
queryset: Complaint queryset filtered to the year
|
||
year: int
|
||
"""
|
||
months = [(m, datetime.strptime(f"{m}", "%m").strftime("%b")) for m in range(1, 13)]
|
||
title = f"Yearly {year}"
|
||
|
||
wb = _build_quarterly_yearly_report(queryset, title, months, year=year)
|
||
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = f'attachment; filename="yearly_calculations_{year}.xlsx"'
|
||
wb.save(response)
|
||
return response
|
||
|
||
|
||
def _fmt_date(dt):
|
||
if dt is None:
|
||
return None
|
||
dt = dt.replace(tzinfo=None) if hasattr(dt, "replace") and getattr(dt, "tzinfo", None) else dt
|
||
return dt.date() if hasattr(dt, "date") else dt
|
||
|
||
|
||
def _fmt_time(dt):
|
||
if dt is None:
|
||
return None
|
||
if hasattr(dt, "strftime"):
|
||
return dt.strftime("%H:%M")
|
||
return None
|
||
|
||
|
||
def _strip_tz(dt):
|
||
if dt is None:
|
||
return None
|
||
if hasattr(dt, "tzinfo") and dt.tzinfo is not None:
|
||
return dt.replace(tzinfo=None)
|
||
return dt
|
||
|
||
|
||
def _fmt_duration(td):
|
||
if td is None:
|
||
return None
|
||
if isinstance(td, timedelta):
|
||
total_sec = int(td.total_seconds())
|
||
h, rem = divmod(total_sec, 3600)
|
||
m, s = divmod(rem, 60)
|
||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||
return td
|
||
|
||
|
||
_INCOMING_MONTHS = [
|
||
"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JULY", "AUG",
|
||
"SEP", "OCT", "NOV", "Dec",
|
||
]
|
||
|
||
|
||
def export_inquiries_report(queryset, year, month, is_outgoing=False):
|
||
"""
|
||
Reports — Incoming/Outgoing Inquiries Excel export.
|
||
|
||
Layout matches the production Excel templates exactly:
|
||
- Row 1: merged group headers (Portal / Time Line / PDF / رأيك الشخصي)
|
||
- Row 2: Arabic sub-headers with merged timeline groups
|
||
- Data rows starting at row 3
|
||
- Calculations sheet with per-employee/per-department breakdowns
|
||
- Drop-down sheet with timeline options, employee list, status options
|
||
"""
|
||
wb = Workbook()
|
||
month_idx = month - 1
|
||
month_label = f"{_INCOMING_MONTHS[month_idx]}-{year % 100}"
|
||
ws = wb.active
|
||
ws.title = month_label
|
||
|
||
CENTER = Alignment(horizontal="center", vertical="center")
|
||
RIGHT_CENTER = Alignment(horizontal="right", vertical="center")
|
||
WRAP_CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||
|
||
R1_FONT = Font(name="Aptos Narrow", bold=True, size=14)
|
||
R1_FONT_LG = Font(name="Aptos Narrow", bold=True, size=16)
|
||
R1_FONT_XL = Font(name="Aptos Narrow", bold=True, size=18)
|
||
R2_FONT = Font(bold=True)
|
||
DATA_FONT = Font(name="Calibri", size=11)
|
||
|
||
PORTAL_FILL = PatternFill("solid", fgColor="FFC7CE")
|
||
PORTAL_FONT = Font(name="Aptos Narrow", bold=True, size=14, color="9C0006")
|
||
TIMELINE_FILL = PatternFill("solid", fgColor="FFFFCC")
|
||
TIMELINE_FONT = Font(name="Aptos Narrow", bold=True, size=16)
|
||
PDF_FILL = PatternFill("solid", fgColor="C6EFCE")
|
||
PDF_FONT = Font(name="Aptos Narrow", bold=True, size=18, color="006100")
|
||
OPINION_FILL = PatternFill("solid", fgColor="FFCC99")
|
||
OPINION_FONT = Font(name="Aptos Narrow", bold=True, size=18, color="3F3F76")
|
||
R2_FILL = PatternFill("solid", fgColor="4472C4")
|
||
R2_FONT_WHITE = Font(bold=True, color="FFFFFF", size=11)
|
||
THIN = Border(
|
||
left=Side("thin"), right=Side("thin"),
|
||
top=Side("thin"), bottom=Side("thin"),
|
||
)
|
||
|
||
if is_outgoing:
|
||
_build_outgoing_sheet(
|
||
ws, queryset, year, month,
|
||
CENTER, RIGHT_CENTER, WRAP_CENTER,
|
||
PORTAL_FILL, PORTAL_FONT, TIMELINE_FILL, TIMELINE_FONT,
|
||
PDF_FILL, PDF_FONT, OPINION_FILL, OPINION_FONT,
|
||
R2_FILL, R2_FONT_WHITE, THIN, DATA_FONT,
|
||
)
|
||
_build_outgoing_calculations(wb, queryset, year, month)
|
||
_build_outgoing_dropdown(wb, queryset)
|
||
else:
|
||
_build_incoming_sheet(
|
||
ws, queryset, year, month,
|
||
CENTER, RIGHT_CENTER, WRAP_CENTER,
|
||
PORTAL_FILL, PORTAL_FONT, TIMELINE_FILL, TIMELINE_FONT,
|
||
PDF_FILL, PDF_FONT, OPINION_FILL, OPINION_FONT,
|
||
R2_FILL, R2_FONT_WHITE, THIN, DATA_FONT,
|
||
)
|
||
_build_incoming_calculations(wb, queryset, year, month)
|
||
_build_incoming_dropdown(wb, queryset)
|
||
|
||
prefix = "Outgoing" if is_outgoing else "Incoming"
|
||
file_label = f"{year}-{month:02d}"
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = f'attachment; filename="{prefix.lower()}_inquiries_{file_label}.xlsx"'
|
||
wb.save(response)
|
||
return response
|
||
|
||
|
||
def _build_incoming_sheet(
|
||
ws, queryset, year, month,
|
||
center, right_center, wrap_center,
|
||
portal_fill, portal_font, timeline_fill, timeline_font,
|
||
pdf_fill, pdf_font, opinion_fill, opinion_font,
|
||
r2_fill, r2_font, border, data_font,
|
||
):
|
||
ws.merge_cells("A1:B1")
|
||
ws.merge_cells("C1:F1")
|
||
ws.merge_cells("G1:V1")
|
||
ws.merge_cells("X1:Y1")
|
||
ws.merge_cells("Z1:AA1")
|
||
|
||
for col, val, fill, font in [
|
||
(3, "Portal", portal_fill, portal_font),
|
||
(7, "Time Line", timeline_fill, timeline_font),
|
||
(24, "PDF", pdf_fill, pdf_font),
|
||
(26, "رأيك الشخصي", opinion_fill, opinion_font),
|
||
]:
|
||
c = ws.cell(row=1, column=col, value=val)
|
||
c.font = font
|
||
c.fill = fill
|
||
c.alignment = center
|
||
|
||
ws.merge_cells("K2:N2")
|
||
ws.merge_cells("O2:R2")
|
||
ws.merge_cells("S2:V2")
|
||
|
||
r2_headers = {
|
||
1: "week", 2: "No.", 3: "التاريخ", 4: "اسم المراجع",
|
||
5: "رقم الجوال", 6: "القسم المرسل", 7: "ID NO.",
|
||
8: "Time line", 9: "تاريخ الاستفسار", 10: "وقت الاستفسار",
|
||
11: "تم التواصل ولم يتم الرد",
|
||
15: "تحت الإجراء",
|
||
19: "تم التواصل ",
|
||
24: "الاستفسار (من الPDF)", 25: "حالة الاستفسار",
|
||
26: "ملاحظات الموظف", 27: "ملاحظات المشرف",
|
||
}
|
||
for col, val in r2_headers.items():
|
||
c = ws.cell(row=2, column=col, value=val)
|
||
c.font = r2_font
|
||
c.fill = r2_fill
|
||
c.alignment = wrap_center
|
||
c.border = border
|
||
|
||
for col in range(1, 28):
|
||
ws.cell(row=2, column=col).border = border
|
||
if col not in r2_headers:
|
||
c = ws.cell(row=2, column=col)
|
||
c.fill = r2_fill
|
||
c.border = border
|
||
|
||
ws.freeze_panes = "C3"
|
||
|
||
qs = queryset.select_related(
|
||
"hospital", "department", "assigned_to", "created_by",
|
||
"outgoing_department", "responded_by",
|
||
"contacted_nr_by", "under_process_by", "contacted_by",
|
||
"source",
|
||
).order_by("created_at")
|
||
|
||
first_half_end = 15
|
||
row_num = 3
|
||
first_half_start_row = 3
|
||
second_half_start_row = None
|
||
idx = 0
|
||
|
||
for inq in qs:
|
||
idx += 1
|
||
week_of_month = (inq.created_at.day - 1) // first_half_end + 1
|
||
is_first_half = inq.created_at.day <= first_half_end
|
||
|
||
if is_first_half:
|
||
week_label = "1st half of month, 1 to 15"
|
||
else:
|
||
week_label = "2nd half of month, 16 to end"
|
||
if second_half_start_row is None:
|
||
second_half_start_row = row_num
|
||
|
||
dept_name = inq.source.name_ar if inq.source else ""
|
||
emp_id = inq.created_by.employee_id if inq.created_by and inq.created_by.employee_id else ""
|
||
|
||
timeline = inq.get_timeline_sla_display() if inq.timeline_sla else ""
|
||
if not timeline and inq.due_at and inq.created_at:
|
||
hours = (inq.due_at - inq.created_at).total_seconds() / 3600
|
||
if hours <= 24:
|
||
timeline = "24 Hours"
|
||
elif hours <= 48:
|
||
timeline = "48 Hours"
|
||
elif hours <= 72:
|
||
timeline = "72 Hours"
|
||
else:
|
||
timeline = "More than 72 hours"
|
||
|
||
row_data = [None] * 27
|
||
row_data[0] = week_label
|
||
row_data[1] = idx
|
||
row_data[2] = _strip_tz(inq.created_at)
|
||
row_data[3] = inq.contact_name or ""
|
||
row_data[4] = inq.contact_phone or ""
|
||
row_data[5] = dept_name
|
||
row_data[6] = emp_id
|
||
row_data[7] = timeline
|
||
row_data[8] = _fmt_date(inq.created_at)
|
||
row_data[9] = _fmt_time(inq.created_at)
|
||
|
||
if inq.contacted_nr_at:
|
||
row_data[10] = _fmt_date(inq.contacted_nr_at)
|
||
row_data[11] = inq.contacted_nr_time or _fmt_time(inq.contacted_nr_at)
|
||
row_data[12] = inq.contacted_nr_by.get_full_name() if inq.contacted_nr_by else ""
|
||
row_data[13] = _fmt_duration(inq.contacted_nr_duration)
|
||
|
||
if inq.under_process_at:
|
||
row_data[14] = _fmt_date(inq.under_process_at)
|
||
row_data[15] = inq.under_process_time or _fmt_time(inq.under_process_at)
|
||
row_data[16] = inq.under_process_by.get_full_name() if inq.under_process_by else ""
|
||
row_data[17] = _fmt_duration(inq.under_process_duration)
|
||
|
||
if inq.contacted_at:
|
||
row_data[18] = _fmt_date(inq.contacted_at)
|
||
row_data[19] = inq.contacted_time or _fmt_time(inq.contacted_at)
|
||
row_data[20] = inq.contacted_by.get_full_name() if inq.contacted_by else ""
|
||
row_data[21] = _fmt_duration(inq.contacted_duration)
|
||
|
||
row_data[22] = None
|
||
row_data[23] = inq.subject
|
||
status_ar = {
|
||
"open": "جديد", "in_progress": "تحت الإجراء",
|
||
"resolved": "تم التواصل", "closed": "تم التواصل",
|
||
"contacted": "تم التواصل",
|
||
"contacted_no_response": "تم التواصل ولم يتم الرد",
|
||
}
|
||
row_data[24] = status_ar.get(inq.status, inq.get_status_display())
|
||
row_data[25] = inq.staff_notes
|
||
row_data[26] = inq.supervisor_notes
|
||
|
||
for col_idx, val in enumerate(row_data):
|
||
c = ws.cell(row=row_num, column=col_idx + 1, value=val)
|
||
c.font = data_font
|
||
c.border = border
|
||
if col_idx in (2, 8):
|
||
c.number_format = "YYYY-MM-DD"
|
||
elif col_idx in (9, 11, 15, 19):
|
||
c.number_format = "HH:MM"
|
||
elif col_idx in (10, 14, 18):
|
||
c.number_format = "YYYY-MM-DD"
|
||
|
||
row_num += 1
|
||
|
||
last_data_row = row_num - 1
|
||
|
||
if first_half_start_row and first_half_start_row <= last_data_row:
|
||
first_half_end_row = (second_half_start_row or (last_data_row + 1)) - 1
|
||
if first_half_start_row < first_half_end_row:
|
||
ws.merge_cells(
|
||
start_row=first_half_start_row, start_column=1,
|
||
end_row=first_half_end_row, end_column=1,
|
||
)
|
||
if second_half_start_row and second_half_start_row <= last_data_row:
|
||
if second_half_start_row < last_data_row:
|
||
ws.merge_cells(
|
||
start_row=second_half_start_row, start_column=1,
|
||
end_row=last_data_row, end_column=1,
|
||
)
|
||
|
||
_set_incoming_widths(ws)
|
||
ws.sheet_view.rightToLeft = True
|
||
|
||
|
||
def _set_incoming_widths(ws):
|
||
widths = {
|
||
"A": 9.13, "B": 8.75, "C": 15.13, "D": 24.25,
|
||
"E": 20.38, "F": 11.38, "G": 13.38, "H": 16.63,
|
||
"I": 26.5, "J": 16.63, "K": 23.0, "L": 20.38,
|
||
"M": 20.63, "N": 10.75, "O": 23.25, "P": 16.88,
|
||
"Q": 26.5, "R": 15.38, "S": 23.5, "T": 16.88,
|
||
"U": 18.63, "V": 18.25, "W": 0.25, "X": 25.5,
|
||
"Y": 22.88, "Z": 57.88, "AA": 14.75,
|
||
}
|
||
for col_letter, w in widths.items():
|
||
ws.column_dimensions[col_letter].width = w
|
||
|
||
|
||
def _build_outgoing_sheet(
|
||
ws, queryset, year, month,
|
||
center, right_center, wrap_center,
|
||
portal_fill, portal_font, timeline_fill, timeline_font,
|
||
pdf_fill, pdf_font, opinion_fill, opinion_font,
|
||
r2_fill, r2_font, border, data_font,
|
||
):
|
||
ws.merge_cells("A1:B1")
|
||
ws.merge_cells("C1:G1")
|
||
ws.merge_cells("H1:U1")
|
||
ws.merge_cells("V1:W1")
|
||
ws.merge_cells("X1:Y1")
|
||
|
||
for col, val, fill, font in [
|
||
(3, "Portal", portal_fill, portal_font),
|
||
(8, "Time Line", timeline_fill, timeline_font),
|
||
(22, "PDF", pdf_fill, pdf_font),
|
||
(24, "رأيك الشخصي", opinion_fill, opinion_font),
|
||
]:
|
||
c = ws.cell(row=1, column=col, value=val)
|
||
c.font = font
|
||
c.fill = fill
|
||
c.alignment = center
|
||
|
||
ws.merge_cells("J2:M2")
|
||
ws.merge_cells("N2:Q2")
|
||
ws.merge_cells("R2:U2")
|
||
|
||
r2_headers = {
|
||
1: "week", 2: "No.", 3: "التاريخ", 4: "اسم المراجع",
|
||
5: "رقم الجوال", 6: "القسم المرسل اليه",
|
||
7: "time line", 8: "تاريخ الاستفسار", 9: "وقت الاستفسار",
|
||
10: "تم التواصل ولم يتم الرد",
|
||
14: "تحت الإجراء",
|
||
18: "تم التواصل ",
|
||
22: "الاستفسار (من الPDF)", 23: "حالة الاستفسار",
|
||
24: "ملاحظات الموظف", 25: "ملاحظات المشرف",
|
||
}
|
||
for col, val in r2_headers.items():
|
||
c = ws.cell(row=2, column=col, value=val)
|
||
c.font = r2_font
|
||
c.fill = r2_fill
|
||
c.alignment = wrap_center
|
||
c.border = border
|
||
|
||
for col in range(1, 26):
|
||
ws.cell(row=2, column=col).border = border
|
||
if col not in r2_headers:
|
||
c = ws.cell(row=2, column=col)
|
||
c.fill = r2_fill
|
||
c.border = border
|
||
|
||
ws.freeze_panes = "C3"
|
||
|
||
qs = queryset.select_related(
|
||
"hospital", "department", "assigned_to", "created_by",
|
||
"outgoing_department", "responded_by",
|
||
"contacted_nr_by", "under_process_by", "contacted_by",
|
||
"source",
|
||
).order_by("created_at")
|
||
|
||
first_half_end = 15
|
||
row_num = 3
|
||
first_half_start_row = 3
|
||
second_half_start_row = None
|
||
idx = 0
|
||
|
||
for inq in qs:
|
||
idx += 1
|
||
is_first_half = inq.created_at.day <= first_half_end
|
||
|
||
if is_first_half:
|
||
week_label = "1st Half of the month, From 1 to 15"
|
||
else:
|
||
week_label = "2nd Half of the month, From 16 to end"
|
||
if second_half_start_row is None:
|
||
second_half_start_row = row_num
|
||
|
||
dept_name = inq.outgoing_department.name if inq.outgoing_department else ""
|
||
|
||
timeline = inq.get_timeline_sla_display() if inq.timeline_sla else ""
|
||
if not timeline and inq.due_at and inq.created_at:
|
||
hours = (inq.due_at - inq.created_at).total_seconds() / 3600
|
||
if hours <= 24:
|
||
timeline = "24 Hours"
|
||
elif hours <= 48:
|
||
timeline = "48 Hours"
|
||
elif hours <= 72:
|
||
timeline = "72 Hours"
|
||
else:
|
||
timeline = "More than 72 hours"
|
||
|
||
row_data = [None] * 25
|
||
row_data[0] = week_label
|
||
row_data[1] = idx
|
||
row_data[2] = _strip_tz(inq.created_at)
|
||
row_data[3] = inq.contact_name or ""
|
||
row_data[4] = inq.contact_phone or ""
|
||
row_data[5] = dept_name
|
||
row_data[6] = timeline
|
||
row_data[7] = _fmt_date(inq.created_at)
|
||
row_data[8] = _fmt_time(inq.created_at)
|
||
|
||
if inq.contacted_nr_at:
|
||
row_data[9] = _fmt_date(inq.contacted_nr_at)
|
||
row_data[10] = inq.contacted_nr_time or _fmt_time(inq.contacted_nr_at)
|
||
row_data[11] = inq.contacted_nr_by.get_full_name() if inq.contacted_nr_by else ""
|
||
row_data[12] = _fmt_duration(inq.contacted_nr_duration)
|
||
|
||
if inq.under_process_at:
|
||
row_data[13] = _fmt_date(inq.under_process_at)
|
||
row_data[14] = inq.under_process_time or _fmt_time(inq.under_process_at)
|
||
row_data[15] = inq.under_process_by.get_full_name() if inq.under_process_by else ""
|
||
row_data[16] = _fmt_duration(inq.under_process_duration)
|
||
|
||
if inq.contacted_at:
|
||
row_data[17] = _fmt_date(inq.contacted_at)
|
||
row_data[18] = inq.contacted_time or _fmt_time(inq.contacted_at)
|
||
row_data[19] = inq.contacted_by.get_full_name() if inq.contacted_by else ""
|
||
row_data[20] = _fmt_duration(inq.contacted_duration)
|
||
|
||
row_data[21] = inq.subject
|
||
status_ar = {
|
||
"open": "جديد", "in_progress": "تحت الإجراء",
|
||
"resolved": "تم التواصل", "closed": "تم التواصل",
|
||
"contacted": "تم التواصل",
|
||
"contacted_no_response": "تم التواصل ولم يتم الرد",
|
||
}
|
||
row_data[22] = status_ar.get(inq.status, inq.get_status_display())
|
||
row_data[23] = inq.staff_notes
|
||
row_data[24] = inq.supervisor_notes
|
||
|
||
for col_idx, val in enumerate(row_data):
|
||
c = ws.cell(row=row_num, column=col_idx + 1, value=val)
|
||
c.font = data_font
|
||
c.border = border
|
||
if col_idx == 2:
|
||
c.number_format = "YYYY-MM-DD"
|
||
elif col_idx in (8, 10, 14, 18):
|
||
c.number_format = "HH:MM"
|
||
elif col_idx in (7, 9, 13, 17):
|
||
c.number_format = "YYYY-MM-DD"
|
||
|
||
row_num += 1
|
||
|
||
last_data_row = row_num - 1
|
||
if first_half_start_row and first_half_start_row <= last_data_row:
|
||
first_half_end_row = (second_half_start_row or (last_data_row + 1)) - 1
|
||
if first_half_start_row < first_half_end_row:
|
||
ws.merge_cells(
|
||
start_row=first_half_start_row, start_column=1,
|
||
end_row=first_half_end_row, end_column=1,
|
||
)
|
||
if second_half_start_row and second_half_start_row <= last_data_row:
|
||
if second_half_start_row < last_data_row:
|
||
ws.merge_cells(
|
||
start_row=second_half_start_row, start_column=1,
|
||
end_row=last_data_row, end_column=1,
|
||
)
|
||
|
||
_set_outgoing_widths(ws)
|
||
ws.sheet_view.rightToLeft = True
|
||
|
||
|
||
def _set_outgoing_widths(ws):
|
||
widths = {
|
||
"A": 9.13, "B": 8.75, "C": 15.13, "D": 24.25,
|
||
"E": 20.38, "F": 30.0, "G": 16.63,
|
||
"H": 26.5, "I": 16.63,
|
||
"J": 23.0, "K": 20.38, "L": 20.63, "M": 10.75,
|
||
"N": 23.25, "O": 16.88, "P": 26.5, "Q": 15.38,
|
||
"R": 23.5, "S": 16.88, "T": 18.63, "U": 18.25,
|
||
"V": 25.5, "W": 22.88, "X": 57.88, "Y": 14.75,
|
||
}
|
||
for col_letter, w in widths.items():
|
||
ws.column_dimensions[col_letter].width = w
|
||
|
||
|
||
def _get_employee_names(queryset):
|
||
creator_ids = queryset.values_list("created_by_id", flat=True).distinct()
|
||
from apps.accounts.models import User
|
||
users = User.objects.filter(id__in=[uid for uid in creator_ids if uid])
|
||
return [(u.get_full_name(), u.employee_id) for u in users if u.get_full_name()]
|
||
|
||
|
||
def _build_incoming_calculations(wb, queryset, year, month):
|
||
ws = wb.create_sheet("Calculations tamplete")
|
||
BOLD = Font(bold=True)
|
||
CENTER = Alignment(horizontal="center", vertical="center")
|
||
|
||
ws.cell(row=3, column=1, value="1st Half").font = BOLD
|
||
ws.cell(row=4, column=1, value="2nd Half").font = BOLD
|
||
ws.cell(row=5, column=1, value="TOTAL").font = BOLD
|
||
|
||
headers = {4: "Employees", 5: "تم التواصل ولم يتم الرد", 6: "تحت الاجراء",
|
||
7: "تم التواصل", 8: "مجموع الاستفسارات للموظف"}
|
||
for col, val in headers.items():
|
||
ws.cell(row=3, column=col, value=val).font = BOLD
|
||
|
||
employees = _get_employee_names(queryset)
|
||
for i, (name, _eid) in enumerate(employees):
|
||
row = 4 + i
|
||
ws.cell(row=row, column=4, value=name)
|
||
for col in (5, 6, 7, 8):
|
||
ws.cell(row=row, column=col, value=0)
|
||
|
||
emp_count = len(employees)
|
||
total_row = 4 + emp_count
|
||
ws.cell(row=total_row, column=4, value="Total").font = BOLD
|
||
for col in (5, 6, 7, 8):
|
||
if emp_count > 0:
|
||
first_data_row = 4
|
||
last_data_row = 4 + emp_count - 1
|
||
col_letter = get_column_letter(col)
|
||
ws.cell(row=total_row, column=col,
|
||
value=f"=SUM({col_letter}{first_data_row}:{col_letter}{last_data_row})")
|
||
|
||
pct_row = total_row + 1
|
||
for col in (5, 6, 7, 8):
|
||
col_letter = get_column_letter(col)
|
||
ws.cell(row=pct_row, column=col,
|
||
value=f"=IF({col_letter}{total_row}=0,0,{col_letter}{total_row}/B{5})")
|
||
ws.cell(row=pct_row, column=col).number_format = "0.00%"
|
||
|
||
ws.cell(row=3, column=11, value="Number of incoming inquiries").font = BOLD
|
||
for col, val in {12: "24 Hours", 13: "48 Hours", 14: "72 Hours", 15: "More than 72 hours"}.items():
|
||
ws.cell(row=3, column=col, value=val).font = BOLD
|
||
|
||
ws.cell(row=4, column=10, value="Total ").font = BOLD
|
||
ws.cell(row=4, column=11, value=0)
|
||
ws.cell(row=4, column=11,
|
||
value=f"=SUM({get_column_letter(12)}4:{get_column_letter(15)}4)")
|
||
|
||
ws.cell(row=5, column=10, value="Percentage ").font = BOLD
|
||
for col in range(11, 16):
|
||
col_letter = get_column_letter(col)
|
||
ws.cell(row=5, column=col,
|
||
value=f"=IF({col_letter}4=0,0,{col_letter}4/K4)")
|
||
ws.cell(row=5, column=col).number_format = "0.00%"
|
||
|
||
status_labels = {18: "تم التواصل ولم يتم الرد", 19: "تحت الاجراء ",
|
||
20: "تم التواصل ", 21: "جديد", 22: "مجموع الاستفسارات خلال الشهر "}
|
||
for col, val in status_labels.items():
|
||
ws.cell(row=3, column=col, value=val).font = BOLD
|
||
|
||
ws.cell(row=4, column=17, value="Total ").font = BOLD
|
||
for col in range(18, 23):
|
||
ws.cell(row=4, column=col, value=0)
|
||
|
||
ws.cell(row=5, column=17, value="Percentage ").font = BOLD
|
||
for col in range(18, 22):
|
||
col_letter = get_column_letter(col)
|
||
ws.cell(row=5, column=col,
|
||
value=f"=IF({col_letter}4=0,0,{col_letter}4/V4)")
|
||
ws.cell(row=5, column=col).number_format = "0.00%"
|
||
ws.cell(row=5, column=22,
|
||
value=f"=SUM({get_column_letter(18)}5:{get_column_letter(21)}5)")
|
||
ws.cell(row=5, column=22).number_format = "0.00%"
|
||
|
||
|
||
def _build_outgoing_calculations(wb, queryset, year, month):
|
||
ws = wb.create_sheet("Calculations tamplete")
|
||
BOLD = Font(bold=True)
|
||
|
||
ws.cell(row=6, column=1, value="1st Half").font = BOLD
|
||
ws.cell(row=7, column=1, value="2nd Half").font = BOLD
|
||
ws.cell(row=8, column=1, value="TOTAL").font = BOLD
|
||
|
||
ws.cell(row=6, column=4, value="Department").font = BOLD
|
||
ws.cell(row=6, column=5, value="Status").font = BOLD
|
||
ws.cell(row=6, column=8, value="Percentage").font = BOLD
|
||
ws.cell(row=6, column=11, value="Total Percentage").font = BOLD
|
||
ws.cell(row=6, column=12, value="Total").font = BOLD
|
||
|
||
status_headers = {5: "تحت الإجراء ", 6: "تم التواصل ولم يتم الرد", 7: "تم التواصل"}
|
||
for col, val in status_headers.items():
|
||
ws.cell(row=7, column=col, value=val).font = BOLD
|
||
pct_headers = {8: "تحت الإجراء ", 9: "تم التواصل ولم يتم الرد", 10: "تم التواصل"}
|
||
for col, val in pct_headers.items():
|
||
ws.cell(row=7, column=col, value=val).font = BOLD
|
||
|
||
outgoing_depts = queryset.values_list(
|
||
"outgoing_department__name", flat=True
|
||
).distinct().order_by("outgoing_department__name")
|
||
|
||
dept_names = [d for d in outgoing_depts if d]
|
||
for i, dept in enumerate(dept_names):
|
||
row = 8 + i
|
||
ws.cell(row=row, column=4, value=dept)
|
||
for col in (5, 6, 7, 12):
|
||
ws.cell(row=row, column=col, value=0)
|
||
for col in (8, 9, 10, 11):
|
||
ws.cell(row=row, column=col, value="#DIV/0!")
|
||
|
||
total_row = 8 + len(dept_names)
|
||
ws.cell(row=total_row, column=4, value="Total").font = BOLD
|
||
for col in (5, 6, 7):
|
||
col_letter = get_column_letter(col)
|
||
ws.cell(row=total_row, column=col,
|
||
value=f"=SUM({col_letter}8:{col_letter}{total_row - 1})")
|
||
|
||
ws.cell(row=7, column=14, value="Total ").font = BOLD
|
||
ws.cell(row=8, column=14, value="Percentage ").font = BOLD
|
||
|
||
ws.cell(row=6, column=15, value="Number of Outgoing inquiries").font = BOLD
|
||
for col, val in {16: "24 Hours", 17: "48 Hours", 18: "72 Hours", 19: "More than 72 hours"}.items():
|
||
ws.cell(row=6, column=col, value=val).font = BOLD
|
||
|
||
ws.cell(row=7, column=15, value=0)
|
||
ws.cell(row=8, column=15,
|
||
value=f"=IF(O7=0,0,O7/O7)")
|
||
|
||
status_labels = {22: "تم التواصل ولم يتم الرد", 23: "تحت الاجراء ",
|
||
24: "تم التواصل ", 25: "جديد", 26: "مجموع الاستفسارات الصادرة خلال الشهر "}
|
||
for col, val in status_labels.items():
|
||
ws.cell(row=6, column=col, value=val).font = BOLD
|
||
ws.cell(row=7, column=21, value="Total ").font = BOLD
|
||
ws.cell(row=8, column=21, value="Percentage ").font = BOLD
|
||
|
||
|
||
def _build_incoming_dropdown(wb, queryset):
|
||
ws = wb.create_sheet("Drop-down")
|
||
timeline_options = ["24 Hours", "48 Hours", "72 Hours", "More than 72 hours"]
|
||
for i, opt in enumerate(timeline_options):
|
||
ws.cell(row=i + 1, column=1, value=opt)
|
||
ws.cell(row=1, column=2, value="More than 72 hours")
|
||
|
||
employees = _get_employee_names(queryset)
|
||
for i, (name, eid) in enumerate(employees):
|
||
ws.cell(row=7 + i, column=1, value=eid or "")
|
||
ws.cell(row=7 + i, column=2, value=name)
|
||
|
||
status_options = ["تم التواصل", "تحت الإجراء ", "تم التواصل ولم يتم الرد"]
|
||
for i, opt in enumerate(status_options):
|
||
ws.cell(row=17 + i, column=1, value=opt)
|
||
|
||
|
||
def _build_outgoing_dropdown(wb, queryset):
|
||
ws = wb.create_sheet("Drop-down")
|
||
timeline_options = ["24 Hours", "48 Hours", "72 Hours", "More than 72 hours"]
|
||
for i, opt in enumerate(timeline_options):
|
||
ws.cell(row=i + 1, column=1, value=opt)
|
||
ws.cell(row=1, column=2, value="More than 72 hours")
|
||
|
||
employees = _get_employee_names(queryset)
|
||
for i, (name, _eid) in enumerate(employees):
|
||
ws.cell(row=7 + i, column=1, value=name)
|
||
|
||
status_options = ["تم التواصل ", "تحت الإجراء ", "تم التواصل ولم يتم الرد"]
|
||
for i, opt in enumerate(status_options):
|
||
ws.cell(row=16 + i, column=1, value=opt)
|
||
|
||
|
||
def export_observations_report(queryset, year, month):
|
||
"""
|
||
Reports — Observations Excel export.
|
||
|
||
Matches the 22-column template from the Observations Excel document.
|
||
"""
|
||
wb = Workbook()
|
||
month_label = f"{year}-{month:02d}"
|
||
ws = wb.active
|
||
ws.title = month_label
|
||
|
||
headers = [
|
||
"Portal",
|
||
"Note No.",
|
||
"Send Date",
|
||
"Send Time",
|
||
"Recipient Mobile",
|
||
"File Number",
|
||
"Sender Employee ID",
|
||
"Observation Source",
|
||
"Main Category",
|
||
"Sub-Category",
|
||
"",
|
||
"Topic",
|
||
"Details (Arabic)",
|
||
"Details (English)",
|
||
"Person Notified",
|
||
"Department Notified",
|
||
"Communication Method",
|
||
"Communication Date",
|
||
"Communication Time",
|
||
"Action Plan / Action Taken",
|
||
"Follow-Up Resolved?",
|
||
"Solutions & Suggestions",
|
||
]
|
||
_write_header(ws, 1, headers)
|
||
ws.freeze_panes = "C2"
|
||
|
||
row_num = 2
|
||
for idx, obs in enumerate(queryset, 1):
|
||
action_taken = ""
|
||
resolved = ""
|
||
solutions = ""
|
||
|
||
for note in obs.notes.all():
|
||
text = note.note.lower() if note.note else ""
|
||
if not action_taken and ("action" in text or "taken" in text):
|
||
action_taken = note.note
|
||
if "resolved" in text or "done" in text:
|
||
resolved = "Yes"
|
||
if "suggestion" in text or "solution" in text:
|
||
solutions = note.note
|
||
|
||
obs_source = ""
|
||
if obs.source:
|
||
source_map = {
|
||
"staff_portal": "Portal",
|
||
"web_form": "Portal",
|
||
"mobile_app": "Barcode",
|
||
"email": "Referral",
|
||
"call_center": "In-person",
|
||
}
|
||
obs_source = source_map.get(obs.source, obs.source)
|
||
|
||
status_map = {
|
||
"resolved": "done",
|
||
"closed": "resolved",
|
||
"in_progress": "under process",
|
||
"new": "",
|
||
}
|
||
resolved_display = status_map.get(obs.status, "")
|
||
|
||
_write_row(
|
||
ws,
|
||
row_num,
|
||
[
|
||
obs_source,
|
||
idx,
|
||
obs.incident_datetime.date() if obs.incident_datetime else "",
|
||
obs.incident_datetime.strftime("%H:%M") if obs.incident_datetime else "",
|
||
obs.reporter_phone or "",
|
||
obs.tracking_code or "",
|
||
obs.reporter_staff_id or "",
|
||
obs_source,
|
||
obs.category.name if obs.category else "",
|
||
obs.assigned_department.name if obs.assigned_department else "",
|
||
"",
|
||
obs.title or "",
|
||
obs.description or "",
|
||
obs.resolution_notes or "",
|
||
obs.assigned_to.get_full_name() if obs.assigned_to else "",
|
||
obs.assigned_department.name if obs.assigned_department else "",
|
||
"",
|
||
obs.activated_at.date() if obs.activated_at else "",
|
||
obs.activated_at.strftime("%H:%M") if obs.activated_at else "",
|
||
action_taken,
|
||
resolved_display,
|
||
solutions,
|
||
],
|
||
)
|
||
row_num += 1
|
||
|
||
for col in range(1, 23):
|
||
ws.column_dimensions[get_column_letter(col)].width = 20
|
||
ws.column_dimensions[get_column_letter(13)].width = 40
|
||
ws.column_dimensions[get_column_letter(14)].width = 40
|
||
ws.column_dimensions[get_column_letter(20)].width = 35
|
||
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = f'attachment; filename="observations_{month_label}.xlsx"'
|
||
wb.save(response)
|
||
return response
|
||
def export_historical_excel(queryset, date_start=None, date_end=None):
|
||
"""
|
||
Export complaints to historical Excel format with Arabic headers.
|
||
|
||
Creates a single sheet with all 105 columns matching the historical
|
||
complaint report format used in the original Excel files.
|
||
|
||
Args:
|
||
queryset: Complaint queryset to export
|
||
date_start: Optional start date filter
|
||
date_end: Optional end date filter
|
||
|
||
Returns:
|
||
HttpResponse with Excel file
|
||
"""
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
|
||
# Set RTL direction for Arabic
|
||
ws.sheet_view.rightToLeft = True
|
||
|
||
# Define styles
|
||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||
thin_border = Border(
|
||
left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin")
|
||
)
|
||
|
||
# All 105 headers in Arabic (matching historical Excel format)
|
||
headers = [
|
||
"Week", # 1
|
||
"No.", # 2
|
||
"رقم الشكوى", # 3 - Complaint Number
|
||
"رقم الملف", # 4 - File Number (MRN)
|
||
"جهة الشكوى", # 5 - Complaint Source
|
||
"الموقع", # 6 - Location
|
||
"القسم الرئيس", # 7 - Main Department
|
||
"القسم الفرعي", # 8 - Sub Department
|
||
"تاريخ إستلام الشكوى", # 9 - Date Received
|
||
"المدخل", # 10 - Entered By
|
||
"Time line", # 11
|
||
"إرسال نموذج الشكوى", # 12 - Form Sent
|
||
"الوقت", # 13 - Time (Form Sent)
|
||
"الموظف", # 14 - Employee
|
||
"تحرير الشكوى", # 15 - Complaint Filed
|
||
"الوقت", # 16 - Time (Complaint Filed)
|
||
"تفعيل الشكوى", # 17 - Activated
|
||
"الوقت", # 18 - Time (Activated)
|
||
"الموظف .1", # 19 - Employee
|
||
"تم ارسال الشكوى", # 20 - Sent
|
||
"الوقت", # 21 - Time (Sent)
|
||
"الموظف .2", # 22 - Employee
|
||
"الوقت بين التحرير والارسال", # 23 - Time: Filed to Sent
|
||
"First Reminder Sent", # 24
|
||
"الوقت", # 25 - Time (First Reminder)
|
||
"الموظف .3", # 26 - Employee
|
||
"الوقت بين اول ايميل والارسال", # 27 - Time: 1st Rem to Sent
|
||
"Second Reminder Sent", # 28
|
||
"الوقت", # 29 - Time (Second Reminder)
|
||
"الموظف .4", # 30 - Employee
|
||
"الوقت بين ثاني ايميل والاول", # 31 - Time: 2nd Rem to 1st
|
||
"Escalated", # 32
|
||
"الوقت", # 33 - Time (Escalation)
|
||
"الموظف .5", # 34 - Employee
|
||
"Reason of Escalation ", # 35
|
||
"الوقت بين التصعيد والارسال", # 36 - Time: Escalation to Sent
|
||
"Closed", # 37
|
||
"الوقت", # 38 - Time (Closure)
|
||
"الموظف .6", # 39 - Employee
|
||
"الوقت بين الاغلاق والارسال", # 40 - Time: Close to Sent
|
||
"تاريخ الرد", # 41 - Response Date
|
||
"الوقت", # 42 - Time (Response)
|
||
"الوقت بين الرد والارسال", # 43 - Time: Response to Sent
|
||
"Resolved", # 44
|
||
"الوقت", # 45 - Time (Resolution)
|
||
"الموظف .7", # 46 - Employee
|
||
"الوقت بين المعالجة و الارسال", # 47 - Time: Resolve to Sent
|
||
"ID", # 48
|
||
"اسم الشخص المشتكى عليه - ان وجد", # 49 - Accused Staff Name
|
||
"Domain", # 50
|
||
"Category", # 51
|
||
"Sub-Category", # 52
|
||
"Classification", # 53
|
||
"محتوى الشكوى (عربي)", # 54 - Content Arabic
|
||
"محتوى الشكوى (English)", # 55 - Content English
|
||
"Satisfied/Dissatisfied", # 56
|
||
"The Rightful Side", # 57
|
||
"Recommendation/Action plan", # 58
|
||
"رقم الشكوى.1", # 59 - Duplicate columns (for formulas)
|
||
"رقم الملف.1", # 60
|
||
"جهة الشكوى.1", # 61
|
||
"الموقع.1", # 62
|
||
"القسم الرئيس.1", # 63
|
||
"القسم الفرعي.1", # 64
|
||
"تاريخ إستلام الشكوى.1", # 65
|
||
"المدخل.1", # 66
|
||
"Time line.1", # 67
|
||
"الوقت", # 68 - Time (Duplicate)
|
||
"رقم الشكوى.2", # 69
|
||
"رقم الملف.2", # 70
|
||
"جهة الشكوى.2", # 71
|
||
"الموقع.2", # 72
|
||
"القسم الرئيس.2", # 73
|
||
"القسم الفرعي.2", # 74
|
||
"تاريخ إستلام الشكوى.2", # 75
|
||
"المدخل.2", # 76
|
||
"Time line.2", # 77
|
||
"الوقت", # 78 - Time (Duplicate)
|
||
"رقم الشكوى.3", # 79
|
||
"رقم الملف.3", # 80
|
||
"جهة الشكوى.3", # 81
|
||
"الموقع.3", # 82
|
||
"القسم الرئيس.3", # 83
|
||
"القسم الفرعي.3", # 84
|
||
"تاريخ إستلام الشكوى.3", # 85
|
||
"المدخل.3", # 86
|
||
"Time line.3", # 87
|
||
"الوقت", # 88 - Time (Duplicate)
|
||
"رقم الشكوى.4", # 89
|
||
"رقم الملف.4", # 90
|
||
"جهة الشكوى.4", # 91
|
||
"الموقع.4", # 92
|
||
"القسم الرئيس.4", # 93
|
||
"القسم الفرعي.4", # 94
|
||
"تاريخ إستلام الشكوى.4", # 95
|
||
"المدخل.4", # 96
|
||
"Time line.4", # 97
|
||
]
|
||
|
||
# Write headers
|
||
for col_num, header in enumerate(headers, 1):
|
||
cell = ws.cell(row=1, column=col_num, value=header)
|
||
cell.font = header_font
|
||
cell.fill = header_fill
|
||
cell.alignment = header_alignment
|
||
cell.border = thin_border
|
||
|
||
# Freeze top row
|
||
ws.freeze_panes = "A2"
|
||
|
||
# Prepare queryset with related fields
|
||
qs = queryset.select_related(
|
||
"patient",
|
||
"hospital",
|
||
"department",
|
||
"main_section",
|
||
"subsection",
|
||
"assigned_to",
|
||
"resolved_by",
|
||
"closed_by",
|
||
"created_by",
|
||
"source",
|
||
"domain",
|
||
"category",
|
||
"subcategory_obj",
|
||
"classification_obj",
|
||
).prefetch_related("updates")
|
||
|
||
row_num = 2
|
||
for idx, c in enumerate(qs, 1):
|
||
# Get week number from created_at
|
||
cal = c.created_at
|
||
week_of_month = (cal.day - 1) // 7 + 1 if cal else ""
|
||
|
||
# Helper to get name with fallback
|
||
def get_name(obj, attr="name"):
|
||
if not obj:
|
||
return ""
|
||
if hasattr(obj, attr):
|
||
val = getattr(obj, attr)
|
||
if hasattr(val, "name_ar"):
|
||
return val.name_ar
|
||
elif hasattr(val, "name"):
|
||
return val.name
|
||
return str(val)
|
||
return ""
|
||
|
||
def get_name_ar(obj):
|
||
if not obj:
|
||
return ""
|
||
if hasattr(obj, "name_ar"):
|
||
return obj.name_ar
|
||
elif hasattr(obj, "name"):
|
||
return obj.name
|
||
return str(obj)
|
||
|
||
def time_diff(dt1, dt2):
|
||
if dt1 and dt2:
|
||
delta = dt1 - dt2
|
||
return str(delta)
|
||
return ""
|
||
|
||
# Source name
|
||
source_name = ""
|
||
if c.source:
|
||
source_name = c.source.name_ar or c.source.name_en or str(c.source)
|
||
|
||
# Location (main_section or location)
|
||
location_name = ""
|
||
if c.main_section:
|
||
location_name = get_name_ar(c.main_section)
|
||
elif c.location:
|
||
location_name = get_name_ar(c.location)
|
||
|
||
# Departments
|
||
main_dept = ""
|
||
if c.main_section:
|
||
main_dept = get_name_ar(c.main_section)
|
||
elif c.department:
|
||
main_dept = get_name_ar(c.department)
|
||
|
||
sub_dept = ""
|
||
if c.subsection:
|
||
sub_dept = get_name_ar(c.subsection)
|
||
|
||
# Entered by (assigned_to or created_by)
|
||
entered_by = ""
|
||
if c.assigned_to:
|
||
entered_by = c.assigned_to.get_full_name() or c.assigned_to.username
|
||
elif c.created_by:
|
||
entered_by = c.created_by.get_full_name() or c.created_by.username
|
||
|
||
# Taxonomy fields
|
||
domain_name = ""
|
||
if c.domain:
|
||
domain_name = c.domain.name_ar or c.domain.name_en or str(c.domain)
|
||
|
||
category_name = ""
|
||
if c.category:
|
||
category_name = c.category.name_ar or c.category.name_en or str(c.category)
|
||
|
||
subcategory_name = ""
|
||
if c.subcategory_obj:
|
||
subcategory_name = c.subcategory_obj.name_ar or c.subcategory_obj.name_en or str(c.subcategory_obj)
|
||
|
||
classification_name = ""
|
||
if c.classification_obj:
|
||
classification_name = (
|
||
c.classification_obj.name_ar or c.classification_obj.name_en or str(c.classification_obj)
|
||
)
|
||
|
||
# Description - use as is (not splitting)
|
||
description = c.description or ""
|
||
|
||
# Satisfaction
|
||
satisfaction = ""
|
||
if c.satisfaction:
|
||
satisfaction = "Satisfied" if c.satisfaction == "satisfied" else "Dissatisfied"
|
||
|
||
# Rightful side
|
||
rightful_side = ""
|
||
if c.resolution_outcome:
|
||
rightful_side = c.get_resolution_outcome_display() or c.resolution_outcome
|
||
|
||
# Staff name
|
||
staff_name = c.staff_name or ""
|
||
if not staff_name and c.staff:
|
||
staff_name = c.staff.get_full_name() or str(c.staff)
|
||
|
||
# Reference number components
|
||
ref_parts = c.reference_number.split("-") if c.reference_number else []
|
||
complaint_num = ref_parts[-1] if len(ref_parts) >= 4 else ""
|
||
|
||
# Time difference calculations
|
||
time_filed_to_sent = time_diff(c.forwarded_to_dept_at, c.created_at)
|
||
time_rem1_to_sent = time_diff(c.forwarded_to_dept_at, c.reminder_sent_at) if c.reminder_sent_at and c.forwarded_to_dept_at else ""
|
||
time_rem2_to_rem1 = time_diff(c.second_reminder_sent_at, c.reminder_sent_at) if c.second_reminder_sent_at and c.reminder_sent_at else ""
|
||
time_escal_to_sent = time_diff(c.forwarded_to_dept_at, c.escalated_at) if c.escalated_at and c.forwarded_to_dept_at else ""
|
||
time_close_to_sent = time_diff(c.resolved_at, c.closed_at) if c.resolved_at and c.closed_at else ""
|
||
time_response_to_sent = time_diff(c.response_date, c.resolved_at) if c.response_date and c.resolved_at else ""
|
||
time_resolve_to_sent = time_diff(c.resolution_sent_at, c.resolved_at) if c.resolution_sent_at and c.resolved_at else ""
|
||
|
||
# Build row data (only first ~60 columns have meaningful data)
|
||
row_data = [""] * 97
|
||
row_data[0] = week_of_month # Week
|
||
row_data[1] = idx # No.
|
||
row_data[2] = complaint_num # رقم الشكوى
|
||
row_data[3] = c.patient.mrn if c.patient else "" # رقم الملف
|
||
row_data[4] = source_name # جهة الشكوى
|
||
row_data[5] = location_name # الموقع
|
||
row_data[6] = main_dept # القسم الرئيس
|
||
row_data[7] = sub_dept # القسم الفرعي
|
||
row_data[8] = cal.strftime("%Y-%m-%d %H:%M:%S") if cal else "" # تاريخ إستلام الشكوى
|
||
row_data[9] = entered_by # المدخل
|
||
# Time difference calculations (timeline columns)
|
||
row_data[22] = time_filed_to_sent
|
||
row_data[26] = time_rem1_to_sent
|
||
row_data[30] = time_rem2_to_rem1
|
||
row_data[35] = time_escal_to_sent
|
||
row_data[39] = time_close_to_sent
|
||
row_data[42] = time_response_to_sent
|
||
row_data[46] = time_resolve_to_sent
|
||
row_data[47] = c.metadata.get("original_complaint_id") or "" if c.metadata else "" # ID
|
||
row_data[48] = staff_name # اسم الشخص المشتكى عليه
|
||
row_data[49] = domain_name # Domain
|
||
row_data[50] = category_name # Category
|
||
row_data[51] = subcategory_name # Sub-Category
|
||
row_data[52] = classification_name # Classification
|
||
row_data[53] = description # محتوى الشكوى (عربي) - full description
|
||
row_data[54] = "" # محتوى الشكوى (English) - empty, Arabic column has full content
|
||
row_data[55] = satisfaction # Satisfied/Dissatisfied
|
||
row_data[56] = rightful_side # The Rightful Side
|
||
row_data[57] = c.recommendation_action_plan or "" # Recommendation/Action plan
|
||
# Duplicate columns (67-105) - formulas reference first set, keep empty
|
||
|
||
# Write row
|
||
for col_num, val in enumerate(row_data, 1):
|
||
cell = ws.cell(row=row_num, column=col_num, value=val)
|
||
cell.border = thin_border
|
||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||
|
||
row_num += 1
|
||
|
||
# Auto-adjust column widths for main columns
|
||
for col_num in range(1, 60):
|
||
col_letter = get_column_letter(col_num)
|
||
if col_num in [3, 4, 5, 6, 7, 8]: # Arabic text columns
|
||
ws.column_dimensions[col_letter].width = 25
|
||
elif col_num in [54, 55]: # Description columns
|
||
ws.column_dimensions[col_letter].width = 50
|
||
elif col_num == 49: # Staff name
|
||
ws.column_dimensions[col_letter].width = 30
|
||
else:
|
||
ws.column_dimensions[col_letter].width = 15
|
||
|
||
# Hide the duplicate columns (67-105) as they're for formulas
|
||
for col_num in range(59, 98):
|
||
col_letter = get_column_letter(col_num)
|
||
ws.column_dimensions[col_letter].hidden = True
|
||
|
||
# Generate filename
|
||
if date_start and date_end:
|
||
sheet_name = f"{date_start}_to_{date_end}"
|
||
elif date_start:
|
||
sheet_name = f"from_{date_start}"
|
||
elif date_end:
|
||
sheet_name = f"until_{date_end}"
|
||
else:
|
||
sheet_name = "all_complaints"
|
||
|
||
ws.title = sheet_name[:31] # Excel sheet name limit
|
||
|
||
filename = f"historical_complaints_{sheet_name}.xlsx"
|
||
|
||
# Save to response
|
||
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
wb.save(response)
|
||
|
||
return response
|