handbookНаписать

patterns/structural/composite/practice/README.md · практика · 4 языка · 28 тестов

база: Компоновщик

Практика: цена корзины с наборами (Bundle pricing) ​

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

Базовый пример: Компоновщик. Там дерево собирается в коде и всегда правильное. В магазине дерево наборов описывает контент-менеджер в админке, и практика отвечает на вопрос, которого учебный пример не задаёт: как превратить такое описание в дерево и не дать ему зациклиться или опустеть.

Задача ​

Магазин техники продаёт наборы: «Рабочее место» (мышь и клавиатура со скидкой 10%), «Офис» (рабочее место, гарнитура и два кабеля, ещё 5% сверху). Наборы заводит отдел маркетинга, вложенность растёт с каждой акцией, и в корзине рядом лежат и отдельные товары, и наборы.

Первая версия считала цену набора в корзине циклом по составу: товар — взять цену, набор — подставить его сумму. Вложенные наборы поддержали отдельной веткой «набор в наборе», третий уровень уже не поместился, а скидка внутреннего набора терялась. Однажды в админке набор «Подарок» сослался на «Промо», а «Промо» на «Подарок», и страница корзины упала с переполнением стека.

Что урезано ​

  • Каталог — JSON-строка в тесте; в проекте это таблицы товаров и состава наборов в БД.
  • Деньги — целые копейки одной валюты, скидка целыми процентами.
  • Количество в составе набора задаётся повтором: два кабеля — два элемента CABLE.
  • Нет остатков, акций по датам и правил «скидка не суммируется».

Решение ​

Общий интерфейс CartItem — одна операция priceCents(). Product отвечает своей ценой, Bundle складывает цены состава и вычитает свою скидку. Внутренний набор отдаёт уже скидочную цену, внешний применяет к сумме свою: скидки складываются по дереву сами, без веток «набор в наборе».

Новое по сравнению с базовым примером — каталог Catalog, который строит дерево из описания и проверяет его при загрузке, а не при первом расчёте корзины. Cart — клиент компоновщика: умножает цену любой позиции на количество и не знает, товар перед ним или набор.

Структура ​

items

builds

lines

«interface»

CartItem

+priceCents() : int

Product

+sku: string

-cents: int

+priceCents() : int

Bundle

+sku: string

-discountPercent: int

-items: CartItem[]

+priceCents() : int

Catalog

-defs: BundleDef map

-items: CartItem map

+item(sku) : CartItem

-resolve(sku, path) : CartItem

Cart

-lines: CartItem x qty

+add(item, qty)

+totalCents() : int

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.

ЯзыкОсобенность
PHPfinal readonly узлы, вариадик CartItem ...$items; каталог из json_decode(..., true), форма массива описана в PHPDoc для PHPStan
Goencoding/json прямо в Definitions по тегам; сентинел-ошибки и errors.Is; обход наборов по отсортированным ключам, иначе путь цикла зависел бы от порядка карты
PythonTypedDict описывает JSON без классов-обёрток; набор — frozen dataclass с проверкой в __post_init__
TypeScriptJSON.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 ничего не проверяет. Поэтому каталог не доверяет описанию: неизвестный артикул, пустой набор и плохая скидка ловятся в коде, а не типами.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS