Skip to content

ТЗ — Цены/типы цен (cms/commerce-pricing)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: masha Статус: ТЗ к разработке

Назначение и возможности

Резолвер цены поверх модели «ТП × тип цены» (см. /cms-v2/commerce-model): видимость типа цены по группе пользователей/уровню лояльности/B2B-договору, тир-прайсинг от количества, региональные цены, перечёркнутая «старая цена». HIGHLOAD: резолюция — один кешируемый запрос на ТП, сегментация кеша по группе, не по пользователю.

  • commerce_price_types (розница/опт/старая/price1-2/b2b) × commerce_prices (variant × тип × город)
  • Три типовых профиля видимости: retail (публичный дефолт), opt (по порогу количества), b2b (договорная, видна только сотрудникам конкретной компании) — см. «Крайние случаи»
  • Видимость типа цены: guest / роль / группа пользователей / уровень лояльности / B2B-договор
  • Тир-прайсинг: qty_from — цена меняется по количеству внутри типа
  • Региональные цены: city_id nullable на записи цены (стык мультигорода)
  • Перечёркнутая «старая цена» — отдельный тип цены, не история изменений (история аудита цен, если нужна, — отдельная append-only таблица, см. «Крайние случаи»)
  • Резолвер «эффективная цена для пользователя» — один запрос, кешируемый по сегменту группы, упорядоченная цепочка типов (price_type_chain) с фиксированным правилом при отсутствии цены
  • Массовое обновление цен импортом (батчевый upsert, идемпотентно по Idempotency-Key)
  • НДС на товаре/категории, розничная цена хранится с НДС
  • Публичный сервисный контракт PricingService::resolveForContract() для договорных цен B2B

Зависимости и выключение

requires: cms/commerce-catalog · suggests: cms/loyalty (уровень → тип цены), cms/commerce-b2b (договорной тип цены через resolveForContract()), мультигород, cms/commerce-currencies (обратная зависимость: currencies requires pricing для конверсии на выводе, не наоборот)

Поведение при выключении: используется единственный дефолтный тип цены (розница) без видимости по группам и без тир-прайсинга — витрина продолжает показывать цены, деградация до плоской модели без сегментации. Модули, зависящие от pricing через requires (commerce-cart, commerce-currencies, commerce-b2b), получают retail-цену без персонализации вместо ошибки.

Модель данных

ТаблицаКлючевые поляПримечание
commerce_price_typesid, code, title, currency_code, visibility (jsonb), is_b2b, is_active, lock_versionрозница/опт/старая/price1-2/b2b; visibility — GIN при фильтрации; lock_version — optimistic lock на конкурентную правку в Filament
commerce_pricesid, variant_id, price_type_id, city_id (nullable), qty_from, amount_minor, lock_versionamount_minor — integer minor units, тир-прайсинг по qty_from; FK variant_id/price_type_idconstrained()->index(), составной индекс (variant_id, price_type_id, city_id, qty_from); уникальность строки — тот же кортеж
commerce_price_resolver_cachecache_key, variant_id, segment, payload (jsonb), expires_atкеш эффективной цены по сегменту группы
commerce_price_history (опционально, price_history_enabled)id, variant_id, price_type_id, old_amount_minor, new_amount_minor, changed_by, created_atappend-only журнал для аналитики/аудита; не путать с типом цены old (маркетинговая «старая цена» — это отдельная запись в commerce_prices, а не история); BRIN по created_at

ПДн-паспорт. Собственные таблицы модуля ПДн не хранят: цены привязаны к variant_id/ price_type_id/city_id, не к конкретному пользователю. Привязка «кому виден B2B-тип» живёт в cms/commerce-b2b (company_id/contract_id), pricing только резолвит price_type_id из контракта, полученного сервис-вызовом. Участие в «выгрузить всё по субъекту»/«забыть по запросу» — не применимо (нет собственных ПДн-строк).

Входные и выходные данные

Входы (whitelist — всё не перечисленное отклоняется):

