Метод POST /api/v1/cook/manual

Документация API: Ручная фиксация приготовленного блюда с таргетным списанием ингредиентов и порционной канонизацией

Published

July 1, 2026

1 Функциональное назначение

Метод предназначен для регистрации факта ручного приготовления сложного составного блюда (например, «Яблочный пирог», «Борщ») внутри экосистемы.

Метод решает три ключевые архитектурные задачи:

  1. Таргетное списание остатков: Бэкенд не использует автоматические стратегии вроде FIFO. Пользователь в интерфейсе Flutter сам выбирает конкретные записи партий продуктов (из доступных в холодильнике), которые он физически потратил на готовку.
  2. Унификация порционности готовых блюд: Жидкие, штучные и составные блюда канонизируются к единой базовой единице измерения упак/шт со значением объема 1.0 (одна кастрюля, один пирог). Дальнейшее дробление на порции в микросервисе потребления будет происходить через уменьшение этого float-коэффициента (например, 1 тарелка супа = 0.2 от кастрюли).
  3. Маршрутизация бизнес-профилей: Метод автоматически считывает тип аккаунта из JWT-токена. Для профиля HOME допускается виртуальный уход остатков в минус (если пользователь забыл внести продукты ранее), для профиля OFFICE строго проверяется физическое наличие веса в выбранных строках.

2 Протокол взаимодействия (HTTP Контракт)

  • Метод: POST
  • Маршрут: /api/v1/cook/manual
  • Формат данных: application/json

2.1 Спецификация заголовков (HTTP Headers)

Заголовок Обязательный Описание Пример значения
Content-Type Да Указывает на передачу строго типизированного JSON-пакета application/json
Authorization Да Токен авторизации (Access Token). Бэкенд извлекает из него user_id, home_group_id и account_type (HOME или OFFICE). Bearer eyJhbGciOiJIUzI1Ni...
X-Request-ID Да Сквозной ID запроса для связывания логов списания и зачисления req-cook-3c9b-11ef

2.2 Спецификация тела запроса (Request Body)

В массиве used_ingredients клиент обязан передавать fridge_item_id — уникальный идентификатор конкретной записи товара в холодильнике, выбранной пользователем вручную.

Поле Тип Обязательный Описание
dish_name String Да Пользовательское название приготовленного блюда
output_unit String Да Каноничная единица измерения для кастрюль/пирогов
used_ingredients Array Да Список списания конкретных выбранных партий товаров

2.2.1 Пример сырого JSON-запроса (Payload):

{
  "dish_name": "Борщ домашний",
  "output_unit": "шт",
  "used_ingredients": [
    {
      "fridge_item_id": 1042,
      "product_name": "Мясо говядина (партия 1)",
      "quantity_to_spend": 0.600,
      "unit": "кг"
    },
    {
      "fridge_item_id": 1089,
      "product_name": "Капуста свежая",
      "quantity_to_spend": 0.400,
      "unit": "кг"
    }
  ]
}

3 Схема обработки запроса пользователя (Mermaid)

На диаграмме представлена логика таргетного списания конкретных партий ингредиентов, выбранных пользователем в интерфейсе, и зачисления составного блюда в холодильник как единого масштабируемого объекта со значением объема 1.0.

