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

Сегодня продолжаем работать с выводом товаров на сайте - у нас осталась пара незаконченых дел.
Во-первых, у нас нет вывода товаров, вложенных в дочерние категории. И поддержки товаров, привязанных через 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 `old_products`
WHERE `active` = '1'
  and (
    `category_id` = '1'
    or exists (
      SELECT *
      FROM `old_product_categories`
      WHERE `old_products`.`id` = `old_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 `old_products`
WHERE `active` = '1'
  and (
    `category_id` = '1'
    or `category_id` in ('11', '12', '13', '14', '10', '15')
    or exists (
      SELECT *
      FROM `old_product_categories`
      WHERE `old_products`.`id` = `old_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. Весь функционал вынесен в отдельные функции, так что будет несложно их доработать, при желании.
Ссылка на изменения в репозитории.

Комментарии

bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Futuris
04.04.2024, 08:56:12
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait FileModel", я поду...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!