Технологическая карта (вкладка в формах Блюдо / Заготовка / Модификатор)
Что это
Технологическая карта (далее — тех.карта) — рецептура номенклатуры: из каких ингредиентов и в каких количествах она готовится, с какими потерями при обработке, с каким выходом и по какой технологии. Вкладка «Технологические карты» присутствует в формах Блюда (DISH), Заготовки (SEMI_FINISHED) и Модификатора (MODIFIER) — это один и тот же раздел карточки номенклатуры.
На основе состава тех.карты сервер рассчитывает себестоимость номенклатуры (costPrice) и по дереву «заготовка → блюдо» каскадно пересчитывает себестоимости родительских позиций. Тех.карта версионируется: значимое изменение рецептуры может создавать новую версию и закрывать предыдущую (управляется флагом номенклатуры). Дополнительно тех.карта несёт настройки по подразделениям (списывать ли заготовку как готовое блюдо; включён ли конкретный ингредиент в конкретном подразделении).
⚠️ Главное для интеграции: тех.карта сохраняется и читается вместе с карточкой номенклатуры одним запросом (составной DTO, одна транзакция). Выделенные эндпоинты тех.карты (
.../tech-card) — вспомогательные (история версий, печать, точечный CRUD ингредиентов) и не покрывают настройки по подразделениям. Для вкладки в форме Блюда/Заготовки/Модификатора по умолчанию используется составной путь.
Картина пользователя
┌─ Форма «Блюдо / Заготовка / Модификатор» ───────────────────────────┐│ [Основное] [Единицы] [Цены] … [Технологические карты] ← вкладка ││ ┌─ Технологические карты ──────────────────────────────────────────┐ ││ │ Норма закладки (выход) [0.95] Время приготовления [30] мин │ ││ │ ⬜ Использовать версии тех.карт │ ││ │ ─────────────────────────────────────────────────────────────── │ ││ │ № Ингредиент Брутто Ед. Х.п.,% Г.п.,% Нетто Выход ₽/ед │ ││ │ 1 Молоко... 0.200 л 0 0 0.200 0.200 12.0 │ ││ │ 2 Кофе зерно... 0.018 кг 2.0 0 0.0176 0.0176 9.9 │ ││ │ [+ Добавить ингредиент] │ ││ │ ─────────────────────────────────────────────────────────────── │ ││ │ Технология приготовления [textarea...] │ ││ │ Себестоимость (расчёт): 21.90 ₽ │ ││ │ ─────────────────────────────────────────────────────────────── │ ││ │ Настройки по подразделениям ▸ (списывать как блюдо / вкл. ингр.) │ ││ │ [История версий] [Печать PDF] │ ││ └──────────────────────────────────────────────────────────────────┘ ││ [Отмена] [Сохранить] │└─────────────────────────────────────────────────────────────────────┘
Открытие карточки → GET /menu-management/nomenclatures/:id (вкладку заполняет блок `currentTechCard`) Селектор «Ингредиент» → GET /menu-management/nomenclatures/search?types=... Селектор «Ед.» строки → GET /menu-management/nomenclatures/:ingredientId/measurement-units Настройки по подразд. → GET /organization/subdivisions «Сохранить» → POST/PATCH /menu-management/nomenclatures (поле `techCard` + `useTechCardVersions`) «История версий» → GET /menu-management/nomenclatures/:id/tech-card/versions GET /menu-management/nomenclatures/:id/tech-card/versions/:versionId «Печать PDF» → GET /menu-management/nomenclatures/:id/tech-card/pdfБазовое
- Auth. Все запросы —
Authorization: Bearer <access_token>. ID корпорации берётся из токена, в query/body его передавать не нужно. На401— refresh, при повторном401— login. - Числа. Все дробные значения (масса, потери, выход, себестоимость) приходят с сервера строками (
"0.200","21.9000") для сохранения точности — парсить вDecimal-библиотеку фронта. В теле запроса передавать какnumber. - Потери (
coldLoss,hotLoss) — проценты в диапазоне0…99.99, не более 2 знаков после запятой.null/не передано трактуется как0. - Транзакционность. Сохранение тех.карты через карточку номенклатуры выполняется в одной БД-транзакции вместе со всеми остальными вкладками. Падение любого шага — полный откат, тех.карта не сохранится частично.
- Мультитенантность. Номенклатура и все подразделения в настройках проверяются на принадлежность корпорации из токена. Чужой
id→404/400. - Soft-delete. У самой номенклатуры — мягкое удаление; у тех.карты удаление версии — жёсткое (см. «Выделенный ресурс»). Удалять тех.карту отдельно для вкладки обычно не требуется.
Два способа работать с тех.картой
| Составной (рекомендуемый для вкладки) | Выделенный ресурс | |
|---|---|---|
| Чтение | GET /menu-management/nomenclatures/:id → блок currentTechCard | GET /menu-management/nomenclatures/:id/tech-card |
| Запись | POST/PATCH /menu-management/nomenclatures → поле techCard | POST/PATCH /menu-management/nomenclatures/:id/tech-card |
| Состав ингредиентов | ✅ массив ingredients (замещает текущий) | ✅ |
| Настройки по подразделениям | ✅ subdivisionSettings, ingredientSubdivisionSettings | ❌ не поддерживаются |
| Версионирование по флагу | ✅ через useTechCardVersions | ⚠️ POST всегда создаёт новую версию, PATCH всегда in-place |
| Одна транзакция со всей карточкой | ✅ | ❌ только тех.карта |
Вывод для фронта вкладки: заполнять вкладку из currentTechCard (чтение карточки) и сохранять её внутри составного POST/PATCH номенклатуры. Выделенный ресурс использовать только для «Истории версий», чтения конкретной версии и печати.
Эндпоинты раздела
| Метод | Маршрут | Когда вызывается из UI |
|---|---|---|
GET | /menu-management/nomenclatures/:id | Открытие карточки — заполнение вкладки из currentTechCard |
POST | /menu-management/nomenclatures | «Сохранить» при создании номенклатуры (с techCard) |
PATCH | /menu-management/nomenclatures/:id | «Сохранить» при редактировании (с techCard) |
GET | /menu-management/nomenclatures/search | Селектор «Ингредиент» (поиск/фильтр/пагинация) |
GET | /menu-management/nomenclatures/:ingredientId/measurement-units | Селектор единицы измерения в строке ингредиента |
GET | /organization/subdivisions | Список подразделений для матриц настроек |
GET | /menu-management/nomenclatures/:id/tech-card | Текущая тех.карта (без матриц настроек) |
GET | /menu-management/nomenclatures/:id/tech-card/versions | «История версий» — список версий |
GET | /menu-management/nomenclatures/:id/tech-card/versions/:versionId | Открытие конкретной исторической версии (с матрицами настроек на тот момент) |
POST | /menu-management/nomenclatures/:id/tech-card | Создать тех.карту отдельным запросом (вспомогательно) |
PATCH | /menu-management/nomenclatures/:id/tech-card | Обновить текущую тех.карту in-place (вспомогательно) |
DELETE | /menu-management/nomenclatures/:id/tech-card | Удалить текущую версию (промоутит предыдущую) |
GET | /menu-management/nomenclatures/:id/tech-card/pdf | «Печать PDF» одной тех.карты |
POST | /menu-management/nomenclatures/tech-cards/pdf | Печать тех.карт по списку id (массовая) |
GET | /menu-management/nomenclature-groups/:id/tech-cards/pdf | Печать всех тех.карт группы |
GET/POST/PATCH/DELETE | /menu-management/tech-cards/:techCardId/ingredients[/:id] | Точечный CRUD строк ингредиентов (вспомогательно) |
Контроллеры: nomenclature.controller.ts, tech-card.controller.ts, tech-card-ingredient.controller.ts.
Сценарий 1. Открыть карточку — заполнить вкладку
Запрос
GET /menu-management/nomenclatures/:idОтвет (фрагмент, только релевантное вкладке)
Полный DTO — nomenclature.mapper.ts. Для вкладки важны useTechCardVersions, costPrice, currentTechCard и techCards.
{ "id": "uuid-номенклатуры", "type": "DISH", "costPrice": "21.9000", // рассчитанная себестоимость (read-only) "useTechCardVersions": false, // состояние чекбокса «Использовать версии»
"techCards": [ // краткий список ВСЕХ версий (для бейджа/счётчика) { "id": "uuid-v2", "version": 2, "isCurrent": true, "description": null }, { "id": "uuid-v1", "version": 1, "isCurrent": false, "description": "Старый рецепт", }, ],
"currentTechCard": { // ← всё для заполнения вкладки; null если карты нет "id": "uuid-v2", "version": 2, "description": null, "batchOutput": "0.95", // норма закладки (выход партии) "preparationTimeMinutes": 30, "technology": "Сварить эспрессо, добавить молоко", "ingredients": [ { "id": "uuid-строки", "gross": "0.200", "coldLoss": null, "hotLoss": null, "measurementUnitId": "uuid-ед", "ingredientId": "uuid-ингредиента", "measurementUnit": { "shortName": "л" }, "ingredient": { "name": "Молоко 3.2%", "type": "GOODS", "costPrice": "60.0000", "sku": "SKU-MILK", }, "calculations": { // расчётные поля строки (read-only, считает сервер) "netto": "0.200", "outputYield": "0.200", "lineCost": "12.0000", "costPerOutputUnit": "12.6316", }, }, ], "subdivisionSettings": [ // матрица «списывать как блюдо» { "subdivisionId": "uuid-подр", "writeOffAsDish": false }, ], "ingredientSubdivisionSettings": [ // матрица вкл/выкл ингредиента { "ingredientId": "uuid-ингредиента", "subdivisionId": "uuid-подр", "isEnabled": true, }, ], },}currentTechCard === null→ у номенклатуры ещё нет тех.карты, вкладка пустая.currentTechCardотдаёт полный срез текущей версии (состав + расчёты + обе матрицы настроек) — отдельных запросов под вкладку делать не нужно.techCards[]— только список версий (id/version/isCurrent/description) для счётчика и переключателя «История версий».ingredientSubdivisionSettings[].ingredientId— это номенклатура-ингредиент, не id строки тех.карты (так удобнее сопоставлять с составом и переносить настройки между версиями).
Сценарий 2. Сохранить вкладку (составной запрос)
Тех.карта передаётся как поле techCard внутри тела создания/обновления номенклатуры. Флаг useTechCardVersions — поле самой номенклатуры (не внутри techCard).
Запрос
POST /menu-management/nomenclatures // созданиеPATCH /menu-management/nomenclatures/:id // редактирование{ // ...остальные поля номенклатуры (name, type, groupId, visibility, ...). // Цена в составном DTO не передаётся: цена меняется только приказом об изменении цен. "useTechCardVersions": false, "techCard": { "description": null, "batchOutput": 0.95, // ОБЯЗАТЕЛЕН (> 0). Для SEMI_FINISHED — тем более "preparationTimeMinutes": 30, // опц., целое ≥ 1 "technology": "Сварить эспрессо...", // опц. "ingredients": [ // ОБЯЗАТЕЛЕН (массив полностью замещает состав) { "gross": 0.2, // > 0 "coldLoss": 0, // опц., 0..99.99 "hotLoss": 0, // опц., 0..99.99 "measurementUnitId": "uuid-ед", "ingredientId": "uuid-ингредиента", }, ], "subdivisionSettings": [ // опц.; см. семантику ниже { "subdivisionId": "uuid-подр", "writeOffAsDish": false }, ], "ingredientSubdivisionSettings": [ // опц.; ключ — (subdivisionId, ingredientId) { "ingredientId": "uuid-ингредиента", "subdivisionId": "uuid-подр", "isEnabled": true, }, ], },}DTO: create-nomenclature.dto.ts, вложенный nomenclature-tech-card-input.dto.ts.
Поведение
- Если
techCardне передан — тех.карта не трогается (полезно при сохранении других вкладок). ingredientsвсегда замещает текущий состав целиком (не diff). Прислать пустой[]= убрать все ингредиенты.subdivisionSettings/ingredientSubdivisionSettings:- не переданы (
undefined) — настройки не трогаются; - переданы массивом — при создании первой версии и при in-place обновлении массив замещает текущий набор (пустой
[]= удалить все); - при создании новой версии (см. ниже) — переносятся со старой версии и перекрываются переданными по ключу.
- не переданы (
- Ответ — тот же
NomenclatureDetailResponseDto, что и в Сценарии 1 (с уже пересчитаннымиcostPriceиcalculations). Перечитывать карточку отдельно не нужно.
Версионирование
Управляется флагом номенклатуры useTechCardVersions в момент составного сохранения с techCard:
| Ситуация | Что происходит |
|---|---|
| Тех.карты ещё нет | Создаётся версия 1. |
useTechCardVersions = true и значимое изменение | Создаётся новая версия (v+1), предыдущая закрывается (isCurrent=false, проставляется validTo). |
useTechCardVersions = false или нет значимых изменений | Текущая версия обновляется in-place. |
Значимое изменение = отличие хотя бы в одном: состав ингредиентов (набор/масса/потери/единица), batchOutput, description, technology, preparationTimeMinutes. Изменение только настроек по подразделениям новую версию не создаёт.
При создании новой версии настройки по подразделениям мигрируют: настройки предыдущей версии копируются на новую и перекрываются переданными в запросе по ключу (subdivisionId — для матрицы тех.карты; subdivisionId + ingredientId — для матрицы ингредиентов). Настройки ингредиентов, выпавших из нового состава, отбрасываются.
Если флаг useTechCardVersions не передан в PATCH — берётся предыдущее значение флага у номенклатуры.
Расчётные поля (read-only, считает сервер)
Фронт эти поля только отображает, не присылает. Формат — строки.
На строку ингредиента (currentTechCard.ingredients[].calculations, см. tech-card-calculations.ts):
| Поле | Смысл | Формула |
|---|---|---|
netto | Нетто (после холодной обработки) | gross × (1 − coldLoss/100) |
outputYield | Выход готового продукта | gross × (1 − coldLoss/100) × (1 − hotLoss/100) |
lineCost | Себестоимость строки | costPrice ингредиента × gross / netFactor / conversionFactor; null, если у ингредиента нет себестоимости |
costPerOutputUnit | Стоимость на единицу выхода | lineCost / batchOutput; null, если расчёт невозможен |
На номенклатуру:
costPrice— себестоимость позиции, считается какΣ lineCost / batchOutputи сохраняется на номенклатуре. Пересчитывается при сохранении тех.карты, при изменении себестоимости ингредиентов и каскадно для родительских блюд, где данная позиция используется как ингредиент.- Если у любого ингредиента нет себестоимости — расчёт
costPriceблюда вернёт ошибку (400), см. ниже. - 🔴 Текущее ограничение бэка: если рассчитанная
costPriceполучается с более чем 2 знаками после запятой, сохранение тех.карты падает с400 «Рассчитанная себестоимость: допустимо не более 2 знаков». На практике это блокирует большинство «некруглых» рецептур (напримерbatchOutput=0.97). Подробнее — в разделе «Известные расхождения».
Настройки по подразделениям
Две независимые матрицы (доступны только через составной запрос):
subdivisionSettings— на пару (тех.карта, подразделение):writeOffAsDish— списывать ли позицию в этом подразделении как готовое блюдо, не разворачивая на ингредиенты (актуально прежде всего для заготовок).ingredientSubdivisionSettings— на тройку (ингредиент, подразделение):isEnabled— участвует ли конкретный ингредиент в списании в этом подразделении.
Источник списка подразделений — GET /organization/subdivisions. Валидация на стороне сервера:
- дубликаты по
subdivisionId(для матрицы тех.карты) и по(subdivisionId, ingredientId)(для матрицы ингредиентов) —400; ingredientIdвingredientSubdivisionSettings, которого нет в составе тех.карты, —400;- подразделение чужой корпорации — ошибка принадлежности.
Выделенный ресурс тех.карты (вспомогательно)
Базовый префикс — /menu-management/nomenclatures/:nomenclatureId/tech-card.
Чтение текущей версии
GET /menu-management/nomenclatures/:id/tech-card → TechCardDetailResponseDto | nullВозвращает текущую версию с ingredients и calculations, без матриц настроек по подразделениям (они есть только в составном чтении номенклатуры и в чтении конкретной версии). null — тех.карты нет.
История версий
GET /menu-management/nomenclatures/:id/tech-card/versions → TechCardResponseDto[]Список всех версий с метаданными: version, isCurrent, validFrom/validTo, batchOutput, preparationTimeMinutes, createdById и createdByName (ФИО автора версии).
GET /menu-management/nomenclatures/:id/tech-card/versions/:versionId → TechCardVersionDetailResponseDtoПолный исторический срез одной версии: состав + расчёты + обе матрицы настроек, действовавшие на момент этой версии (а не текущие). 404, если версия не принадлежит номенклатуре.
Запись (без настроек по подразделениям)
POST /menu-management/nomenclatures/:id/tech-card // ВСЕГДА создаёт новую версиюPATCH /menu-management/nomenclatures/:id/tech-card // ВСЕГДА обновляет текущую in-placeDELETE /menu-management/nomenclatures/:id/tech-card // удаляет текущую версию, промоутит предыдущую в isCurrentDTO: create-tech-card.dto.ts, update-tech-card.dto.ts. Эти эндпоинты не принимают subdivisionSettings/ingredientSubdivisionSettings и не смотрят на useTechCardVersions — режим версии жёстко зашит в метод (POST = новая, PATCH = in-place). Для вкладки в форме предпочтителен составной путь.
⚠️ В выделенном
POSTbatchOutputопционален, в составномtechCard.batchOutput— обязателен. Для заготовок (SEMI_FINISHED)batchOutputобязателен в обоих путях.
Точечный CRUD ингредиентов
/menu-management/tech-cards/:techCardId/ingredients — GET/POST/PATCH/:id/DELETE/:id отдельной строкой. Изменение пересчитывает себестоимость. Для вкладки, где состав сохраняется массивом целиком, обычно не нужен.
Селекторы и справочники
| Селектор | Источник | Параметры / примечания |
|---|---|---|
| Ингредиент (строка состава) | GET /menu-management/nomenclatures/search | search (строка), types (DISH,SEMI_FINISHED,GOODS,...), status, groupId, warehouseIds, page, limit. Ответ — пагинированный объект { items, total, page, limit }. У каждой позиции в items есть name, type, costPrice, sku — их и показывать в выпадашке. Себестоимость без значения у ингредиента → строка состава посчитается, но lineCost = null и costPrice блюда не рассчитается. |
| Единица измерения строки | GET /menu-management/nomenclatures/:ingredientId/measurement-units | Единицы выбранного ингредиента (measurementUnitId, conversionFactor, isBase). measurementUnitId строки тех.карты должен быть из единиц этого ингредиента; иначе при расчёте conversionFactor берётся 1 (с предупреждением в логах). Сокращение для отображения приходит в ответе тех.карты как ingredients[].measurementUnit.shortName. |
| Подразделение (матрицы настроек) | GET /organization/subdivisions | Подразделения корпорации. |
Маппинг полей вкладки UI ↔ DTO
| Поле UI | Чтение (из currentTechCard) | Запись (в techCard) | Направление |
|---|---|---|---|
| Норма закладки / выход | batchOutput (строка) | batchOutput (number, > 0) | ввод |
| Время приготовления, мин | preparationTimeMinutes | preparationTimeMinutes (int ≥ 1) | ввод (опц.) |
| Описание | description | description | ввод (опц.) |
| Технология приготовления | technology | technology | ввод (опц.) |
| Чекбокс «Использовать версии» | useTechCardVersions (поле номенклатуры) | useTechCardVersions (поле номенклатуры) | ввод |
| Ингредиент (строка) | ingredients[].ingredientId + ingredients[].ingredient.{name,type,costPrice,sku} | ingredients[].ingredientId | ввод |
| Брутто | ingredients[].gross | ingredients[].gross (number, > 0) | ввод |
| Ед. изм. строки | ingredients[].measurementUnitId + ingredients[].measurementUnit.shortName | ingredients[].measurementUnitId | ввод |
| Х.п., % (холодные потери) | ingredients[].coldLoss | ingredients[].coldLoss (0..99.99) | ввод (опц.) |
| Г.п., % (горячие потери) | ingredients[].hotLoss | ingredients[].hotLoss (0..99.99) | ввод (опц.) |
| Нетто / Выход / ₽ строки | ingredients[].calculations.{netto,outputYield,lineCost,costPerOutputUnit} | — | только чтение |
| Себестоимость позиции | costPrice (поле номенклатуры) | — | только чтение |
| «Списывать как блюдо» | subdivisionSettings[] | subdivisionSettings[] | ввод (опц.) |
| Вкл/выкл ингредиента по подразделению | ingredientSubdivisionSettings[] | ingredientSubdivisionSettings[] | ввод (опц.) |
Печать PDF
GET /menu-management/nomenclatures/:id/tech-card/pdf // одна тех.карта (inline PDF)POST /menu-management/nomenclatures/tech-cards/pdf // по списку id: { "nomenclatureIds": ["uuid", ...] }GET /menu-management/nomenclature-groups/:id/tech-cards/pdf // все тех.карты группы- Ответ —
application/pdf. - Печать одной карты:
400, если у номенклатуры нет актуальной тех.карты. - Массовая печать: список
nomenclatureIdsне пустой; позиции без актуальной тех.карты пропускаются без ошибки; если ни у одной из списка нет тех.карты —400.
Валидации и типичные ошибки
| Ситуация | Ответ |
|---|---|
batchOutput ≤ 0 (или отсутствует в составном techCard) | 400 |
SEMI_FINISHED без batchOutput | 400 |
gross ≤ 0 | 400 |
coldLoss/hotLoss вне 0..99.99 или > 2 знаков | 400 |
| Суммарные потери дают нулевой/отрицательный нетто-выход | 400 |
| Дубли ингредиентов в составе | 400 |
| Номенклатура — ингредиент собственной тех.карты | 400 |
| Циклическая зависимость (A → B → A через тех.карты) | 400 |
ingredientId строки не найден / удалён | 400 |
У ингредиента нет себестоимости при расчёте costPrice блюда | 400 с указанием ингредиента |
🔴 Рассчитанная costPrice имеет > 2 знаков после запятой (баг, см. ниже) | 400 «не более 2 знаков» |
| Дубли/чужие подразделения в матрицах настроек | 400 |
| Номенклатура/версия не найдена или чужой корпорации | 404 |
Известные расхождения и план доработок
- ✅ Чтение вкладки одним запросом (
currentTechCard), составное сохранение, версии, расчётные поля, настройки по подразделениям, печать — реализованы (LOCALIOFFICE-950, LOCALIOFFICE-977). - ⚠️ Настройки по подразделениям (
subdivisionSettings,ingredientSubdivisionSettings) пишутся и читаются только составным путём (через карточку номенклатуры) и в чтении конкретной версии. ВыделенныеPOST/PATCH .../tech-cardих не покрывают — для вкладки в форме использовать составной путь. - ⚠️ Выделенный
GET .../tech-cardне возвращает матрицы настроек (только состав и расчёты). Для матриц текущей версии — читать карточку номенклатуры; для исторической версии —…/tech-card/versions/:versionId. - ⚠️
useTechCardVersions— поле номенклатуры, применяется только при составном сохранении сtechCard. Выделенные эндпоинты тех.карты на него не смотрят. - 🔴 Баг (подтверждён на живом API 2026-06-01): сохранение тех.карты падает с
400 «Рассчитанная себестоимость: допустимо не более 2 знаков», если рассчитаннаяcostPriceимеет более 2 знаков после запятой. Себестоимость считается с округлением до 4 знаков, но валидируется денежным типом с точностью 2 знака — конфликт. Воспроизводится на тривиальных данных (напримерbatchOutput=0.97), то есть блокирует сохранение большинства реальных рецептур. До фикса фронту стоит учитывать, что не любая комбинация состава/нормы закладки сохранится. Фикс — отдельной задачей (точность себестоимости 2 vs 4 знака — продуктовое решение). - Структура ответа селектора ингредиентов (
/nomenclatures/search) — пагинированный объект{ items, total, page, limit }(не голый массив). - Фронт-задача интеграции вкладки — LOCALIOFFICE-1064. Документ ведётся в рамках LOCALIOFFICE-1065.