Dans les entrailles des transporteurs d'Ibexa Commerce

Après avoir comparer Ibexa Commerce à Sylius, entrons maintenant plus dans les détails techniques.

Installation d'Ibexa Commerce

Comme pour Ibexa DXP, vous avez besoin d'une clé d'autorisation et d'un token pour vous authentifier sur les serveurs d'Ibexa et télécharger les dépendances.

L'installation est similaire à tout projet initialisé avec composer. Les commandes post-installation fournies dans la documentation fonctionnent bien pour MariaDB.

Par défaut, tous les projets sont installés avec une configuration par défaut de Symfony et Docker pour l'utilisation de PostgreSQL. Comme j'utilise Docker sur mon poste de développement, j'ai du modifié la configuration Docker Compose et Symfony (.env ) pour passer de PostgreSQL à MariaDB.

Le stockage des données

Entrons directement dans la façon utilisée pour stocker les données dans le module commerce. Les choix réalisés par les équipes d'Ibexa sont simples : utiliser la même recette que pour les contenus. Nous trouvons donc le "Product Type" dans les mêmes tables que les "Content Type" et les "Products" sont dans les mêmes tables que les "Contents".

Les attributs, qu'il est possible d'ajouter à un type de produit, sont considérés comme un type de champs personnalisés pour le stockage.

A part cela, il y a de nouvelles tables donc voici celles qui ont retenues mon attention :

  • ibexa_product_specification_availability contient le stock pour les produits.
  • ibexa_product_specification_price contient le prix d'un produit pour tout le monde et pour les groupes de clients. Malheureusement les montants sont stockés en nombre décimal à 4 chiffres après la virgule. Les arrondis !
  • ibexa_order contient les informations sur une commande. La colonne context stocke au format JSON l'adresse de facturation et d'expédition avec d'autres informations sur le prix.
  • Les tables ibexa_order_itemibexa_order_item_productibexa_order_item_product_assignmentibexa_order_item_value stockent les informations sur les produits commandés.
  • ibexa_order_value contient les totaux des commandes.
  • ibexa_payment contient les informations de paiement des commandes.

La Poste

Maintenant, essayons de mettre en place le transporteur dans notre instance d'Ibexa Commerce en suivant la documentation.

Prérequis

Avant de commencer, nous avons besoin de définir le format des tranches de poids avec le prix correspondant.

Pour faire simple, les poids sont notés en gramme et les prix en centime. Le poids et le prix sont séparés par un deux-points ":" et les différents prix sont séparés par un point-virgule ";". Enfin, les poids doivent être dans l'ordre croissant pour faciliter l'utilisation de la liste.

