Nouveau modèle de filtre pour l'affichage des produits en promotion

  • De le 16 mai 2010
  • Difficulté : 4/4

Nouveau modèle de filtre pour l'affichage des produits en promotion Il peut arriver que le mécanisme de filtre sur certains attributs ne soit pas toujours satisfaisant. C'est le cas par exemple de l'attribut "special_price", utilisé pour établir une promotion sur un produit. Un nouveau module peut nous permettre de modifier le comportement par défaut d'un filtre sur un attribut spécifique afin d'appliquer nos propres règles.

Objectif

L'objectif est de se soustraire du modèle utilisé par défaut pour l'affichage des filtres afin d'établir nos propres règles. Selon le type ou le code de l'attribut, 3 blocs sont utilisés :

  • catalog/layer_filter_price s'il s'agit d'un prix
  • catalog/layer_filter_decimal s'il s'agit d'un nombre décimal
  • catalog/layer_filter_attribute pour le reste

Dans cet article nous allons nous attarder sur le cas de l'attribut special_price, utilisé pour appliquer un prix remisé. L'attribut special_price est de type décimal. Le bloc catalog/layer_filter_decimal va alors générer un affichage de type pallier, où l'internaute pourra filtrer les produits selon une section établie en fonction des écarts.

Le comportement de ce filtre n'est pas adapté pour isoler les produits en promotion. Nous allons donc en modifier le mécanisme :

Module PromoFilter

Le module final est disponible en téléchargement. Cet article a pour vocation de détailler les étapes de développement d'une telle extension, dans le but d'en comprendre le sens. Il permettra également de se familiariser avec le mécanisme de filtre de Magento.

Architecture du module

  • app/code/local/Magentix/PromoFilter/Block/Layer/
  • View.php
  • app/code/local/Magentix/PromoFilter/Block/Layer/Filter/
  • Promo.php
  • app/code/local/Magentix/PromoFilter/etc/
  • config.xml
  • app/code/local/Magentix/PromoFilter/Helper/
  • Data.php
  • app/code/local/Magentix/PromoFilter/Model/
  • Promo.php
  • app/code/local/Magentix/PromoFilter/Model/Mysql4/
  • Promo.php
  • app/etc/modules/
  • Magentix_PromoFilter.xml

Développement du module

Etape 1 : préparation

La première étape est l'étape inévitable lors de la création d'un nouveau module : génération des fichiers, activation de l'extension et configuration.

app/etc/modules/Magentix_PromoFilter.xml

<?xml version="1.0"?>
<config>
    <modules>
        <Magentix_PromoFilter>
            <active>true</active>
            <codePool>local</codePool>
             <depends>
                 <Mage_Catalog />
             </depends>
             <version>0.1.0</version>
        </Magentix_PromoFilter>
    </modules>
</config> 

Notre extension a besoin du module catalog du core pour fonctionner. Il est important d'inclure ici les dépendances (depends).

Sans détailler précisément l'utilité de l'ensemble des noeuds du fichier de configuration (config.xml), nous devons les renseigner afin de suivre les prochaines étapes de l'article. Le fichier contient la déclaration des blocs, modèles, helper et ressources nécessaires au fonctionnement du module :

app/code/local/Magentix/PromoFilter/etc/config.xml

<?xml version="1.0"?>
<config>
     <modules>
          <Magentix_PromoFilter>
               <version>0.1.0</version>
          </Magentix_PromoFilter>
     </modules>
     <global>
          <blocks>
               <catalog>
                    <rewrite>
                         <layer_view>Magentix_PromoFilter_Block_Layer_View</layer_view>
                    </rewrite>
               </catalog>
               <promofilter>
                    <class>Magentix_PromoFilter_Block</class>
               </promofilter>
          </blocks>
          <models>
               <promofilter>
                    <class>Magentix_PromoFilter_Mode</class>
                    <resourceModel>promofilter_mysql4</resourceModel>
               </promofilter>
               <promofilter_mysql4>
                    <class>Magentix_PromoFilter_Model_Mysql4</class>
               </promofilter_mysql4>
          </models>
          <helpers>
               <promofilter>
                    <class>Magentix_PromoFilter_Helper</class>
                </promofilter>
          </helpers>
          <resources>
               <promofilter_read>
                    <connection>
                         <use>core_read</use>
                    </connection>
               </promofilter_read>
          </resources>
     </global>
     <frontend>
          <translate>
               <modules>
                    <Magentix_PromoFilter>
                         <files>
                              <default>Magentix_PromoFilter.csv</default>
                         </files>
                    </Magentix_PromoFilter>
               </modules>
          </translate>
     </frontend>
