Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Shopping : la commande
Lundi 11 mai 2020 15:48
Notre boutique sait afficher des produits et gérer un panier. Dans cet article on va aborder la commande. Vous pouvez télécharger un ZIP du projet ici.

Contrôleur et route

On crée un contrôleur pour la création d'une commande :
php artisan make:controller OrderController --resource
On ne va conserver que les méthodes create et store. Pour les routes on veut que ce soit seulement accessible aux utilisateurs authentifiés :
// Utilisateur authentifié
Route::middleware('auth')->group(function () {
  // Commandes
  Route::prefix('commandes')->group(function () {
      Route::resource('/', 'OrderController')->names([
          'create' => 'commandes.create',
          'store' => 'commandes.store',
      ])->only(['create', 'store']);
  });
});
On est obligé de spécifier les noms parce que celui de la ressource est vide.

J'espère que le mélange anglais/français dans les appellations ne vous dérange pas parce que je mélange souvent les deux au gré du code...

Par contre je m'attache à créer toutes les urls en français pour cette application et là ce n'est pas le cas !

Dans la méthode boot de AppServiceProvider on va ajouter ce code pour la traduction automatique :
use Illuminate\Support\Facades\Route;

...

Route::resourceVerbs([
    'edit' => 'modification',
    'create' => 'creation',
]);
Maintenant ça va mieux : On peut maintenant ajouter le lien dans la vue views/cart/index.blade.php :
<a href="{{ route('commandes.create') }}">Commander</a>

D'autre part lorsque l'utilisateur n'est pas authentifié et qu'il clique sur le bouton "Commander" dans le panier il est renvoyé sur la page de connexion. Il est judicieux alors de lui afficher un message explicatif. On complète la vue views/auth/loging.blade.php :

<form  method="POST" action="{{ route('login') }}">
  @if(url()->previous() === route('panier.index'))
    <div class="col s12">
      <div class="card purple darken-3">
        <div class="card-content white-text center-align">
          Vous devez être connecté pour passer une commande, si vous n'avez pas encore de compte vous pouvez en créer un en utilisant le lien sous ce formulaire.
        </div>
      </div>
    </div>
  @endif

Un service pour les frais d'envoi

Il va falloir calculer plusieurs fois les frais d'envoi en plusieurs emplacements, on va créer un service pour ça :

Avec ce code :
<?php

namespace App\Services;
use App\Models\Range;
use Cart;

class Shipping
{
    public function compute($country_id)
    {
        $items = Cart::getContent();

        $weight = $items->sum(function ($item) {
            return $item->quantity * $item->model->weight;
        });

        $range = Range::orderBy('max')->where('max', '>=', $weight)->first();
        
        return $range->countries()->where('countries.id', $country_id)->first()->pivot->price;
    }
}
On commence par récupérer le contenu du panier actif. Ensuite on calcule le poids total des produits en utilisant la méthode sum de la collection. La plage est ensuite définie à partir du poids. Enfin le tarif est déterminé en fonction du pays prévu pour l'expédition.

Les données

Dans le contrôleur OrderController on va récupérer toutes les données utiles et ouvrir la vue :
public function create(Request $request, Shipping $ship)
{        
    $addresses = $request->user()->addresses()->get();

    if($addresses->isEmpty()) {
        // Là il faudra renvoyer l'utilisateur sur son compte quand on l'aura créé
    }

    $country_id = $addresses->first()->country_id;

    $shipping = $ship->compute($country_id);
    
    $content = Cart::getContent();

    $total = Cart::getTotal();

    $tax = Country::findOrFail($country_id)->tax;

    return view('command.index', compact('addresses', 'shipping', 'content', 'total', 'tax'));
}
Il nous faut toutes ces informations :
  • les adresses de l'utilisateur
  • l'identifiant du pays de la première adresse pour initialiser les frais de ports et la TVA
  • les frais d'expédition
  • les contenu du panier
  • le coût total du panier
  • la valeur de ta TVA
