Skip to content

ТЗ — Мульти-склад (cms/commerce-stock)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: masha (masha/src/app/Domain/{Stock,Inventory,Reservations,Supply}/) Статус: ТЗ к разработке

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

Остатки товаров по складам как модель «variant × warehouse → qty/reserved» с append-only журналом движений (см. /cms-v2/commerce-model). Резервирование при заказе — транзакционно. HIGHLOAD: журнал движений проектируется под партиционирование и BRIN.

  • Склады: приоритет, привязка к городу, vendor_id nullable (заготовка мультивендора)
  • Остатки qty/reserved per variant × warehouse, доступность = Σ qty - reserved по видимым складам
  • Журнал stock_moves (append-only): заказ/поставка/коррекция, баланс всегда восстановим
  • Резервирование при оформлении заказа — транзакционно (SELECT FOR UPDATE)
  • Доступность по городу/каналу продаж: «есть на одних складах, нет на других» — витрина показывает наличие по складам текущего города + статус «под заказ, N дней» с прочих складов
  • Инвентаризация (сверка фактического остатка) и поставки (приход от поставщика)
  • Снятие резерва при отмене заказа/таймауте оформления

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

requires: cms/commerce-catalog · suggests: мультигород

Поведение при выключении: используется единственный виртуальный склад с безлимитным остатком — резервирование и журнал движений отключаются, каталог продолжает продавать без учёта остатков (деградация до модели «под заказ всегда»). Отдельно от полного выключения — оперативный reservation_kill_switch (см. «Настройки»): остатки и журнал продолжают работать, но резерв не берёт блокировку SELECT FOR UPDATE (аварийный режим при инцидентах с блокировками БД).

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

ТаблицаКлючевые поляПримечание
commerce_warehousesid, name, type (own|store|supplier|virtual), priority, city_id (nullable), vendor_id (nullable), is_activeсклад, vendor_id — заготовка мультивендора; type — PHP Enum
commerce_stockvariant_id, warehouse_id, qty, reservedостаток и резерв per ТП × склад; составной индекс (variant_id, warehouse_id); CHECK (qty - reserved >= 0)
commerce_stock_movesid, variant_id, warehouse_id, delta, reason (order|supply|correction|reversal), ref_id, created_atappend-only журнал движений; BRIN по created_at, партиционирование при большом объёме; reason — PHP Enum; исправление ошибочного движения — только сторно новой строкой, не UPDATE/DELETE
commerce_inventoriesid, warehouse_id, started_at, finished_at, statusинвентаризация склада; status — PHP Enum
commerce_inventory_itemsinventory_id, variant_id, counted_qty, system_qty_at_startпострочный результат пересчёта; расхождение counted_qty - system_qty_at_start пишется в stock_moves как reason=correction только после закрытия инвентаризации
commerce_suppliesid, warehouse_id, supplier_ref, received_atпоставка (приход остатков)

ПДн-паспорт: остатки, движения, поставки и инвентаризации — не ПДн (supplier_ref — идентификатор юрлица/фида поставщика, не физлицо). Единственное косвенное поле — ref_id движения с reason=order, ссылающееся на заказ; сам модуль ФИО/контакты не хранит и не раскрывает, декларирует «ПДн не храню».

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

Входы:

ИсточникПоляЧем валидируется
Резервирование (сервис-вызов от cms/commerce-orders, requires)variant_id, warehouse_id[], qty, order_idсумма qty по кандидатным складам не превышает доступный остаток; неизвестный variant_id/warehouse_idcode: validation_failed
Форма поставки (Filament/API)warehouse_id, supplier_ref, items[{variant_id, qty, price?}]FormRequest-whitelist, qty > 0, Idempotency-Key (поставка с ценами — денежный эффект)
Мастер инвентаризации (Filament)warehouse_id, items[{variant_id, counted_qty}]FormRequest, counted_qty >= 0; закрытие инвентаризации — отдельное действие с подтверждением
Импорт остатков (джоба/фид 1С)CSV/XML с sku/variant_id, warehouse_ref, qtycms:commerce-stock:import-supply — построчная валидация перед батчевым upsert, невалидные строки — в отчёт, не в БД
Событие заказа (шина, отмена/таймаут)order_id, reasonвнутреннее, снятие резерва по ref_id=order_id

