EPIC #315 · Склад — управление складом OPENNew Feature · stock

Полная складская подсистема для филиалов вендора: склады, номенклатура, рецепты (BOM) и per-branch тех-карты, производство (станции/KDS), append-only леджер движений со средневзвешенной себестоимостью (WAC), шесть складских операций и интеграция с заказами — приготовление/продажа позиции автоматически списывает ингредиенты с адресных складов. · github #315 · 18 под-задач (#391–#408)

Фазы реализации — порядок по зависимостям

Граф зависимостей

Foundations Operations Order integration Cross-cutting Единицы измерения#391 Склады (locations)#392 Stock items#393 Ledger + WAC#394 · ядро Рецепты (BOM)#395 Производство#406 · станции Тех-карты#407 · routing Приход#396 Расход#397 Списание#398 Перемещение#399 Инвентаризация#400 Заготовка#401 KDS / заявки#408 · cooking status Sale backflush#402 · reservation Cancel-промпт#403 · списать/оставить Permissions stock.*#404 Отчёты и балансы#405

ядро — критический путь синие рёбра = зависимость от Ledger #394 (спинной хребет всех операций)

Зафиксированные архитектурные решения — 14 шт.

#РешениеВыбор
1Модель балансовAppend-only ledger + кэшированные балансы
2Себестоимостьweighted moving average (точность суб-цент, micro-cents)
3Stock vs catalogРаздельные сущности; stock_item связан с каталогом через рецепт
4Тип позицииingredient / заготовка / goods — мягкая метка + дефолты формы, не gate
5Capabilitiesproducible / sellable / consumable — определяются связями, доступны любому типу
6БлюдаStocked, make-to-order: производим shortfall, остатки переиспользуются
7Рецепт vs тех-карта newРазделены: рецепт = состав (орг); тех-карта = маршрут (per-branch)
8Роутинг складов newper-ingredient source + один target, всё на тех-карте; у станции склада нет
9Производство (станции) newPer-branch; станция↔рецепт = тех-карта (один рецепт → много тех-карт); tiebreaker isDefault
10Триггер produce changedПереехал на заявку «готово» (KDS); per-item cooking status; consume @ выдан
11Отмена после produceRuntime-промпт (оставить/списать) по статусу готово; дефолт из флага item
12Резервированиеon_hand / reserved / available
13ЕдиницыBase unit + purchase packs (Option B); целочисленное хранение в суб-единицах
14Отрицательный остатокWarning + !force, gated by stock.force_negative

Модель данных — ядро

// #393 — номенклатура
stock_item { id, orgId, name, type: 'ingredient'|'заготовка'|'goods',
             baseUnitCode, packs: UnitDef[], isStocked: bool,
             cancelPolicy: 'keep'|'writeoff', minStockSubUnits? }

// #394 — append-only леджер (спинной хребет)
stock_movement { id, stockItemId, locationId,
                 qtySubUnits,            // signed: + inbound, − outbound
                 type: ПРИХОД|РАСХОД|СПИСАНИЕ|ПЕРЕМЕЩЕНИЕ|ИНВЕНТАРИЗАЦИЯ|ЗАГОТОВКА,
                 unitCostMicro?, refDocType, refDocId, createdBy, createdAt }

stock_balance  { stockItemId, locationId, onHand, reserved }   // available = onHand − reserved
// инвариант: balance == SUM(movements), пересчитываем транзакционно

// #395 — РЕЦЕПТ (BOM / состав, орг-уровень): только что и сколько
recipe      { id, producibleType: 'stock_item'|'catalog_item', producibleId }
recipe_line { recipeId, ingredientStockItemId, qtySubUnits }
// explosion engine: рекурсивно до stocked-листьев, стоп на isStocked, защита от циклов

// #407 — ТЕХ-КАРТА (маршрут, per-branch): где и из каких складов
tech_card      { id, branchId, recipeId, productionId,
                 targetStockId,                 // куда оприходовать продукт
                 isDefault }                    // tiebreaker при нескольких
