diff --git a/docs/pr-5-metrics-audit.md b/docs/pr-5-metrics-audit.md new file mode 100644 index 000000000..f0256ff43 --- /dev/null +++ b/docs/pr-5-metrics-audit.md @@ -0,0 +1,58 @@ +# PR 5 - Auditoria Operativa y Metricas Comparativas + +## Objetivo +Elevar la seccion de metricas a una experiencia de auditoria operativa util para negocio y liderazgo tecnico, permitiendo analisis diario/mensual, lectura rapida de tendencias y comparacion entre periodos sin salir del flujo principal. + +## Alcance implementado +- Renombre funcional en navegacion: `Metricas` -> `Auditoria`. +- Rediseno de la seccion hacia un layout ejecutivo, con: + - Cabecera de contexto operativo (fecha de corte). + - KPIs principales del dia con variacion porcentual. + - Vistas por periodo (diaria, mensual y combinada). + - Widgets configurables para tabla, barras y resumen ejecutivo. +- Nuevos indicadores procesados en backend: + - Tasa de cancelacion (diaria y mensual). + - Ticket promedio (diario y mensual). + - Pico de facturacion (dia y mes). + - Acumulados de facturacion para ventanas de analisis. +- Visualizacion comparativa sin librerias externas: + - Barras normalizadas para reservas creadas y facturacion. + - Lectura rapida para detectar picos/caidas. +- Ajuste de pruebas para reflejar la nueva narrativa de pantalla. + +## Archivos modificados +- `pms/templates/main.html` +- `pms/templates/metrics_audit.html` +- `pms/views.py` +- `pms/statics/css/style.css` +- `pms/tests.py` + +## Detalle tecnico por componente +### Navegacion +Se actualiza el label del item en toolbar a `Auditoria`, alineado con el objetivo de negocio de la funcionalidad. + +### Backend (procesamiento) +La vista `MetricsAuditView` incorpora calculos adicionales para enriquecer la toma de decisiones: +- porcentajes normalizados para visualizaciones de barras, +- metricas derivadas (rate/avg/peak), +- agregados diarios y mensuales listos para presentacion. + +### Frontend (UX/UI) +Se implementa una interfaz mas orientada a analitica operativa: +- controles de vista por periodo, +- selector de widgets visibles, +- bloques de resumen ejecutivo, +- tablas y barras para analisis comparativo, +- comportamiento responsive para desktop y mobile. + +### Calidad y mantenibilidad +La solucion reutiliza datos existentes del dominio `Booking` y evita dependencias adicionales, manteniendo bajo costo de mantenimiento y facil evolucion futura. + +## Pruebas ejecutadas +- `python manage.py test pms.tests.MetricsAuditTests` +- Resultado: **OK (3/3)** + +## Impacto esperado +- Mayor velocidad para identificar cambios de comportamiento operativo. +- Mejor soporte para conversaciones de negocio (ingresos, cancelacion, tendencia). +- Base solida para evolucionar a widgets avanzados (forecast, cohortes, alertas). diff --git a/pms/statics/css/style.css b/pms/statics/css/style.css index 91d9999a8..e286a998c 100644 --- a/pms/statics/css/style.css +++ b/pms/statics/css/style.css @@ -38,4 +38,201 @@ body{ justify-content: center; height: 100%; align-items: center; -} \ No newline at end of file +} +.metrics-grid{ + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} + +.metrics-value{ + font-size: 1.8rem; + font-weight: 700; + line-height: 1.15; +} + +.metrics-delta{ + font-size: 0.9rem; + font-weight: 600; + margin-top: 0.35rem; +} + +.metrics-up{ + color: #0f766e; +} + +.metrics-down{ + color: #c2410c; +} + +.metrics-flat{ + color: #6c757d; +} + +.metrics-compact{ + background: #f5f5f5; + border: 1px solid #e7e7e7; + border-radius: 12px; + padding: 0.75rem; + height: 100%; +} + +.audit-page{ + padding-bottom: 2rem; +} + +.audit-hero{ + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.audit-period-badge{ + border: 1px solid var(--app-border); + border-radius: 999px; + padding: 0.35rem 0.75rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--app-muted); + background: var(--app-surface); +} + +.audit-toolbar .btn{ + border-radius: 999px; +} + +.audit-toolbar .btn-outline-secondary{ + color: var(--app-text); + border-color: var(--app-border); +} + +.audit-toolbar .btn-outline-secondary:hover{ + color: var(--app-text); + border-color: var(--app-border); + background: var(--app-hover); +} + +.audit-toggle-btn.is-active, +.audit-widget-btn.is-active{ + background: var(--app-primary); + border-color: var(--app-primary); + color: #fff; +} + +.audit-mini-title{ + font-size: 0.8rem; + color: var(--app-muted); + font-weight: 600; +} + +.audit-mini-value{ + font-size: 1.45rem; + font-weight: 700; + color: var(--app-text); + margin-top: 0.3rem; +} + +.audit-mini-sub{ + margin-top: 0.2rem; + color: var(--app-muted); + font-size: 0.8rem; +} + +.audit-bar-row{ + display: grid; + grid-template-columns: 90px 1fr auto; + gap: 0.6rem; + align-items: center; + margin-bottom: 0.4rem; +} + +.audit-bar-label{ + color: var(--app-muted); + font-size: 0.78rem; + font-weight: 600; +} + +.audit-bar-track{ + width: 100%; + height: 10px; + border-radius: 999px; + background: var(--app-hover); + overflow: hidden; +} + +.audit-bar{ + height: 10px; + border-radius: 999px; +} + +.audit-bar-created{ + background: linear-gradient(90deg, #1f4a6d, #2f6f9f); +} + +.audit-bar-revenue{ + background: linear-gradient(90deg, #2c6b2f, #49a356); +} + +.audit-bar-number{ + font-size: 0.78rem; + color: var(--app-muted); + font-weight: 600; + min-width: 84px; + text-align: right; +} + +.audit-page .table{ + color: var(--app-text); +} + +.audit-page .table thead th{ + color: var(--app-muted); + font-weight: 700; +} + +.audit-page .table > :not(caption) > * > *{ + border-color: var(--app-border); +} + +[data-theme="dark"] .audit-period-badge{ + background: #111827; + border-color: #334155; + color: #cbd5e1; +} + +[data-theme="dark"] .audit-toolbar .btn-outline-secondary{ + color: #cbd5e1; + border-color: #475569; +} + +[data-theme="dark"] .audit-toolbar .btn-outline-secondary:hover{ + color: #e2e8f0; + border-color: #64748b; + background: #334155; +} + +[data-theme="dark"] .audit-toggle-btn.is-active, +[data-theme="dark"] .audit-widget-btn.is-active{ + background: #3aa7d8; + border-color: #3aa7d8; + color: #04111b; +} + +[data-theme="dark"] .audit-bar-created{ + background: linear-gradient(90deg, #3aa7d8, #60c0ea); +} + +[data-theme="dark"] .audit-bar-revenue{ + background: linear-gradient(90deg, #22c55e, #4ade80); +} + +@media (max-width: 768px){ + .audit-hero{ + flex-direction: column; + } + + .audit-bar-row{ + grid-template-columns: 68px 1fr auto; + } +} diff --git a/pms/templates/main.html b/pms/templates/main.html index b2216a759..7a4ed3386 100644 --- a/pms/templates/main.html +++ b/pms/templates/main.html @@ -34,6 +34,9 @@ +
@@ -49,4 +52,4 @@ {% endblock %} - \ No newline at end of file + diff --git a/pms/templates/metrics_audit.html b/pms/templates/metrics_audit.html new file mode 100644 index 000000000..7c40038b3 --- /dev/null +++ b/pms/templates/metrics_audit.html @@ -0,0 +1,343 @@ +{% extends "main.html"%} + +{% block content %} +
+
+
+

