Skip to content

Модель e-commerce: ТП, склады, цены, налоги, скидки

Статус: проектирование объёма (доменная модель модулей магазина; реализация — фаза 3 по заказу, но модель фиксируется сейчас — ретрофит складов/типов цен в живой магазин болезнен). Детализирует раздел E каталога модулей до уровня сущностей. Референсы: Битрикс (типы цен, склады, торговые предложения), Lunar (модель variants/pricing — путь C), донор masha (склад/остатки), notal (платежи/эскроу).

Принцип: одна модель, включаемая слоями

Магазинные модули наращивают одну доменную модель, а не свои параллельные: ТП → склады → цены → скидки → налоги/чеки. Каждый слой — отдельный модуль (включается по потребности), но схемы спроектированы совместно — здесь.

Торговые предложения (ТП / варианты / SKU)

Продаём «футболку» — а на складе лежат «футболка красная M» и «футболка синяя L».

  • Product (товар-«зонт»: описание, категория, SEO) → Variant (ТП): комбинация значений опций (цвет × размер);
  • опции (option: цвет, размер, объём) и их значения — декларируются через движок полей на типе товара; не хардкод;
  • у ТП свои: SKU, штрихкод, вес/габариты, цены (см. ниже), остатки по складам (см. ниже), опционально свои медиа (фото красной футболки);
  • товар без вариаций = один implicit-вариант. Корзина/заказ/склад всегда работают с variant_id — единая модель без if ($hasVariants) по всему коду;
  • листинг показывает товар; карточка — выбор ТП (переключатели опций, живой остаток/цена);
  • фасетный фильтр агрегирует свойства ТП («есть размер L в наличии»);
  • SEO: канонический URL — у товара; ТП не плодят индексируемых страниц (выбор на карточке). Осознанные посадочные под опцию («красные футболки») — это умный SEO-фильтр, не URL ТП.

Битрикс-аналогия: инфоблок торговых предложений. Lunar: product variants — модель заимствуем оттуда (уже Filament-native).

Склады и остатки (multi-warehouse)

commerce_warehouses   id, name, type ∈ {own, store, supplier, virtual},
                      priority, city_id (nullable — стык мультигорода), is_active
commerce_stock        variant_id × warehouse_id → qty, reserved
commerce_stock_moves  журнал движений: variant, warehouse, delta, reason (order/supply/correction), ref
  • доступность к продаже = Σ qty - reserved по складам, видимым текущему контексту (город/канал продаж). «На складе А есть, на Б нет» — витрина показывает наличие выбранного города/склада + статус «под заказ, N дней» с других складов (правило — конфиг модуля, не хардкод);
  • резервирование при оформлении заказа (reserved+), списание при отгрузке, снятие резерва при отмене/таймауте — только через журнал движений (баланс всегда восстановим);
  • стратегия отгрузки: приоритет складов; сплит заказа по складам — отложенная возможность (модель позволяет: позиции заказа несут warehouse_id);
  • виртуальный склад поставщика — остатки из фида/1С, «под заказ»;
  • у склада может быть владелец (vendor_id nullable) — заготовка мультивендора (см. ниже).

Цены (типы цен, группы, лояльность)

Цена — не колонка товара, а запись «ТП × тип цены»:

commerce_price_types  id, code (retail, wholesale, old, promo, price1, price2, …),
                      title, currency, visibility (jsonb)   # кому виден тип
commerce_prices       variant_id × price_type_id (× city/region nullable) → value,
                      qty_from (nullable — оптовые пороги)
  • видимость типа цены — правило: guest / роль или группа пользователя (spatie/permission) / уровень лояльности / B2B-договор. Розница видит retail, опт — wholesale, партнёр — price1;
  • резолвер цены: контекст (пользователь → группы/уровень, город, канал, количество) → упорядоченная цепочка типов → итоговая цена + перечёркнутая old (маркетинговая «старая цена» — отдельный тип, а не история);
  • опт от количества: qty_from — тир-прайсинг внутри типа;
  • скидка ≠ тип цены: скидки применяются поверх резолвера (ниже);
  • кеш: цена в page-cache допустима только гостевая (retail); страница для авторизованных групп — фрагмент цены островом/сегментированный ключ по группе — иначе оптовая цена утечёт в общий кеш (производительность);
  • региональные цены — city_id на записи цены (стык мультигорода).