sequenceDiagram
    autonumber
    actor User as Пользователь (Экран Cook)
    participant APP as Мобильное приложение (Flutter)
    participant API as Бэкенд-шлюз (FastAPI)
    participant DB as Реляционная СУБД (PostgreSQL)
    participant KAFKA as Брокер событий (Kafka)

    User->>APP: Вводит "Борщ", тапает по конкретным строкам партий, жмет "Приготовить"
    APP->>API: HTTP POST /api/v1/cook/manual (С указанием fridge_item_id и веса)
    activate API
    
    note over API: Шаг 3: Декодирование JWT (Определение HOME или OFFICE)
    
    Note over API, DB: Старт атомарной SQL-транзакции
    
    loop По каждому ингредиенту из массива
        API->>DB: SELECT quantity FROM fridge_inventory WHERE item_id = fridge_item_id
        activate DB
        DB-->>API: Возвращает текущий остаток выбранной партии
        deactivate DB
        
        alt Остатка партии ХВАТАЕТ
            API->>DB: UPDATE fridge_inventory SET quantity = quantity - spend
        else Остатка НЕ ХВАТАЕТ / Товар отсутствует
            alt Тип аккаунта == OFFICE
                API-->>APP: HTTP 400 Bad Request (ERR-STOCK-SHORTAGE)
            else Тип аккаунта == HOME
                note over API: Шаг 9: Логика виртуального ухода в минус
                API->>DB: UPDATE fridge_inventory SET quantity = quantity - spend (Значение становится < 0)
            end
        end
    end

    note over API: Шаг 11: Канонизация блюда к объему 1.0
    API->>DB: INSERT INTO fridge_inventory (product_name="Борщ домашний", quantity=1.0, unit="шт")
    
    Note over API, DB: Коммит атомарной транзакции
    
    API->>KAFKA: send_to_kafka_async("bupar.fridge.events.cook")
    API-->>APP: HTTP 201 Created (Блюдо добавлено, остатки списаны)
    deactivate API
    
    note over APP: Экран обновляется: списанные партии уменьшились, появилась 1 шт Борща

4 Расшифровка шагов

  • Шаг 1 (User -> APP): Пользователь на экране готовки вводит текстовое наименование блюда (например, "Борщ домашний"). Ниже в списке остатков холодильника он вручную тапает (выбирает чекбоксами) конкретные строки поставок ингредиентов, которые собирается физически израсходовать (например, старую партию мяса от 20 июня, игнорируя свежую). Пользователь указывает вес списания для каждой строки и нажимает кнопку «Приготовить».
  • Шаг 2 (APP -> API): Приложение через клиент Dio отправляет запрос HTTP POST /api/v1/cook/manual. В теле JSON передается массив used_ingredients, где каждая запись строго привязана к системному fridge_item_id, выбранному пользователем на предыдущем шаге.
  • Шаг 3 (API -> API): Бэкенд-шлюз FastAPI принимает пакет, генерирует сквозной X-Request-ID и декодирует JWT-токен из заголовка Authorization. Из токена извлекается флаг типа аккаунта (account_type), определяющий бизнес-логику транзакции (HOME или OFFICE).
  • Шаг 4 (API -> DB): Бэкенд открывает атомарную SQL-транзакцию в PostgreSQL. Запускается цикл валидации и списания ингредиентов по переданному массиву. Бэкенд делает точечный запрос SELECT quantity FROM fridge_inventory WHERE item_id = $1 для первой выбранной пользователем партии.
  • Шаг 5 (DB -> API): База данных возвращает текущий физический объем или штуки для конкретно этого fridge_item_id.
  • Шаг 6 (API -> DB): Если текущего остатка в выбранной строке достаточно для покрытия указанного веса готовки, бэкенд выполняет операцию вычитания: UPDATE fridge_inventory SET quantity = quantity - $1 WHERE item_id = $2.
  • Шаг 7 (API -> API): Если пользователь указал вес списания больше, чем физически есть в этой строке холодильника (или строка вообще отсутствует), бэкенд переходит к вилке условий в зависимости от типа аккаунта.
  • Шаг 8 (API -> APP): Ветка OFFICE: Строгий корпоративный контур. Бэкенд мгновенно прерывает транзакцию, делает ROLLBACK всех изменений и возвращает мобильному приложению ошибку HTTP 400 Bad Request с кодом ERR-STOCK-SHORTAGE (запрет готовки при дефиците продуктов).
  • Шаг 9 (API -> DB): Ветка HOME: Контур ограниченного доверия для живой семьи. Срабатывает логика виртуального ухода в минус. Система предполагает, что продукты физически есть в холодильнике, но пользователь просто забыл вовремя отсканировать чек при покупке. Бэкенд намеренно выполняет запрос UPDATE fridge_inventory SET quantity = quantity - $1, уводя поле quantity конкретной записи в отрицательное float-значение (например, -0.250 кг).
  • Шаг 10 (API -> DB): Процесс шагов 4–9 циклически повторяется для каждого ингредиента из массива used_ingredients, гарантируя, что спишутся именно те партии, которые выбрал пользователь.
  • Шаг 11 (API -> DB): После завершения цикла списания ингредиентов, бэкенд производит канонизацию и зачисление готового сложного блюда. В таблицу инвентаря добавляется новая независимая запись с наименованием блюда. Объём выставляется в базовое значение 1.0 (одна целая кастрюля или один целый пирог), а единица измерения — в "шт". Это позволит следующему микросервису потребления делить данный объект на дробные порции (например, списать 0.2 от кастрюли супа как одну тарелку).
  • Шаг 12 (API -> KAFKA): Атомарная транзакция фиксируется в PostgreSQL (COMMIT). Бэкенд асинхронно отправляет бизнес-событие в топик Kafka fridge.events.cook, содержащее ID созданного блюда и список потраченных item_id для сквозной продуктовой аналитики.
  • Шаг 13 (API -> APP): Бэкенд возвращает успешный ответ HTTP 201 Created. Во Flutter-приложении обновляется состояние экрана склада: выбранные ингредиенты уменьшают свой вес (или уходят в минус), а в списке холодильника появляется новая строчка готового блюда с объемом 1.0 шт.