ИсточникДанные/поляЧем валидируется
API GET /catalog/variants/{id}/pricevariant_id (путь), контекст из RequestContext (пользователь/группа/город/канал), qty (query, опционально)variant_id существует и активен; qty целое ≥1
API POST /admin/commerce-pricing/importCSV/JSON: variant_external_id, price_type_code, city_code (nullable), qty_from (nullable), amount_minor, currency_codeFormRequest whitelist колонок, amount_minor целое ≥0, Idempotency-Key обязателен, лимит строк max_import_rows
Filament: справочник типов ценcode, title, currency_code, visibility, is_b2b, is_active, lock_versionFormRequest, code уникален, visibility — whitelist допустимых ключей правила (роль/группа/уровень/qty/b2b-договор), не произвольный JSON
Filament: таблица цен ТПvariant_id, price_type_id, city_id, qty_from, amount_minor, lock_versionFormRequest, lock_version совпадает с текущим (иначе 409), amount_minor целое ≥0
Сервис-вызов cms/commerce-b2b (requires со стороны b2b, канал 4)company_id/contract_id, variant_idPricingService::resolveForContract()вызывающий модуль уже проверил принадлежность сотрудника компании; pricing принимает только объявленную сигнатуру метода
Событие LoyaltyLevelChanged (cms/loyalty, если установлен)user_id, level_id, previous_level_idвнутренний источник, не пользовательский ввод — используется только для инвалидации кеша сегмента

Выходы:

ПотребительДанныеФормат
GET /variants/{id}/priceэффективная цена{data:{amount_minor, currency_code, price_type_code, old_price_minor?, tier_applied?}, meta:{degraded?}}
commerce-cart (requires со стороны корзины, канал 4)результат резолвера в момент add-to-cartсинхронный ответ сервиса — корзина сама решает, что снапшотить (см. «Крайние случаи»)
cms/commerce-b2bрезультат resolveForContract()синхронный ответ сервиса (канал 4)
cms/commerce-currencies (FilterBus, канал 2)результат резолвера до конверсии в валюту пользователязначение в базовой валюте, конверсия — на стороне currencies
Filamentсправочник типов цен, таблица цен с тир-прайсингомтаблицы
События PriceChanged/PricesBulkImportedсм. «События и обмен»payload события
Ошибка резолюцииконверт ошибок ядра{message, code, errors}, codeno_price, variant_inactive, version_conflict

Настройки (группа commerce-pricing)

КлючТипДефолтaffectsPageCacheОписание
commerce-pricing.default_price_typestringretailдаТип цены по умолчанию для гостя
commerce-pricing.price_type_chainarray["b2b","wholesale","retail"]нетУпорядоченная цепочка типов резолвера при поиске применимой цены (см. «Крайние случаи»)
commerce-pricing.resolver_cache_ttlint1800нетTTL кеша резолвера эффективной цены
commerce-pricing.show_old_pricebooltrueдаПоказ перечёркнутой старой цены
commerce-pricing.currency_codestringRUBнетБазовая валюта хранения цен
commerce-pricing.rounding_modestringhalf_upнетСтратегия округления при тир-прайсинге/конверсии (half_up/half_even) — anti-hardcode, единая точка правды
commerce-pricing.price_history_enabledboolfalseнетВедение append-only журнала изменений цен для аналитики (отдельно от типа old)
commerce-pricing.max_import_rowsint50000нетЛимит строк одного импорта — защита от неконтролируемого батча
commerce-pricing.pause_bulk_importboolfalseнетKill-switch: аварийная остановка приёма новых импортов без выключения модуля (уже идущий импорт довершается чанками)

price_type_chain и no_price (код ошибки) — единственный источник правды о поведении при отсутствии цены нужного типа; переопределение цепочки на лету (per-request) не предусмотрено — это конфигурация магазина, не персонализация.

API

