Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
Shop : les statistiques
Samedi 22 février 2025 11:08

Lorsqu'on a un commerce en ligne, on a envie de savoir comment les choses se passent, en gros pouvoir mesurer certains paramètres. Le sujet est à la fois vaste et plutôt complexe. Dans cet article, je vais m'intéresser à un nombre réduit de paramètres pour rester didactique. On va s'intéresser à des données annualisées pour le chiffre d'affaires, les produits les mieux vendus et la valeur du panier moyen.

Vous pouvez trouver le code dans ce dépôt Github.

Les données

Pour obtenir suffisamment de données, j'ai un peu enrichi la base. Dans OrderFactory, j'ai étalé la zone de date à 3 années :

'created_at' => fake()->dateTimeBetween('-3 years'),

Et dans DatabaseSeeder, je suis passé de 30 à 100 commandes :

Order::factory()
    ->count(100)

On dispose ainsi de plus de matériaux pour nos statistiques.

Un composant

C'est devenu un rituel dans cette série de créer un composant pour une fonctionnalité. Nous n'allons pas y déroger :

php artisan make:volt admin/stats --class

On prévoit une route :

Volt::route('/stats', 'admin.stats')->name('admin.stats');

Et un lien dans la barre latérale :

<x-menu-item title="{{ __('Statistics') }}" icon="s-chart-pie" link="{{ route('admin.stats') }}" /> 

Visualiser les statistiques

Vous avez remarqué que j'utilise beaucoup de composant de Maryui. C'est une bibliothèque riche et pratique. Non seulement elle propose l'ensemble des composants utiles pour la plupart des situations, mais en plus, elle expose plusieurs composants tiers dont j'ai déjà utilisé pour ce projet Le Rich Text Editor qui est couplé à TinyMCE.

Pour les statistiques, MaryUI expose le célèbre et efficace Chart.js. Le composant de MaryUI est simple à utiliser et permet d'éviter de mettre les mains dans Javascript. Mais il faut quand même charger la librairie, on va le faire dans le layout (components.layouts.admin) :

<head>

    ...  

    @if(Route::currentRouteName() == 'admin.stats')
        <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
    @endIf

</head>

On réserve le chargement de la librairie pour la seule route qui a besoin de l'utiliser.

On code le composant

Il ne nous reste plus qu'à coder notre composant :

<?php

use Livewire\Volt\Component;
use Livewire\Attributes\{Layout, Title};
use App\Models\{Order, Product, User};
use Carbon\Carbon;

new #[Title('Statistics')] #[Layout('components.layouts.admin')] class extends Component
{
    public bool $openGlance = true;
    public bool $openOrders = false;
    public bool $openProducts = false;
    public array $ordersChart = [];
    public array $productsChart = [];
    public int $selectedYear;
    public array $years = [];

    public function mount(): void
    {
        $this->selectedYear = date('Y');
        $olderYear = Order::whereRelation('state', 'indice', '>', 3)->oldest()->first()->created_at->format('Y');
        $this->years = [];
        for ($year = now()->format('Y'); $year >= $olderYear; $year--) {
            $this->years[] = ['id' => $year, 'name' => $year];
        }
        $this->updatedSelectedYear();
    }

    public function updatedSelectedYear()
    {
        $this->orders = Order::whereYear('created_at', $this->selectedYear)
            ->whereRelation('state', 'indice', '>', 3)
            ->with('products')
            ->get();

        $this->ordersChart = $this->getOrdersChart();
        $this->productsChart = $this->getProductsChart();
    }

    public function getOrdersChart(): array
    {
        $sortedOrders = $this->orders->sortBy('created_at');
        $groupedOrders = $sortedOrders->groupBy(fn ($order) => $order->created_at->format('M'));
        $labels = $groupedOrders->keys()->map(fn($month) => Carbon::createFromFormat('M', $month)->translatedFormat('F'));

        return [
            'type' => 'bar',
            'data' => [
                'labels' => $labels->values(),
                'datasets' => [
                    [
                        'label' => __('Sales revenue'),
                        'data' => $groupedOrders->map(fn ($group) => $group->sum('total'))->values(),
                        'backgroundColor' => 'rgba(75, 192, 192, 0.2)',
                        'borderColor' => 'rgba(75, 192, 192, 1)',
                        'borderWidth' => 1,
                    ]
                ]
            ]
        ];
    }

    public function getProductsChart(): array
    {
        $productSales = $this->orders
            ->flatMap(function ($order) {
                return $order->products;
            })
            ->groupBy('name')
            ->map(function ($items) {
                return [
                    'product' => $items->first()->name,
                    'total_quantity' => $items->sum('quantity')
                ];
            })
            ->sortByDesc('total_quantity')
            ->take(10);

        return [
            'type' => 'pie',
            'data' => [
                'labels' => $productSales->pluck('product'),
                'datasets' => [
                    [
                        'label' => __('Quantity Sold'),
                        'data' => $productSales->pluck('total_quantity'),
                    ]
                ]
            ]
        ];
    }

    public function with(): array
    {
        return [
            'ordersCount' => $this->orders->count(),
            'averageBasket' => $this->orders->avg('total'),
            'salesRevenue' => $this->orders->sum('total'),
        ];
    }

}; ?>

