Метод POST /api/v1/fridge/items/merge

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

Published

July 1, 2026

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

Метод предназначен для оптимизации складского пространства и наведения порядка в интерфейсе холодильника. Если в процессе нескольких независимых покупок (через фото чека или интеграцию с магазинами и продуктовыми сетями) в системе образовалось несколько раздельных записей одного и того же товара (например, две строки «Яблоки» с весом 1.0 кг и 0.5 кг), пользователь может вручную “схлопнуть” их в одну общую позицию.

Метод решает следующие задачи:

  1. Валидация однородности (Защита от смешивания): Гарантирует, что слияние возможно только для товаров с идентичным наименованием и одинаковой единицей измерения (кг с кг, шт со шт). Слияние «яблок» с «апельсинами» блокируется на уровне бизнес-логики.
  2. Математическая калибровка (Погашение долгов): Корректно суммирует объемы float/double, включая случаи, когда одна из партий находится в техническом отрицательном состоянии (виртуальном минусе).
  3. Консолидация данных: Суммирует объемы, переносит вес на целевую запись (Target), а исходную запись (Source) безвозвратно удаляет из таблицы fridge_inventory.

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

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

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

Заголовок Обязательный Описание Пример значения
Content-Type Да Указывает на отправку JSON-пакета application/json
Authorization Да Токен авторизации (Access Token) для проверки прав на владение холодильником Bearer eyJhbGciOiJIUzI1Ni...
X-Request-ID Да Сквозной ID запроса для распределенного логирования req-merge-77aa

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

Клиент передает ID записи, которую нужно поглотить (source_item_id), и ID записи, в которую перетечет суммарный вес (target_item_id).

Поле Тип Обязательный Описание Пример значения
source_item_id Integer Да ID записи инвентаря, которая будет удалена после переноса веса 1042
target_item_id Integer Да ID целевой записи, баланс которой увеличится на объем исходной 2085

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

{
  "source_item_id": 1042,
  "target_item_id": 2085
}

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

Диаграмма описывает логику валидации однородности продуктов, проверку принадлежности к одной семье/офису (home_group_id) и математику схлопывания скрытых отрицательных остатков для домашнего контура.

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

    User->>APP: Перетаскивает карточку "Яблоки" на другие "Яблоки" (Слияние)
    APP->>API: HTTP POST /api/v1/fridge/items/merge (JSON с source_id и target_id)
    activate API
    
    note over API: Шаг 3: Декодирование JWT (Выделение home_group_id и account_type)
    
    Note over API, DB: Старт атомарной SQL-транзакции
    
    API->>DB: SELECT * FROM fridge_inventory WHERE item_id IN (source_id, target_id)
    activate DB
    DB-->>API: Возвращает метаданные обеих партий товара
    deactivate DB
    
    note over API: Шаг 6: Валидация безопасности (home_group_id совпадает?)
    note over API: Шаг 7: Валидация однородности (Имена и Unit совпадают?)
    
    alt Имена, Единицы или Группы НЕ СОВПАДАЮТ
        API-->>APP: HTTP 422 Unprocessable Entity (ERR-MERGE-VIOLATION)
    else Валидация успешна
        alt Контекст аккаунта == OFFICE
            note over API: Шаг 9: Ветка OFFICE (Оба значения гарантированно > 0)
            API->>DB: UPDATE target SET quantity = target.qty + source.qty
            API->>DB: DELETE FROM fridge_inventory WHERE item_id = source_id
        else Контекст аккаунта == HOME
            note over API: Шаг 10: Ветка HOME (Схлопывание с учетом скрытого минуса)
            API->>DB: UPDATE target SET quantity = target.qty + source.qty
            API->>DB: DELETE FROM fridge_inventory WHERE item_id = source_id
        end
        
        Note over API, DB: Коммит атомарной транзакции (COMMIT)
        API->>KAFKA: send_to_kafka_async("bupar.fridge.events.merged")
        API-->>APP: HTTP 200 OK (status: "SUCCESS_MERGED")
    end
    deactivate API
    note over APP: Экран обновляется: исходная карточка исчезает, целевая увеличивает вес

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

  • Шаг 1 (User -> APP): Пользователь в интерфейсе холодильника (или в окне задолженностей) решает объединить две записи однородного товара. Например, перетаскивает карточку скрытого минуса яблок (-0.3 кг) на карточку свежей закупки яблок (+1.0 кг).
  • Шаг 2 (APP -> API): Мобильное приложение через Dio отправляет запрос HTTP POST /api/v1/fridge/items/merge. В теле запроса передаются идентификаторы source_item_id (что поглощается) и target_item_id (куда перетекает вес).
  • Шаг 3 (API -> API): Бэкенд-шлюз FastAPI декодирует JWT-токен. Из него извлекаются home_group_id и account_type. Бэкенд запоминает эту ветку для применения специфичных правил (в OFFICE запрещены отрицательные значения, в HOME — разрешено математическое схлопывание долга).
  • Шаг 4 (API -> DB): Бэкенд открывает атомарную SQL-транзакцию в PostgreSQL и блокирует строки запросом SELECT * FROM fridge_inventory WHERE item_id IN ($1, $2) FOR UPDATE. Это защищает данные от параллельного списания во время слияния.
  • Шаг 5 (DB -> API): База данных возвращает полные физические структуры обеих записей: их имена, текущие количества (включая скрытые минусы для HOME), единицы измерения (unit) и идентификаторы владельцев группы.
  • Шаг 6 (API -> API): Проверка Контекста Группы: Бэкенд сверяет home_group_id обеих записей с home_group_id из токена. Если пользователь попытался подменить ID и слить свои продукты с чужим холодильником, транзакция падает со статусом HTTP 403 Forbidden.
  • Шаг 7 (API -> API): Проверка Однородности: Бэкенд сравнивает текстовые наименования и единицы измерения: LOWER(source.product_name) == LOWER(target.product_name) AND source.unit == target.unit. Если пользователь пытается слить яблоки с апельсинами или килограммы со штуками, транзакция откатывается (ROLLBACK) с ошибкой HTTP 422.
  • Шаг 8 (API -> API): Если все проверки пройдены успешно, бэкенд разветвляет логику в зависимости от account_type.
  • Шаг 9 (API -> DB): Ветка OFFICE: Так как в офисе минусов быть не может, система берет два чистых положительных float-значения, суммирует их, записывает результат в целевую строку (target_item_id), а исходную строку (source_item_id) физически удаляет из СУБД.
  • Шаг 10 (API -> DB): Ветка HOME: Включается логика схлопывания скрытого долга. Если исходная запись была в скрытом минусе (-0.3 кг), а целевая в плюсе (+1.0 кг), СУБД производит честное алгебраическое сложение: 1.0 + (-0.3) = 0.7 кг. Новый чистый остаток записывается в целевую строку, а строка долга удаляется из базы, автоматически гася алерт в интерфейсе.
  • Шаг 11 (API -> KAFKA): Атомарная транзакция фиксируется (COMMIT). Бэкенд отправляет асинхронное сообщение в топик Kafka bupar.fridge.events.merged для пересчета продуктовых метрик в дата-лейке.
  • Шаг 12 (API -> APP): Сервер возвращает статус HTTP 200 OK. Во Flutter-приложении срабатывает триггер: экран холодильника перерисовывается, старая карточка исчезает, а целевая отображает консолидированный вес продукта.

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

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

