Магазин на Laravel 7, часть 24. Фильтр товаров категории по цене, новинкам и лидерам продаж

16.11.2020

Теги: LaravelMySQLPHPWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровПрактикаФормаФреймворкШаблонСайта

Фильтр для товаров категории

Поскольку у нас теперь товары имеют атрибуты new, hit и sale, мы можем их отбирать по этим атрибутам. Другими словами — реализовать фильтр товаров. Давайте создадим шаблон filter.blade.php в директрии views/catalog/part и подключим его в шаблоне catalog.category.

@extends('layout.site', ['title' => $category->name])

@section('content')
    <h1>{{ $category->name }}</h1>
    <p>{{ $category->content }}</p>
    <div class="row">
        @foreach ($category->children as $child)
            @include('catalog.part.category', ['category' => $child])
        @endforeach
    </div>
    <div class="bg-info p-2 mb-4">
        <!-- Фильтр для товаров категории -->
        <form method="get"
              action="{{ route('catalog.category', ['category' => $category->slug]) }}">
            @include('catalog.part.filter')
            <a href="{{ route('catalog.category', ['category' => $category->slug]) }}"
               class="btn btn-light">Сбросить</a>
        </form>
    </div>
    <div class="row">
        @foreach ($products as $product)
            @include('catalog.part.product', ['product' => $product])
        @endforeach
    </div>
    {{ $products->links() }}
@endsection
<!-- цена (руб) -->
<select name="price" class="form-control d-inline w-25 mr-4" title="Цена">
    <option value="0">Выберите цену</option>
    <option value="min"@if(request()->price == 'min') selected @endif>Дешевые товары</option>
    <option value="max"@if(request()->price == 'max') selected @endif>Дорогие товары</option>
</select>
<!-- новинка -->
<div class="form-check form-check-inline">
    <input type="checkbox" name="new" class="form-check-input" id="new-product"
           @if(request()->has('new')) checked @endif value="yes">
    <label class="form-check-label" for="new-product">Новинка</label>
</div>
<!-- лидер продаж -->
<div class="form-check form-check-inline">
    <input type="checkbox" name="hit" class="form-check-input" id="hit-product"
           @if(request()->has('hit')) checked @endif value="yes">
    <label class="form-check-label" for="hit-product">Лидер продаж</label>
</div>
<!-- распродажа -->
<div class="form-check form-check-inline ">
    <input type="checkbox" name="sale" class="form-check-input" id="sale-product"
           @if(request()->has('sale')) checked @endif value="yes">
    <label class="form-check-label" for="sale-product">Распродажа</label>
</div>
<button type="submit" class="btn btn-light">Фильтровать</button>

Изменим метод category() контроллера CatalogController:

class CatalogController extends Controller {
    /* ... */
    public function category(Request $request, Category $category) {
        $descendants = $category->getAllChildren($category->id);
        $descendants[] = $category->id;
        $builder = Product::whereIn('category_id', $descendants);

        // дешевые или дорогие товары
        if ($request->has('price') && in_array($request->price, ['min', 'max'])) {
            $products = $builder->get();
            $count = $products->count();
            if ($count > 1) {
                $max = $builder->get()->max('price'); // цена самого дорогого товара
                $min = $builder->get()->min('price'); // цена самого дешевого товара
                $avg = ($min + $max) * 0.5;
                if ($request->price == 'min') {
                    $builder->where('price', '<=', $avg);
                } else {
                    $builder->where('price', '>=', $avg);
                }
            }
        }
        // отбираем только новинки
        if ($request->has('new')) { 
            $builder->where('new', true);
        }
        // отбираем только лидеров продаж
        if ($request->has('hit')) {
            $builder->where('hit', true);
        }
        // отбираем только со скидкой
        if ($request->has('sale')) {
            $builder->where('sale', true);
        }

        $products = $builder->paginate(6)->withQueryString();
        return view('catalog.category', compact('category', 'products'));
    }
    /* ... */
}

