from collections import defaultdict from datetime import timedelta from django.db.models import Count from django.db.models.functions import TruncDate from apps.observations.models import Observation SOURCE_MAP = { "staff_portal": "Portal", "web_form": "Portal", "mobile_app": "Barcode", "email": "Referral", "call_center": "In-person", "other": "Other", } STATUS_LABELS = { "new": "New", "triaged": "Triaged", "assigned": "Assigned", "in_progress": "In Progress", "resolved": "Resolved", "closed": "Closed", "rejected": "Rejected", "duplicate": "Duplicate", "contacted": "Contacted", "contacted_no_response": "No Response", } STATUS_COLORS = { "new": "#3B82F6", "triaged": "#06B6D4", "assigned": "#8B5CF6", "in_progress": "#F59E0B", "resolved": "#10B981", "closed": "#6B7280", "rejected": "#EF4444", "duplicate": "#9CA3AF", "contacted": "#22C55E", "contacted_no_response": "#DC2626", } SEVERITY_LABELS = { "low": "Low", "medium": "Medium", "high": "High", "critical": "Critical", } SEVERITY_COLORS = { "low": "#10B981", "medium": "#F59E0B", "high": "#F97316", "critical": "#EF4444", } SEVERITY_ORDER = ["low", "medium", "high", "critical"] class ObservationReportService: def __init__(self, hospital_id, date_from, date_to): self.hospital_id = hospital_id self.date_from = date_from self.date_to = date_to def _base_qs(self): return Observation.objects.filter( hospital_id=self.hospital_id, created_at__date__gte=self.date_from, created_at__date__lte=self.date_to, ).select_related( "category", "assigned_department", "assigned_to", "hospital" ) def get_summary(self): qs = self._base_qs() total = qs.count() by_status = dict( qs.values_list("status").annotate(count=Count("id")).order_by() ) by_severity = dict( qs.values_list("severity").annotate(count=Count("id")).order_by() ) resolved_statuses = ["resolved", "closed"] resolved_count = sum(by_status.get(s, 0) for s in resolved_statuses) resolution_rate = (resolved_count / total * 100) if total > 0 else 0 by_source = dict( qs.values_list("source").annotate(count=Count("id")).order_by() ) return { "total": total, "by_status": by_status, "by_severity": by_severity, "by_source": by_source, "resolution_rate": round(resolution_rate, 1), "resolved_count": resolved_count, } def get_daily_trend(self): qs = self._base_qs() daily_raw = ( qs.annotate(day=TruncDate("created_at")) .values("day") .annotate(count=Count("id")) .order_by("day") ) daily = {} for entry in daily_raw: if entry["day"]: daily[entry["day"]] = entry["count"] result = [] current = self.date_from while current <= self.date_to: result.append({ "day": current, "label": current.strftime("%b %d"), "count": daily.get(current, 0), }) current += timedelta(days=1) return result def get_category_breakdown(self): qs = self._base_qs() raw = ( qs.values("category__name_en") .annotate(count=Count("id")) .order_by("-count") ) return [ {"name": r["category__name_en"] or "Uncategorized", "count": r["count"]} for r in raw ] def get_department_breakdown(self): qs = self._base_qs() dept_data = defaultdict(lambda: {"total": 0, "resolved": 0, "open": 0, "in_progress": 0}) for obs in qs.iterator(chunk_size=2000): name = obs.assigned_department.name if obs.assigned_department else "Unassigned" dept_data[name]["total"] += 1 if obs.status in ("resolved", "closed"): dept_data[name]["resolved"] += 1 elif obs.status == "in_progress": dept_data[name]["in_progress"] += 1 else: dept_data[name]["open"] += 1 return sorted( [{"name": name, **counts} for name, counts in dept_data.items()], key=lambda x: x["total"], reverse=True, ) def get_employee_breakdown(self): qs = self._base_qs() emp_data = defaultdict(lambda: {"total": 0, "resolved": 0, "open": 0, "in_progress": 0}) for obs in qs.iterator(chunk_size=2000): name = obs.assigned_to.get_full_name() if obs.assigned_to else "Unassigned" emp_data[name]["total"] += 1 if obs.status in ("resolved", "closed"): emp_data[name]["resolved"] += 1 elif obs.status == "in_progress": emp_data[name]["in_progress"] += 1 else: emp_data[name]["open"] += 1 return sorted( [{"name": name, **counts} for name, counts in emp_data.items()], key=lambda x: x["total"], reverse=True, ) def get_chart_data(self): daily = self.get_daily_trend() summary = self.get_summary() by_status_chart = [ {"x": STATUS_LABELS.get(s, s), "y": c} for s, c in summary["by_status"].items() ] by_severity_chart = [ { "x": SEVERITY_LABELS.get(s, s), "y": c, "color": SEVERITY_COLORS.get(s, "#6B7280"), } for s, c in sorted( summary["by_severity"].items(), key=lambda x: SEVERITY_ORDER.index(x[0]) if x[0] in SEVERITY_ORDER else 99, ) ] by_source_chart = [ {"x": SOURCE_MAP.get(s, s), "y": c} for s, c in sorted(summary["by_source"].items(), key=lambda x: -x[1]) ] category = self.get_category_breakdown() by_category_chart = [ {"x": c["name"], "y": c["count"]} for c in category ] return { "daily": [{"x": d["label"], "y": d["count"]} for d in daily], "by_status": by_status_chart, "by_severity": by_severity_chart, "by_source": by_source_chart, "by_category": by_category_chart, }