Хлебные крошки и мультикатегории

Сегодня продолжаем работать с выводом товаров на сайте - у нас осталась пара незаконченых дел.

Во-первых, у нас нет вывода товаров, вложенных в дочерние категории. И поддержки товаров, привязанных через ProductCategory тоже нет. Через это, например, большая категория /products/tabak выводится пустой.

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

Надо это исправить.

Вывод товаров из мультикатегорий

Это вопрос решается довольно-таки просто, и он уже даже решён в админке - я просто забыл об этом рассказать.

Меняем условие вывода товаров категории в Controllers/Web/Category/Products.php:

    protected function beforeCount(Builder $c): Builder
    {
        $c->where('active', true);
        $c->where(function (Builder $c) {
            // Товары категории
            $c->where('category_id', $this->category->id);
            // Товары мультикатегорий
            $c->orWhereHas('productCategories', function (Builder $c) {
                $c->where('category_id', $this->category->id);
            });
        });

        return $c;
    }

Получается вот такой запрос:

SELECT *
FROM `app_products`
WHERE `active` = '1'
  and (
    `category_id` = '1'
    or exists (
      SELECT *
      FROM `app_product_categories`
      WHERE `app_products`.`id` = `app_product_categories`.`product_id`
        and `category_id` = '1'
    )
  )
ORDER BY `price` ASC
LIMIT 9 offset 0

На этом, вроде бы, всё. Но мне бы хотелось выводить не только вручную связанные с категорией товары, но и товары из её подкатегорий.

Сделать это можно только одним способом - получить все id активных подкатегорий и добавить их в условие запроса.

Метод это полезный, так что добавляем его напрямую в модель категории:

    public static function getChildIds(int $parentId, bool $onlyActive = false): array {
        $ids = [];

        $children = self::query()->where('parent_id', $parentId)->select('id');
        if ($onlyActive) {
            $children->where('active', true);
        }
        foreach ($children->cursor() as $child) {
            $ids = [...$ids, $child->id, ...self::getChildIds($child->id, $onlyActive)];
        }

        return $ids;
    }

Это статический метод, который требует только указание id категории для рекурсивного получения массива c id всех её подкатегорий. Опционально можно выбрать только активные подкатегории - для публичного сайта.

Обновляем условие Web/Category/Products:

    protected function beforeCount(Builder $c): Builder
    {
        $c->where('active', true);
        $c->where(function (Builder $c) {
            // Товары категории
            $c->where('category_id', $this->category->id);
            // Товары подкатегорий
            if ($children = Category::getChildIds($this->category->id, true)) {
                $c->orWhereIn('category_id', $children);
            }
            // Товары мультикатегорий
            $c->orWhereHas('productCategories', function (Builder $c) {
                $c->where('category_id', $this->category->id);
            });
        });

        return $c;
    }

С помощью Clockwork смотрим в итоговый SQL:

SELECT *
FROM `app_products`
WHERE `active` = '1'
  and (
    `category_id` = '1'
    or `category_id` in ('11', '12', '13', '14', '10', '15')
    or exists (
      SELECT *
      FROM `app_product_categories`
      WHERE `app_products`.`id` = `app_product_categories`.`product_id`
        and `category_id` = '1'
    )
  )
ORDER BY `price` ASC
LIMIT 9 offset 0

Насколько я вижу по мультикатегориям исходной базы miniShop2, владельцы как раз добавляли вручную товары подкатегорий в основную категорию.

Обновляем и контроллер админки Admin/Products:

    protected function beforeCount(Builder $c): Builder
    {
        if ($category = (int)$this->getProperty('category')) {
            $c->where(static function (Builder $c) use ($category) {
                $c->where('category_id', $category);
                if ($children = Category::getChildIds($category)) {
                    $c->orWhereIn('category_id', $children);
                }
                $c->orWhereHas('productCategories', static function (Builder $c) use ($category) {
                    $c->where('category_id', $category);
                });
            });
        }
        // ...
    }

В админке будут выводиться товары всех категорий, включая неактивные.

Хлебные крошки

Вывод хлебных крошек от товара или категории - очень похожая задача, только здесь нужно получать массив с id родителей, а не потомков.

Создаём соответствующий метод в модели категорий:

    public static function getParentIds(int $childId, bool $onlyActive = false): array
    {
        $ids = [];

        $child = self::query()->where('id', $childId)->select('parent_id');
        if ($onlyActive) {
            $child->where('active', true);
        }
        if ($parentId = $child->value('parent_id')) {
            $ids = [$parentId, ...self::getParentIds($parentId)];
        }

        return $ids;
    }