</config>

Notez la présence du fichier de langue Magentix_PromoFilter.csv. Le fichier est à créer dans le dossier de langue correspondant à la traduction, soit app/locale/fr_FR/Magentix_PromoFilter.csv pour la France. Ce fichier pourra être complété lorsque le module sera achevé.

J'ai toujours pour habitude de créer la classe du Helper avant de me lancer dans le développement de l'extension. Il sera ainsi possible d'activer directement les traductions dans le code sans générer d'erreur (Mage::helper('promofilter')->__('My Word')). Il ne restera plus qu'à traduire l'ensemble des textes une fois l'extension fonctionnelle.

app/code/local/Magentix/PromoFilter/Helper/Data.php

<?php

class Magentix_PromoFilter_Helper_Data extends Mage_Core_Helper_Abstract {

}
Etape 2 : nouveau bloc et surcharge

La surcharge d'un bloc du core est ici obligatoire. La méthode dont nous souhaitons modifier le comportement est _prepareLayout de la classe Mage_Catalog_Block_Layer_View. Elle est en charge de l'attribution du bloc selon le type d'attribut :

app/code/core/Mage/Catalog/Block/Layer/View.php

/* ... */

foreach ($filterableAttributes as $attribute) {
     $filterBlockName = $this->_getAttributeFilterBlockName();

     if ($attribute->getAttributeCode() == 'price') {
          $filterBlockName = 'catalog/layer_filter_price';
     } else if ($attribute->getBackendType() == 'decimal') {
          $filterBlockName = 'catalog/layer_filter_decimal';
     }

     $this->setChild($attribute->getAttributeCode().'_filter',
          $this->getLayout()->createBlock($filterBlockName)
                    ->setLayer($this->getLayer())
                    ->setAttributeModel($attribute)
                    ->init());
}

/* ... */

Le nom du bloc associé par défaut est ici catalog/layer_filter_attribute, récupéré depuis la méthode _getAttributeFilterBlockName. Une condition altère le nom du bloc si le code de l'attribut est price ou si le type est decimal. Le bloc enfant est ensuite initialisé.

La nouvelle méthode issue de la surcharge a pour but de supprimer le bloc attribué initialement à l'attribut special_price s'il existe afin de lui associer le nouveau bloc de notre module. On exécute préalablement la méthode parente.

app/code/local/Magentix/PromoFilter/Block/Layer/View.php

<?php

class Magentix_PromoFilter_Block_Layer_View extends Mage_Catalog_Block_Layer_View {

     protected function _prepareLayout() {
          parent::_prepareLayout();
                
          if($this->getChild('special_price_filter')) {
               $this->unsetChild('special_price_filter');

               $attribute = Mage::getModel('eav/entity_attribute')->loadByCode('catalog_product','special_price');

               $this->setChild('special_price_filter',
                    $this->getLayout()->createBlock('promofilter/layer_filter_promo')
                              ->setLayer($this->getLayer())
                              ->setAttributeModel($attribute)
                              ->init());
          }
     }

}

Notez qu'il aurait été possible de réécrire la méthode à l'identique en ajoutant une simple condition sur le code de l'attribut. Cela peut cependant poser problème si la méthode initiale du core est amenée à évoluer dans une version future de Magento. On limite ainsi les risques...

A ce stade, le nouveau bloc attribué à l'attribut special_price est désormais promofilter/layer_filter_promo. En imitant les méthodes des classes utilisées pour les autres attributs, nous pouvons facilement coder la classe de notre bloc :

app/code/local/Magentix/PromoFilter/Block/Layer/Filter/Promo.php

<?php

class Magentix_PromoFilter_Block_Layer_Filter_Promo extends Mage_Catalog_Block_Layer_Filter_Abstract {

     public function __construct() {
          parent::__construct();
          $this->_filterModelName = 'promofilter/promo';
     }
        
     protected function _prepareFilter() {
          $this->_filter->setAttributeModel($this->getAttributeModel());
          return $this;
     }

}

Le constructeur définit le modèle dont les méthodes dicteront le comportement du filtre (Etape 3). La méthode _prepareFilter associe simplement au filtre le modèle de l'attribut instancié puis attribué au bloc.