Фильтр по цене вычисляет среднюю цену товара в категории как среднее арифметическое максимальной и минимальной цены. Если выбраны дешевые товары — будут показаны товары, у которых цена меньше или равна средней арифметической, если выбраны дорогие товары — будут показаны товары, у которых цена больше или равна средней арифметической.

У этого алгоритма есть недостаток — если в категории большинство товаров дешевые (например — 100.00, 200.00, 300.00 и 400.рублей), а дорогих товаров мало (например — только один 1000.00 рублей), то средняя цена будет 550.00 рублей. Когда выбраны дешевые товары — будут показано четыре товара, а когда дорогие — только один.

Возможно, есть смысл вычислить среднюю цену как среднее арифметическре всех цен в разделе каталога, тогда распределение по дешевым и дорогим будет более равномерным. Еще один алгоритм отбора дешевых и дорогих товаров — делить их ровно пополам. То есть 50% товаров попадают в дешевые, а еще 50% — попадают в дорогие.

class CatalogController extends Controller {
    /* ... */
    public function category(Request $request, Category $category) {
        $descendants = $category->getAllChildren($category->id);
        $descendants[] = $category->id;
        $builder = Product::whereIn('category_id', $descendants);

        // дешевые или дорогие товары
        if ($request->has('price') && in_array($request->price, ['min', 'max'])) {
            $products = $builder->get()->sortBy('price')->values();
            $count = $products->count();
            if ($count > 1) {
                $half = intdiv($count, 2);
                if ($count % 2) {
                    // нечетное кол-во товаров, надо найти цену товара, который ровно посередине
                    $avg = $products[$half]['price'];
                } else {
                    // четное количество, надо найти такую цену, которая поделит товары пополам
                    $avg = 0.5 * ($products[$half - 1]['price'] + $products[$half]['price']);
                }
                if ($request->price == 'min') {
                    $builder->where('price', '<=', $avg);
                } else {
                    $builder->where('price', '>=', $avg);
                }
            }
        }
        // отбираем только новинки
        if ($request->has('new')) { 
            $builder->where('new', true);
        }
        // отбираем только лидеров продаж
        if ($request->has('hit')) {
            $builder->where('hit', true);
        }
        // отбираем только со скидкой
        if ($request->has('sale')) {
            $builder->where('sale', true);
        }

        $products = $builder->paginate(6)->withQueryString();
        return view('catalog.category', compact('category', 'products'));
    }
    /* ... */
}

Рефакторинг кода

Теперь метод category() контроллера выглядит запутанно, и выполняет слишком много работы. Давайте вынесем фильтрацию товаров в отдельный класс app/Helpers/ProductFilter:

namespace App\Helpers;

use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;

class ProductFilter {

    private $builder;
    private $request;

    public function __construct(Builder $builder, Request $request) {
        $this->builder = $builder;
        $this->request = $request;
    }

    public function apply() {
        foreach ($this->request->query() as $filter => $value) {
            if (method_exists($this, $filter)) {
                $this->$filter($value);
            }
        }
        return $this->builder;
    }

    private function price($value) {
        if (in_array($value, ['min', 'max'])) {
            $products = $this->builder->get();
            $count = $products->count();
            if ($count > 1) {
                $max = $this->builder->get()->max('price'); // цена самого дорогого товара
                $min = $this->builder->get()->min('price'); // цена самого дешевого товара
                $avg = ($min + $max) * 0.5;
                if ($value == 'min') {
                    $this->builder->where('price', '<=', $avg);
                } else {
                    $this->builder->where('price', '>=', $avg);
                }
            }
        }
    }

    private function new($value) {
        if ('yes' == $value) {
            $this->builder->where('new', true);
        }
    }

    private function hit($value) {
        if ('yes' == $value) {
            $this->builder->where('hit', true);
        }
    }