On va aussi partager les données de la boutique dans AppServiceProvider :
use App\Models\Shop;

...

public function boot()
{
    View::share('shop', Shop::firstOrFail());

Les vues

On va créer 3 vues pour la commande :

Les adresses

On aura pas mal de vues pour le compte de l'utilisateur, on prépare le terrain avec une vue partielle pour afficher une adresse : Avec ce code :
<ul class="list-unstyled">
  @isset($address->name)
    <li>{{ "$address->civility $address->name $address->firstname" }}</li>
  @endif
  @if($address->company)
    <li>{{ $address->company }}</li>
  @endif            
  <li>{{ $address->address }}</li>
  @if($address->addressbis)
    <li>{{ $address->addressbis }}</li>
  @endif
  @if($address->bp)
    <li>{{ $address->bp }}</li>
  @endif
  <li>{{ "$address->postal $address->city" }}</li>
  <li>{{ $address->country->name }}</li>
  <li>{{ $address->phone }}</li>
</ul>

Maintenant on crée une vue pour afficher les adresses dans le formulaire de création de commande (partials/addresses.blade.php) qui utilise la vue partielle créée ci-dessus :

@if(!$addresses->count())
  <p>Vous n'avez pas encore créé d'adresse dans votre compte, vous devez en créer au moins une.</p>
  <br>
  <p><a href="#" style="width: 100%" class="btn waves-effect waves-light">Je crée une adresse</a>
  </p>
@else
  <div class="row">
    @foreach($addresses as $address)
      <div class="col m12 l6">
        <div class="card">                          
          <div class="card-content address">
            <p><label><input name="{{ $name }}" value="{{ $address->id }}" type="radio" @if($loop->first) checked @endif><span></span></label></p>
            @include('account.addresses.partials.address')
          </div>
        </div>
      </div>
    @endforeach
  </div>                  
@endif

Le détail de la commande

On crée aussi une vue partielle pour afficher le détail de la commande (partials/detail.blade.php) :
<h5>Détails de ma commande</h5>
@foreach ($content as $item)
  <hr><br>
  <div class="row">
    <div class="col m6 s12">
      {{ $item->name }} ({{ $item->quantity }} @if($item->quantity > 1) exemplaires) @else exemplaire) @endif
    </div>
    <div class="col m6 s12"><strong>{{ number_format($item->total_price_gross ?? ($tax > 0 ? $item->price : $item->price / 1.2) * $item->quantity, 2, ',', ' ') }} €</strong></div>
  </div>
@endforeach
<hr><br>
<div class="row" style="background-color: lightgrey">
  <div class="col s6">
    Livraison en Colissimo
  </div>
  <div class="col s6">
    <strong>{{ number_format($shipping, 2, ',', ' ') }} €</strong>
  </div>
</div>
@if($tax > 0)
  <div class="row" style="background-color: lightgrey">
    <div class="col s6">
      TVA à {{ $tax * 100 }}%
    </div>
    <div class="col s6">
      <strong>{{ number_format($total / (1 + $tax) * $tax, 2, ',', ' ') }} €</strong>
    </div>
  </div>
@endif
<div class="row" style="background-color: lightgrey">
  <div class="col s6">
    Total TTC
  </div>
  <div class="col s6">
    <strong>{{ number_format($total + $shipping, 2, ',', ' ') }} €</strong>
  </div>
</div>

Le formulaire

Enfin on a la vue du formulaire (command/index.blade.php) :
@extends('layouts.app')

