HH/apps/complaints/utils.py
2026-03-28 14:03:56 +03:00

1840 lines
64 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(),
complaint.patient.mrn,
complaint.hospital.name_en,
complaint.department.name_en if complaint.department else "",
complaint.get_category_display(),
complaint.get_severity_display(),
complaint.get_priority_display(),
complaint.get_status_display(),
complaint.get_source_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"),
"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],
]
)
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())
ws.cell(row=row_num, column=4, value=complaint.patient.mrn)
ws.cell(row=row_num, column=5, value=complaint.hospital.name_en)
ws.cell(row=row_num, column=6, value=complaint.department.name_en if complaint.department else "")
ws.cell(row=row_num, column=7, value=complaint.get_category_display())
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_source_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"))
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])
# 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.created_at:
return "More than 72 hours"
delta = complaint.resolved_at - complaint.created_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 == "moh":
source_counts["MOH"] += 1
elif c.source and c.source.code == "chi":
source_counts["CCHI"] += 1
elif c.complaint_source_type == "internal":
if c.source and c.source.code == "chi":
source_counts["CCHI"] += 1
elif c.source and c.source.code == "moh":
source_counts["MOH"] += 1
else:
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.resolved_at - c.created_at).total_seconds() <= 86400)
h48 = sum(
1 for c in emp_qs if c.resolved_at and 86400 < (c.resolved_at - c.created_at).total_seconds() <= 172800
)
h72 = sum(
1 for c in emp_qs if c.resolved_at and 172800 < (c.resolved_at - c.created_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.created_at:
if (c.resolved_at - c.created_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.created_at:
if (c.resolved_at - c.created_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)
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 = {"In-Patient": 0, "Out-Patient": 0, "ER": 0}
category_breakdown = {}
for c in complaints_list:
is_external = False
if c.source and c.source.code == "moh":
source_breakdown["MOH"] += 1
external += 1
is_external = True
elif c.source and c.source.code == "chi":
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 = "In-Patient"
elif "outpatient" in loc_name or "op" in loc_name or "clinic" in loc_name:
loc = "Out-Patient"
elif "er" in loc_name or "emergency" in loc_name:
loc = "ER"
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 in ("moh", "chi"))]
elif source_filter == "chi":
filtered = [c for c in complaints_list if c.source and c.source.code == "chi"]
else:
filtered = [c for c in complaints_list if c.source and c.source.code == "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.created_at:
hours = (c.resolved_at - c.created_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 export_inquiries_report(queryset, year, month, is_outgoing=False):
"""
Reports — Incoming/Outgoing Inquiries Excel export.
Matches the 27-column template from the Excel documents.
"""
wb = Workbook()
month_label = f"{year}-{month:02d}"
ws = wb.active
ws.title = month_label
prefix = "Outgoing" if is_outgoing else "Incoming"
headers = [
"Week",
"No.",
"Date/Time",
"Visitor Name",
"Mobile",
"Department",
"Employee ID",
"Timeline",
"Inquiry Date",
"Inquiry Time",
"Contacted NR Date",
"Contacted NR Time",
"Contacted NR Employee",
"Response Duration",
"Under Process Date",
"Under Process Time",
"Under Process Employee",
"Processing Duration",
"Contacted Date",
"Contacted Time",
"Contacted Employee",
"Contact Duration",
"",
"Inquiry Description",
"Status",
"Employee Notes",
"Supervisor Notes",
]
_write_header(ws, 1, headers)
ws.freeze_panes = "C2"
qs = queryset.select_related(
"hospital",
"department",
"assigned_to",
"created_by",
"outgoing_department",
"responded_by",
).prefetch_related("updates")
row_num = 2
for idx, inq in enumerate(qs, 1):
week_of_month = (inq.created_at.day - 1) // 15 + 1
week_label = "1st Half" if week_of_month == 1 else "2nd Half"
dept_name = ""
if is_outgoing:
dept_name = inq.outgoing_department.name if inq.outgoing_department else ""
else:
dept_name = inq.department.name if inq.department else ""
created_date = inq.created_at.date() if inq.created_at else ""
created_time = inq.created_at.strftime("%H:%M") if inq.created_at else ""
activated_date = inq.activated_at or inq.assigned_at
responded_date = inq.responded_at
timeline = ""
if 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"
_write_row(
ws,
row_num,
[
week_label,
idx,
f"{created_date} {created_time}",
inq.contact_name or "",
inq.contact_phone or "",
dept_name,
"",
timeline,
inq.assigned_at.date() if inq.assigned_at else "",
inq.assigned_at.strftime("%H:%M") if inq.assigned_at else "",
"",
"",
"",
"",
"",
"",
"",
"",
responded_date.date() if responded_date else "",
responded_date.strftime("%H:%M") if responded_date else "",
inq.responded_by.get_full_name() if inq.responded_by else "",
"",
"",
inq.subject,
inq.get_status_display(),
"",
"",
],
)
row_num += 1
summary_row = row_num + 2
total = queryset.count()
status_counts = queryset.values("status").annotate(count=Count("pk"))
timeline_counts = {"24 Hours": 0, "48 Hours": 0, "72 Hours": 0, "More than 72 hours": 0}
for inq in qs:
if inq.due_at and inq.created_at:
hours = (inq.due_at - inq.created_at).total_seconds() / 3600
if hours <= 24:
timeline_counts["24 Hours"] += 1
elif hours <= 48:
timeline_counts["48 Hours"] += 1
elif hours <= 72:
timeline_counts["72 Hours"] += 1
else:
timeline_counts["More than 72 hours"] += 1
ws.cell(row=summary_row, column=1, value="Summary").font = SECTION_FONT
summary_row += 1
ws.cell(row=summary_row, column=1, value=f"Total {prefix} Inquiries: {total}")
summary_row += 2
ws.cell(row=summary_row, column=1, value="By Status").font = SECTION_FONT
summary_row += 1
_write_header(ws, summary_row, ["Status", "Count"])
summary_row += 1
for sc in status_counts:
ws.cell(row=summary_row, column=1, value=sc["status"])
ws.cell(row=summary_row, column=2, value=sc["count"])
summary_row += 1
summary_row += 1
ws.cell(row=summary_row, column=1, value="By Timeline").font = SECTION_FONT
summary_row += 1
_write_header(ws, summary_row, ["Timeline", "Count"])
summary_row += 1
for tl, cnt in timeline_counts.items():
ws.cell(row=summary_row, column=1, value=tl)
ws.cell(row=summary_row, column=2, value=cnt)
summary_row += 1
for col in range(1, 10):
ws.column_dimensions[get_column_letter(col)].width = 18
ws.column_dimensions[get_column_letter(4)].width = 25
ws.column_dimensions[get_column_letter(23)].width = 40
ws.column_dimensions[get_column_letter(24)].width = 20
ws.column_dimensions[get_column_letter(26)].width = 30
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
response["Content-Disposition"] = f'attachment; filename="{prefix.lower()}_inquiries_{month_label}.xlsx"'
wb.save(response)
return response
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