<div>    
    <x-header title="{{ __('Statistics') }}" separator progress-indicator>
        <x-slot:actions>
            <x-select wire:model="selectedYear" :options="$years" wire:change="$refresh" />
            <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline lg:hidden"
                link="{{ route('admin') }}" />
        </x-slot:actions>
    </x-header>
    <x-collapse wire:model="openGlance" class="shadow-md">
        <x-slot:heading>
            @lang('In a glance')
        </x-slot:heading>
        <x-slot:content class="flex flex-wrap gap-4">
            <div class="flex-grow">
                <x-stat title="{{ __('Successful orders') }}" description="" value="{{ $ordersCount }}"
                    icon="s-shopping-bag" class="shadow-hover" />
            </div>
            <div class="flex-grow">
                <x-stat title="{{ __('Average basket') }}" description="" value="{{ number_format($averageBasket, 2, ',', ' ') . ' €' }}" value2="{{ $averageBasket }}"
                    icon="s-shopping-cart" class="shadow-hover" />
            </div>
            <div class="flex-grow">
                <x-stat title="{!! __('Sales revenue') !!}" description="" value="{{ number_format($salesRevenue, 2, ',', ' ') . ' €' }}" value2="{{ $salesRevenue }}"
                    icon="s-currency-euro" class="shadow-hover" />
            </div>
        </x-slot:content>
    </x-collapse><br>
    <x-collapse wire:model="openOrders" class="shadow-md">
        <x-slot:heading>
            @lang('Sales revenue')
        </x-slot:heading>
        <x-slot:content>
            <x-chart wire:model="ordersChart" class="w-full" />
        </x-slot:content>        
    </x-collapse><br>
    <x-collapse wire:model="openProducts" class="shadow-md">
        <x-slot:heading>
            @lang('Most Sold Products')
        </x-slot:heading>
        <x-slot:content class="flex justify-center">
            <div class="max-w-md w-full">
                <x-chart wire:model="productsChart" class="w-full" />
            </div>
        </x-slot:content>   
    </x-collapse>
</div>

Avec quelques traductions :

"Statistics": "Statistiques",
"Average basket": "Panier moyen",
"Sales revenue": "Chiffre d'affaires",
"Most Sold Products": "Produits les plus vendus",
"Quantity Sold": "Quantité vendue",

Dans la zone supérieure, on a le titre et le sélecteur pour l'année :

Au-dessous quelques données importantes pour l'année choisie :

Ensuite le chiffre d'affaires par mois :

Et enfin les produits les plus vendus :

Commentaires sur l'utilisation du composant Chart

Le composant de MaryUI est facile à utiliser. Si je prends le cas des commandes :

