From 61b18df8e860b98cc53c8dd676a703010094d727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Sun, 24 May 2026 15:42:26 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20WhatsApp=20promociones=20=E2=80=94=20fe?= =?UTF-8?q?chas=20en=20espa=C3=B1ol,=20status=20webhook=20y=20analytics=20?= =?UTF-8?q?inspector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Corrige formato de fechas a español con Carbon isoFormat('D [de] MMMM') - Webhook procesa statuses de Meta (delivered/read/failed) y actualiza pivot alumno_promocion - Analytics: nuevas métricas de entregados/leídos, embudo de entrega, filtro por tipo - Inspector de payload raw por log con modal y botón copiar Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 5 +- .../WhatsAppAnalyticsController.php | 21 +- .../Controllers/WhatsAppWebhookController.php | 38 ++++ app/Jobs/EnviarPromocionJob.php | 3 +- app/Services/WhatsAppService.php | 94 +++++---- resources/views/promocion/analytics.blade.php | 185 +++++++++++++++--- 6 files changed, 273 insertions(+), 73 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 181dfe6d..adf59c80 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,10 @@ "Bash(php -l app/Models/Entrega.php)", "Bash(php -l app/Livewire/GrupoDocente.php)", "Bash(composer require *)", - "Bash(Select-String -Pattern \"entrega|Entrega\" -Context 0,0)" + "Bash(Select-String -Pattern \"entrega|Entrega\" -Context 0,0)", + "Bash(php -l app/Services/WhatsAppService.php)", + "Bash(php -l app/Http/Controllers/WhatsAppWebhookController.php)", + "Bash(php -l app/Http/Controllers/WhatsAppAnalyticsController.php)" ] } } diff --git a/app/Http/Controllers/WhatsAppAnalyticsController.php b/app/Http/Controllers/WhatsAppAnalyticsController.php index b986d00d..80d2edd9 100644 --- a/app/Http/Controllers/WhatsAppAnalyticsController.php +++ b/app/Http/Controllers/WhatsAppAnalyticsController.php @@ -37,12 +37,21 @@ class WhatsAppAnalyticsController extends Controller $query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59'); } - $enviados = (clone $query)->where('tipo', 'enviado')->count(); - $fallidos = (clone $query)->where('tipo', 'error')->count(); - $optOuts = (clone $query)->where('tipo', 'opt_out')->count(); - $recibidos = (clone $query)->where('tipo', 'recibido')->count(); + // Métricas siempre sobre todos los tipos (sin filtro de tipo) + $enviados = (clone $query)->where('tipo', 'enviado')->count(); + $fallidos = (clone $query)->where('tipo', 'error')->count(); + $optOuts = (clone $query)->where('tipo', 'opt_out')->count(); + $recibidos = (clone $query)->where('tipo', 'recibido')->count(); + $entregados = (clone $query)->where('tipo', 'status_delivered')->count(); + $leidos = (clone $query)->where('tipo', 'status_read')->count(); - $logsRecientes = (clone $query) + // El filtro de tipo solo aplica al listado de logs + $logsQuery = clone $query; + if ($request->filled('tipo')) { + $logsQuery->where('tipo', $request->tipo); + } + + $logsRecientes = $logsQuery ->with('alumno.alumnos') ->latest('created_at') ->paginate(20); @@ -52,7 +61,7 @@ class WhatsAppAnalyticsController extends Controller })->latest()->get(); return view('promocion.analytics', compact( - 'enviados', 'fallidos', 'optOuts', 'recibidos', + 'enviados', 'fallidos', 'optOuts', 'recibidos', 'entregados', 'leidos', 'logsRecientes', 'planteles', 'promociones' )); } diff --git a/app/Http/Controllers/WhatsAppWebhookController.php b/app/Http/Controllers/WhatsAppWebhookController.php index 81b4fc5b..46f3b244 100644 --- a/app/Http/Controllers/WhatsAppWebhookController.php +++ b/app/Http/Controllers/WhatsAppWebhookController.php @@ -7,6 +7,7 @@ use App\Models\Alumno; use App\Models\WhatsappLog; use App\Services\WhatsAppService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class WhatsAppWebhookController extends Controller @@ -37,6 +38,13 @@ class WhatsAppWebhookController extends Controller $value = $changes['value'] ?? null; $message = $value['messages'][0] ?? null; + // Procesar status updates (entregado/leído/fallido) de Meta + if (!empty($value['statuses'])) { + foreach ($value['statuses'] as $status) { + $this->procesarStatus($status, $payload); + } + } + if (!$message) { return response()->json(['status' => 'ok']); } @@ -113,4 +121,34 @@ class WhatsAppWebhookController extends Controller return false; } + + private function procesarStatus(array $status, array $payload): void + { + $mensajeId = $status['id'] ?? null; + $estadoMeta = $status['status'] ?? null; // sent, delivered, read, failed + $telefono = $status['recipient_id'] ?? null; + $alumno = $this->alumnoPorTelefono($telefono); + + WhatsappLog::create([ + 'alumno_id' => $alumno?->id, + 'telefono' => $telefono, + 'tipo' => 'status_' . $estadoMeta, + 'mensaje' => "Acuse de {$estadoMeta} para mensaje {$mensajeId}", + 'payload' => $payload, + ]); + + // Actualizar pivot alumno_promocion cuando hay confirmación de entrega o lectura + if ($mensajeId && in_array($estadoMeta, ['delivered', 'read', 'failed'])) { + $nuevoStatus = match($estadoMeta) { + 'delivered' => 'entregado', + 'read' => 'leido', + 'failed' => 'fallido', + default => $estadoMeta, + }; + + DB::table('alumno_promocion') + ->where('whatsapp_mensaje_id', $mensajeId) + ->update(['status_envio' => $nuevoStatus]); + } + } } diff --git a/app/Jobs/EnviarPromocionJob.php b/app/Jobs/EnviarPromocionJob.php index bf066379..53e5619f 100644 --- a/app/Jobs/EnviarPromocionJob.php +++ b/app/Jobs/EnviarPromocionJob.php @@ -48,7 +48,8 @@ class EnviarPromocionJob implements ShouldQueue } try { - $mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion); + $nombre = $this->alumno->alumnos()->first()?->name ?? 'estudiante'; + $mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion, $nombre); } catch (\RuntimeException $e) { WhatsappLog::create([ 'alumno_id' => $this->alumno->id, diff --git a/app/Services/WhatsAppService.php b/app/Services/WhatsAppService.php index b2870b9e..548211d5 100644 --- a/app/Services/WhatsAppService.php +++ b/app/Services/WhatsAppService.php @@ -31,36 +31,48 @@ class WhatsAppService $this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages"; } - public function enviarTemplate(string $telefono, string $templateName, array $parametros = []): ?string - { - $components = []; + public function enviarTemplate(string $telefono, string $templateName, array $parametros = [], ?string $imagenUrl = null): ?string +{ + $components = []; - if (!empty($parametros)) { - $components[] = [ - 'type' => 'body', - 'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros), - ]; - } - - $response = Http::withToken($this->token)->post($this->apiUrl, [ - 'messaging_product' => 'whatsapp', - 'to' => $this->normalizarTelefono($telefono), - 'type' => 'template', - 'template' => [ - 'name' => $templateName, - 'language' => ['code' => 'es_MX'], - 'components' => $components, + if ($imagenUrl) { + $components[] = [ + 'type' => 'header', + 'parameters' => [ + [ + 'type' => 'image', + 'image' => ['link' => $imagenUrl], + ] ], - ]); - - if ($response->failed()) { - Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json()]); - return null; - } - - return $response->json('messages.0.id'); + ]; } + if (!empty($parametros)) { + $components[] = [ + 'type' => 'body', + 'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros), + ]; + } + + $response = Http::withToken($this->token)->post($this->apiUrl, [ + 'messaging_product' => 'whatsapp', + 'to' => $this->normalizarTelefono($telefono), + 'type' => 'template', + 'template' => [ + 'name' => $templateName, + 'language' => ['code' => 'es_MX'], + 'components' => $components, + ], + ]); + + if ($response->failed()) { + Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json()]); + return null; + } + + return $response->json('messages.0.id'); +} + /*public function enviarPromocion(string $telefono, Promocion $promocion): string { $payload = [ @@ -94,23 +106,27 @@ class WhatsAppService return $response->json('messages.0.id'); }*/ - public function enviarPromocion(string $telefono, Promocion $promocion): string +public function enviarPromocion(string $telefono, Promocion $promocion, string $nombre = ''): string { - $response = Http::withToken($this->token)->post($this->apiUrl, [ - 'messaging_product' => 'whatsapp', - 'to' => $this->normalizarTelefono($telefono), - 'type' => 'template', - 'template' => [ - 'name' => 'hello_world', - 'language' => ['code' => 'en_US'], - ], - ]); + $codigo = 'PROMO-' . strtoupper(substr(md5($promocion->id . $telefono), 0, 6)); - if ($response->failed()) { - throw new \RuntimeException($response->body()); + $mensajeId = $this->enviarTemplate( + $telefono, + 'promocion_laravel', + [ + $nombre, + $promocion->fecha_inicio->locale('es')->isoFormat('D [de] MMMM'), + $promocion->fecha_fin->locale('es')->isoFormat('D [de] MMMM'), + $codigo, + ], + $promocion->imagenUrl() + ); + + if (!$mensajeId) { + throw new \RuntimeException('No se pudo enviar la plantilla'); } - return $response->json('messages.0.id'); + return $mensajeId; } public function procesarOptOut(string $telefono): void diff --git a/resources/views/promocion/analytics.blade.php b/resources/views/promocion/analytics.blade.php index 9d7cab2c..e68ad750 100644 --- a/resources/views/promocion/analytics.blade.php +++ b/resources/views/promocion/analytics.blade.php @@ -7,7 +7,7 @@ {{-- Tarjetas de métricas --}}
-
+
@@ -15,15 +15,41 @@
Enviados
{{ number_format($enviados) }}
-
- -
+
-
+
+
+
+
+
+
Entregados
+
{{ number_format($entregados) }}
+
+
+
+
+
+
+ +
+
+
+
+
+
Leídos
+
{{ number_format($leidos) }}
+
+
+
+
+
+
+ +
@@ -31,15 +57,13 @@
Recibidos
{{ number_format($recibidos) }}
-
- -
+
-
+
@@ -47,15 +71,13 @@
Fallidos
{{ number_format($fallidos) }}
-
- -
+
-
+
@@ -63,15 +85,48 @@
Opt-Outs
{{ number_format($optOuts) }}
-
- -
+
+ @if($enviados > 0) + {{-- Barra de progreso del embudo de entrega --}} +
+
+
+ Embudo de entrega +
+
+
+
+ Enviados{{ $enviados }} +
+
+
100%
+
+ @if($entregados > 0) +
+ Entregados{{ $entregados }} ({{ round($entregados/$enviados*100) }}%) +
+
+
{{ round($entregados/$enviados*100) }}%
+
+ @endif + @if($leidos > 0) +
+ Leídos{{ $leidos }} ({{ round($leidos/$enviados*100) }}%) +
+
+
{{ round($leidos/$enviados*100) }}%
+
+ @endif +
+
+ @endif + {{-- Filtros --}}
@@ -106,6 +161,15 @@ @endforeach
+
+ + +
-
+
Logs recientes
+ Haz clic en para ver el payload raw del webhook
@@ -141,29 +206,34 @@ # Prospecto Teléfono - Tipo + Tipo Mensaje Media Fecha + JSON @forelse($logsRecientes as $log) @php $user = $log->alumno?->alumnos->first(); - $badge = match($log->tipo) { - 'enviado' => 'success', - 'recibido' => 'info', - 'opt_out' => 'warning', - 'error' => 'danger', - default => 'secondary', + $badge = match(true) { + $log->tipo === 'enviado' => 'success', + $log->tipo === 'recibido' => 'info', + $log->tipo === 'opt_out' => 'warning', + $log->tipo === 'error' => 'danger', + $log->tipo === 'status_delivered' => 'primary', + $log->tipo === 'status_read' => 'info', + $log->tipo === 'status_sent' => 'secondary', + $log->tipo === 'status_failed' => 'danger', + default => 'secondary', }; @endphp {{ $log->id }} @if($user) - {{ $user->name }} {{ $user->apellidoPaterno }} + {{ $user->name }} {{ $user->apellidoPaterno ?? '' }} @else @endif @@ -183,10 +253,22 @@ @endif {{ $log->created_at?->format('d/m/Y H:i') }} + + @if($log->payload) + + @else + — + @endif + @empty - Sin registros. + Sin registros. @endforelse @@ -199,4 +281,55 @@
+ +{{-- Modal inspector de payload --}} + + +@push('scripts') + +@endpush + @endsection