Nouveau modèle de filtre pour l'affichage des produits en promotion
- Par Magentix le 16/05/2010
- Difficulté : 4/4
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 :
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
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 {
}
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.
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) :
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 :
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) :
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).
j'ai installé votre module et je l'ai testé, il marche bien
j'aurai juste une question, les produits qui sont affichés dans les promotions sont ceux auquel on a spécifié un special price et nom ceux sur lesquels sont appliquées une promotion catalog, c'est bien cela?
Merci d'avance
Par contre, est-il possible sur cette base de créer une page avec tous les produits en promo toutes catégories confondues avec la pagination ?
- Créer une catégorie "promotions" dans laquelle on viendrait placer manuellement tous les produits en promotion
- Créer un module avec un contrôleur spécifique et un bloc qui pourrait initialiser la collection avec des filtres personnalisés (avec le bloc de base en héritage : Mage_Catalog_Block_Product_List), et en utilisant des templates existants...
Je ferai peut être un tuto sur le 2ème point ;)
Dans son exemple le développeur se base sur le nom de la règle mais l'idée est là. Je pense que je vais tenter quelque chose ces prochains jours pour essayer d'intégrer les règles...
Par contre maintenant lorsque j'imagine la requête çà risque de peser pas mal niveau performances... Sachant qu'il existe pour les règles un prix calculé par produit, par groupe client et par website, que le produit peut posséder un prix spécial mais pas forcement de règle et qu'à l'inverse il peut présenter une règle sans pourtant que l'attribut special_price soit renseigné... Bon à tester mais sur un gros catalogue çà pourrait ralentir pas mal.
Est ce que ce module reste adapté à la version 1.6, le special price apparait bien lors que je ne charge pas le module, mais lors que le module Magentix_promofilter.xml est present dans la directory etc/modules et qu'il est active, je n'ai pas le filtres par promo.
D'autres part est il compatible avec votre module de reecritures des url de categories.
Bien cordialement
Je viens de tester sur une version neuve de magento 1.6 et le module n'a pas l'air de fonctionner. Tout les filtres apparaissent normalement sauf le special price qui disparait de la liste.
Cordialement
Atinaus