    private function sale($value) {
        if ('yes' == $value) {
            $this->builder->where('sale', true);
        }
    }
}
class CatalogController extends Controller {
    /* ... */
    public function category(Request $request, Category $category) {
        $descendants = $category->getAllChildren($category->id);
        $descendants[] = $category->id;
        $builder = Product::whereIn('category_id', $descendants);

        $products = (new ProductFilter($builder, $request))->apply()->paginate(6)->withQueryString();

        return view('catalog.category', compact('category', 'products'));
    }
    /* ... */
}

Используем скоупы

Сейчас, чтобы получить товары категории, мы используем следующий код:

class CatalogController extends Controller {
    /* ... */
    public function category(Request $request, Category $category) {
        $descendants = $category->getAllChildren($category->id);
        $descendants[] = $category->id;
        $builder = Product::whereIn('category_id', $descendants);

        $products = (new ProductFilter($builder, $request))->apply()->paginate(6)->withQueryString();

        return view('catalog.category', compact('category', 'products'));
    }
    /* ... */
}

При использовании локального скоупа (scope), получаем уже такой код:

class CatalogController extends Controller {
    /* ... */
    public function category(Request $request, Category $category) {
        $descendants = $category->getAllChildren($category->id);
        $descendants[] = $category->id;
        $builder = Product::categoryProducts($descendants);

        $products = (new ProductFilter($builder, $request))->apply()->paginate(6)->withQueryString();

        return view('catalog.category', compact('category', 'products'));
    }
    /* ... */
}
class Product extends Model {
    /**
     * Позволяет выбирать товары категории и всех ее потомков
     *
     * @param \Illuminate\Database\Eloquent\Builder $builder
     * @param array $parents
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeCategoryProducts($builder, $parents) {
        return $builder->whereIn('category_id', $parents);
    }
    /* ... */
}

Кажется, что в этом нет особого смысла, но давайте добавим еще один скоуп:

class CatalogController extends Controller {
    /* ... */
    public function category(Category $category, ProductFilter $filters) {
        $descendants = $category->getAllChildren($category->id);
        $descendants[] = $category->id;
        // получаем товары категории и ее потомков, потом применяем фильтры
        $builder = Product::categoryProducts($descendants)->filterProducts($filters);

        $products = $builder->paginate(6)->withQueryString();

        return view('catalog.category', compact('category', 'products'));
    }
    /* ... */
}
class Product extends Model {
    /**
     * Позволяет выбирать товары категории и всех ее потомков
     *
     * @param \Illuminate\Database\Eloquent\Builder $builder
     * @param array $parents
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeCategoryProducts($builder, $parents) {
        return $builder->whereIn('category_id', $parents);
    }

    /**
     * Позволяет фильтровать товары по нескольким условиям
     *
     * @param \Illuminate\Database\Eloquent\Builder $builder
     * @param $filters
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeFilterProducts($builder, $filters) {
        return $filters->apply($builder);
    }
    /* ... */
}

Мы внедряем экземпляр класса ProductFilter в метод category() контроллера. Создаем экземпляр построителя запроса, используя скоуп scopeCategoryProducts() и отбираем товары категории. Но скоуп scopeFilterProducts() используем немного не так, как принято делать. Вместо того, чтобы добавить условие where() — вызываем метод apply() класса ProductFilter. Который вернет нам все тот же объект построителя запроса, к которому добавлено несколько условий where() — это наши фильтры.

class ProductFilter {

    private $builder;
    private $request;

    public function __construct(Request $request) {
        $this->request = $request;
    }

    public function apply($builder) {
        $this->builder = $builder;
        foreach ($this->request->query() as $filter => $value) {
            if (method_exists($this, $filter)) {
                $this->$filter($value);
            }
        }
        return $this->builder;
    }

    /* ... */
}

Только вызов метода Category::getAllChildren() все портит — давайте сделаем этот метод статическим. И будем его вызывать в методе scopeCategoryProducts() модели Product, а не в методе category() контроллера CatalogController.

