Тема
ТЗ — Мульти-склад (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_idnullable (заготовка мультивендора) - Остатки
qty/reservedper variant × warehouse, доступность = Σqty - reservedпо видимым складам - Журнал
stock_moves(append-only): заказ/поставка/коррекция, баланс всегда восстановим - Резервирование при оформлении заказа — транзакционно (
SELECT FOR UPDATE) - Доступность по городу/каналу продаж: «есть на одних складах, нет на других» — витрина показывает наличие по складам текущего города + статус «под заказ, N дней» с прочих складов
- Инвентаризация (сверка фактического остатка) и поставки (приход от поставщика)
- Снятие резерва при отмене заказа/таймауте оформления
Зависимости и выключение
requires: cms/commerce-catalog · suggests: мультигород
Поведение при выключении: используется единственный виртуальный склад с безлимитным остатком — резервирование и журнал движений отключаются, каталог продолжает продавать без учёта остатков (деградация до модели «под заказ всегда»). Отдельно от полного выключения — оперативный reservation_kill_switch (см. «Настройки»): остатки и журнал продолжают работать, но резерв не берёт блокировку SELECT FOR UPDATE (аварийный режим при инцидентах с блокировками БД).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_warehouses | id, name, type (own|store|supplier|virtual), priority, city_id (nullable), vendor_id (nullable), is_active | склад, vendor_id — заготовка мультивендора; type — PHP Enum |
commerce_stock | variant_id, warehouse_id, qty, reserved | остаток и резерв per ТП × склад; составной индекс (variant_id, warehouse_id); CHECK (qty - reserved >= 0) |
commerce_stock_moves | id, variant_id, warehouse_id, delta, reason (order|supply|correction|reversal), ref_id, created_at | append-only журнал движений; BRIN по created_at, партиционирование при большом объёме; reason — PHP Enum; исправление ошибочного движения — только сторно новой строкой, не UPDATE/DELETE |
commerce_inventories | id, warehouse_id, started_at, finished_at, status | инвентаризация склада; status — PHP Enum |
commerce_inventory_items | inventory_id, variant_id, counted_qty, system_qty_at_start | построчный результат пересчёта; расхождение counted_qty - system_qty_at_start пишется в stock_moves как reason=correction только после закрытия инвентаризации |
commerce_supplies | id, 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_id → code: 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, qty | cms: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, SupplyReceived | payload по таблице ниже |
| Экспорт/фид (1С, партнёры) | текущие остатки по складам | плановый экспорт джобой, формат — конфиг профиля |
Настройки (группа commerce-stock)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-stock.enabled | bool | true | да | Включение учёта остатков (false — безлимитная продажа) |
commerce-stock.reservation_ttl_minutes | int | 30 | нет | Время жизни резерва до автоснятия по таймауту |
commerce-stock.show_other_warehouses | bool | true | да | Показ «под заказ с другого склада» на карточке |
commerce-stock.reservation_kill_switch | bool | false | нет | Kill-switch: аварийное отключение блокировки SELECT FOR UPDATE при резервировании без выключения модуля целиком (инцидент с блокировками БД) |
commerce-stock.max_supply_batch_size | int | 5000 | нет | Лимит позиций в одной поставке/импорте за один батч-upsert |
commerce-stock.moves_page_size | int | 100 | нет | Размер страницы журнала движений в admin API (keyset) |
Лимиты и квоты: импорт поставки/остатков свыше max_supply_batch_size — модуль сам режет на несколько батчей через Bus::batch, ручной запрос с превышением лимита не 500, а отчёт «разбито на N батчей». reservation_kill_switch=true — временная мера, Filament показывает баннер «резервирование без блокировки — риск перепродажи» пока флаг включён.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/catalog/variants/{id}/stock | public | Доступность ТП по складам текущего города |
| POST | /api/v1/admin/commerce-stock/supplies | admin (commerce-stock.manage) | Регистрация поставки на склад (Idempotency-Key) |
| POST | /api/v1/admin/commerce-stock/inventories | admin (commerce-stock.manage) | Запуск инвентаризации склада |
| POST | /api/v1/admin/commerce-stock/inventories/{id}/close | admin (commerce-stock.manage) | Закрытие инвентаризации, фиксация расхождений в журнал |
| GET | /api/v1/admin/commerce-stock/moves | admin (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 шт.: только атомарный декремент под
SELECT FOR UPDATE; первый запрос резервирует, второй получает отказcode: out_of_stockсинхронным ответом сервис-вызова — не «минус единица» и не повторная попытка вслепую. - Резерв протух, но оплата пришла позже — TTL истёк,
release-expired-reservationsснимает резерв, а пользователь тем временем завершает оплату на старой странице: перед финальным списанием (reason=orderвstock_moves) обязательна повторная проверка актуальности резерва/заказа — если резерв уже снят джобой, списание не проходит вслепую, заказ уходит в ручной разбор («оплачен, но остатка не осталось»), а не в минус на складе. - Отрицательный остаток запрещён конструктивно —
CHECK (qty - reserved >= 0)на уровне БД, а не только проверкой в сервисе: баг в коде не может продать в минус. - Приоритет складов по городу берётся из
RequestContext.city(провайдер контекста ядра/мультигорода), не из параметра запроса или заголовка — иначе подменой параметра можно получить наличие «не своего» города. - Инвентаризация при живых резервах: на складе есть активные
reserved(незавершённые заказы), инвентаризация обнаруживает фактический остаток меньше системного — расхождение пишется какcorrectionвstock_moves, ноreservedинвентаризацией не трогается и не обнуляется; если после коррекцииqty - reserved < 0стало бы отрицательным — закрытие инвентаризации блокируется с явной ошибкой «расхождение конфликтует с активным резервом N заказов», решение — вручную развести (отменить лишние резервы или скорректировать иначе). ⚠️ Противоречие: «зафиксировать факт склада как есть» и «не ломать активные заказы» несовместимы при большом расхождении — разрешение: constraint на неотрицательный баланс выигрывает всегда, инвентаризация в конфликтном случае требует ручного решения оператора, автоматического обнуления резерва нет. - Огромная поставка/импорт (сотни тысяч строк из 1С) — батчевый upsert через
Bus::batchчанками поmax_supply_batch_size, без загрузки всего файла в память и без построчныхINSERTвне транзакции чанка. - Выключение модуля с активными резервами — резервы не «пропадают»: витрина переходит в безлимитный режим для новых заказов, но снятие/подтверждение уже существующих резервов при последующем включении обратно продолжается из журнала (append-only не терял данные, пока был выключен только резолвер доступности).
- Отсутствует
suggestsмультигород —city_idскладов не используется как фильтр, видимыми считаются все активные склады по приоритету без региональной фильтрации; поведение не ошибка, а деградация до «один список складов на всех». - Противоречивые настройки:
show_other_warehouses=falseи на складе текущего города остаток 0 — карточка обязана показать «нет в наличии», а не скрыть товар полностью и не молча показать «под заказ» в обход настройки. - Списание при отгрузке до подтверждения оплаты (ошибка интеграции с оплатой) — списание остатка (
reason=order,delta<0) допустимо только после события подтверждения оплаты/сборки заказа, не в момент создания резерва — иначе отменённый до оплаты заказ уже списал бы остаток без возможности простого возврата (потребовалось бы сторно, а не обычныйStockReleased). - Коррекция ошибочного движения — исправление найденной ошибки в журнале только сторно новой строкой с обратным знаком и ссылкой на исходное движение,
UPDATE/DELETEстрокиstock_movesзапрещены даже администратору. - Импорт с неизвестным
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запрещены