5.1.1 HTTP 200 OK (Ответ на Шаге 12)

Возвращается после успешного завершения атомарной транзакции слияния партий. Исходная запись удаляется, а целевая обновляется с учетом консолидированного float-объема.

  • Заголовки ответа (Response Headers):
    • Content-Type: application/json
  • Тело ответа (Response Body):
{
  "status": "SUCCESS_MERGED",
  "data": {
    "target_item_id": 2085,
    "product_name": "Яблоки",
    "consolidated_quantity": 0.7,
    "unit": "кг",
    "account_context": "HOME",
    "deleted_source_item_id": 1042,
    "timestamp": "2026-07-01T23:28:00Z"
  }
}

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

5.2.1 Ошибка коллизии однородности и единиц измерения (HTTP 422 Unprocessable Entity — Шаг 7)

Выбрасывается, если пользователь пытается объединить разнородные продукты (яблоки с апельсинами) или однородные продукты с несовпадающими единицами измерения (unit), которые нельзя математически сложить напрямую.

{
  "error_code": "ERR-MERGE-VIOLATION",
  "message": "Ошибка слияния: продукты должны иметь одинаковое наименование и идентичные единицы измерения.",
  "details": {
    "reason": "Product name or unit token mismatch between source and target rows.",
    "source": {
      "product_name": "Яблоки",
      "unit": "шт"
    },
    "target": {
      "product_name": "Яблоки",
      "unit": "кг"
    }
  }
}

5.2.2 Ошибка нарушения контекста безопасности (HTTP 403 Forbidden — Шаг 6)

Выбрасывается, если один из переданных идентификаторов (source_item_id или target_item_id) принадлежит чужой семье или другому офису. Система блокирует попытку межгруппового смешивания данных.

{
  "error_code": "ERR-CROSS-GROUP-MERGE",
  "message": "Операция отклонена: запрещено объединять продукты из разных холодильников или бизнес-контекстов.",
  "details": {
    "reason": "Security token home_group_id does not match the database scope for one of the entities."
  }
}

5.2.3 Ошибка: Запись инвентаря не найдена (HTTP 404 Not Found — Шаг 5)

Выбрасывается, если один из указанных ID физически отсутствует в таблице fridge_inventory (например, запись была параллельно удалена или списана в ноль другим членом семьи).

{
  "error_code": "ERR-MERGE-RECORD-NOT-FOUND",
  "message": "Одна или обе указанные партии товара не найдены в системе.",
  "details": {
    "source_id": 1042,
    "target_id": 9999
  }
}