class Category extends Model {
    /**
     * Возвращает всех потомков категории с идентификатором $id
     */
    public static function getAllChildren($id) {
        // получаем прямых потомков категории с идентификатором $id
        $children = self::where('parent_id', $id)->with('children')->get();
        $ids = [];
        foreach ($children as $child) {
            $ids[] = $child->id;
            // для каждого прямого потомка получаем его прямых потомков
            if ($child->children->count()) {
                $ids = array_merge($ids, self::getAllChildren($child->id));
            }
        }
        return $ids;
    }
}
class Product extends Model {
    /**
     * Позволяет выбирать товары категории и всех ее потомков
     *
     * @param \Illuminate\Database\Eloquent\Builder $builder
     * @param integer $id
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeCategoryProducts($builder, $id) {
        $descendants = Category::getAllChildren($id);
        $descendants[] = $id;
        return $builder->whereIn('category_id', $descendants);
    }

    /**
     * Позволяет фильтровать товары по нескольким условиям
     *
     * @param \Illuminate\Database\Eloquent\Builder $builder
     * @param \App\Helpers\ProductFilter $filters
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeFilterProducts($builder, $filters) {
        return $filters->apply($builder);
    }
    /* ... */
}

Вот теперь метод category() контроллера выглядит вполне прилично:

class CatalogController extends Controller {
    /* ... */
    public function category(Category $category, ProductFilter $filters) {
        $products = Product::categoryProducts($category->id) // товары категории и всех ее потомков
            ->filterProducts($filters) // фильтруем товары категории и всех ее потомков
            ->paginate(6)
            ->withQueryString();
        return view('catalog.category', compact('category', 'products'));
    }
    /* ... */
}

Фильтр для товаров бренда

Аналогичный фильтр можем сделать для товаров бренда, для этого добавляем форму в шаблон catalog.brand:

@extends('layout.site', ['title' => $brand->name])

@section('content')
    <h1>{{ $brand->name }}</h1>
    <p>{{ $brand->content }}</p>
    <div class="bg-info p-2 mb-4">
        <!-- Фильтр для товаров бренда -->
        <form method="get"
              action="{{ route('catalog.brand', ['brand' => $brand->slug]) }}">
            @include('catalog.part.filter')
            <a href="{{ route('catalog.brand', ['brand' => $brand->slug]) }}"
               class="btn btn-light">Сбросить</a>
        </form>
    </div>
    <div class="row">
        @foreach ($products as $product)
            @include('catalog.part.product', ['product' => $product])
        @endforeach
    </div>
    {{ $products->links() }}
@endsection

И изменяем метод brand() контроллера CatalogController:

class CatalogController extends Controller {
    /* ... */
    public function brand(Brand $brand, ProductFilter $filters) {
        $products = $brand
            ->products() // возвращает построитель запроса
            ->filterProducts($filters)
            ->paginate(6)
            ->withQueryString();
        return view('catalog.brand', compact('brand', 'products'));
    }
    /* ... */
}

Приводим в порядок маршруты

Это файл routes/web.php — чтобы сразу было понятно, какой маршрут за что отвечает:

/*
 * Главная страница интернет-магазина
 */
Route::get('/', 'IndexController')->name('index');

/*
 * Страницы «Доставка», «Контакты» и прочие
 */
Route::get('/page/{page:slug}', 'PageController')->name('page.show');

/*
 * Каталог товаров: категория, бренд и товар
 */
Route::group([
    'as' => 'catalog.', // имя маршрута, например catalog.index
    'prefix' => 'catalog', // префикс маршрута, например catalog/index
], function () {
    // главная страница каталога
    Route::get('index', 'CatalogController@index')
        ->name('index');
    // категория каталога товаров
    Route::get('category/{category:slug}', 'CatalogController@category')
        ->name('category');
    // бренд каталога товаров
    Route::get('brand/{brand:slug}', 'CatalogController@brand')
        ->name('brand');
    // страница товара каталога
    Route::get('product/{product:slug}', 'CatalogController@product')
        ->name('product');
});