@section('content')
<div class="container">
  <form id="form" action="{{ route('commandes.store') }}" method="POST">
    @csrf      
    @if(session()->has('message'))
      <h5 class="center-align red-text">{{ session('message') }}</h5>
      <br>
    @endif
    <ul class="collection with-header">

      <li class="collection-header"><h4>Ma commande</h4></li>

      <div id="wrapper">

      <li class="collection-item">
        <h5>Adresse de facturation <span id="solo">et de livraison</span></h5>
        @include('command.partials.addresses', ['name' => 'facturation'])
        <div class="row">
          <div class="col s12">
            <a href="#" class="btn" style="width: 100%"><i class="material-icons left">location_on</i>Gérer mes Adresses</a>
          </div>
        </div>
        <div class="row">
          <div class="col s12">
            <label>
              <input id="different" name="different" type="checkbox" @if($addresses->count() === 1)  disabled="disabled" @endif>
              <span>
                @if($addresses->count() === 1)
                  Vous n'avez qu'une adresse enregistrée, si vous voulez une adresse différente pour la livraison <a href="{{ route('adresses.create') }}">vous pouvez en créer une autre</a>.
                @else
                  Mon adresse de livraison est différente de mon adresse de facturation
                @endif
              </span>
            </label>
          </div>
        </div>
      </li>

      <li id="liLivraison" class="collection-item hide">
        <h5>Adresse de livraison</h5>
        @include('command.partials.addresses', ['name' => 'livraison'])      
      </li>

      <li class="collection-item">
        <h5>Mode de livraison</h5>
        <p>
          <label>
            <input name="expedition" type="radio" value="colissimo" checked>
            <span>Colissimo</span>
          </label>
        </p>
        <p>
          <label>
            <input name="expedition" type="radio" value="retrait">
            <span>Retrait sur place</span>
          </label>
        </p>
      </li>

      <li class="collection-item">
        <h5>Paiement</h5>
        @if($shop->card)
          <p>
            <label>
              <input class="payment" name="payment" type="radio" value="carte" checked>
              <span>Carte bancaire</span>
            </label>
          </p>
          <p style="margin-left: 40px" class="hide">
            Vous devrez renseigner un formulaire de paiement sur la page de confirmation de cette commande.
          </p>
        @endif
        @if($shop->mandat)
          <p>
            <label>
              <input class="payment" name="payment" type="radio" value="mandat">
              <span>Mandat administratif</span>
            </label>
          </p>
          <p style="margin-left: 40px" class="hide">
            Envoyez un bon de commande avec la mention "Bon pour accord". Votre commande sera expédiée dès réception de ce bon de commande. N'oubliez pas de préciser la référence de la commande dans votre bon.
          </p>
        @endif
        @if($shop->transfer)         
          <p>
            <label>
              <input class="payment" name="payment" type="radio" value="virement">
              <span>Virement bancaire</span>
            </label>
          </p>
          <p style="margin-left: 40px" class="hide">
            Il vous faudra transférer le montant de la commande sur notre compte bancaire. Vous recevrez votre confirmation de commande comprenant nos coordonnées bancaires et le numéro de commande. Les biens seront mis de côté 30 jours pour vous et nous traiterons votre commande dès la réception du paiement. 
          </p>
        @endif
        @if($shop->check)
          <p>
            <label>
              <input class="payment" name="payment" type="radio" value="cheque">
              <span>Chèque</span>
            </label>
          </p>
          <p style="margin-left: 40px" class="hide">
            Il vous faudra nous envoyer un chèque du montant de la commande. Vous recevrez votre confirmation de commande comprenant nos coordonnées bancaires et le numéro de commande. Les biens seront mis de côté 30 jours pour vous et nous traiterons votre commande dès la réception du paiement. 
          </p>
        @endif
      </li>

      <li id="detail" class="collection-item">
        @include('command.partials.detail')      
      </li> 
        
      <li class="collection-item">
        <h5>Veuillez vérifier votre commande avant le paiement !</h5>
        <br>
        <div class="row">
          <div class="col s12">
            <label>
              <input id="ok" name="ok" type="checkbox">
              <span>J'ai lu <a href="#" target="_blank">les conditions générales de vente et les conditions d'annulation</a> et j'y adhère sans réserve. </span>
            </label>
          </div>
        </div>
      </li>

      </div>

      <div id="loader" class="hide">
        <div class="loader"></div>
      </div>

    </ul>
    <div class="row">
      <div class="col s12">
        <button id="commande" type="submit" class="btn disabled" style="width: 100%">Commande avec obligation de paiement</button>
      </div>
    </div>
  </form>