La surcharge et le nouveau bloc en place, nous pouvons maintenant nous attaquer au modèle.

Etape 3 : le modèle du filtre

Dans un premier temps nous souhaitons gérer l'affichage du filtre. Après création de la classe du modèle, intéressons nous à la méthode _getItemsData. Cette méthode retourne un tableau contenant les différentes options du filtre. Dans notre cas une seule option sera générée : "Toutes les promotions". Le tableau pourra contenir l'option à condition :

  • Que le filtre ne soit pas déjà appliqué
  • Que le nombre de produits après application du filtre soit positif
  • Que l'attribut accepte l'affichage du filtre même s'il n'y a pas de résultat (aucun produit)

Nous ne sommes pas encore en mesure de récupérer le nombre de produits généré après application du filtre (Etape 4). La méthode getCount chargée de retourner cette valeur retournera pour le moment le nombre 1.

app/code/local/Magentix/PromoFilter/Model/Promo.php

<?php

class Magentix_PromoFilter_Model_Promo extends Mage_Catalog_Model_Layer_Filter_Abstract {

     const OPTIONS_ONLY_WITH_RESULTS = 1;

     protected $_appliedPromo = 0;

     public function __construct() {
          parent::__construct();
     }

     public function getName() {
          return Mage::helper('promofilter')->__('Promotions');
     }

     public function getCount() {
          return 1;
     }

     protected function _getIsFilterableAttribute($attribute) {
          return $attribute->getIsFilterable();
     }

     protected function _getItemsData() {
          $attribute = $this->getAttributeModel();
          $this->_requestVar = $attribute->getAttributeCode();
                
          $key = $this->getLayer()->getStateKey().'_'.$this->_requestVar;
          $data = $this->getLayer()->getAggregator()->getCacheData($key);
        
          if($data === null) {
               $data = array();
               if(!$this->_appliedPromo) {
                    if($this->_getIsFilterableAttribute($attribute) == self::OPTIONS_ONLY_WITH_RESULTS) {
                         if($count = $this->getCount()) $data[] = array('label' => Mage::helper('promofilter')->__('All promotions'),'value' => '1','count' => $count);
                    } else {
                         $data[] = array('label' => Mage::helper('promofilter')->__('All promotions'),'value' => '1','count' => $this->getCount());
                    }
               }
               $tags = array(Mage_Eav_Model_Entity_Attribute::CACHE_TAG.':'.$this->getAttributeModel()->getId());
               $tags = $this->getLayer()->getStateTags($tags);
               $this->getLayer()->getAggregator()->saveCacheData($data, $key, $tags);
          }
        
          return $data;
     }

}

La méthode _getItemsData retourne un tableau. Ce tableau nommé data contient l'ensemble des options générées à l'affichage. Il est enrichi d'un unique tableau associatif de 3 éléments :

  • label : le nom de l'option
  • value : la valeur de l'option (Ex : ...?special_price=1)
  • count : le nombre de produits généré après application du filtre

Puisqu'elle est unique, la valeur de l'option sera ici toujours égal à 1. Un système de cache est utilisé, nous n'en détaillerons pas le fonctionnement.

La méthode getName a pour utilité de retourner le nom de filtre : "Promotion". Cette méthode n'est pas obligatoire. Il est possible de l'omettre si vous ne souhaitez pas que le nom du filtre apparaisse (dépend du thème) :

Affichage du filtre

Le lien généré est du type : /categorie-1.html?special_price=1. Cette nouvelle page doit nous permettre d'isoler les produits en promotion de la catégorie. Si le filtre est présent alors il nous faut modifier la requête en influant sur la collection. La méthode nous permettant de le faire se nomme apply. Elle vérifie si l'attribut est son option sont présents dans l'URL, contrôle si la valeur est cohérente, puis applique le filtre. En paramètre, l'objet de la requête et le bloc du filtre sont renseignés.

app/code/local/Magentix/PromoFilter/Model/Promo.php

public function apply(Zend_Controller_Request_Abstract $request, $filterBlock) {
     $filter = $request->getParam($this->getRequestVar());
     if (!$filter || $filter != 1) return $this;

     $this->_appliedPromo = 1;

     $this->getLayer()->getProductCollection()
                      ->addAttributeToFilter('special_price',array('gt'=>0))
                      ->addAttributeToFilter('special_from_date', array('date' => true, 'to' => date("Y-m-d")))
                      ->addAttributeToFilter(array(
                        array('attribute' => 'special_to_date', 'date' => true, 'from' => date("Y-m-d")),
                        array('attribute' => 'special_to_date', 'is' => new Zend_Db_Expr('null'))
                      ));
                                                 
     $this->getLayer()->getState()->addFilter($this->_createItem(Mage::helper('promofilter')->__('All'), $filter));
        
     return $this;
}

