Полная складская подсистема для филиалов вендора: склады, номенклатура, рецепты (BOM) и per-branch тех-карты, производство (станции/KDS), append-only леджер движений со средневзвешенной себестоимостью (WAC), шесть складских операций и интеграция с заказами — приготовление/продажа позиции автоматически списывает ингредиенты с адресных складов. · github #315 · 18 под-задач (#391–#408)
ядро — критический путь синие рёбра = зависимость от Ledger #394 (спинной хребет всех операций)
| # | Решение | Выбор |
|---|---|---|
| 1 | Модель балансов | Append-only ledger + кэшированные балансы |
| 2 | Себестоимость | weighted moving average (точность суб-цент, micro-cents) |
| 3 | Stock vs catalog | Раздельные сущности; stock_item связан с каталогом через рецепт |
| 4 | Тип позиции | ingredient / заготовка / goods — мягкая метка + дефолты формы, не gate |
| 5 | Capabilities | producible / sellable / consumable — определяются связями, доступны любому типу |
| 6 | Блюда | Stocked, make-to-order: производим shortfall, остатки переиспользуются |
| 7 | Рецепт vs тех-карта new | Разделены: рецепт = состав (орг); тех-карта = маршрут (per-branch) |
| 8 | Роутинг складов new | per-ingredient source + один target, всё на тех-карте; у станции склада нет |
| 9 | Производство (станции) new | Per-branch; станция↔рецепт = тех-карта (один рецепт → много тех-карт); tiebreaker isDefault |
| 10 | Триггер produce changed | Переехал на заявку «готово» (KDS); per-item cooking status; consume @ выдан |
| 11 | Отмена после produce | Runtime-промпт (оставить/списать) по статусу готово; дефолт из флага 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
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 шт
нет остатка → заявка(новая)
по тех-карте на станцию
есть остаток → резерв, без заявки
Отмена до «готово» — тихий выход: отменяем заявку, склад не тронут, без промпта.
shortfall = max(0, needed − available)
−строки рецепта с их sourceStock
+продукт в targetStock
reserved += needed
Per-ingredient source из тех-карты. Остаток отменённого заказа переиспользуется — рецепт не стреляет повторно.
on_hand −= needed (из targetStock)
reserved −= needed
Расход по WAC = COGS. Резерв не даёт двум заказам забрать одну единицу.
оставить: reserved −= needed
списать : reserved −= needed
on_hand −= needed
Промпт #403 — только по строкам со статусом готово.
Примитивы produce / consume / release привязаны к статусу заявки (готово/выдана/отмена) — это и есть per-item kitchen-статусы (теперь in-scope). Resale-товары (1:1 self-line) и блюда (рецепт) идут одним путём.
// 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. отмена: до «готово» тихо · после «готово» промпт списать/оставить
Что сделать с приготовленными позициями?
Дефолт строки = cancelPolicy позиции (латте — скоропорт → списать). «Списать» требует stock.writeoff.create и постит СПИСАНИЕ.
Registry-driven units, integer sub-unit storage, base unit + purchase packs.
CRUD складов в рамках филиала; ровно один primary на филиал.
CRUD stock items: тип = мягкая метка + дефолты формы, не gate. Capabilities — от связей.
Append-only движения, кэш-балансы транзакционно, WAC, negative-stock guard.
Чистый состав (орг-уровень): что и сколько. Маршрут вынесен в тех-карту. Explosion до stocked-листьев.
Станция/цех — куда падают заявки. Per-branch, БЕЗ склада (роутинг — из тех-карты). Бар/Кухня/Пиццерия/Заготовки.
Per-branch маршрут поверх рецепта: станция + per-ingredient source + target. Один рецепт → много тех-карт (= M2M станция↔рецепт).
Inbound-документ: строки (item, qty в pack/unit, цена). Draft / post / void.
Ручной outbound для проданного: −qty по WAC (COGS) + sale price (revenue).
Outbound без revenue — потери/порча. Используется веткой «списать» cancel-промпта.
Source → destination: −qty / +qty атомарно, cost наследуется.
Сверка учёта с фактом: on_hand → actual, diff красным/зелёным, adjustment-движения.
Производство stocked-позиции впрок = ручная заявка без заказа. Склады из тех-карты (source per-ingredient, target — продукт).
Catalog↔stock link (1:1 self-line или рецепт), авто-shadow при создании catalog item. Produce — на заявке «готово»; адресные склады (per-ingredient source → target).
Экран заявок станции + per-item статусы готовки + роутинг заказа в заявки. Здесь живёт момент produce.
Отмена после статуса готово: per-item оставить/списать + bulk, дефолт из cancelPolicy.
Полный набор stock.* по конвенции vendor.*/go.*, сиды дефолтных ролей.
Текущие остатки, история движений с drill-down, low-stock алерты, валюация.
| Группа | Owner | Manager | Кладовщик | Повар | Кассир/Официант |
|---|---|---|---|---|---|
| location.* | ✅ | view/create/update | view | view | – |
| item.* | ✅ | ✅ | view/create/update | view | – |
| recipe.* | ✅ | ✅ | view | view | – |
| receipt / transfer / inventory | ✅ | ✅ | create/view | view | – |
| writeoff / production | ✅ | ✅ | create/view | create/view | – |
| issue (Расход) | ✅ | ✅ | create/view | – | – |
| balance.view | ✅ | ✅ | ✅ | ✅ | ✅ |
| cost.view (sensitive) | ✅ | ✅ | – | – | – |
| force_negative | ✅ | ✅ | – | – | – |
| inventory.approve | ✅ | ✅ | – | – | – |
Кассиру/официанту stock-права не нужны: продажа списывает автоматически через order flow.
Per-item kitchen-статусы (новая/готовится/готово) — теперь в scope, реализуются через KDS (#408).