МетодПутьДоступНазначение
GET/api/v1/catalog/variants/{id}/pricepublic/authЭффективная цена ТП для текущего контекста
POST/api/v1/admin/commerce-pricing/importadmin (commerce-pricing.manage)Массовое обновление цен импортом
GET/api/v1/admin/commerce-pricing/price-typesadmin (commerce-pricing.view)Справочник типов цен и видимости
PUT/api/v1/admin/commerce-pricing/prices/{id}admin (commerce-pricing.manage)Точечная правка цены ТП (lock_version в теле — 409 при конфликте)

Мутации с денежным/внешним эффектом (import, точечная правка) — Idempotency-Key на импорте обязателен; точечная правка защищена lock_version, не Idempotency-Key (один админ, одна операция, конфликт — с другим админом, а не с повтором сети).

Компоненты

Filament: справочник типов цен с конструктором видимости (роль/группа/уровень лояльности/ qty-порог/B2B-договор — из whitelist, не произвольный JSON), таблица цен ТП с тир-прайсингом и массовым редактированием (grid-правка нескольких строк за один сабмит). Демо-сидер: CommercePricingDemoSeeder создаёт 3 типа цены (retail/wholesale/b2b) и цены для демо-ТП с тир-прайсингом — галерея карточки товара и playground показывают резолвер без ручного ввода. Фронтенд-бюджет: цена рендерится сервером (нет клиентского пересчёта), гостевой фрагмент кешируем целиком, авторизованный — остров без собственного тяжёлого JS.

Команды: cms:commerce-pricing:import --json, cms:commerce-pricing:warm-resolver-cache --json, cms:commerce-pricing:recalculate --json (пересчёт резолвер-кеша без изменения данных).

Эксплуатация (ранбук): метрики — длительность import, доля cache-miss резолвера, глубина очереди commerce-pricing. Алерт — import не завершился за плановое окно, либо доля miss резко выросла (признак массовой инвалидации без прогрева).

СимптомЧто проверить / команда
Цена не резолвится (no_price)price_type_chain покрывает retail как последнее звено; наличие строки в commerce_prices для variant_id+retail
Импорт зависcms:commerce-pricing:import --dry-run --json, глубина очереди, pause_bulk_import
Кеш резолвера расходится с БДcms:commerce-pricing:warm-resolver-cache --json по сегменту
B2B-цена не подхватиласьконтракт активен в cms/commerce-b2b, price_type_id указан, resolveForContract() возвращает ожидаемый тип

Бэкап/рестор: все таблицы модуля попадают в обычный бэкап целиком. После рестора пересоздаётся только commerce_price_resolver_cache (прогрев warm-resolver-cache) — сами цены и типы восстанавливаются как есть.

События и обмен

СобытиеКогдаPayload
PriceChangedизменена цена ТП по типу (точечно или строкой импорта)variant_id, price_type_id, amount_minor
PricesBulkImportedзавершён массовый импорт ценimported_count, price_type_id

Слушает: LoyaltyLevelChanged из cms/loyalty для инвалидации кеша сегмента пользователя (тип цены, открытый уровнем, начинает резолвиться со следующего запроса).

Provides-контракты: не предоставляет из канонического реестра; выступает источником данных для rates-provider-подобной логики резолвера, потребляемой cms/commerce-cart и cms/commerce-catalog через сервис-вызов по requires. FilterBus: не издаёт собственных фильтров — конверсия валюты (cms/commerce-currencies) и скидки (cms/commerce-promo) применяются поверх результата резолвера как отдельные фильтры своих владельцев.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-cart (requires со стороны корзины)сервис-вызов resolve() (канал 4)pricing → cartцена в момент add-to-cart; корзина сама снапшотит результат в price_snapshot_minor
cms/commerce-cartсобытие PriceChanged (канал 1)pricing → cartкорзина инвалидирует снапшот асинхронно, партиями (не пересчитывает синхронно на каждое событие)
cms/commerce-b2b (requires со стороны b2b)сервис-вызов resolveForContract() (канал 4)b2b → pricingдоговорная цена компании подставляется вместо общей цепочки
cms/loyalty (suggests)событие LoyaltyLevelChanged (канал 1)loyalty → pricingинвалидация кеша сегмента при смене уровня — новый тип цены доступен без ручного сброса кеша
cms/commerce-currencies (requires со стороны currencies)FilterBus (канал 2)pricing → currenciesконверсия отображаемой цены после резолвера, хранимая сумма не меняется
cms/commerce-facetsсобытие PricesBulkImported (канал 1)pricing → facetsтриггер debounce-окна пересчёта ценовых фасетов вместо реакции на каждый PriceChanged
cms/commerce-catalogсобытие PriceChanged (канал 1)pricing → catalogпересчёт денормализованной минимальной цены ТП в проекции листинга
Очередь commerce-pricingочередь (канал 5)внутри модуляimport, warm-resolver-cache, recalculate