Si les conditions sont remplies, la variable _appliedPromo passe à 1, puis le filtre est appliqué. Le filtre demande l'intervention de 2 autres attributs : special_to_date et special_from_date. Ces attributs permettent d'appliquer la promotion sur une période donnée. Il faut donc vérifier que la date du jour est bien comprise dans l'interval. On peut enfin renseigner le statut et lui attribuer la chaîne "Toutes".

L'utilisation de la fonction date peut poser un problème de décalage dans certains cas. Le module final téléchargeable dans cet article applique les méthodes de Magento permettant la génération de la date locale exacte.

Nous disposons maintenant d'un filtre fonctionnel. Lorsque le filtre est appliqué, seul les produits en promotion seront générés à l'affichage :

Application du filtre promotion
Etape 4 : accès aux ressources

Nous souhaitons pour finir récupérer le nombre de produits en promotion avant même que le filtre ne soit appliqué. Dans la catégorie sélectionnée, si 2 produits sur 10 sont en promotion le filtre doit l'indiquer (le résultat apparaît entre parenthèse dans le template de base) :

Résultat du nombre de promotions

Le module doit permettre un accès aux ressources pour l'exécution d'une requête SQL spécifique. Le modèle de ressource a été spécifié dans le fichier de configuration établi lors de la première étape :

app/code/local/Magentix/PromoFilter/etc/config.xml

<!-- ... -->
<models>
     <promofilter>
          <class>Magentix_PromoFilter_Mode</class>
          <resourceModel>promofilter_mysql4</resourceModel>
     </promofilter>
     <promofilter_mysql4>
          <class>Magentix_PromoFilter_Model_Mysql4</class>
     </promofilter_mysql4>
</models>
<!-- ... -->

La classe Magentix_Promofilter_Model_Mysql4_Promo contiendra une méthode nommé getCount permettant d'obtenir le nombre total de produits en promotion de la catégorie, en tenant compte du fait que d'autres filtres ont déjà pu être appliqués.

A partir de la requête actuelle exécutée pour l'obtention de la collection de produits, nous allons simplement y ajouter jointures et conditions.

Transposons le problème sur une requête simple d'un modèle relationnel quelconque. Considérons la requête suivante :

Requête 1

SELECT COUNT(*) FROM produits WHERE categorie = 5 AND couleur = 3

Cette requête nous permet de récupérer le nombre total de produits dont l'id de la catégorie est 5 et l'id de la couleur 3. Si je souhaite par la suite que ce nombre contienne uniquement les produits en promotion, il nous suffit d'ajouter une condition.

Requête 2

SELECT COUNT(*) FROM produits WHERE categorie = 5 AND couleur = 3 [[b]]AND prix_promo > 0[[/b]]

Nous souhaitons faire exactement la même chose, mais à partir du modèle EAV de Magento. L'objectif est de nettoyer la requête courante des éléments dont nous n'avons pas besoin, puis d'ajouter nos conditions, le tout avec les méthodes offertes par Zend.

3 attributs doivent entrer en jeu : special_price, special_from_date et special_to_date. Les valeurs de ces attributs sont reparties dans 2 tables : catalog_product_entity_decimal pour l'attribut special_price et catalog_product_entity_datetime pour les 2 autres. Nous avons également besoin de la date courante pour les conditions.

app/code/local/Magentix/PromoFilter/Model/Mysql4/Promo.php

<?php

class Magentix_Promofilter_Model_Mysql4_Promo extends Mage_Core_Model_Mysql4_Abstract {

     protected function _construct() {
          $this->_init('promofilter/promo', 'entity_id');
     }