Auditoría Operativa

+

Indicadores diarios y mensuales para seguimiento comercial, control de cancelaciones y comparación de desempeño.

+
+
Corte: {{ today|date:"d/m/Y" }}
+
+ +
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+
Reservas creadas hoy
+
{{ daily_current.created_count }}
+
+ {% if daily_comparison.created_count.direction == "up" %}▲{% elif daily_comparison.created_count.direction == "down" %}▼{% else %}■{% endif %} + {{ daily_comparison.created_count.percent|floatformat:1 }}% vs ayer +
+
+
+
Confirmadas hoy
+
{{ daily_current.confirmed_count }}
+
+ {% if daily_comparison.confirmed_count.direction == "up" %}▲{% elif daily_comparison.confirmed_count.direction == "down" %}▼{% else %}■{% endif %} + {{ daily_comparison.confirmed_count.percent|floatformat:1 }}% vs ayer +
+
+
+
Canceladas hoy
+
{{ daily_current.cancelled_count }}
+
+ {% if daily_comparison.cancelled_count.direction == "up" %}▲{% elif daily_comparison.cancelled_count.direction == "down" %}▼{% else %}■{% endif %} + {{ daily_comparison.cancelled_count.percent|floatformat:1 }}% vs ayer +
+
+
+
Facturación hoy
+
€ {{ daily_current.revenue|floatformat:2 }}
+
+ {% if daily_comparison.revenue.direction == "up" %}▲{% elif daily_comparison.revenue.direction == "down" %}▼{% else %}■{% endif %} + {{ daily_comparison.revenue.percent|floatformat:1 }}% vs ayer +
+
+
+ +
+
+
+
+
+
Cancelación 7 días
+
{{ daily_cancellation_rate|floatformat:1 }}%
+
Base: {{ daily_current.created_count }} creadas hoy
+
+
+
+
+
+
+
Ticket promedio diario
+
€ {{ daily_avg_ticket|floatformat:2 }}
+
Solo reservas confirmadas
+
+
+
+
+
+
+
Pico facturación (día)
+
{{ peak_day.label }}
+
€ {{ peak_day.revenue|floatformat:2 }}
+
+
+
+
+
+
+
Facturación 7 días
+
€ {{ total_daily_revenue|floatformat:2 }}
+
Acumulado operativo
+
+
+
+
+ +
+
+
+
+
Auditoría Diaria (últimos 7 días)
+
+ + + + + + + + + + + {% for row in daily_audit %} + + + + + + + {% endfor %} + +
DíaCreadasCanceladasFacturación
{{ row.label }}{{ row.created_count }}{{ row.cancelled_count }}€ {{ row.revenue|floatformat:2 }}
+
+
+
+
+ +
+
+
+
Tendencia diaria (barras normalizadas)
+ {% for row in daily_audit %} +
+
{{ row.label }}
+
+
+
+
{{ row.created_count }}
+
+
+
 
