feat: WhatsApp promociones — fechas en español, status webhook y analytics inspector

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:42:26 -06:00
parent 19a236818e
commit 61b18df8e8
6 changed files with 273 additions and 73 deletions
@@ -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'
));
}
@@ -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]);
}
}
}
+2 -1
View File
@@ -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,
+55 -39
View File
@@ -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