/*
 * Корзина покупателя
 */
Route::group([
    'as' => 'basket.', // имя маршрута, например basket.index
    'prefix' => 'basket', // префикс маршрута, например bsaket/index
], function () {
    // список всех товаров в корзине
    Route::get('index', 'BasketController@index')
        ->name('index');
    // страница с формой оформления заказа
    Route::get('checkout', 'BasketController@checkout')
        ->name('checkout');
    // получение данных профиля для оформления
    Route::post('profile', 'BasketController@profile')
        ->name('profile');
    // отправка данных формы для сохранения заказа в БД
    Route::post('saveorder', 'BasketController@saveOrder')
        ->name('saveorder');
    // страница после успешного сохранения заказа в БД
    Route::get('success', 'BasketController@success')
        ->name('success');
    // отправка формы добавления товара в корзину
    Route::post('add/{id}', 'BasketController@add')
        ->where('id', '[0-9]+')
        ->name('add');
    // отправка формы изменения кол-ва отдельного товара в корзине
    Route::post('plus/{id}', 'BasketController@plus')
        ->where('id', '[0-9]+')
        ->name('plus');
    // отправка формы изменения кол-ва отдельного товара в корзине
    Route::post('minus/{id}', 'BasketController@minus')
        ->where('id', '[0-9]+')
        ->name('minus');
    // отправка формы удаления отдельного товара из корзины
    Route::post('remove/{id}', 'BasketController@remove')
        ->where('id', '[0-9]+')
        ->name('remove');
    // отправка формы для удаления всех товаров из корзины
    Route::post('clear', 'BasketController@clear')
        ->name('clear');
});

/*
 * Регистрация, вход в ЛК, восстановление пароля
 */
Route::name('user.')->prefix('user')->group(function () {
    Auth::routes();
});

/*
 * Личный кабинет зарегистрированного пользователя
 */
Route::group([
    'as' => 'user.', // имя маршрута, например user.index
    'prefix' => 'user', // префикс маршрута, например user/index
    'middleware' => ['auth'] // один или несколько посредников
], function () {
    // главная страница личного кабинета пользователя
    Route::get('index', 'UserController@index')->name('index');
    // CRUD-операции над профилями пользователя
    Route::resource('profile', 'ProfileController');
    // просмотр списка заказов в личном кабинете
    Route::get('order', 'OrderController@index')->name('order.index');
    // просмотр отдельного заказа в личном кабинете
    Route::get('order/{order}', 'OrderController@show')->name('order.show');
});

/*
 * Панель управления магазином для администратора сайта
 */
Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллера
    'middleware' => ['auth', 'admin'] // один или несколько посредников
], function () {
    // главная страница панели управления
    Route::get('index', 'IndexController')->name('index');
    // CRUD-операции над категориями каталога
    Route::resource('category', 'CategoryController');
    // CRUD-операции над брендами каталога
    Route::resource('brand', 'BrandController');
    // CRUD-операции над товарами каталога
    Route::resource('product', 'ProductController');
    // доп.маршрут для показа товаров категории
    Route::get('product/category/{category}', 'ProductController@category')
        ->name('product.category');
    // просмотр и редактирование заказов
    Route::resource('order', 'OrderController', ['except' => [
        'create', 'store', 'destroy'
    ]]);
    // просмотр и редактирование пользователей
    Route::resource('user', 'UserController', ['except' => [
        'create', 'store', 'show', 'destroy'
    ]]);
    // CRUD-операции над страницами сайта
    Route::resource('page', 'PageController');
    // загрузка изображения из wysiwyg-редактора
    Route::post('page/upload/image', 'PageController@uploadImage')
        ->name('page.upload.image');
    // удаление изображения в wysiwyg-редакторе
    Route::delete('page/remove/image', 'PageController@removeImage')
        ->name('page.remove.image');
});

Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Интернет магазин • Каталог товаров • Практика • Форма • Фреймворк • Шаблон сайта • Фильтр

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.