     protected function _getSelect($filter) {
          /* Collection actuelle */
          $collection = $filter->getLayer()->getProductCollection();
                
          /* Clonage de la requête */
          $select = clone $collection->getSelect();
                
          /* Nettoyage de la requête */
          $select->reset(Zend_Db_Select::COLUMNS);
          $select->reset(Zend_Db_Select::ORDER);
          $select->reset(Zend_Db_Select::LIMIT_COUNT);
          $select->reset(Zend_Db_Select::LIMIT_OFFSET);
                
          /* Obtention du prix spécial */
          $attributeId = $filter->getAttributeModel()->getAttributeId();
          $select->join(
                    array('decimal_index' => 'catalog_product_entity_decimal'),
                    "e.entity_id = decimal_index.entity_id AND decimal_index.attribute_id ={$attributeId}",array());
                
          /* Obtention de la date de début du prix spécial */
          $specialFromDateId = Mage::getModel('eav/entity_attribute')->loadByCode('catalog_product','special_from_date')->getId();
          $select->join(
                    array('datetime_from_index' => 'catalog_product_entity_datetime'),
                    "e.entity_id = datetime_from_index.entity_id AND datetime_from_index.attribute_id = {$specialFromDateId}",array());
                
          /* Obtention de la date de fin du prix spécial */
          $specialToDateId = Mage::getModel('eav/entity_attribute')->loadByCode('catalog_product','special_to_date')->getId();
          $select->join(
                    array('datetime_to_index' => 'catalog_product_entity_datetime'),
                    "e.entity_id = datetime_to_index.entity_id AND datetime_to_index.attribute_id = {$specialToDateId}",array());
                
          return $select;
     }

     public function getCount($filter,$todayDate) {
          $connection = $this->_getReadAdapter();
                
          $select = $this->_getSelect($filter);
                
          /* Nombre de ligne via la fonction COUNT */
          $select->columns(array('count' => new Zend_Db_Expr("COUNT(*)")));
                
          /* Ajout des conditions */
          $select->where("decimal_index.value > 0");
          $select->where("datetime_from_index.value <= '".$todayDate."'");
          $select->where("datetime_to_index.value >= '".$todayDate."'");
                
          return $connection->fetchOne($select);
     }

}

Nous pouvons enfin modifier et compléter la classe Magentix_PromoFilter_Model_Promo du modèle (Etape 3) :

app/code/local/Magentix/PromoFilter/Model/Promo.php

<?php

class Magentix_PromoFilter_Model_Promo extends Mage_Catalog_Model_Layer_Filter_Abstract {

     protected $_resource;

     /* ... */
        
     protected function _getResource() {
          if (is_null($this->_resource)) {
               $this->_resource = Mage::getResourceModel('promofilter/promo');
          }
          return $this->_resource;
     }
        
     public function getCount() {
          return $this->_getResource()->getCount($this,$this->getTodayDate());
     }
        
     public function getTodayDate() {
          $date = Mage::app()->getLocale()->date(time());
          return $this->_getResource()->formatDate($date,false);
     }

     /* ... */

}

L'extension est à présent pleinement fonctionnelle. Quelques modifications sur le fonctionnement des filtres ont été effectuées entre la version 1.3 et la version 1.4 de Magento, notamment sur la manière de récupérer le nombre total de produits lorsque le filtre n'est pas appliqué. Ce module est calqué sur le modèle de la version 1.4 mais fonctionne parfaitement avec les versions antérieures.

Pour terminer, Il est nécessaire de compléter le fichier de langue afin que les termes (peu nombreux) contenus dans le module puissent être affiché en Français.

app/locale/fr_FR/Magentix_PromoFilter.csv

"All promotions","Toutes les promotions"
"All","Toutes"
"Promotions","Promotions"

Conclusion

Le développement de cette extension nous a permis d'y voir plus claire sur le mécanisme des filtres. L'intégration de ce système de recherche poussé fait partie des très bonnes idées apportées par Magento. Il s'agit d'un atout majeur dans l'aide à la recherche pour l'internaute.

Les filtres sont cependant critiqués pour 2 raisons : par de réécriture d'URL et absence de balises méta spécifiques. L'utilisation des filtres peut engendrer un léger risque : balises métas identiques à celles de la catégorie principale dépourvue de filtre. Cette duplication de pages au sein d'une même catégorie ne peut néanmoins pas être associée à une duplication de contenu réelle, l'affichage restant différent pour chacune des pages. Un impact sur le positionnement peut tout de même se ressentir.

Ce problème peut être facilement résolu de plusieurs manières : balise canonical, no follow ou encore intégration du nom des filtres dans le titre et la description de la page (voir les articles Ajouter le nom des filtres dans le titre des pages catégories et Optimiser Magento pour le référencement).


commentaires

Commentez cet article : Nouveau modèle de filtre pour l'affichage des produits en promotion