Выходы:

ПотребительДанныеФормат
Витрина (карточка/листинг)доступность по складам текущего города, статус «под заказ, N дней»JSON через /api/v1/catalog/variants/{id}/stock
cms/commerce-orders (потребитель requires)подтверждение/отказ резервасинхронный ответ сервис-вызова, отказ — code: out_of_stock
Filament adminтаблица остатков, журнал движений, история инвентаризацийkeyset-JSON
Подписчики событийStockReserved, StockReleased, StockMoved, SupplyReceivedpayload по таблице ниже
Экспорт/фид (1С, партнёры)текущие остатки по складамплановый экспорт джобой, формат — конфиг профиля

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

КлючТипДефолтaffectsPageCacheОписание
commerce-stock.enabledbooltrueдаВключение учёта остатков (false — безлимитная продажа)
commerce-stock.reservation_ttl_minutesint30нетВремя жизни резерва до автоснятия по таймауту
commerce-stock.show_other_warehousesbooltrueдаПоказ «под заказ с другого склада» на карточке
commerce-stock.reservation_kill_switchboolfalseнетKill-switch: аварийное отключение блокировки SELECT FOR UPDATE при резервировании без выключения модуля целиком (инцидент с блокировками БД)
commerce-stock.max_supply_batch_sizeint5000нетЛимит позиций в одной поставке/импорте за один батч-upsert
commerce-stock.moves_page_sizeint100нетРазмер страницы журнала движений в admin API (keyset)

Лимиты и квоты: импорт поставки/остатков свыше max_supply_batch_size — модуль сам режет на несколько батчей через Bus::batch, ручной запрос с превышением лимита не 500, а отчёт «разбито на N батчей». reservation_kill_switch=true — временная мера, Filament показывает баннер «резервирование без блокировки — риск перепродажи» пока флаг включён.

API

МетодПутьДоступНазначение
GET/api/v1/catalog/variants/{id}/stockpublicДоступность ТП по складам текущего города
POST/api/v1/admin/commerce-stock/suppliesadmin (commerce-stock.manage)Регистрация поставки на склад (Idempotency-Key)
POST/api/v1/admin/commerce-stock/inventoriesadmin (commerce-stock.manage)Запуск инвентаризации склада
POST/api/v1/admin/commerce-stock/inventories/{id}/closeadmin (commerce-stock.manage)Закрытие инвентаризации, фиксация расхождений в журнал
GET/api/v1/admin/commerce-stock/movesadmin (commerce-stock.view)Журнал движений с фильтром по складу/ТП (keyset-пагинация)

Компоненты

Filament: справочник складов с приоритетом, таблица остатков, журнал движений с фильтрами, мастер инвентаризации (со сравнением «система/факт» и подтверждением закрытия). Команды: cms:commerce-stock:release-expired-reservations --json --dry-run, cms:commerce-stock:import-supply --json --dry-run.

Демо-контент: CommerceStockDemoSeeder создаёт 2 склада (own, virtual) с остатками на демо-ТП и одну демо-поставку — галерея /_gallery показывает карточку с наличием/«под заказ» без ручного ввода данных.

Фронтенд-бюджет: индикатор наличия на карточке — часть уже загруженных данных ТП, без отдельного блокирующего запроса; статус «под заказ, N дней» рендерится сразу, не подгружается асинхронно (не создаёт скачок layout).

