Перейти к содержимому

Технологическая карта (вкладка в формах Блюдо / Заготовка / Модификатор)

Что это

Технологическая карта (далее — тех.карта) — рецептура номенклатуры: из каких ингредиентов и в каких количествах она готовится, с какими потерями при обработке, с каким выходом и по какой технологии. Вкладка «Технологические карты» присутствует в формах Блюда (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.
  • Транзакционность. Сохранение тех.карты через карточку номенклатуры выполняется в одной БД-транзакции вместе со всеми остальными вкладками. Падение любого шага — полный откат, тех.карта не сохранится частично.
  • Мультитенантность. Номенклатура и все подразделения в настройках проверяются на принадлежность корпорации из токена. Чужой id404/400.
  • Soft-delete. У самой номенклатуры — мягкое удаление; у тех.карты удаление версии — жёсткое (см. «Выделенный ресурс»). Удалять тех.карту отдельно для вкладки обычно не требуется.

Два способа работать с тех.картой

Составной (рекомендуемый для вкладки)Выделенный ресурс
ЧтениеGET /menu-management/nomenclatures/:id → блок currentTechCardGET /menu-management/nomenclatures/:id/tech-card
ЗаписьPOST/PATCH /menu-management/nomenclatures → поле techCardPOST/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). Подробнее — в разделе «Известные расхождения».

Настройки по подразделениям

Две независимые матрицы (доступны только через составной запрос):

  1. subdivisionSettings — на пару (тех.карта, подразделение): writeOffAsDish — списывать ли позицию в этом подразделении как готовое блюдо, не разворачивая на ингредиенты (актуально прежде всего для заготовок).
  2. 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-place
DELETE /menu-management/nomenclatures/:id/tech-card // удаляет текущую версию, промоутит предыдущую в isCurrent

DTO: create-tech-card.dto.ts, update-tech-card.dto.ts. Эти эндпоинты не принимают subdivisionSettings/ingredientSubdivisionSettings и не смотрят на useTechCardVersions — режим версии жёстко зашит в метод (POST = новая, PATCH = in-place). Для вкладки в форме предпочтителен составной путь.

⚠️ В выделенном POST batchOutput опционален, в составном techCard.batchOutput — обязателен. Для заготовок (SEMI_FINISHED) batchOutput обязателен в обоих путях.

Точечный CRUD ингредиентов

/menu-management/tech-cards/:techCardId/ingredientsGET/POST/PATCH/:id/DELETE/:id отдельной строкой. Изменение пересчитывает себестоимость. Для вкладки, где состав сохраняется массивом целиком, обычно не нужен.


Селекторы и справочники

СелекторИсточникПараметры / примечания
Ингредиент (строка состава)GET /menu-management/nomenclatures/searchsearch (строка), 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)ввод
Время приготовления, минpreparationTimeMinutespreparationTimeMinutes (int ≥ 1)ввод (опц.)
Описаниеdescriptiondescriptionввод (опц.)
Технология приготовленияtechnologytechnologyввод (опц.)
Чекбокс «Использовать версии»useTechCardVersions (поле номенклатуры)useTechCardVersions (поле номенклатуры)ввод
Ингредиент (строка)ingredients[].ingredientId + ingredients[].ingredient.{name,type,costPrice,sku}ingredients[].ingredientIdввод
Бруттоingredients[].grossingredients[].gross (number, > 0)ввод
Ед. изм. строкиingredients[].measurementUnitId + ingredients[].measurementUnit.shortNameingredients[].measurementUnitIdввод
Х.п., % (холодные потери)ingredients[].coldLossingredients[].coldLoss (0..99.99)ввод (опц.)
Г.п., % (горячие потери)ingredients[].hotLossingredients[].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 без batchOutput400
gross ≤ 0400
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.