Фоновая работа

Очередь commerce-pricing: массовый импорт цен (import) батчевым upsert чанками (Bus::batch, проверка pause_bulk_import перед постановкой каждого нового чанка), прогрев кеша резолвера (warm-resolver-cache) после PricesBulkImported. Расписание — не требуется, все джобы событийные или по явной команде.

Производительность и кеш

Ожидаемые объёмы: миллионы ТП × несколько типов цены × опционально десятки городов — таблица commerce_prices растёт как произведение измерений, кандидат на партиционирование (по price_type_id или диапазону variant_id) при приближении к сотням миллионов строк (highload-требования).

Горячий путь — резолюция цены на карточке/листинге: бюджет 1 запрос к commerce_price_resolver_cache при тёплом кеше (0 запросов к commerce_prices напрямую); холодный промах — один запрос по составному индексу (variant_id, price_type_id, city_id, qty_from) с перебором цепочки price_type_chain в порядке приоритета, без N+1 по вариантам листинга (гидратация — батчем на весь листинг, не по одному ТП).

Критичные индексы: составной (variant_id, price_type_id, city_id, qty_from) на commerce_prices (уникальность строки и путь резолвера), GIN на visibility в commerce_price_types для правил видимости, (cache_key) уникальный на commerce_price_resolver_cache.

Теги кеша и инвалидация: commerce-pricing:variant:{id}, commerce-pricing:segment:{group}. Сегментация кеша резолвера — по группе пользователей, не по user_id (иначе page-cache не переиспользуется). Инвалидация — событиями PriceChanged (точечно), PricesBulkImported (батчево по затронутым price_type_id, не по каждому ТП импорта отдельно), LoyaltyLevelChanged (точечно по сегменту пользователя).

Кеш в page-cache: цена в общем page-cache допустима только гостевая (retail); цены закрытых типов (opt/b2b) не попадают в общий page-cache — отдаются фрагментом/островом с personalized: true (§10 стандарта, производительность).

Безопасность

Границы входа: импорт цен — только через FormRequest-whitelist полей и Idempotency-Key (денежная мутация с внешним эффектом); точечная правка — lock_version; конструктор visibility типа цены — whitelist допустимых ключей правила (роль/группа/уровень/qty/b2b-договор), не произвольная структура JSON, исполняемая как код. resolveForContract() вызывается только сервис-вызовом cms/commerce-b2b по requires — прямого публичного API для произвольной компании нет (иначе утечка чужих договорных цен).

Матрица ролей:

Действие / permissionАдминМенеджерРедакторStudio
Просмотр цены на витрине (view, публично)
Просмотр справочника типов/цен (commerce-pricing.view)
Правка цен ТП, тир-прайсинга (commerce-pricing.manage)
Создание/правка типов цены и правил видимости
Массовый импорт цен

Права: commerce-pricing.view, commerce-pricing.manage.

UX-требования

Админ:

  • пустое состояние справочника типов цены — «Типов цены пока нет — создайте розничный тип, он обязателен для резолвера» (без него ничего не резолвится);
  • массовые действия: грид-правка цен нескольких строк за один сабмит, массовая деактивация типа цены с подтверждением («затронет N позиций каталога»);
  • человеческая ошибка вместо технической: «Цена не может быть отрицательной», «Строка импорта №N: неизвестный price_type_code, пропущена» (построчный отчёт, не общий отказ файла);
  • подтверждение перед деактивацией типа цены, который используется активными правилами B2B/opt (необратимо влияет на резолюцию у живых покупателей).