НДС и налоги

  • справочник TaxRate (0 / 10 / 20 / без НДС) — ставка на товаре/услуге/ТП (переопределение ТП допустимо);
  • розничные цены хранятся с НДС (РФ-практика); B2B-документы выделяют НДС из цены;
  • режим юрлица продавца (ОСН/УСН) — конфиг; УСН → «без НДС» в чеках;
  • позиция заказа снапшотит цену, ставку НДС и признак предмета расчёта на момент оформления — последующие правки каталога не меняют историю.

Чеки (54-ФЗ) — уточнение

  • чек привязан к платежу и позициям заказа (снапшот); типы: предоплата / полный расчёт / возврат (чек возврата формируется при рефанде);
  • каждая позиция несёт: ставку НДС, признак предмета расчёта (товар / услуга / цифровой товар), признак способа расчёта;
  • оплата бонусами уменьшает цену позиций (пересчёт по позициям), а не проводится «иным способом» — иначе фискализация некорректна;
  • шлюзы с собственной фискализацией (ЮKassa-чеки) — capability коннектора; без неё — модуль ОФД (АТОЛ) поверх PaymentGateway-контракта;
  • мультивендор: раздельные чеки по продавцам (сплит) — кабинеты.

Цифровые товары

  • ТП с fulfilment: digital — нет склада и доставки; «остаток» = ∞ либо пул лицензий;
  • выдача после оплаты (событие PaymentSucceeded → job, идемпотентно): подписанная ссылка на файл (TTL + лимит скачиваний, приватный диск), лицензионный ключ из пула, или доступ в кабинете;
  • чек: признак «цифровой товар/услуга», корректная ставка НДС;
  • политика возврата цифровых — конфиг (по умолчанию невозвратны после выдачи).

Продажа услуг

  • услуга = ТП с fulfilment: service — без склада/доставки; чек с признаком «услуга»;
  • в одном заказе смешиваются товары и услуги (монтаж + материалы);
  • бронирование слота (календарь мастера/зала) — отдельный модуль поверх;
  • мост с модулем «Услуги (иерархия)»: карточка услуги artel-модели может и собирать заявку (лид — по умолчанию), и продаваться онлайн (кнопка «оплатить» → заказ) — включается модулем магазина, не переделкой услуг.

Бонусы и скидки (rule engine)

Скидки — декларативные правила «условие → действие», данные, а не код (Битрикс-аналогия: «правила работы с корзиной»):

condition: { cart_total ≥ N | group = X | has_coupon | category in [...] | qty ≥ N | first_order }
action:    { percent | fixed | cheapest_free | free_shipping }
priority, stop_further (bool), active_from/to
  • правила комбинируются по приоритету; stop_further обрывает цепочку;
  • промокод = правило с условием «введён код» + лимиты (использований, на пользователя);
  • аудит применения: заказ хранит, какие правила сработали и на сколько изменили цену.

Бонусы (лояльность) — транзакционный счёт:

  • bonus_transactions: начисление/списание/сгорание, баланс = Σ (никогда не колонка);
  • правила начисления — тот же rule-engine: % от заказа, фикс за действие (регистрация, отзыв), множители акций; начисление по событию OrderCompleted (после выкупа, не оплаты) — идемпотентный job;
  • списание — лимит % от чека (конфиг), пересчёт по позициям (см. чеки);
  • возврат/отмена → сторно транзакций (не удаление);
  • уровни лояльности ← накопленная сумма покупок; уровень открывает тип цены и/или повышенный % бонусов (стык с видимостью типов цен).

Заготовка мультивендора (UGC-маркетплейс)

