У магазина 19 сегментов покупателей, и у каждого свои цены на один и тот же товар: розница, опт, дилеры, партнёры с индивидуальными условиями. Как эти цены рассчитываются, мы разобрали в статье про персональные цены для 19 сегментов. Но мало показать покупателю его цену в карточке товара - каталогом пользуются так, как привыкли пользоваться любым магазином.
- 01"Покажи оборудование от 30 до 50 тысяч".
- 02"Отсортируй: сначала дешёвые".
- 03"Только то, что есть в наличии".
- 04"От какой суммы вообще начинается этот раздел".
Все эти вопросы - про цену. А цена у каждого покупателя своя, и до того, как посетитель открыл страницу, она неизвестна. Обычный магазинный фильтр в такой системе перестаёт работать.
Цена вопроса
Это не теоретическая тонкость. Вот один и тот же фильтр в рабочем каталоге - глазами розничного клиента и глазами дилера.
Инструментальная тележка стоит рознице около 39 800 ₽ и попадает в диапазон, а дилеру - около 29 900 ₽, и в выборку "от 30 000" она уже не входит. Компрессор наоборот: рознице он стоит около 50 400 ₽ и в диапазон не проходит, а дилеру - около 37 700 ₽, и он должен его увидеть.
Если посчитать по всему каталогу, расхождение перестаёт быть частным случаем.
Из 289 товаров, попадающих в диапазон хотя бы у одного из двух покупателей, общих - всего 100. Фильтр, который считал бы по единой для всех цене, показал бы дилеру список розницы: 117 товаров не из его диапазона и ни одного из 72, которые он искал. На каждой странице каталога, при каждом обращении к фильтру.
Почему это не решается настройкой магазина
Стандартный каталог собирает страницу одним запросом к базе данных: там же отбор, там же сортировка, там же деление на страницы. Это быстро и надёжно ровно до того момента, пока цена хранится в базе рядом с товаром. Персональной цены там нет: она своя для каждого из 19 сегментов и хранится отдельно. Отсюда сразу три препятствия.
- →Сортировать нечем. Магазин умеет упорядочить товары по колонке в базе данных. Колонки "цена этого посетителя" не существует.
- →Фильтровать нечем. Условие "от 30 до 50 тысяч" магазин переводит в условие для базы данных - но переводить его не во что.
- →Сложнее всего с постраничным выводом. Номера страниц и общее число найденных товаров магазин берёт из того же запроса, которым отбирал товары. Если порядок и состав выборки определяются вне этого запроса, счётчики оказываются неверными: число найденного меняется от страницы к странице, а часть товаров не показывается вовсе. Именно на этом обычно и останавливаются попытки добавить собственную цену в готовый каталог.
Решение
Отбор идёт воронкой
Каждый фильтр не пытается переписать запрос к базе данных - он превращается в список товаров. Списки пересекаются между собой и с обычным фильтром по характеристикам. Поэтому личная цена совместима со штатным умным фильтром: "дилерская цена от 30 до 50 тысяч, напряжение 380 В, в наличии" - это одна выдача, а не конфликт двух механизмов.
Порядок шагов выбран по стоимости вычисления. Сначала работает база данных - она отсекает товары по ярлыкам: распродажа, новинки, хиты. Затем подключаются готовые персональные цены. И только в конце считается наличие - самый дорогой шаг, потому что нужно собрать склады, доступные именно этому сегменту. Каждый шаг получает на вход уже суженный список.
Наличие - тоже персональное
"В наличии" здесь означает не "на складе что-то лежит", а "этот покупатель может это купить": у него есть цена на этот товар и есть остаток на складах, которые ему доступны. Разным сегментам доступны разные склады, а часть покупателей видит ещё и товары в пути, с датами прихода. Поэтому один и тот же товар обоснованно показывается "в наличии" одному покупателю и не показывается другому.
Страница собирается по личным ценам
Порядок товаров вычисляется по готовой таблице персональных цен, страница выделяется заранее - в запрос к базе данных уходят только те товары, которые действительно попадут на экран. А счётчики постраничного вывода передаются штатному механизму навигации явно, вместо тех, что он обычно берёт из запроса. Внешне это выглядит как обычный каталог: те же номера страниц, то же "найдено 217 товаров" - только всё это посчитано по ценам конкретного посетителя.
Отдельно решён вопрос товаров без цены - их около 2 000 из 13 125. Они не выпадают из каталога: при сортировке "сначала дешёвые" встают в начало, при "сначала дорогие" - в конец, и попадают в фильтр "от 0". Такой товар можно заказать, и скрывать его от покупателя было бы неправильно.
Как это устроено внутри
| Решение | Как сделано |
|---|---|
| Компонент каталога | Наследник штатного компонента Битрикса: переопределены 8 методов, снаружи - равноценная замена. Шаблоны, умный фильтр и постраничная навигация остаются стандартными |
| Фильтр по цене | Не транслируется в запрос к базе данных, а материализуется в список идентификаторов и пересекается с уже собранным фильтром - за счёт этого совместим со штатным умным фильтром |
| Сортировка | Перехватывается на входе (CUSTOM_PRICE и любое CATALOG_PRICE_N), изымается из параметров сортировки и заменяется полным упорядоченным списком идентификаторов из таблицы персональных цен |
| Постраничный вывод | Страница выделяется на стороне PHP; счётчики NavRecordCount, NavPageCount и NavPageNomer проставляются в объекте результата до вызова родительского метода - штатная навигация выводит верные страницы |
| Порядок фильтров | Распродажа, затем цена, затем наличие - с ранним выходом на пустом результате; самый дорогой шаг получает минимум кандидатов |
| Наличие | Пакетный расчёт: остатки по складам сегмента и, при необходимости, товары в пути; критерий - есть цена сегмента и есть положительный остаток. Ни одного отдельного запроса на каждый товар |
| Хранение | Предрассчитанная таблица "товар × сегмент × раздел" в оперативной памяти: сотни тысяч строк занимают около 53 МБ |
| Кеширование и персонализация | Страница отдаётся из кеша, а персональные блоки (наличие, кнопка корзины) досчитываются отдельным механизмом отложенного контента - кеш не мешает персонализации |
Два решения, которые обычно вызывают вопросы у коллег.
Почему раздел входит в ключ таблицы. Цена от раздела не зависит, но каталогу нужны ответы в разрезе раздела: границы фильтра, "Цена от…" на странице раздела, сортировка внутри раздела. Раздел в ключе избавляет от соединения с таблицей привязок при каждом таком запросе, а оно обходится заметно дороже, чем хранение готовых строк.
Почему постраничный вывод не потребовал переписывать компонент. Можно было отказаться от штатного компонента и написать свой каталог целиком. Вместо этого перехвачены ровно три вещи: сортировка, состав страницы и счётчики навигации.
Отказаться от штатного компонента и написать каталог целиком: свой умный фильтр, свои SEO-настройки, своё кеширование - и всё это поддерживать самим при каждом обновлении платформы.
Перехвачены ровно три вещи: сортировка, состав страницы и счётчики навигации. Шаблоны, умный фильтр, SEO-настройки и кеширование работают штатно и обновляются вместе с платформой.
Что это дало бизнесу
| Раньше | Стало |
|---|---|
| Фильтр по цене показывал бы всем одинаковый результат - неверный для 18 сегментов из 19 | Каждый покупатель фильтрует по своим ценам: дилер находит те 72 товара, которых при единой цене не увидел бы вовсе |
| "Сначала дешёвые" означало "дешёвые для розницы" | Сортировка корректна для каждого сегмента |
| "В наличии" - справочная информация | "В наличии" означает "вы можете это купить": есть ваша цена и остаток на ваших складах |
| "Цена от…" на разделе - общая для всех | Своя для каждого сегмента: дилер видит свою нижнюю границу |
| Персональные цены - повод отказаться от штатного каталога и писать свой | Штатный каталог сохранён: обновления платформы, умный фильтр и SEO работают как прежде |
В цифрах
| Показатель | Значение |
|---|---|
| Товаров в каталоге | 13 125 активных |
| Разделов каталога | 756 активных |
| Сегментов покупателей | 19 |
| Готовых персональных ценников | около 220 тысяч |
| Объём таблицы персональных цен в памяти | около 53 МБ |
| Товаров без цены, корректно обработанных | около 2 000 (15% каталога) |
| Складов | 17 активных |
| Расхождение выборки по фильтру 30-50 тысяч | розница - 217 товаров, дилер - 172, совпадают - 100 |
| Запросов к данным на страницу каталога | единицы: все проверки пакетные, независимо от числа товаров в разделе |
Каталог с личными ценами - часть большой B2B-платформы: персональные цены, дилерский кабинет и двусторонний обмен с 1С. Что вошло в проект целиком - в кейсе B2B e-commerce платформы.