tech_card_line { techCardId, recipeLineId, sourceStockId }   // PER-INGREDIENT источник

// #406 — ПРОИЗВОДСТВО (станция, per-branch): БЕЗ склада (роутинг — из тех-карты)
production { id, branchId, name, type? }     // Бар, Горячая кухня, Пиццерия, Заготовки…

// #408 — ЗАЯВКА (KDS): одна строка заказа на станции, per-item статус готовки
production_ticket { id, productionId, techCardId, qty, orderId?, orderLineId?,
                    status: новая|готовится|готово|выдана|отменена }
// produce @ «готово»: −строки рецепта с их sourceStock, +продукт в targetStock, reserved+=needed

Единицы — Option B (#391, пример: мука)

Item: мука — dimension mass, base kg, хранение в граммах, pack «Мешок» = 25 kg

Приход:  выбрали «Мешок», qty 2    → store 2 × 25000 = 50000 g
Balance: 50000 g                   → показываем "50 кг"
Recipe:  ввели 0.15 (locked kg)    → store 150 g в строке рецепта
Cost:    2000 c / Мешок ÷ 25000 g  = 0.08 c/g (хранится в micro-cents)

// pack = просто ещё одна единица c бóльшим factor; один конвертер для всего:
toBase(1.5, kg) → 1500   ·   fromBase(1500, kg) → 1.5   ·   format(1500, kg) → "1.5 кг"
// общий модуль api + vendor web + flutter; backend авторитетен, клиентской математике не верим
// count: дроби запрещены — никаких 1.5 шт

Жизненный цикл продажи (#402 + #408 — заявка → backflush + reservation)

1 · Заказ создан → заявки

нет остатка → заявка(новая) по тех-карте на станцию есть остаток → резерв, без заявки

Отмена до «готово» — тихий выход: отменяем заявку, склад не тронут, без промпта.

2 · KDS «готово» → produce

shortfall = max(0, needed − available) −строки рецепта с их sourceStock +продукт в targetStock reserved += needed

Per-ingredient source из тех-карты. Остаток отменённого заказа переиспользуется — рецепт не стреляет повторно.

3 · выдан → consume

on_hand −= needed (из targetStock) reserved −= needed

Расход по WAC = COGS. Резерв не даёт двум заказам забрать одну единицу.

4 · cancel после «готово»

оставить: reserved −= needed списать : reserved −= needed on_hand −= needed

Промпт #403 — только по строкам со статусом готово.

Примитивы produce / consume / release привязаны к статусу заявки (готово/выдана/отмена) — это и есть per-item kitchen-статусы (теперь in-scope). Resale-товары (1:1 self-line) и блюда (рецепт) идут одним путём.

Производство — рецепт vs тех-карта (#406 / #407 / #408)

// BOM vs routing: рецепт «из чего» (орг), тех-карта «как и где» (филиал)
recipe (орг)        : { producible, lines:[(ingredient, qty)] }            // состав
tech_card (филиал)  : { recipeId, productionId, targetStockId, isDefault,
                        lines:[(recipeLine, sourceStockId)] }            // per-ingredient source
production (филиал)  : { name, type? }   // Бар/Кухня/Пиццерия/Заготовки — БЕЗ склада
заявка              : { productionId, techCardId, qty, orderId?,
                        status: новая→готовится→готово→выдана|отменена }

// поток
заказ → нет остатка → резолв тех-карты (recipe, branch; isDefault) → заявка на станцию
KDS «готово» → −ингредиенты с per-ingredient source, +продукт в target, reserved → заказу
выдан → consume.   отмена: до «готово» тихо · после «готово» промпт списать/оставить

Промпт отмены (#403 — мок UI)

Отмена заказа #1234

Что сделать с приготовленными позициями?

Бургер ×1 оставитьсписать
Латте ×1 оставитьсписать

Дефолт строки = cancelPolicy позиции (латте — скоропорт → списать). «Списать» требует stock.writeoff.create и постит СПИСАНИЕ.

Под-задачи — детали и acceptance criteria

#391 Единицы измерения

apiweb + flutter

Registry-driven units, integer sub-unit storage, base unit + purchase packs.

  • 2× Мешок муки → 50000 g, cost 0.08 c/g
  • Рецепт 0.15 kg → 150 g; «1.5 шт» отклоняется
  • Один converter-модуль для api + web + flutter
  • WAC в суб-центах, округление только на дисплее
Зависит от: —

#392 Склады (locations)

apibranch settings

CRUD складов в рамках филиала; ровно один primary на филиал.

  • Создание филиала авто-создаёт primary-склад
  • Второй primary на филиал невозможен
  • Склад с остатками не удаляется
  • Tenant isolation между организациями
Зависит от: —

#393 Номенклатура

apiсписок + форма

CRUD stock items: тип = мягкая метка + дефолты формы, не gate. Capabilities — от связей.

  • Любые комбинации тип/флаги создаваемы
  • Packs редактируются на каждом item
  • Тип префиллит дефолты, но не блокирует
  • Tenant isolation на каждом read/write
Зависит от: #391 Units

#394 Ledger + балансы + WAC

api · ядро

Append-only движения, кэш-балансы транзакционно, WAC, negative-stock guard.

  • balance == SUM(movements) после каждой операции
  • WAC корректен при разноценовых приходах
  • Минус блокируется без force; с force + perm — аудит
  • Recompute-job из леджера сходится с кэшем
Зависит от: #391 Units, #393 Items

#395 Рецепты (BOM / состав)

apirecipe editor

Чистый состав (орг-уровень): что и сколько. Маршрут вынесен в тех-карту. Explosion до stocked-листьев.

  • Бургер → котлета (non-stocked) → фарш: explosion до листа
  • Cycle detection против бесконечной рекурсии
  • Правка рецепта не переписывает историю движений
  • Рецепт не содержит ссылок на склады/станции
Зависит от: #393 Items

#406 Производство (станции)

apibranch settings

Станция/цех — куда падают заявки. Per-branch, БЕЗ склада (роутинг — из тех-карты). Бар/Кухня/Пиццерия/Заготовки.

  • CRUD станций в рамках филиала
  • Станция без склада; роутинг — только тех-карта
  • Переиспользуется множеством тех-карт
  • Нельзя удалить при ссылках/открытых заявках
Зависит от: —

#407 Тех-карты (routing)

apitech-card editor

Per-branch маршрут поверх рецепта: станция + per-ingredient source + target. Один рецепт → много тех-карт (= M2M станция↔рецепт).

  • Per-ingredient source: каждая строка — со своего склада
  • Один target-склад для продукта
  • Резолв по (recipe, branch); >1 → isDefault; 0 → warning
  • Ссылается только на склады своего филиала
Зависит от: #395 BOM, #406 Станции, #392 Склады

#396 Приход

apiдокумент-форма

Inbound-документ: строки (item, qty в pack/unit, цена). Draft / post / void.

  • Post обновляет onHand и WAC
  • Pack-qty и цена конвертируются в base / micro-cents
  • Void чисто реверсит движения документа
Зависит от: #394 Ledger

#397 Расход

apiдокумент-форма

Ручной outbound для проданного: −qty по WAC (COGS) + sale price (revenue).

  • Декрементит балансы по строкам
  • Negative guard: warn + force
  • Фиксируются и sale price, и WAC-cost
Зависит от: #394 Ledger

#398 Списание

apiдокумент-форма

Outbound без revenue — потери/порча. Используется веткой «списать» cancel-промпта.

  • Декрементит балансы
  • Reason опционален на строку
  • Оценка по WAC, отчёт как loss
Зависит от: #394 Ledger

#399 Перемещение

apiдокумент-форма

Source → destination: −qty / +qty атомарно, cost наследуется.

  • Обе ноги постятся атомарно (нет полу-трансферов)
  • Guard на source (warn + force)
  • Cost сохраняется на destination
Зависит от: #394 Ledger

#400 Инвентаризация

apicount-формаstock.inventory.approve

Сверка учёта с фактом: on_hand → actual, diff красным/зелёным, adjustment-движения.

  • Diff посчитан и раскрашен (over/under)
  • Adjustment приводит кэш к посчитанному
  • Non-stocked items исключены
  • Post требует stock.inventory.approve
Зависит от: #394 Ledger

#401 Заготовка

apiproduction-форма

Производство stocked-позиции впрок = ручная заявка без заказа. Склады из тех-карты (source per-ingredient, target — продукт).

  • Explosion показан, редактируем per batch
  • Cost продукта = Σ WAC ингредиентов
  • Один механизм с заявками заказа (orderId = null)
  • Guard на ингредиенты (warn + force)
Зависит от: #394 Ledger, #395 BOM, #407 Тех-карты

#402 Sale backflush

api · order flow

Catalog↔stock link (1:1 self-line или рецепт), авто-shadow при создании catalog item. Produce — на заявке «готово»; адресные склады (per-ingredient source → target).

  • Продажа списывает ингредиенты ровно один раз, с нужных source-складов
  • Остаток отменённого заказа переиспользуется
  • Reservation против гонки двух заказов
  • Resale (1:1) и блюда (рецепт) — один путь, продукт в target
Зависит от: #394, #395, #407, #408, #401

#408 KDS / заявки

apiKDS-экран

Экран заявок станции + per-item статусы готовки + роутинг заказа в заявки. Здесь живёт момент produce.

  • Заявки роутятся по тех-карте на станцию; в наличии — без заявки
  • новая→готовится→готово→выдана(+отменена), зеркало в позицию заказа
  • На «готово» — списание (source) + оприходование (target), reserved
  • Ручная заявка без заказа (заготовки впрок)
Зависит от: #407 Тех-карты, #406 Станции, #394 Ledger, #402 Backflush

#403 Cancel-промпт

apiмодалка

Отмена после статуса готово: per-item оставить/списать + bulk, дефолт из cancelPolicy.

  • Только строки со статусом готово, преселект из cancelPolicy
  • Bulk и per-item override работают
  • оставить = release; списать = СПИСАНИЕ из target-склада
  • Отмена до «готово» — без промпта и без следа
Зависит от: #402 Backflush, #408 KDS

#404 Permissions stock.*

api@Perms(...)

Полный набор stock.* по конвенции vendor.*/go.*, сиды дефолтных ролей.

  • Каждый мутирующий endpoint под @Perms
  • Дефолтные роли засижены по матрице
  • cost.view прячет цены от кладовщика/повара
Cross-cuts все операции

#405 Отчёты и балансы

read-side UI

Текущие остатки, история движений с drill-down, low-stock алерты, валюация.

  • Балансы per location / per item
  • История → drill-down до операции/заказа
  • Low-stock против minStock
  • Валюация = Σ on_hand × WAC, скрыта без cost.view
Зависит от: #394 Ledger

Матрица ролей (#404)

ГруппаOwnerManagerКладовщикПоварКассир/Официант
location.*view/create/updateviewview
item.*view/create/updateview
recipe.*viewview
receipt / transfer / inventorycreate/viewview
writeoff / productioncreate/viewcreate/view
issue (Расход)create/view
balance.view
cost.view (sensitive)
force_negative
inventory.approve

Кассиру/официанту stock-права не нужны: продажа списывает автоматически через order flow.

Вне scope — future

Per-item kitchen-статусы (новая/готовится/готово) — теперь в scope, реализуются через KDS (#408).