handbookНаписать

patterns/behavioral/strategy/practice-pricing/README.md · практика · 4 языка · 43 теста

Практика: цены из прайса поставщика (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

-markups: Map<string, Markup>

-fallback: Markup

-minMarginPercent: int

-roundToCents: int

+fromConfig(config) : Pricing

+priceFor(item) : int

SupplierItem

+sku: string

+category: string

+costCents: int

«interface»

Markup

+apply(costCents) : int

PercentMarkup

-percent: int

FixedMarkup

-amountCents: int

TieredMarkup

-tiers: Tier[]

Tier

+upToCents: int

+percent: int

Pricing здесь и контекст стратегии, и место, где живут общие правила. Это сознательно: правила одни на весь магазин, и если раздать их стратегиям, каждая новая стратегия будет обязана их не забыть.

Конфиг категорий ​

json
{
  "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, поэтому цены совпадают до копейки во всех языках.

ЯзыкОсобенность
PHPinterface 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
TypeScriptMarkupConfig - размеченное объединение по 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 по числу, без целочисленного деления.

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

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