Практика: цена корзины с наборами (Bundle pricing)
Наборы товаров со своей скидкой, вложенные друг в друга: цена считается по дереву, а дерево приходит из каталога, где редактор может ошибиться.
Базовый пример: Компоновщик. Там дерево собирается в коде и всегда правильное. В магазине дерево наборов описывает контент-менеджер в админке, и практика отвечает на вопрос, которого учебный пример не задаёт: как превратить такое описание в дерево и не дать ему зациклиться или опустеть.
Задача
Магазин техники продаёт наборы: «Рабочее место» (мышь и клавиатура со скидкой 10%), «Офис» (рабочее место, гарнитура и два кабеля, ещё 5% сверху). Наборы заводит отдел маркетинга, вложенность растёт с каждой акцией, и в корзине рядом лежат и отдельные товары, и наборы.
Первая версия считала цену набора в корзине циклом по составу: товар — взять цену, набор — подставить его сумму. Вложенные наборы поддержали отдельной веткой «набор в наборе», третий уровень уже не поместился, а скидка внутреннего набора терялась. Однажды в админке набор «Подарок» сослался на «Промо», а «Промо» на «Подарок», и страница корзины упала с переполнением стека.
Что урезано
- Каталог — JSON-строка в тесте; в проекте это таблицы товаров и состава наборов в БД.
- Деньги — целые копейки одной валюты, скидка целыми процентами.
- Количество в составе набора задаётся повтором: два кабеля — два элемента
CABLE. - Нет остатков, акций по датам и правил «скидка не суммируется».
Решение
Общий интерфейс CartItem — одна операция priceCents(). Product отвечает своей ценой, Bundle складывает цены состава и вычитает свою скидку. Внутренний набор отдаёт уже скидочную цену, внешний применяет к сумме свою: скидки складываются по дереву сами, без веток «набор в наборе».
Новое по сравнению с базовым примером — каталог Catalog, который строит дерево из описания и проверяет его при загрузке, а не при первом расчёте корзины. Cart — клиент компоновщика: умножает цену любой позиции на количество и не знает, товар перед ним или набор.
Структура
Product — лист без сюрпризов: цена в копейках, артикул. Целые копейки здесь не украшение: с дробными рублями две скидки подряд дают копейку «из воздуха», которую потом ищет бухгалтерия.
Скидка набора
Bundle проверяет себя в конструкторе: пустой набор и скидка вне 0..100 не создаются. Это инвариант узла, а не каталога, поэтому он держится, кто бы ни собирал набор. Скидка считается в копейках и округляется вниз на каждом уровне: «Рабочее место» 6500 − 650 = 5850, «Офис» 12 850 − 642 = 12 208 (5% от 12 850 — 642,5). Округление на каждом уровне — бизнес-решение: цена набора не зависит от того, куда его вложили.
Каталог
Catalog получает описание: цены товаров и состав наборов по артикулам. Наборы строятся рекурсивно, с путём от корня. Встретил артикул, который уже есть в пути, — цикл, и в ошибке весь путь: GIFT-BOX -> PROMO-SET -> GIFT-BOX, по нему видно, какую ссылку убрать. Собранные наборы запоминаются, так что набор, входящий в несколько других, строится один раз и делится. Все наборы строятся сразу в конструкторе: ошибка редактора всплывает при загрузке каталога, а не в корзине у покупателя.
Как это собирается
Каталог собирается при старте приложения и при каждой публикации акций из админки. В примере живёт текущая версия с наборами WORKPLACE и OFFICE, а маркетинг публикует новую, где GIFT и PROMO ссылаются друг на друга. Catalog строит дерево в конструкторе и падает на цикле с BundleError, поэтому вызывающий код ловит ошибку, пишет причину и оставляет в работе прежний каталог: покупатели не видят сломанных наборов, а корзина не падает.
Дальше обработчик корзины берёт строки заказа, достаёт позиции из каталога через item() и кладёт их в Cart. Незнакомый артикул отклоняется только в своей строке, остальные считаются. Цена набора OFFICE с вложенным WORKPLACE получается из дерева сама, обработчик умножает её на количество так же, как цену кабеля.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Cart — то, ради чего всё затевалось: одна строка priceCents() * qty для любой позиции. Новый вид позиции (подарочный сертификат, услуга сборки) встанет в корзину и в наборы, если реализует CartItem.
| Язык | Особенность |
|---|---|
| PHP | final readonly узлы, вариадик CartItem ...$items; каталог из json_decode(..., true), форма массива описана в PHPDoc для PHPStan |
| Go | encoding/json прямо в Definitions по тегам; сентинел-ошибки и errors.Is; обход наборов по отсортированным ключам, иначе путь цикла зависел бы от порядка карты |
| Python | TypedDict описывает JSON без классов-обёрток; набор — frozen dataclass с проверкой в __post_init__ |
| TypeScript | JSON.parse(...) as Definitions — приведение без проверки, поэтому все проверки в каталоге и наборе; BundleError наследует Error |
Запуск: make test T=patterns/structural/composite/practice
Что проверяют тесты
Тесты одинаковы во всех языках и читают каталог из JSON. Первая таблица — цена по дереву: товар — своя цена (2500), набор — сумма минус 10% (5850), вложенный набор — скидка внешнего поверх уже скидочной цены внутреннего с округлением вниз (12 208). Корзина с двумя «Офисами» и мышью — 26 916: товар и набор лежат в ней одинаково. Вторая таблица — сломанные каталоги: цикл через два набора, пустой набор, ссылка на несуществующий товар, скидка 120%. Каждый падает при загрузке с точным текстом, по которому видно, что чинить.
Где граница
- Скидки начинают зависеть от контекста: «не суммируется с промокодом», «только для оптовиков», «до конца месяца». Тогда цену считает не узел, а движок правил над деревом (Стратегия или Спецификация), а набор только описывает состав.
- Дерево большое и цены меняются часто: каждый расчёт обходит всё поддерево. Понадобится кэш цены набора и его сброс при изменении цены товара.
- Наборов нет или вложенность не нужна — хватит строки корзины со скидкой; компоновщик ради одного уровня избыточен.
- Проверку циклов можно вынести в админку (при сохранении набора). Проверка при загрузке остаётся страховкой: данные приходят не только из админки.
Отличия между языками
- PHP —
readonlyнабор копирует состав вlistодин раз в конструкторе. Форма JSON-массива задана в PHPDoc (array{products: ..., bundles: ...}): PHPStan проверяет обращения к ключам, хотя во время выполнения это обычный массив. - Go —
json.Unmarshalраскладывает каталог по структурам с тегами, ручного разбора нет. Карты в Go обходятся в случайном порядке, поэтому наборы строятся поslices.Sorted(maps.Keys(...)): иначе цикл находился бы с разных концов. Ошибки — сентинелы, обёрнутые с артикулом. - Python —
TypedDictдаёт типы ключам словаря изjson.loads. Путь в рекурсии — кортеж: его нельзя случайно изменить из соседней ветки. - TypeScript —
JSON.parseвозвращаетany, иas Definitionsничего не проверяет. Поэтому каталог не доверяет описанию: неизвестный артикул, пустой набор и плохая скидка ловятся в коде, а не типами.
Связанные темы
- Компоновщик (базовый пример)
- Практика Стратегии: расчёт доставки — когда правил цены становится много, их выносят из узлов в стратегии