214 lines
6.4 KiB
Python
214 lines
6.4 KiB
Python
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,
|
|
}
|