</div>
@endsection

@section('javascript')
  <script>

    const changePayment = () => {
      document.querySelectorAll('.payment').forEach(payment => {
        const list = payment.parentNode.parentNode.nextElementSibling.classList;
        if(payment.checked) {
          list.remove('hide');
        } else {
          list.add('hide');
        } 
      });      
    };

    const getDetails = async () => {
      document.querySelector('#wrapper').classList.add('hide');
      document.querySelector('#loader').classList.remove('hide');
      const response = await fetch('#', { 
        method: 'POST',
        headers: { 
          'X-CSRF-TOKEN': '{{ csrf_token() }}', 
          'Content-Type': 'application/json' 
        },
        body: JSON.stringify({ 
          facturation: document.querySelector('input[type=radio][name=facturation]:checked').value, 
          livraison: document.querySelector('input[type=radio][name=livraison]:checked').value,
          different: document.querySelector('#different').checked,
          pick: document.querySelector('input[type=radio][name=expedition]:checked').value == 'retrait'
        })
      });
      const data = await response.json();
      document.querySelector('#detail').innerHTML = data.view;
      document.querySelector('#loader').classList.add('hide');
      document.querySelector('#wrapper').classList.remove('hide');      
    };

    document.addEventListener('DOMContentLoaded', () => {
      
      document.querySelector('#different').checked = false;
      
      document.querySelector('#ok').checked = false;
      
      document.querySelector('#different').addEventListener('change', () => {
        document.querySelector('#liLivraison').classList.toggle('hide');
        document.querySelector('#solo').classList.toggle('hide');
        getDetails();
      });

      document.querySelectorAll('.payment').forEach(payment => {
        payment.addEventListener('change', () => changePayment());
      });
      
      document.querySelector('#ok').addEventListener('change', () => document.querySelector('#commande').classList.toggle('disabled'));
      
      document.querySelectorAll('input[type=radio][name=facturation]').forEach(input => {
        input.addEventListener('change', () => getDetails());
      });

      document.querySelectorAll('input[type=radio][name=livraison]').forEach(input => {
        input.addEventListener('change', () => getDetails());
      });

      document.querySelectorAll('input[type=radio][name=expedition]').forEach(input => {
        input.addEventListener('change', () => {
          if(document.querySelector('input[type=radio][name=expedition][value=retrait]').checked) {            
            if(document.querySelector('#different').checked) {
              document.querySelector('#different').checked = false;              
              document.querySelector('#liLivraison').classList.toggle('hide');
            }
            document.querySelector('#different').disabled = true;  
            document.querySelector('#solo').classList.add('hide');          
          }
          if(document.querySelector('input[type=radio][name=expedition][value=colissimo]').checked) {            
            document.querySelector('#different').disabled = false;
            if(document.querySelector('#different').checked) {
              document.querySelector('#solo').classList.add('hide');
            } else {
              document.querySelector('#solo').classList.remove('hide');
            }          
          } 
          getDetails()
        });
      });

      document.querySelector('#form').addEventListener('submit', () => {
        const button = document.querySelector('#commande');
        button.classList.toggle('disabled');
        button.textContent = 'Confirmation de la commande en cours, ne fermez pas cette fenêtre...'
      });

      changePayment();
    });

  </script>
@endsection

C'est un peu chargé forcément parce qu'il y a pas mal d'informations et que j'ai opté pour un affichage global sur une seule page.

Dans la partie supérieure on a les adresses :

On pourra préciser l'adresse de livraison différente. On verra ça plus loin, la case à cocher est en place. Le bouton de gestion des adresses n'est pas actif non plus parce qu'on n'a pas créé le compte client.