public function getOrdersChart(): array
{
    // Trie les commandes par date de création
    $sortedOrders = $this->orders->sortBy('created_at');

    // Groupe les commandes triées par mois de création
    $groupedOrders = $sortedOrders->groupBy(fn ($order) => $order->created_at->format('M'));

    // Crée une collection de labels pour chaque mois, traduit en format long (ex: "Janvier")
    $labels = $groupedOrders->keys()->map(fn($month) => Carbon::createFromFormat('M', $month)->translatedFormat('F'));

    // Retourne un tableau contenant les données pour un graphique à barres
    return [
        'type' => 'bar', // Type de graphique : barres
        'data' => [
            'labels' => $labels->values(), // Les labels des mois
            'datasets' => [
                [
                    'label' => __('Sales revenue'), // Étiquette du dataset
                    'data' => $groupedOrders->map(fn ($group) => $group->sum('total'))->values(), // Données : somme des totaux par mois
                    'backgroundColor' => 'rgba(75, 192, 192, 0.2)', // Couleur de fond des barres
                    'borderColor' => 'rgba(75, 192, 192, 1)', // Couleur de bordure des barres
                    'borderWidth' => 1, // Largeur de la bordure
                ]
            ]
        ]
    ];
}

J'ai ajouté des commentaires pour décrire ce que réalise le code. Voici quelques explications supplémentaires :

  • sortBy('created_at') : trie les commandes par la date de création.
  • groupBy(fn ($order) => $order->created_at->format('M')) : groupe les commandes par mois en utilisant le format 'M' (ex: 'Jan', 'Feb').
  • Carbon::createFromFormat('M', $month)->translatedFormat('F') : convertit le mois abrégé en format long et traduit (ex: 'Jan' devient 'Janvier').
  • map(fn ($group) => $group->sum('total')) : calcule la somme des totaux pour chaque groupe de commandes (par mois).

Pour le graphique des produits, c'est le même principe à part que la récupération des données est plus délicate. J'ai aussi commenté le code :

public function getProductsChart(): array
{
    // Transforme la collection de commandes en une collection de produits
    $productSales = $this->orders
        ->flatMap(function ($order) {
            return $order->products; // Extrait les produits de chaque commande
        })
        ->groupBy('name') // Groupe les produits par nom
        ->map(function ($items) {
            return [
                'product' => $items->first()->name, // Nom du produit
                'total_quantity' => $items->sum('quantity') // Quantité totale vendue
            ];
        })
        ->sortByDesc('total_quantity') // Trie les produits par quantité totale décroissante
        ->take(10); // Prend les 10 premiers produits

    // Retourne un tableau contenant les données pour un graphique en secteurs (pie chart)
    return [
        'type' => 'pie', // Type de graphique : secteurs
        'data' => [
            'labels' => $productSales->pluck('product'), // Les labels des produits
            'datasets' => [
                [
                    'label' => __('Quantity Sold'), // Étiquette du dataset
                    'data' => $productSales->pluck('total_quantity'), // Données : quantité totale vendue par produit
                ]
            ]
        ]
    ];
}

Voici quelques explications supplémentaires :

  • flatMap(function ($order) { return $order->products; }) : transforme chaque commande en une liste de produits, puis aplatit la structure pour obtenir une collection unique de produits.
  • groupBy('name') : groupe les produits par leur nom.
  • map(function ($items) { ... }) : transforme chaque groupe de produits en un tableau contenant le nom du produit et la quantité totale vendue.
  • sortByDesc('total_quantity') : trie les produits par quantité totale vendue, du plus grand au plus petit.
  • take(10) : limite le résultat aux 10 premiers produits.

Conclusion

Notre boutique s'est enrichie de statistiques. On pourrait évidemment en ajouter d'autres ou les considérer différemment. J'ai juste fait un choix qui me paraît judicieux et adapté à la fois à la structure de la boutique et au caractère didactique de l'article.



Par bestmomo

Aucun commentaire