Практика: цены из прайса поставщика (Supplier pricing)
Наценку выбирает категория товара, а описана она в конфиге: тип и параметры. Общие правила магазина применяются после любой стратегии, и стратегия не может их нарушить.
Базовый пример: Стратегия. Там стратегия создаётся в коде с готовыми параметрами. Здесь она целиком приходит из данных, а поверх неё стоят правила, которые держат цену в рамках при любой наценке.
Задача
Магазин раз в сутки загружает прайс поставщика: артикул, категория, закупочная цена. Продажную цену считает импорт. Телефоны идут с наценкой 12 процентов, кабели с фиксированной надбавкой 150 рублей, инструмент по шкале: чем дороже закупка, тем меньше процент. Остальное по умолчанию 30 процентов. Категорийный менеджер меняет проценты и пороги каждую неделю и не хочет ждать релиза.
Сверху два правила, которые финансы требуют для любого товара: цена округляется вверх до 10 рублей, и она не ниже закупки плюс 5 процентов. Первая версия держала всё это в одном switch по категории, и правило минимальной наценки было только в двух ветках из трёх: дорогой кабель с фиксированной надбавкой ушёл почти по закупке.
Что урезано
- Деньги - целые копейки, одна валюта, без НДС.
- Конфиг - массив в тесте. В проекте это
pricing.jsonили таблица, которую правит менеджер; форма та же. - Прайс уже разобран в
SupplierItem. Чтение файла поставщика - отдельная задача, см. практику фабричного метода. - Нет акций, ручных цен и истории изменений.
Решение
Три вопроса разводятся по трём местам.
Как считать наценку - стратегия Markup: закупка на входе, цена до общих правил на выходе. Реализаций три: PercentMarkup, FixedMarkup и TieredMarkup, которая по закупочной цене выбирает ступень Tier и считает её процент.
Какая наценка у категории - данные. Pricing::fromConfig читает по каждой категории поле kind и параметры и собирает объект стратегии. Категории нет в конфиге - работает default. Неизвестный kind или кривая шкала падают при загрузке конфига с именем категории, а не при расчёте тысячной строки прайса.
Что верно для любой цены - правила в Pricing::priceFor: сначала нижняя граница закупка + min_margin_percent, потом округление вверх до round_to_cents. Стратегия не знает об этих правилах и не может их обойти: даже наценка, которая вернула цену ниже закупки, будет поднята до минимума.
Структура
Pricing здесь и контекст стратегии, и место, где живут общие правила. Это сознательно: правила одни на весь магазин, и если раздать их стратегиям, каждая новая стратегия будет обязана их не забыть.
Конфиг категорий
{
"min_margin_percent": 5,
"round_to_cents": 1000,
"default": { "kind": "percent", "percent": 30 },
"categories": {
"phones": { "kind": "percent", "percent": 12 },
"cables": { "kind": "fixed", "amount_cents": 15000 },
"tools": { "kind": "tiered", "tiers": [
{ "up_to_cents": 100000, "percent": 40 },
{ "up_to_cents": 500000, "percent": 25 },
{ "percent": 15 }
] }
}
}kind называет стратегию, остальные поля - её параметры. Граница ступени включительная: закупка ровно 1000 рублей ещё в первой ступени. Последняя ступень без up_to_cents, иначе для дорогого товара не нашлось бы процента: такой конфиг отвергается при загрузке.
Сменить процент, добавить категорию или перевести категорию с процента на шкалу - правка конфига. Новый вид наценки, которого нет среди kind, - новый класс и одна ветка в fromConfig; priceFor и остальные стратегии не меняются.
Как это собирается
Конфиг читается один раз, когда стартует задача импорта: Pricing::fromConfig превращает его в объекты стратегий, и если в нём ошибка, импорт не начинается вовсе. Дальше задача идёт по строкам прайса и для каждой зовёт priceFor, результат пишется в каталог.
В примере шесть строк. Телефон получает 12 процентов и округление, дешёвый кабель - надбавку 150 рублей, а дорогой кабель упирается в минимальную наценку: 150 рублей к 50 000 меньше 5 процентов. Инструмент считается по своей ступени, категории garden в конфиге нет, и она уходит в наценку по умолчанию.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Стратегии не знают ни о категориях, ни о правилах магазина: на входе закупка, на выходе число. Все деления целочисленные, округление вверх через (x + step - 1) / step, поэтому цены совпадают до копейки во всех языках.
| Язык | Особенность |
|---|---|
| PHP | interface Markup и final readonly классы; форма конфига описана @phpstan-type, разбор через match по kind |
| Go | Стратегия - type Markup func(int) int; Config с json-тегами, тест декодирует JSON стандартным encoding/json; нулевой UpToCents = открытая ступень |
| Python | Стратегия - callable из фабрик percent_markup, tiered_markup; разбор конфига через match с захватом неизвестного kind |
| TypeScript | MarkupConfig - размеченное объединение по kind, компилятор проверяет поля каждой ветки; ветка default всё равно нужна для JSON |
Запуск: make test T=patterns/behavioral/strategy/practice-pricing
Что проверяют тесты
Тесты одинаковы во всех языках. Таблица из семи строк проверяет выбор и расчёт: 12 процентов с округлением вверх, фиксированная надбавка, дорогой кабель, поднятый до минимальной наценки, три ступени шкалы, включая границу ровно 1000 рублей, и категорию без конфига. Второй тест подставляет стратегию распродажи, которая возвращает цену ниже закупки, и убеждается, что правила вытянули её до закупка + 5% с округлением. Третий - сломанный конфиг падает при загрузке, и ошибка называет категорию.
Где граница
- Наценка начинает зависеть не только от закупки (бренд, остаток, цена конкурента) - стратегии нужен весь
SupplierItemили контекст, и это всё ещё стратегия. Когда условий становится много и их правит менеджер, это движок правил, а не набор классов. - Правил поверх стратегии стало много (не ниже МРЦ, не выше цены конкурента, психологические цены на 9) - их удобно выстроить цепочкой обязанностей или декораторами над
Markup, а не держать в одномpriceFor. - Видов наценки два и они не меняются - хватит
ifпо категории и двух чисел в конфиге; разборkindздесь избыточен. - Если менеджеру нужна произвольная формула, конфиг с
kindупирается в потолок. Тогда выбирают маленький язык выражений или таблицу цен, а стратегия остаётся только у разработчика.
Отличия между языками
- PHP - форма конфига описана один раз через
@phpstan-type MarkupConfigи переиспользуется в сигнатурах; обязательный параметр проверяется?? throwпрямо в выраженииmatch. - Go - JSON декодируется в структуры без парсера, но "нет поля" и "ноль" неразличимы: открытая ступень выражена нулём, и это надо помнить. Ошибки обёрнуты в сентинел
ErrConfigдляerrors.Is. - Python -
match raw["kind"]с последней веткойcase kind:ловит неизвестное значение и сразу даёт его в сообщение; стратегии - замыкания, классы не нужны. - TypeScript - размеченное объединение делает конфиг типобезопасным в коде, но JSON с диска компилятор не видит, поэтому ветка
defaultс ошибкой остаётся; округление черезMath.ceilпо числу, без целочисленного деления.
Связанные темы
- Стратегия (базовый пример)
- Практика Стратегии: расчёт доставки - другой способ выбора: правила называют код, стратегии регистрируются в коде
- Декоратор (Decorator) - куда уходят общие правила, когда их становится много
- Практика Фабричного метода: импорт прайс-листов - откуда берутся строки прайса