+
+
+
+
€ {{ row.revenue|floatformat:0 }}
+
+ {% endfor %} +
+
+
+
+
+ +
+
+
+
+
+
Cancelación mensual
+
{{ monthly_cancellation_rate|floatformat:1 }}%
+
Ventana 6 meses
+
+
+
+
+
+
+
Ticket promedio mensual
+
€ {{ monthly_avg_ticket|floatformat:2 }}
+
Mes en curso
+
+
+
+
+
+
+
Pico facturación (mes)
+
{{ peak_month.label }}
+
€ {{ peak_month.revenue|floatformat:2 }}
+
+
+
+
+
+
+
Facturación 6 meses
+
€ {{ total_monthly_revenue|floatformat:2 }}
+
Acumulado estratégico
+
+
+
+
+ +
+
+
+
+
Auditoría Mensual (últimos 6 meses)
+
+ + + + + + + + + + + {% for row in monthly_audit %} + + + + + + + {% endfor %} + +
MesCreadasCanceladasFacturación
{{ row.label }}{{ row.created_count }}{{ row.cancelled_count }}€ {{ row.revenue|floatformat:2 }}
+
+
+
+
+ +
+
+
+
Tendencia mensual (barras normalizadas)
+ {% for row in monthly_audit %} +
+
{{ row.label }}
+
+
+
+
{{ row.created_count }}
+
+
+
 