Покупатель:

  • отсутствие цены нужного типа — карточка товара показывает «Цена по запросу» и скрывает кнопку «в корзину» вместо пустой/нулевой цены (см. правило no_price в «Крайние случаи»);
  • изменение цены товара, уже лежащего в корзине, — баннер расхождения с явной новой ценой и кнопкой подтверждения, не молчаливая подмена суммы при оформлении;
  • перечёркнутая старая цена показывается только при show_old_price=true и наличии типа old у конкретного ТП — не рендерится «пустой зачёркнутый ноль», когда типа нет.

Крайние случаи и типовые баги

  1. Три профиля видимости и как резолвер их различает. retail — публичный дефолт, видим всегда (guest/авторизованный без иных правил); opt (wholesale) — видимость по правилу visibility.min_qty (порог количества в запросе/корзине), резолвер подключает тип в цепочку, когда qty контекста ≥ порога; b2b — видимость по активному B2B-договору конкретной компании (cms/commerce-b2b), тип резолвится только через resolveForContract(), не через общий whitelist ролей — сотрудник другой компании его не видит.
  2. Цена нужного типа не задана для конкретного ТП → правило зафиксировано. Резолвер идёт по price_type_chain (дефолт b2b → wholesale → retail) и берёт первую цену, которая фактически существует у ТП; если после прохода всей цепочки цены нет нигде (даже retail) — жёсткое «нет цены»: код ошибки no_price, товар нельзя добавить в корзину. Частичный fallback (например b2b без цены → берётся общая retail) — осознанное поведение, не баг: продолжение покупки важнее строгой изоляции договорных цен на уровне отдельного ТП.
  3. Смена цены товара, когда он уже в корзине пользователя → правило зафиксировано. Цена фиксируется как снапшот на add-to-cart (commerce_cart_items.price_snapshot_minor, владелец поля — cms/commerce-cart); pricing не решает за корзину, что показывать, но checkout всегда переспрашивает резолвер заново перед оплатой. Если пересчитанная цена отличается от снапшота — оформление не продолжается по старой цене автоматически: покупатель видит расхождение (meta.diff на стороне cms/commerce-cart) и обязан явно подтвердить новую цену; после подтверждения снапшот обновляется. Это защищает и от устаревшей цены в затянутой сессии, и от подмены суммы без ведома покупателя.
  4. Integer minor units — округление. Тир-прайсинг и конверсия валюты не должны порождать дробные минорные единицы: округление — по rounding_mode (half_up дефолт), применяется один раз, на последнем шаге вычисления (после выбора тира, до записи/выдачи), не на каждом промежуточном шаге (иначе накопленная погрешность на больших qty). amount_minor — всегда integer, float в резолвере и хранении запрещён (contract-тест).
  5. История цен ≠ старая цена. old — маркетинговый тип цены, обычная запись в commerce_prices, не журнал. Если нужна история изменений для аналитики/аудита («кто и когда поменял цену») — это commerce_price_history (append-only, price_history_enabled), заполняется на каждый PriceChanged; отсутствие этой таблицы не мешает работе типа old.
  6. Гонка: два параллельных импорта одного price_type_id. Upsert по уникальному кортежу (variant_id, price_type_id, city_id, qty_from) — детерминированный результат по последнему применённому чанку, не по порядку старта job; Idempotency-Key не даёт запустить дубль того же логического импорта повторным кликом (второй запрос с тем же ключом получает статус уже идущей задачи).
  7. Региональная цена не задана для города покупателя. city_id = null — общая (не региональная) цена; резолвер использует её как fallback, когда точной строки под конкретный city_id нет — это штатное измерение (§4 стандарта), не отдельная ветка кода и не no_price.
  8. Деактивация типа цены, используемого правилами B2B/opt. Прямое удаление типа с существующими строками commerce_prices запрещено (guard на уровне сервиса) — только is_active=false; резолвер сразу перестаёт подключать тип в цепочку (кеш сегмента инвалидируется), уже оформленные заказы не меняются — позиция заказа хранит собственный снапшот цены и ставки НДС на момент оформления (commerce-model, «НДС и налоги»), не ссылку на живой тип цены.
  9. Смена уровня лояльности в момент резолюции запроса. Резолвер использует контекст, снятый в начале запроса (RequestContext); гонка с асинхронным пересчётом уровня (cms/loyalty) не блокируется — новый тип цены становится доступен со следующего запроса после обработки LoyaltyLevelChanged, eventual consistency, не синхронная блокировка оформления.
  10. Огромный импорт (миллионы строк на десятки городов). max_import_rows ограничивает один вызов import; более крупные обновления — несколько последовательных импортов или чанкование на стороне интеграции. Каждый чанк Bus::batch проверяет pause_bulk_import перед стартом — аварийная остановка не обрывает уже взятый в работу чанк, но не берёт новые.
  11. Пустой контекст (гость без города/группы, qty не передан). Резолвер возвращает retail с city_id = null, qty трактуется как 1 — не ошибка, дефолтный путь для самого массового случая (первый визит на карточку товара).
  12. Конкурентная правка одной строки цены двумя админами. lock_version на commerce_prices/commerce_price_types — второй PUT без актуальной версии получает 409 с текущим значением, UI предлагает подтвердить поверх (не «последний победил» молча).