Эксплуатация (ранбук): метрики commerce_stock_reservation_conflicts_total (гонки за последнюю единицу), commerce_stock_expired_releases_total, commerce_stock_negative_attempts_total (попытки ушедшие в constraint); алерт — рост negative_attempts_total выше нуля (сигнал обхода резервирования в обход журнала).

СимптомЧто проверить / команда
Остаток «плывёт» (не сходится с журналом)пересчитать баланс из stock_moves по variant_id, warehouse_id, сверить с commerce_stock
Резервы не снимаются по таймаутуcms:commerce-stock:release-expired-reservations --dry-run — что джоба видит; расписание в ScheduleRegistrar
Импорт поставки зависстатус батча Bus::batch, max_supply_batch_size, невалидные строки в отчёте импорта
Массовая перепродажа на витринеcommerce-stock.enabled, reservation_kill_switch — не оставлен ли включённым после инцидента

Бэкап/рестор: commerce_stock*, commerce_supplies, commerce_inventories* — в обычном бэкапе БД, stock_moves как append-only журнал восстанавливается вместе с базой без дополнительных шагов. После рестора на отставшую копию — принудительный пересчёт commerce_stock из stock_moves командой перед вводом в эксплуатацию.

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

СобытиеКогдаPayload
StockReservedрезерв создан при оформлении заказаvariant_id, warehouse_id, qty, order_id
StockReleasedрезерв снят (отмена/таймаут)variant_id, warehouse_id, qty, reason
StockMovedзафиксировано движение остаткаvariant_id, warehouse_id, delta, reason
SupplyReceivedоформлена поставка на складwarehouse_id, supply_id, items_count
InventoryClosedзакрыта инвентаризация, расхождения зафиксированыwarehouse_id, inventory_id, diffs_count

Слушает: события оформления/отмены заказа коммерции для резервирования и снятия резерва; RequestContext ядра — для приоритета складов по городу (см. «Крайние случаи», п. 4).

Provides-контракты: не предоставляет из канонического реестра. FilterBus: не использует; резервирование вызывается сервисом cms/commerce-orders через requires, ответ — доступность/отказ.

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

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-ordersпрямой сервис-вызов (4, requires)orders → commerce-stockрезервирование/списание/снятие резерва по позициям заказа
RequestContext ядраprovides-контракт (3) city-context, если включён cms/multicityядро → commerce-stockприоритет складов по городу текущего запроса, не из query-параметра
Подписчики (cms/commerce-orders, аналитика)шина событий (1)commerce-stock → подписчикиStockReserved/Released/Moved/SupplyReceived/InventoryClosed — fire-and-forget
Витрина каталогаFilterBus (2)commerce-catalog → commerce-stockдоработка карточки/листинга флагом наличия и статусом «под заказ»
Очередь commerce-stockочередь (5)commerce-stock → воркерrelease-expired-reservations, import-supply

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

Очередь commerce-stock: release-expired-reservations по расписанию через ScheduleRegistrar (снятие резервов старше reservation_ttl_minutes, с обязательной проверкой актуального статуса оплаты перед снятием — см. «Крайние случаи», п. 2), обработка поставок (import-supply) — джобой, идемпотентной по supplier_ref и батчами по max_supply_batch_size.

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

Ожидаемые объёмы: десятки–сотни складов, сотни тысяч–миллионы строк commerce_stock (вариант × склад), stock_moves — миллионы записей в год на крупном магазине. Горячий путь — чтение доступности на карточке/листинге (один запрос по индексу (variant_id, warehouse_id) с батч-загрузкой для листинга, whereIn, без N+1 на карточку) и резервирование при оформлении (транзакция с SELECT FOR UPDATE на затронутых строках commerce_stock, не на всей таблице). Бюджет: доступность одного ТП — 1 запрос из кеша/1 запрос к БД при промахе; резервирование заказа из K позиций — не более K запросов внутри одной транзакции.