+
+
+
+
€ {{ row.revenue|floatformat:0 }}
+
+ {% endfor %} +
+
+
+
+
+ +
+
+
+
Comparación mensual consolidada
+
+
+
+
Reservas creadas
+
{{ monthly_current.created_count }}
+
+ {{ monthly_comparison.created_count.percent|floatformat:1 }}% vs mes anterior +
+
+
+
+
+
Confirmadas
+
{{ monthly_current.confirmed_count }}
+
+ {{ monthly_comparison.confirmed_count.percent|floatformat:1 }}% vs mes anterior +
+
+
+
+
+
Canceladas
+
{{ monthly_current.cancelled_count }}
+
+ {{ monthly_comparison.cancelled_count.percent|floatformat:1 }}% vs mes anterior +
+
+
+
+
+
Facturación
+
€ {{ monthly_current.revenue|floatformat:2 }}
+
+ {{ monthly_comparison.revenue.percent|floatformat:1 }}% vs mes anterior +
+
+
+
+
+
+
+
+ + +{% endblock content%} diff --git a/pms/tests.py b/pms/tests.py index 7ce503c2d..4d5b17940 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,3 +1,63 @@ +from datetime import date, datetime, time, timedelta + from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse + +from .models import Booking, Room, Room_type + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class MetricsAuditTests(TestCase): + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Simple", price=20, max_guests=1) + cls.room = Room.objects.create(room_type=room_type, name="Room 3.1", description="Desc") + cls.today = date.today() + cls.yesterday = cls.today - timedelta(days=1) + cls.month_start = cls.today.replace(day=1) + cls.prev_month_end = cls.month_start - timedelta(days=1) + cls.prev_month_start = cls.prev_month_end.replace(day=1) + + cls._create_booking("NEW", 100, datetime.combine(cls.today, time(11, 0)), "MTA00001") + cls._create_booking("DEL", 120, datetime.combine(cls.today, time(12, 0)), "MTA00002") + cls._create_booking("NEW", 60, datetime.combine(cls.yesterday, time(10, 0)), "MTA00003") + cls._create_booking("NEW", 40, datetime.combine(cls.prev_month_start + timedelta(days=2), time(10, 0)), "MTA00004") + cls._create_booking("DEL", 35, datetime.combine(cls.prev_month_start + timedelta(days=3), time(10, 0)), "MTA00005") + + @classmethod + def _create_booking(cls, state, total, created_at, code): + booking = Booking.objects.create( + state=state, + checkin=created_at.date(), + checkout=created_at.date() + timedelta(days=1), + room=cls.room, + guests=1, + total=total, + code=code, + ) + Booking.objects.filter(id=booking.id).update(created=created_at) + + def test_metrics_audit_view_renders_and_contains_sections(self): + response = self.client.get(reverse("metrics_audit")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Auditoría Operativa") + self.assertContains(response, "Auditoría Diaria") + self.assertContains(response, "Auditoría Mensual") + self.assertContains(response, "Comparación mensual consolidada") + + def test_metrics_audit_daily_metrics_values(self): + response = self.client.get(reverse("metrics_audit")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["daily_current"]["created_count"], 2) + self.assertEqual(response.context["daily_current"]["confirmed_count"], 1) + self.assertEqual(response.context["daily_current"]["cancelled_count"], 1) + self.assertEqual(response.context["daily_current"]["revenue"], 100.0) + self.assertEqual(response.context["daily_previous"]["created_count"], 1) + self.assertEqual(response.context["daily_previous"]["revenue"], 60.0) -# Create your tests here. + def test_metrics_audit_generates_expected_audit_rows(self): + response = self.client.get(reverse("metrics_audit")) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["daily_audit"]), 7) + self.assertEqual(len(response.context["monthly_audit"]), 6) diff --git a/pms/urls.py b/pms/urls.py index c18714abf..359feffd4 100644 --- a/pms/urls.py +++ b/pms/urls.py @@ -11,5 +11,6 @@ path("booking//delete", views.DeleteBookingView.as_view(), name="delete_booking"), path("rooms/", views.RoomsView.as_view(), name="rooms"), path("room//", views.RoomDetailsView.as_view(), name="room_details"), - path("dashboard/", views.DashboardView.as_view(), name="dashboard") + path("dashboard/", views.DashboardView.as_view(), name="dashboard"), + path("metrics/", views.MetricsAuditView.as_view(), name="metrics_audit"), ] diff --git a/pms/views.py b/pms/views.py index f38563933..591d1037a 100644 --- a/pms/views.py +++ b/pms/views.py @@ -1,4 +1,7 @@ +from datetime import date, timedelta + from django.db.models import F, Q, Count, Sum +from django.db.models.functions import TruncDate, TruncMonth from django.shortcuts import render, redirect from django.utils.decorators import method_decorator from django.views import View @@ -244,3 +247,155 @@ def get(self, request): 'rooms': rooms } return render(request, "rooms.html", context) + + +class MetricsAuditView(View): + @staticmethod + def _period_metrics(start_date, end_date): + bookings = Booking.objects.filter(created__date__gte=start_date, created__date__lte=end_date) + confirmed = bookings.exclude(state="DEL") + return { + "created_count": bookings.count(), + "confirmed_count": confirmed.count(), + "cancelled_count": bookings.filter(state="DEL").count(), + "revenue": float(confirmed.aggregate(total=Sum("total"))["total"] or 0), + } + + @staticmethod + def _comparison(current, previous): + delta = current - previous + if previous == 0: + percent = 0 if current == 0 else 100 + else: + percent = (delta / previous) * 100 + direction = "up" if delta > 0 else "down" if delta < 0 else "flat" + return { + "delta": delta, + "percent": percent, + "direction": direction, + } + + def get(self, request): + today = date.today() + yesterday = today - timedelta(days=1) + month_start = today.replace(day=1) + previous_month_end = month_start - timedelta(days=1) + previous_month_start = previous_month_end.replace(day=1) + + daily_current = self._period_metrics(today, today) + daily_previous = self._period_metrics(yesterday, yesterday) + monthly_current = self._period_metrics(month_start, today) + monthly_previous = self._period_metrics(previous_month_start, previous_month_end) + + daily_comparison = { + "created_count": self._comparison(daily_current["created_count"], daily_previous["created_count"]), + "confirmed_count": self._comparison(daily_current["confirmed_count"], daily_previous["confirmed_count"]), + "cancelled_count": self._comparison(daily_current["cancelled_count"], daily_previous["cancelled_count"]), + "revenue": self._comparison(daily_current["revenue"], daily_previous["revenue"]), + } + monthly_comparison = { + "created_count": self._comparison(monthly_current["created_count"], monthly_previous["created_count"]), + "confirmed_count": self._comparison(monthly_current["confirmed_count"], monthly_previous["confirmed_count"]), + "cancelled_count": self._comparison(monthly_current["cancelled_count"], monthly_previous["cancelled_count"]), + "revenue": self._comparison(monthly_current["revenue"], monthly_previous["revenue"]), + } + + last_7_days_start = today - timedelta(days=6) + raw_daily = (Booking.objects + .filter(created__date__gte=last_7_days_start, created__date__lte=today) + .annotate(period=TruncDate("created")) + .values("period") + .annotate( + created_count=Count("id"), + cancelled_count=Count("id", filter=Q(state="DEL")), + revenue=Sum("total", filter=~Q(state="DEL")), + ) + .order_by("period")) + daily_map = {entry["period"]: entry for entry in raw_daily} + daily_audit = [] + for day_offset in range(6, -1, -1): + day = today - timedelta(days=day_offset) + entry = daily_map.get(day, {}) + daily_audit.append({ + "label": day.strftime("%d/%m"), + "created_count": entry.get("created_count", 0), + "cancelled_count": entry.get("cancelled_count", 0), + "revenue": float(entry.get("revenue") or 0), + }) + + month_cursor = month_start + for _ in range(5): + month_cursor = (month_cursor - timedelta(days=1)).replace(day=1) + raw_monthly = (Booking.objects + .filter(created__date__gte=month_cursor, created__date__lte=today) + .annotate(period=TruncMonth("created")) + .values("period") + .annotate( + created_count=Count("id"), + cancelled_count=Count("id", filter=Q(state="DEL")), + revenue=Sum("total", filter=~Q(state="DEL")), + ) + .order_by("period")) + monthly_map = { + (entry["period"].year, entry["period"].month): entry for entry in raw_monthly + } + monthly_audit = [] + month_iter = month_cursor + for _ in range(6): + key = (month_iter.year, month_iter.month) + entry = monthly_map.get(key, {}) + monthly_audit.append({ + "label": month_iter.strftime("%b %Y"), + "created_count": entry.get("created_count", 0), + "cancelled_count": entry.get("cancelled_count", 0), + "revenue": float(entry.get("revenue") or 0), + }) + month_iter = (month_iter + timedelta(days=32)).replace(day=1) + + daily_max_created = max((item["created_count"] for item in daily_audit), default=0) + daily_max_revenue = max((item["revenue"] for item in daily_audit), default=0) + for item in daily_audit: + item["created_pct"] = 0 if daily_max_created == 0 else int((item["created_count"] / daily_max_created) * 100) + item["revenue_pct"] = 0 if daily_max_revenue == 0 else int((item["revenue"] / daily_max_revenue) * 100) + + monthly_max_created = max((item["created_count"] for item in monthly_audit), default=0) + monthly_max_revenue = max((item["revenue"] for item in monthly_audit), default=0) + for item in monthly_audit: + item["created_pct"] = 0 if monthly_max_created == 0 else int((item["created_count"] / monthly_max_created) * 100) + item["revenue_pct"] = 0 if monthly_max_revenue == 0 else int((item["revenue"] / monthly_max_revenue) * 100) + + total_daily_created = sum(item["created_count"] for item in daily_audit) + total_daily_cancelled = sum(item["cancelled_count"] for item in daily_audit) + total_daily_revenue = sum(item["revenue"] for item in daily_audit) + total_monthly_created = sum(item["created_count"] for item in monthly_audit) + total_monthly_cancelled = sum(item["cancelled_count"] for item in monthly_audit) + total_monthly_revenue = sum(item["revenue"] for item in monthly_audit) + + daily_cancellation_rate = 0 if total_daily_created == 0 else (total_daily_cancelled / total_daily_created) * 100 + monthly_cancellation_rate = 0 if total_monthly_created == 0 else (total_monthly_cancelled / total_monthly_created) * 100 + daily_avg_ticket = 0 if daily_current["confirmed_count"] == 0 else daily_current["revenue"] / daily_current["confirmed_count"] + monthly_avg_ticket = 0 if monthly_current["confirmed_count"] == 0 else monthly_current["revenue"] / monthly_current["confirmed_count"] + + peak_day = max(daily_audit, key=lambda x: x["revenue"], default={"label": "-", "revenue": 0}) + peak_month = max(monthly_audit, key=lambda x: x["revenue"], default={"label": "-", "revenue": 0}) + + context = { + "daily_current": daily_current, + "daily_previous": daily_previous, + "monthly_current": monthly_current, + "monthly_previous": monthly_previous, + "daily_comparison": daily_comparison, + "monthly_comparison": monthly_comparison, + "daily_audit": daily_audit, + "monthly_audit": monthly_audit, + "daily_cancellation_rate": daily_cancellation_rate, + "monthly_cancellation_rate": monthly_cancellation_rate, + "daily_avg_ticket": daily_avg_ticket, + "monthly_avg_ticket": monthly_avg_ticket, + "total_daily_revenue": total_daily_revenue, + "total_monthly_revenue": total_monthly_revenue, + "peak_day": peak_day, + "peak_month": peak_month, + "today": today, + } + return render(request, "metrics_audit.html", context)