5 Спецификация ответов сервера (Response Body) и ошибок

5.1 1. Успешный ответ (Success Response)

5.1.1 HTTP 201 Created (Ответ на Шаге 13)

Возвращается после успешного завершения атомарной транзакции (точечного списания выбранных партий ингредиентов и зачисления готового блюда с базовым объемом 1.0).

  • Заголовки ответа (Response Headers):
    • Content-Type: application/json
  • Тело ответа (Response Body):
{
  "status": "DISH_CREATED",
  "data": {
    "fridge_item_id": 5012,
    "dish_name": "Борщ домашний",
    "allocated_quantity": 1.0,
    "unit": "шт",
    "account_context": "HOME",
    "timestamp": "2026-07-01T21:51:30Z"
  }
}

5.2 2. Спецификация ошибок и вилок исключений (Error Responses)

5.2.1 Ошибка дефицита остатков (HTTP 400 Bad Request — Шаг 8)

Выбрасывается строго в контексте аккаунта OFFICE, если в процессе проверки на Шаге 5 обнаружилось, что физический объем выбранного товара (fridge_item_id) меньше, чем пользователь указал в поле quantity_to_spend. Транзакция полностью откатывается (ROLLBACK).

{
  "error_code": "ERR-STOCK-SHORTAGE",
  "message": "Операция отклонена: в корпоративном контуре запрещено списывать отсутствующие продукты.",
  "details": {
    "reason": "Physical volume validation failed for requested ingredient cluster.",
    "failed_item_id": 1042,
    "requested_quantity": 0.600,
    "available_quantity": 0.150,
    "unit": "кг"
  }
}

5.2.2 Ошибка валидации структуры входных данных (HTTP 422 Unprocessable Entity)

Выбрасывается на Шаге 3, если Pydantic-модель на FastAPI обнаружила отрицательные значения в полях списания веса или передана невалидная строка UUID.

{
  "error_code": "ERR-VALIDATION-FAILED",
  "message": "Переданный JSON-пакет содержит некорректные типы данных или отрицательные веса.",
  "details": [
    {
      "loc": ["body", "used_ingredients", 0, "quantity_to_spend"],
      "msg": "Значение веса или количества должно быть строго больше 0.0001",
      "type": "value_error.number.not_gt"
    }
  ]
}

5.3 3. Техническое примечание для разработчиков (База данных и Стрим Потребления)

  1. Хранение виртуального минуса в PostgreSQL: Поле quantity в таблице fridge_inventory имеет тип NUMERIC(12, 4). Для аккаунтов типа HOME ограничение CHECK (quantity >= 0) отсутствует. Отрицательное значение (например, -0.2500) является валидным техническим состоянием системы, сигнализирующим о задолженности по вводу ресурсов.

  2. Масштабируемость для последующего микросервиса Потребления: Зачисленное блюдо "Борщ домашний" со значением 1.0 шт готово к порционному делению. В следующем микросервисе метод списания порции будет выполнять обычное математическое вычитание долей:

    -- Списание одной порции (1 тарелка = 0.2 от объема кастрюли)
    UPDATE fridge_inventory 
    SET quantity = quantity - 0.2000 
    WHERE item_id = 5012;

    Как только значение quantity достигает 0.0000, запись переводится в статус архива и скрывается из интерфейса Flutter.