Voici donc la valeur à stocker (ce n'est pas la vrai grille de prix de la poste) : 125:50;250:120;1000:1280;5000:2850

Nous avons également besoin de connaitre la monnaie utilisée dans la grille.

Voilà pour les données que nous gérons avec le transporteur. Nous avons besoin que tous les produits transportables aient une propriété "poids" (weight en anglais) de type "Measurement (single)".

Configuration du type de transporteur

La première chose réalisée est l'ajout d'un type de transporteur. Cette première étape définie certaines choses pour toute la suite.

   

services:
    app.shipping.shipping_method_type.custom:
        class: Ibexa\Shipping\ShippingMethod\ShippingMethodType
        arguments:
            $identifier: 'custom'
        tags:
            - name: ibexa.shipping.shipping_method_type
              alias: custom
  
   

Le terme "custom" doit être remplacé par votre type personnalisé. Ce terme sera utilisé par la suite pour toutes les configurations. Dans notre exemple, j'ai choisi "grid" car c'est un transporteur utilisant une grille de prix.

Formulaire de configuration du transporteur

La prochaine subtilité est au niveau du formulaire qui sera utilisé pour la configuration du transporteur.

Le type de champ à utiliser pour la monnaie est Ibexa\Bundle\ProductCatalog\Form\Type\CurrencyChoiceType . Mais pour éviter les erreurs de conversion de type, il faut utiliser le transformer Ibexa\Bundle\Shipping\Form\DataTransformer\CurrencyTransformer .

Voici la classe complète :

   

<?php
declare(strict_types=1); 
namespace App\ShippingMethodType\Form\Type; 

use Ibexa\Bundle\ProductCatalog\Form\Type\CurrencyChoiceType;
use Ibexa\Bundle\Shipping\Form\DataTransformer\CurrencyTransformer;
use Ibexa\Contracts\ProductCatalog\CurrencyServiceInterface;
use Ibexa\Contracts\ProductCatalog\Values\Currency\Query\Criterion\IsCurrencyEnabledCriterion;
use JMS\TranslationBundle\Translation\TranslationContainerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use JMS\TranslationBundle\Model\Message;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\OptionsResolver\OptionsResolver; 
final class GridShippingMethodOptionsType extends AbstractType implements TranslationContainerInterface
{ 
    private CurrencyServiceInterface $currencyService; 
    public function __construct(CurrencyServiceInterface $currencyService)
    {
        $this->currencyService = $currencyService;
    }
    public function getBlockPrefix(): string
    {
        return 'ibexa_shipping_method_grid';
    } 
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('price_grid', TextType::class);
        $builder->add('currency', CurrencyChoiceType::class, [
            'criterion' => new IsCurrencyEnabledCriterion(),
            'required' => true,
            'disabled' => $options['translation_mode'],
        ]); 
        $builder->get('currency')
            ->addModelTransformer(new CurrencyTransformer($this->currencyService)); 
    } 
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'translation_domain' => 'ibexa_shipping',
            'translation_mode' => false,
        ]);
        $resolver->setAllowedTypes('translation_mode', 'bool');
    } 
    public static function getTranslationMessages(): array
    {
        return [
            Message::create('ibexa.shipping_types.grid.name', 'ibexa_shipping')->setDesc('Grid'),
        ];
    }
}
  
   

Option Validator

Après avoir demandé des informations à l'utilisateur, il est obligatoire de valider que la saisie est correcte.

Avec ce service Symfony, j'ai vérifié que la grille des poids et des prix est cohérente et ne contient pas de caractères étranges.

Voici une implémentation simple de la vérification :

   

    public function validateOptions(OptionsBag $options): array
    {
        $priceGrid = $options->get('price_grid'); 
        if ($priceGrid === null) {
            return [
                new OptionsValidatorError('[price_grid]', self::MESSAGE),
            ];
        }
        $errors = [];
        $ranges = explode(';', $priceGrid);
        foreach ($ranges as $key => $range) {
            if (empty($range)) {
                $errors[] = new OptionsValidatorError(
                    '[price_grid]',
                    'Empty range is not allowed (index: ' . ($key + 1) . ')'
                );
                continue;
            }
            if (str_contains($range, ':') === false) {
                $errors[] = new OptionsValidatorError(
                    '[price_grid]',
                    'Invalid range format. Use ":" to separate weight and price (index: ' . ($key + 1) . ')'
                );
                continue;
            } 
            [$weight, $price] = explode(':', $range); 
            if (!preg_match('/^[0-9]+$/', $weight)) {
                $errors[] = new OptionsValidatorError(
                    '[price_grid]',
                    'Invalid weight format. Type the weight in gramme (index: ' . ($key + 1) . ', wrong value: '.$weight.')'
                );
            }
            if (!preg_match('/^[0-9]+$/', $price)) {
                $errors[] = new OptionsValidatorError(
                    '[price_grid]',
                    'Invalid price format. Type the price in cent (index: ' . ($key + 1) . ', wrong value: '.$price.')'
                );
            }
        } 
        return $errors;
    } 
   

Stockage en base de données

Pour le stockage, j'ai personnalisé les objets pour correspondre à ce schéma de base de données:

   