Au-dessous on a le mode de livraison : On a deux options : livraison en Colissimo ou retrait sur place. Ensuite on a le mode de paiement : On peut déjà cliquer pour changer d'option, ça affiche à chaque fois un petit texte explicatif. Vient ensuite le détail de la commande qui doit être calculé dynamiquement selon les options choisies :

Enfin dans la partie inférieure la case à cocher pour confirmer la lecture des conditions générales de vente et le bouton d'envoi actif si la case est cochée :

Calcul du détail

Le détail de la commande change si :
  • l'adresse d'expédition change (frais de port et éventuellement TVA différente)
  • le mode de livraison change (pas de frais de port en cas de retrait sur place)

Il faut donc prévoir un calcul dynamique. Le Javascript est déjà en place dans la vue, je n'ai juste pas précisé la route pour éviter une erreur d'exécution.

On crée un contrôleur pour ce calcul :
php artisan make:controller DetailsController
On n'aura qu'une fonction :
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\Shipping;
use App\Models\ { Address, Country };
use Cart;

class DetailsController extends Controller
{
    /**
     * Show the order details
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Services\Shipping  $shipping
     * @return \Illuminate\Http\Response
     */
    public function __invoke(Request $request, Shipping $ship)
    {
        // Facturation
        $country_facturation = Address::findOrFail($request->facturation)->country;

        // Livraison
        $country_livraison = $request->different ? Address::findOrFail($request->livraison)->country : $country_facturation;
        $shipping = $request->pick ? 0 : $ship->compute($country_livraison->id);

        // TVA
        $tvaBase = Country::whereName('France')->first()->tax;
        $tax = $request->pick ? $tvaBase : $country_livraison->tax; 
        
        // Panier
        $content = Cart::getContent();
        $total = $tax > 0 ? Cart::getTotal() : Cart::getTotal() / (1 + $tvaBase);              

        return response()->json([ 
            'view' => view('command.partials.detail', compact('shipping', 'content', 'total', 'tax'))->render(), 
        ]);
    }
}
On génère et renvoie le HTML pour la partie du détail. On ajoute la route :
// Utilisateur authentifié
Route::middleware('auth')->group(function () {
  // Commandes
  Route::prefix('commandes')->group(function () {
      Route::name('commandes.details')->post('details', 'DetailsController');
Il ne reste plus qu'à ajouter la route dans la vue command/index.blade.php au niveau du Javascript :
const response = await fetch('{{ route("commandes.details") }}', {

Fonctionnement

Maintenant tout doit fonctionner dans le formulaire de commande. On peut préciser une adresse de livraison différente (s'il y a au moins deux adresses) :

A chaque changement l'affichage total du formulaire est remplacé par une image d'attente animée (la même qu'on a déjà utilisée précédemment tant qu'à faire) jusqu'à réponse du serveur. Ca évite que l'utilisateur ne clique encore ailleurs et lui indique que le traitement de son action est en cours.

En cas de changement de mode de livraison on a aussi un recalcul et une mise à jour. D'autre part si on choisit le retrait sur place le choix éventuel d'une adresse de livraison différente est supprimé et la case à cocher correspondante inactivée.

On va aussi prévoir à la fin du Javascript le déclenchement systématique du calcul du détail en cas de rechargement de la page alors que des options ont été changées :

  changePayment();
  getDetails();
});
Je ne rentre pas dans le détail du code pour ne pas trop alourdir cet article.

La création de la commande

Maintenant qu'on a notre formulaire il ne reste plus qu'à enregistrer la commande lors de la soumission. On code la méthode create du contrôleur OrderController :

use App\Models\ { Address, Country, State, Shop, Product, User };

...

/**
 * Store a newly created resource in storage.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \App\Services\Shipping  $ship
 * @return \Illuminate\Http\Response
 */
public function store(Request $request, Shipping $ship)
{
    // Vérification du stock
    $items = Cart::getContent();
    foreach($items as $row) {
        $product = Product::findOrFail($row->id);
        if($product->quantity < $row->quantity) {
            $request->session()->flash('message', 'Nous sommes désolés mais le produit "' . $row->name . '" ne dispose pas d\'un stock suffisant pour satisfaire votre demande. Il ne nous reste plus que ' . $product->quantity . ' exemplaires disponibles.');
            return back();
        }
    }

    // Client
    $user = $request->user();

    // Facturation
    $address_facturation = Address::with('country')->findOrFail($request->facturation);

    // Livraison
    $address_livraison = $request->different ? Address::with('country')->findOrFail($request->livraison) : $address_facturation;
    $shipping = $request->expedition === 'colissimo' ? $ship->compute($address_livraison->country->id) : 0;

    // TVA
    $tvaBase = Country::whereName('France')->first()->tax;
    $tax = $request->expedition === 'colissimo' ? $address_livraison->country->tax : $tvaBase;

    // Enregistrement commande
    $order = $user->orders()->create([
        'reference' => strtoupper(Str::random(8)),
        'shipping' => $shipping,
        'tax' => $tax,
        'total' => $tax > 0 ? Cart::getTotal() : Cart::getTotal() / (1 + $tvaBase),
        'payment' => $request->payment,
        'pick' => $request->expedition === 'retrait',
        'state_id' => State::whereSlug($request->payment)->first()->id,
    ]);

    // Enregistrement adresse de facturation
    $order->adresses()->create($address_facturation->toArray());

    // Enregistrement éventuel adresse de livraison
    if($request->different) {
        $address_livraison->facturation = false;
        $order->adresses()->create($address_livraison->toArray());
    }

    // Enregistrement des produits
    foreach($items as $row) {
        $order->products()->create(
            [
                'name' => $row->name,
                'total_price_gross' => ($tax > 0 ? $row->price : $row->price / (1 + $tvaBase)) * $row->quantity,
                'quantity' => $row->quantity,
            ]
        );        
        // Mise à jour du stock
        $product = Product::findOrFail($row->id);
        $product->quantity -= $row->quantity;
        $product->save();
        // Alerte stock
        if($product->quantity <= $product->quantity_alert) {
            // Notifications à prévoir pour les administrateurs 
        }
    }

    // On vide le panier
    Cart::clear();
    Cart::session($request->user())->clear();

    // Notifications à prévoir pour les administrateurs et l'utilisateur

    //return redirect(route('commandes.confirmation', $order->id));
}
Je viens de me rendre compte que je n'avais pas prévu encore la propriété $fillable dans le modèle Order, on va l'ajouter :
protected $fillable = [
    'shipping', 'tax', 'user_id', 'state_id', 'payment', 'reference', 'pick', 'total',
];
On accomplit un certain nombre d'actions dans le contrôleur :
  • vérification du stock, si c'est insuffisant on retourne le formulaire avec une alerte :
  • on récupère l'adresse de facturation
  • on détermine l'adresse de livraison et on calcule les frais de port
  • on détermine le taux TVA
  • on enregistre la commande avec :
    • une référence générée aléatoirement
    • les frais de port
    • le taux de TVA
    • le coût total
    • le mode de paiement
    • le mode de livraison
    • l'état selon le mode de paiement
  • on enregistre l'adresse de facturation
  • on enregistre une éventuelle adresse de livraison
  • on enregistre les produits
  • on met à jour le stock
  • on vide le panier
  • on verra plus tard les notifications (alerte stock, commande créée pour les administrateurs et le client)
Chez moi ça fonctionne, la commande : Les adresses : Les produits : Au retour on renverra une vue de confirmation de commande.

Conclusion

On a avancé dans la développement de la boutique. On peut maintenant passer une commande avec toutes les options disponibles. Dans le prochain article on verra la confirmation de la commande et le paiement par carte bancaire avec Stripe.

 


Par bestmomo

Nombre de commentaires : 57