Будущий модуль «пользователи размещают товары/объявления и получают заявки» (кабинеты/UGC) использует эти же модели — поэтому уже сейчас:

  • vendor_id (nullable) на товаре, складе, типе цены — владелец-продавец; null = магазин;
  • заказ с товарами разных продавцов → сплит-подзаказы; комиссия — поверх, не внутри цены;
  • заявка «по объявлению» — это лид ядра с привязкой к товару/публикации (модель cms_leads уже полиморфна) — UGC-«получать заявки» не требует новой подсистемы.

Ретрофит vendor_id в живую схему дороже, чем nullable-колонка с первого дня.

Highload-требования каталога (миллионы товаров)

Каталог обязан держать миллионы товаров/ТП без деградации и полностью утилизировать многоядерный сервер с большим ОЗУ. Требования — контрактные (нарушение = падение теста), не «пожелания»:

Чтение (витрина):

  • листинги/карточки — только keyset-пагинация (cursorPaginate); OFFSET на глубине — запрещён (на миллионах строк это seq-scan хвоста);
  • фасетные агрегации и полнотекст — из поискового индекса (Meilisearch/ES через cms/search), не COUNT(*)/GROUP BY по живым таблицам; PG-fallback (tsvector) допустим только до ~100k товаров;
  • горячие пути (листинг, карточка, фасеты) — query builder с явным select нужных колонок, без гидрации лишних Eloquent-моделей и без N+1 (contract-тест);
  • деривативы для листинга (минимальная цена ТП, суммарный остаток, флаг «в наличии») — денормализованные проекции, пересчитываемые событиями через очередь, а не подзапросы на лету;
  • кеш: карточка/листинг в Redis + page-cache; stampede-защита обязательна (performance).

Схема PG (16):

  • составные/покрывающие индексы под реальные WHERE/ORDER BY листингов; GIN — по JSONB атрибутов; BRIN — по created_at журнальных таблиц (stock_moves);
  • крупные таблицы (prices = variant×price_type×city, stock, stock_moves) — проектировать сразу под декларативное партиционирование (по складу/диапазону дат);
  • ANALYZE/autovacuum-тюнинг для каталожных таблиц — часть провижининга; большое ОЗУ → shared_buffers/effective_cache_size по правилам инфры студии (не более 25% WSL-лимита).

Запись (импорт/ETL):

  • массовый импорт — COPY/батчевый upsert чанками через Bus::batch (параллельно по ядрам), не по-строчный Eloquent save();
  • на время ETL — max_parallel_workers_per_gather=1 (правило инфры против OOM), после — вернуть; индексы при первичной загрузке — после данных;
  • инвалидация кеша при импорте — батчевая по тегам разделов, не по-товарная лавина.

Многоядерность/ОЗУ: пулы Horizon per-queue (импорт/индексация/медиа — отдельные очереди со своими воркерами), FPM static-pool по ядрам, поисковая индексация — фоном чанками; read-replica поддерживается конфигурацией Laravel (read/write connections) без изменений кода модулей.

Разбивка на модули (уточнение каталога)

МодульСодержимоеЗависит от
commerce-catalogProduct + Variant (ТП) + опцииядро (движок полей), Медиа
commerce-stockсклады, остатки, резервы, движенияcommerce-catalog
commerce-pricingтипы цен, видимость по группам/лояльности, тир-прайсингcommerce-catalog
commerce-promorule-engine скидок, промокодыКорзина
loyalty (раздел D каталога)бонусный счёт, правила начисления, уровниЗаказы
commerce-digitalцифровые ТП, выдача файлов/лицензийcommerce-catalog, Платежи
commerce-servicesпродажа услуг, мост с модулем «Услуги»commerce-catalog
commerce-taxставки НДС, режимы юрлицаЗаказы

Витрина без покупки (universal-модель «каталог + лид») использует только commerce-catalog (+ опц. pricing) — граница «лид ≠ заказ» сохраняется.

Связи

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