Этот метод выдаст нам все id родителей категории, в порядке убывания уровня вложения. Опциональный второй параметр onlyActive позволяет остановить выборку, если один из родителей отключен.

Но для вывода хлебных крошек этой информации нам не хватит, ведь нужно подгрузить еще и переводы. Также хотелось бы не останавливать вывод категорий при нахождении неактивной, а просто прятать.

Пишем еще один метод:

    public static function getBreadcrumbs(int $categoryId, bool $onlyActive = true): array
    {
        $ids = [$categoryId, ...self::getParentIds($categoryId)];

        $breadcrumbs = self::query()
            ->select('id', 'uri')
            ->whereIn('id', $ids)
            ->with('translations:category_id,lang,title')
            ->orderByRaw('FIELD(id, ' . implode(',', $ids) . ')');
        if ($onlyActive) {
            $breadcrumbs->where('active', true);
        }

        return $breadcrumbs->get()->toArray();
    }

Вот теперь мы выбираем всё нужное:

  • включаем саму категорию, от которой идёт выборка
  • выбираем только активные записи
  • добираем переводы и uri
  • сохраняем порядок записей от категории к корню сайта

В контроллере категорий мы вызываем эту функцию вот так:

    public function prepareRow(Model $object): array
    {
        /** @var Category $object */
        $array = $object->toArray();
        if ($this->getPrimaryKey()) {
            $array['breadcrumbs'] = Category::getBreadcrumbs($object->id);
        }

        return $array;
    }

Метод prepareRow() позволяет модифицировать вывод данных на фронтенд для каждой записи, а проверка наличия getPrimaryKey() позволяет нам не делать ненужные выборки при выводе списка категорий.

Остаётся доработать наш компонент вывода хлебных крошек:

<template>
  <b-breadcrumb>
    <!-- Ссылка на корень каталога выводится всегда -->
    <b-breadcrumb-item :to="home">
      <fa icon="home" />
    </b-breadcrumb-item>
    <!-- Дальше выводим подготовленные крошки -->
    <b-breadcrumb-item v-for="(i, idx) in breadcrumbs" :key="idx" :to="i.route" :active="i.active">
      {{ i.title }}
    </b-breadcrumb-item>
  </b-breadcrumb>
</template>

<script>
export default {
  name: 'Breadcrumbs',
  props: {
    // Этот параметр теперь требует массив с категориями, вместо объекта с одной категорией
    categories: {
      type: Array,
      required: true,
    },
    product: {
      type: Object,
      default: null,
    },
  },
  computed: {
    // Ссылка на корень каталога зависит от нашей системной настройки
    home() {
      return '/' + this.$config.PRODUCTS_PREFIX
    },
    breadcrumbs() {
      // Переворачиваем порядок массива категорий, чтобы было от корня к текущей категории
      const res = [...this.categories].reverse().map((i, idx) => {
        // Пробегаем по элементам и формируем данные
        return {
          id: i.id,
          title: this.$translate(i.translations),
          route: this.$productLink(i),
          // Категория активна, если не указан товар и она последняя в массиве
          active: !this.product && this.categories.length === idx + 1,
        }
      })
      // Если указан товар
      if (this.product) {
        // Добавляем его в итоговый массив без ссылки
        res.push({
          id: this.product.id,
          title: this.$translate(this.product.translations),
          route: null,
          active: true,
        })
      }

      return res
    },
  },
}
</script>

Теперь обновляем вызов в компоненте категории:

<breadcrumbs v-if="category && category.breadcrumbs" :categories="category.breadcrumbs" />

И в компоненте товара:

<breadcrumbs v-if="product.breadcrumbs" :categories="product.breadcrumbs" :product="product" />

Проверяем результат:

Заключение

Вот мы и накрутили новых дополнительных запросов в БД, при этом на текущей базе я не вижу никаких тормозов.

Возможно, на огромном каталоге с десятком уровней вложения категорий могут возникнуть какие-то проблемы - но там можно и дописать промежуточное хранение дерева категорий в JSON файле, примерно как это сделано у MODX. Весь функционал вынесен в отдельные функции, так что будет несложно их доработать, при желании.

Ссылка на изменения в репозитории.

← Предыдущая заметка
Вывод товаров по uri
Следующая заметка →
Фасетные фильтры
Комментарии (0)
bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...