Теги commerce-stock:variant:{id}, commerce-stock:warehouse:{id}. Инвалидация — событиями StockMoved, StockReserved, StockReleased; батчевая инвалидация по тегу склада при массовой поставке/инвентаризации (не поштучно на каждую позицию). stock_moves — BRIN по created_at, партиционирование по диапазону дат при превышении объёма (см. commerce-model.md).

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

Резервирование — только в транзакции с SELECT FOR UPDATE на строках commerce_stock, второй конкурентный запрос на последнюю единицу получает отказ code: out_of_stock, не отрицательный остаток; отрицательный баланс дополнительно исключён constraint'ом на уровне БД (не только дисциплиной кода). Вход в поставки/инвентаризацию — FormRequest-whitelist; операции с денежным эффектом (поставка с ценами) — обязательный Idempotency-Key. Rate-limit на публичном эндпоинте доступности — по IP/сессии (частые запросы карточки не должны давать DoS на резервирующий путь). Права: commerce-stock.view, commerce-stock.manage.

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

ДействиеАдминистраторМенеджерКладовщикStudio
Просмотр остатков и журнала (view)
Регистрация поставки, запуск/закрытие инвентаризации (manage)
Ручная коррекция остатка вне инвентаризации
Справочник складов, приоритет, reservation_kill_switch

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

Админ: пустое состояние справочника складов — «Складов нет — включите модуль или добавьте склад» со ссылкой на форму; массовая поставка (импорт файла) показывает построчный отчёт «принято/отклонено» с причиной отклонения по строке; человеческая ошибка при попытке закрыть инвентаризацию с расхождением, обнуляющим активный резерв — блокирующее предупреждение (см. «Крайние случаи», п. 5), не тихая перезапись; подтверждение перед закрытием инвентаризации (необратимо пишет correction в журнал) и перед включением reservation_kill_switch.

Покупатель: индикатор наличия/«под заказ» на карточке без задержки/мигания при переключении опций варианта; попытка добавить в корзину количество сверх доступного — понятная ошибка «Доступно N шт.» до перехода к оформлению, не отказ на последнем шаге оплаты; повторная попытка оформления после out_of_stock предлагает актуальный доступный остаток, не тот же тупик.

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

  1. Гонка за последнюю единицу — два одновременных заказа претендуют на 1 шт.: только атомарный декремент под SELECT FOR UPDATE; первый запрос резервирует, второй получает отказ code: out_of_stock синхронным ответом сервис-вызова — не «минус единица» и не повторная попытка вслепую.
  2. Резерв протух, но оплата пришла позже — TTL истёк, release-expired-reservations снимает резерв, а пользователь тем временем завершает оплату на старой странице: перед финальным списанием (reason=order в stock_moves) обязательна повторная проверка актуальности резерва/заказа — если резерв уже снят джобой, списание не проходит вслепую, заказ уходит в ручной разбор («оплачен, но остатка не осталось»), а не в минус на складе.
  3. Отрицательный остаток запрещён конструктивно — CHECK (qty - reserved >= 0) на уровне БД, а не только проверкой в сервисе: баг в коде не может продать в минус.
  4. Приоритет складов по городу берётся из RequestContext.city (провайдер контекста ядра/мультигорода), не из параметра запроса или заголовка — иначе подменой параметра можно получить наличие «не своего» города.
  5. Инвентаризация при живых резервах: на складе есть активные reserved (незавершённые заказы), инвентаризация обнаруживает фактический остаток меньше системного — расхождение пишется как correction в stock_moves, но reserved инвентаризацией не трогается и не обнуляется; если после коррекции qty - reserved < 0 стало бы отрицательным — закрытие инвентаризации блокируется с явной ошибкой «расхождение конфликтует с активным резервом N заказов», решение — вручную развести (отменить лишние резервы или скорректировать иначе). ⚠️ Противоречие: «зафиксировать факт склада как есть» и «не ломать активные заказы» несовместимы при большом расхождении — разрешение: constraint на неотрицательный баланс выигрывает всегда, инвентаризация в конфликтном случае требует ручного решения оператора, автоматического обнуления резерва нет.
  6. Огромная поставка/импорт (сотни тысяч строк из 1С) — батчевый upsert через Bus::batch чанками по max_supply_batch_size, без загрузки всего файла в память и без построчных INSERT вне транзакции чанка.
  7. Выключение модуля с активными резервами — резервы не «пропадают»: витрина переходит в безлимитный режим для новых заказов, но снятие/подтверждение уже существующих резервов при последующем включении обратно продолжается из журнала (append-only не терял данные, пока был выключен только резолвер доступности).
  8. Отсутствует suggests мультигородcity_id складов не используется как фильтр, видимыми считаются все активные склады по приоритету без региональной фильтрации; поведение не ошибка, а деградация до «один список складов на всех».
  9. Противоречивые настройки: show_other_warehouses=false и на складе текущего города остаток 0 — карточка обязана показать «нет в наличии», а не скрыть товар полностью и не молча показать «под заказ» в обход настройки.
  10. Списание при отгрузке до подтверждения оплаты (ошибка интеграции с оплатой) — списание остатка (reason=order, delta<0) допустимо только после события подтверждения оплаты/сборки заказа, не в момент создания резерва — иначе отменённый до оплаты заказ уже списал бы остаток без возможности простого возврата (потребовалось бы сторно, а не обычный StockReleased).
  11. Коррекция ошибочного движения — исправление найденной ошибки в журнале только сторно новой строкой с обратным знаком и ссылкой на исходное движение, UPDATE/DELETE строки stock_moves запрещены даже администратору.
  12. Импорт с неизвестным warehouse_ref (фид поставщика ссылается на несуществующий склад) — строка отклоняется в отчёт импорта, не создаёт склад неявно и не падает весь батч целиком.

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

