Тема
ТЗ — Цены/типы цен (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_idnullable на записи цены (стык мультигорода) - Перечёркнутая «старая цена» — отдельный тип цены, не история изменений (история аудита цен, если нужна, — отдельная 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_types | id, code, title, currency_code, visibility (jsonb), is_b2b, is_active, lock_version | розница/опт/старая/price1-2/b2b; visibility — GIN при фильтрации; lock_version — optimistic lock на конкурентную правку в Filament |
commerce_prices | id, variant_id, price_type_id, city_id (nullable), qty_from, amount_minor, lock_version | amount_minor — integer minor units, тир-прайсинг по qty_from; FK variant_id/price_type_id — constrained()->index(), составной индекс (variant_id, price_type_id, city_id, qty_from); уникальность строки — тот же кортеж |
commerce_price_resolver_cache | cache_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_at | append-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}/price | variant_id (путь), контекст из RequestContext (пользователь/группа/город/канал), qty (query, опционально) | variant_id существует и активен; qty целое ≥1 |
API POST /admin/commerce-pricing/import | CSV/JSON: variant_external_id, price_type_code, city_code (nullable), qty_from (nullable), amount_minor, currency_code | FormRequest whitelist колонок, amount_minor целое ≥0, Idempotency-Key обязателен, лимит строк max_import_rows |
| Filament: справочник типов цен | code, title, currency_code, visibility, is_b2b, is_active, lock_version | FormRequest, code уникален, visibility — whitelist допустимых ключей правила (роль/группа/уровень/qty/b2b-договор), не произвольный JSON |
| Filament: таблица цен ТП | variant_id, price_type_id, city_id, qty_from, amount_minor, lock_version | FormRequest, lock_version совпадает с текущим (иначе 409), amount_minor целое ≥0 |
Сервис-вызов cms/commerce-b2b (requires со стороны b2b, канал 4) | company_id/contract_id, variant_id → PricingService::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}, code ∈ no_price, variant_inactive, version_conflict |
Настройки (группа commerce-pricing)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-pricing.default_price_type | string | retail | да | Тип цены по умолчанию для гостя |
commerce-pricing.price_type_chain | array | ["b2b","wholesale","retail"] | нет | Упорядоченная цепочка типов резолвера при поиске применимой цены (см. «Крайние случаи») |
commerce-pricing.resolver_cache_ttl | int | 1800 | нет | TTL кеша резолвера эффективной цены |
commerce-pricing.show_old_price | bool | true | да | Показ перечёркнутой старой цены |
commerce-pricing.currency_code | string | RUB | нет | Базовая валюта хранения цен |
commerce-pricing.rounding_mode | string | half_up | нет | Стратегия округления при тир-прайсинге/конверсии (half_up/half_even) — anti-hardcode, единая точка правды |
commerce-pricing.price_history_enabled | bool | false | нет | Ведение append-only журнала изменений цен для аналитики (отдельно от типа old) |
commerce-pricing.max_import_rows | int | 50000 | нет | Лимит строк одного импорта — защита от неконтролируемого батча |
commerce-pricing.pause_bulk_import | bool | false | нет | Kill-switch: аварийная остановка приёма новых импортов без выключения модуля (уже идущий импорт довершается чанками) |
price_type_chain и no_price (код ошибки) — единственный источник правды о поведении при отсутствии цены нужного типа; переопределение цепочки на лету (per-request) не предусмотрено — это конфигурация магазина, не персонализация.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/catalog/variants/{id}/price | public/auth | Эффективная цена ТП для текущего контекста |
| POST | /api/v1/admin/commerce-pricing/import | admin (commerce-pricing.manage) | Массовое обновление цен импортом |
| GET | /api/v1/admin/commerce-pricing/price-types | admin (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у конкретного ТП — не рендерится «пустой зачёркнутый ноль», когда типа нет.
Крайние случаи и типовые баги
- Три профиля видимости и как резолвер их различает.
retail— публичный дефолт, видим всегда (guest/авторизованный без иных правил);opt(wholesale) — видимость по правилуvisibility.min_qty(порог количества в запросе/корзине), резолвер подключает тип в цепочку, когдаqtyконтекста ≥ порога;b2b— видимость по активному B2B-договору конкретной компании (cms/commerce-b2b), тип резолвится только черезresolveForContract(), не через общий whitelist ролей — сотрудник другой компании его не видит. - Цена нужного типа не задана для конкретного ТП → правило зафиксировано. Резолвер идёт по
price_type_chain(дефолтb2b → wholesale → retail) и берёт первую цену, которая фактически существует у ТП; если после прохода всей цепочки цены нет нигде (дажеretail) — жёсткое «нет цены»: код ошибкиno_price, товар нельзя добавить в корзину. Частичный fallback (например b2b без цены → берётся общая retail) — осознанное поведение, не баг: продолжение покупки важнее строгой изоляции договорных цен на уровне отдельного ТП. - Смена цены товара, когда он уже в корзине пользователя → правило зафиксировано. Цена фиксируется как снапшот на
add-to-cart(commerce_cart_items.price_snapshot_minor, владелец поля —cms/commerce-cart); pricing не решает за корзину, что показывать, но checkout всегда переспрашивает резолвер заново перед оплатой. Если пересчитанная цена отличается от снапшота — оформление не продолжается по старой цене автоматически: покупатель видит расхождение (meta.diffна сторонеcms/commerce-cart) и обязан явно подтвердить новую цену; после подтверждения снапшот обновляется. Это защищает и от устаревшей цены в затянутой сессии, и от подмены суммы без ведома покупателя. - Integer minor units — округление. Тир-прайсинг и конверсия валюты не должны порождать дробные минорные единицы: округление — по
rounding_mode(half_upдефолт), применяется один раз, на последнем шаге вычисления (после выбора тира, до записи/выдачи), не на каждом промежуточном шаге (иначе накопленная погрешность на большихqty).amount_minor— всегда integer,floatв резолвере и хранении запрещён (contract-тест). - История цен ≠ старая цена.
old— маркетинговый тип цены, обычная запись вcommerce_prices, не журнал. Если нужна история изменений для аналитики/аудита («кто и когда поменял цену») — этоcommerce_price_history(append-only,price_history_enabled), заполняется на каждыйPriceChanged; отсутствие этой таблицы не мешает работе типаold. - Гонка: два параллельных импорта одного
price_type_id. Upsert по уникальному кортежу(variant_id, price_type_id, city_id, qty_from)— детерминированный результат по последнему применённому чанку, не по порядку старта job;Idempotency-Keyне даёт запустить дубль того же логического импорта повторным кликом (второй запрос с тем же ключом получает статус уже идущей задачи). - Региональная цена не задана для города покупателя.
city_id = null— общая (не региональная) цена; резолвер использует её как fallback, когда точной строки под конкретныйcity_idнет — это штатное измерение (§4 стандарта), не отдельная ветка кода и неno_price. - Деактивация типа цены, используемого правилами B2B/opt. Прямое удаление типа с существующими строками
commerce_pricesзапрещено (guard на уровне сервиса) — толькоis_active=false; резолвер сразу перестаёт подключать тип в цепочку (кеш сегмента инвалидируется), уже оформленные заказы не меняются — позиция заказа хранит собственный снапшот цены и ставки НДС на момент оформления (commerce-model, «НДС и налоги»), не ссылку на живой тип цены. - Смена уровня лояльности в момент резолюции запроса. Резолвер использует контекст, снятый в начале запроса (
RequestContext); гонка с асинхронным пересчётом уровня (cms/loyalty) не блокируется — новый тип цены становится доступен со следующего запроса после обработкиLoyaltyLevelChanged, eventual consistency, не синхронная блокировка оформления. - Огромный импорт (миллионы строк на десятки городов).
max_import_rowsограничивает один вызовimport; более крупные обновления — несколько последовательных импортов или чанкование на стороне интеграции. Каждый чанкBus::batchпроверяетpause_bulk_importперед стартом — аварийная остановка не обрывает уже взятый в работу чанк, но не берёт новые. - Пустой контекст (гость без города/группы,
qtyне передан). Резолвер возвращаетretailсcity_id = null,qtyтрактуется как1— не ошибка, дефолтный путь для самого массового случая (первый визит на карточку товара). - Конкурентная правка одной строки цены двумя админами.
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запрещены