create table ibexa_shipping_method_region_grid
(
    id                        int auto_increment primary key,
    price_grid                text not null,
    currency_id               int  not null,
    shipping_method_region_id int not  null
); 
   

Le fichier de définition de schéma :

   

<?php
declare(strict_types=1); 
namespace App\ShippingMethodType\Storage; 
use Doctrine\DBAL\Types\Types;
use Ibexa\Contracts\Shipping\Local\ShippingMethod\StorageDefinitionInterface;
use Ibexa\Shipping\Persistence\Legacy\ShippingMethod\AbstractOptionsStorageSchema; 
final class StorageDefinition implements StorageDefinitionInterface
{
    public function getColumns(): array
    {
        return [
            AbstractOptionsStorageSchema::COLUMN_SHIPPING_METHOD_REGION_ID => Types::INTEGER,
            StorageSchema::COLUMN_PRICE_GRID => Types::STRING,
            StorageSchema::COLUMN_CURRENCY => Types::INTEGER,
        ];
    } 
    public function getTableName(): string
    {
        return StorageSchema::TABLE_NAME;
    }
}
  
   

Voici la classe de conversion des données entre la base de données et l'objet configuration du transporteur :

   

<?php
declare(strict_types=1); 
namespace App\ShippingMethodType\Storage; 
use Ibexa\Contracts\Shipping\Local\ShippingMethod\StorageConverterInterface; 
final class StorageConverter implements StorageConverterInterface
{
    public function fromPersistence(array $data)
    {
        $value['price_grid'] = $data['price_grid'];
        $value['currency'] = $data['currency_id']??null; 
        return $value;
    } 
    public function toPersistence($value): array
    {
        return [
            StorageSchema::COLUMN_PRICE_GRID => $value['price_grid'],
            StorageSchema::COLUMN_CURRENCY => $value['currency'],
        ];
    } 
}
  
   

Toutes les constantes sont définies dans le fichier src/ShippingMethodType/Storage/StorageSchema.php et contiennent les noms de la table et de la colonne présentes dans la base de données.

Afficher ou non le transporteur pour une commande

Pour mon test, le voter active systématiquement le transporteur. Mais il est intéressant de calculer à ce moment si le poids total de la commande n'est pas supérieur au poids maximum de la grille de tarif.

En cas de dépassement, le transporteur ne sera pas sélectionnable lors du choix du transporteur et du moyen de paiement.

Une autre façon de faire est de diviser le poids du colis par le poids maximum du transporteur. En arrondissant au supérieur cela donne une idée du nombre de colis.

Multiplier le prix de la dernière tranche par le nombre de colis donne un prix.

Ce calcul n'est valable que si vous savez qu'aucun produit n'a un poids supérieur au poids maximum du transporteur. Il existe également d'autre possibilité de calcul que je vous laisse imaginer.

 

Afficher le prix du transport dans le backoffice

Lorsque vous ajoutez un transporteur, il faut pouvoir afficher la grille de prix de façon plus lisible pour les administrateurs.

Voici le code utilisé pour transformer la grille:

   

<?php
declare(strict_types=1); 
namespace App\ShippingMethodType\Formatter; 
use Ibexa\Contracts\Shipping\ShippingMethod\CostFormatterInterface;
use Ibexa\Contracts\Shipping\Value\ShippingMethod\ShippingMethodInterface; 
final class GridCostFormatter implements CostFormatterInterface
{
    public function formatCost(ShippingMethodInterface $shippingMethod, array $parameters = []): ?string
    {
        $listPrices = explode(';', $shippingMethod->getOptions()->get('price_grid') ?? '');
        $listPrices = array_map(function ($item) {
            [$weight, $price] = explode(':', $item);
            $weight = (int)$weight / 100;
            $price = (int)$price / 100;
            return sprintf('%0.2f kg => %0.2f €', $weight, $price);
        }, $listPrices);
        return implode(' ; ', $listPrices);
    }
}
  
   