Что взятьПуть
Домены складов, остатков, резервирования и поставокmasha/src/app/Domain/{Stock,Inventory,Reservations,Supply}/

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

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

  • [ ] Контрактный тест: резервирование остатка идёт в транзакции с SELECT FOR UPDATE, гонка за последнюю единицу отдаёт второму запросу code: out_of_stock, не отрицательный остаток
  • [ ] Баланс остатка всегда восстановим из журнала stock_moves (append-only, без прямых UPDATE/DELETE баланса и движений в обход журнала; исправления — только сторно)
  • [ ] CHECK (qty - reserved >= 0) реально блокирует запись, а не только проверяется в сервисном слое
  • [ ] commerce_stock_moves партиционирована/готова к партиционированию, с BRIN-индексом по created_at
  • [ ] Просроченные резервы снимаются планировщиком по reservation_ttl_minutes; гонка «оплата пришла после автоснятия» не создаёт минус на складе (см. «Крайние случаи», п. 2)
  • [ ] Доступность по городу корректно комбинирует «в наличии на складе города» и «под заказ с другого склада»; приоритет — из RequestContext.city, не из параметра запроса
  • [ ] Инвентаризация с расхождением, конфликтующим с активным резервом, блокирует закрытие, а не обнуляет резерв молча
  • [ ] При выключении модуля продажа идёт без проверки остатков, без ошибок оформления заказа; активные резервы не теряются в журнале
  • [ ] Массовый импорт поставки — батчевый upsert по max_supply_batch_size, идемпотентен по supplier_ref
  • [ ] Права commerce-stock.view/.manage разграничивают чтение журнала и операции поставки/инвентаризации
  • [ ] cms:commerce-stock:import-legacy --dry-run строит корректный отчёт расхождений на копии донора
  • [ ] Контрактный набор cms-testing и testbench-изоляция зелёные, feature-тест на каждый роут
  • [ ] Тестовая БД только commerce-stock_test; migrate:fresh/refresh/reset запрещены

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