Донорский код

Что взятьПуть
Модель типов цен и резолвер эффективной ценыmasha (путь не выдан)

Legacy-импорт: cms:commerce-pricing:import-legacy --source=<профиль> — маппинг прайс-листов/таблиц типов цены донора на commerce_price_types/commerce_prices по ключу external_id (тот же, что использует общий import); идемпотентен, поддерживает --dry-run с отчётом расхождений. Прогон на копии донорских данных masha — часть приёмки модуля.

Тесты и приёмка

  • [ ] Контрактный тест: резолвер возвращает эффективную цену за один запрос без N+1 по вариантам
  • [ ] Резолвер различает retail/opt(wholesale, по qty-порогу)/b2b(по договору) корректно
  • [ ] Отсутствие цены после полного прохода price_type_chain отдаёт no_price, товар нельзя добавить в корзину
  • [ ] Частичный fallback по цепочке (например b2b без цены → retail) не считается ошибкой
  • [ ] Кеш резолвера сегментирован по группе пользователей, не по user_id (page-cache не утекает между пользователями)
  • [ ] Тир-прайсинг qty_from выбирает корректную цену при пересчёте количества в корзине
  • [ ] Округление тир-прайсинга/конверсии идёт по rounding_mode, дробных минорных единиц не возникает
  • [ ] Гостевая (retail) цена допустима в page-cache; цены закрытых типов — только во фрагменте/острове
  • [ ] Смена цены товара в корзине показывает meta.diff и требует подтверждения на checkout, не тихую подмену суммы
  • [ ] Массовый импорт цен идёт батчевым upsert, не по-строчным save(); повтор с тем же Idempotency-Key не дублирует эффект
  • [ ] Деактивация типа цены с существующими ценами не удаляет строки, только снимает is_active
  • [ ] Конкурентная правка одной цены двумя админами отдаёт 409, не «последний победил»
  • [ ] pause_bulk_import (kill-switch) останавливает приём новых чанков импорта, не обрывая взятые в работу
  • [ ] Деньги хранятся как amount_minor (integer) + currency_code, float нигде не используется
  • [ ] При выключении модуля используется единственный дефолтный тип цены без ошибок
  • [ ] cms:commerce-pricing:import-legacy --dry-run строит отчёт расхождений на копии донора
  • [ ] Матрица ролей: редактор не может править цены/типы, но публичная выдача цены ему доступна
  • [ ] Контрактный набор cms-testing и testbench-изоляция зелёные, feature-тест на каждый роут
  • [ ] Тестовая БД только commerce-pricing_test; migrate:fresh/refresh/reset запрещены

Внутренняя база знаний студии