Et c'est tout ! Avez-vous la même sensation que moi ?

La documentation est terminée et pourtant à aucun moment nous utilisons la grille pour calculer le prix d'une commande. Voyons comment ajouter ce service.

Ajouter le calculateur de prix

La première chose est l'ajout d'une classe PHP qui implémente l'interface "Ibexa\Contracts\Shipping\ShippingMethod\CostCalculatorInterface ".

C'est cette classe qui recevra le transporteur avec sa configuration et la commande pour laquelle il est nécessaire de calculer un prix de transport.

Voici mon implémentation :

   

<?php
declare(strict_types=1); 
namespace App\ShippingMethodType\Calculator; 

use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Measurement\Value\SimpleValueInterface;
use Ibexa\Contracts\ProductCatalog\CurrencyServiceInterface;
use Ibexa\Contracts\Shipping\ShippingMethod\CostCalculatorInterface;
use Ibexa\Contracts\Shipping\Value\ShippingMethod\ShippingMethodInterface;
use Ibexa\ProductCatalog\Money\DecimalMoneyFactory;
use Money\Currency;
use Money\Money; 
final class GridCalculator implements CostCalculatorInterface
{
    private DecimalMoneyFactory $decimalMoneyFactory;
    private CurrencyServiceInterface $currencyService; 
    public function __construct(
        DecimalMoneyFactory $decimalMoneyFactory,
        CurrencyServiceInterface $currencyService
    ) { 
        $this->decimalMoneyFactory = $decimalMoneyFactory;
        $this->currencyService = $currencyService;
    } 
    public function calculate(ShippingMethodInterface $method, CartInterface $cart): Money
    {
        $listPrices = explode(';', $method->getOptions()->get('price_grid') ?? '');
        $prices = [];
        foreach ($listPrices as $item) {
            [$weight, $price] = explode(':', $item);
            $prices[$weight] = number_format($price / 100, 2, '.', '');
        }
        $currencyId = $method->getOptions()->get('currency'); 
        $currency = $this->currencyService->getCurrency($currencyId); 
        $weight = 0;
        foreach ($cart->getEntries() as $entry) {
            foreach ($entry->getProduct()->getAttributes() as $key => $attribute) {
                if ($key === 'weight') {
                    $value = $attribute->getValue();
                    if ($value instanceof SimpleValueInterface === false) {
                        continue;
                    }
                    $weight += ($value->getValue() * $entry->getQuantity());
                }
            }
        }
        $price = "500.00";
        foreach ($prices as $maxWeight => $levelPrice) {
            if ($maxWeight < $weight) {
                continue;
            }
            $price = $levelPrice;
        } 
        return $this->decimalMoneyFactory->getMoneyParser()->parse(
            $price,
            new Currency($currency->getCode())
        );
    }
}
  
   

Si vous avez l’œil, vous aurez remarqué que le prix du transport en cas de dépassement de la grille est de 500 €.

Il reste une étape. La configuration du service dans le fichier service.

   

services:
    App\ShippingMethodType\Calculator\GridCalculator:
        tags:
            - { name: 'ibexa.shipping.shipping.cost_calculator', method: 'grid' } 
   

Et voilà le résultat dans le tunnel de commande:

Conclusion

L'ajout d'un transporteur est une procédure un peu longue mais la documentation est bien fournie et presque complète.

Pour ma part, j'aurais préféré l’implémentation d'un tag automatique sur les interfaces avec une fonction statique dans le service pour obtenir le type de transporteur. Cela simplifie la configuration tant que le service est utilisé pour un seul type de transporteur.

L'ajout de ce type de transporteur a mis en évidence que pour Ibexa Commerce, il n'est pas possible de gérer le multi-colis pour une commande. Il n'est pas possible de gérer un numéro de colis pour ajouter un lien de suivi dans l'espace client. Cette fonctionnalité même si elle est moins utile pour les professionnels reste intéressante à ajouter dans votre projet.