# Итерация 2: Управление продуктами **Цель:** дать пользователю возможность вести список продуктов — вручную или через автодополнение. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты. **Зависимости:** Итерация 1 (рекомендации должны принимать список продуктов). **Ориентир:** [Summary.md](./Summary.md) --- ## Структура задач ``` 2.1 Backend: ingredient_mappings ├── 2.1.1 Миграция: таблица ingredient_mappings ├── 2.1.2 Seed: топ-200 базовых ингредиентов (JSON-файл) ├── 2.1.3 Repository (поиск по aliases) └── 2.1.4 GET /ingredients/search?q= 2.2 Backend: products ├── 2.2.1 Миграция: таблица products ├── 2.2.2 Repository (CRUD) ├── 2.2.3 Service layer ├── 2.2.4 GET /products ├── 2.2.5 POST /products ├── 2.2.6 POST /products/batch ├── 2.2.7 PUT /products/{id} ├── 2.2.8 DELETE /products/{id} └── 2.2.9 GET /products/expiring (скоро истекают) 2.3 Backend: интеграция с рекомендациями └── 2.3.1 GET /recommendations — добавить продукты в промпт 2.4 Flutter: экран продуктов ├── 2.4.1 ProductsScreen (список с истекающими) ├── 2.4.2 Форма добавления с автодополнением (debounce 300мс) ├── 2.4.3 Редактирование (количество, единица, срок) └── 2.4.4 Удаление (свайп или кнопка) ``` --- ## 2.1 Ingredient Mappings ### 2.1.1 Миграция ```sql -- migrations/003_create_ingredient_mappings.sql -- +goose Up CREATE TABLE ingredient_mappings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), canonical_name TEXT NOT NULL UNIQUE, canonical_name_ru TEXT NOT NULL, aliases JSONB NOT NULL DEFAULT '[]', category TEXT NOT NULL, default_unit TEXT NOT NULL DEFAULT 'g', calories_per_100g DECIMAL(8,2), protein_per_100g DECIMAL(8,2), fat_per_100g DECIMAL(8,2), carbs_per_100g DECIMAL(8,2), storage_days INT NOT NULL DEFAULT 7, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN(aliases); CREATE INDEX idx_ingredient_mappings_canonical_ru ON ingredient_mappings USING GIN(to_tsvector('russian', canonical_name_ru)); CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_ingredient_mappings_trgm ON ingredient_mappings USING GIN(canonical_name_ru gin_trgm_ops); -- +goose Down DROP TABLE ingredient_mappings; ``` ### 2.1.2 Seed-данные Файл `migrations/seed_ingredient_mappings.json` с топ-200 ингредиентами: ```json [ { "canonical_name": "chicken_breast", "canonical_name_ru": "куриная грудка", "aliases": ["куриное филе", "куриная грудка", "грудка курицы", "chicken breast"], "category": "meat", "default_unit": "g", "calories_per_100g": 165, "protein_per_100g": 31, "fat_per_100g": 3.6, "carbs_per_100g": 0, "storage_days": 3 }, ... ] ``` Seed применяется отдельным скриптом/Makefile-таргетом `make seed`. ### 2.1.3 Поиск ```go // GET /ingredients/search?q=кур&limit=10 // Трёхуровневый поиск: // 1. Точное совпадение в aliases (@>) // 2. ILIKE на canonical_name_ru // 3. pg_trgm similarity > 0.3 SELECT * FROM ingredient_mappings WHERE aliases @> to_jsonb(lower($1)::text) OR canonical_name_ru ILIKE '%' || $1 || '%' OR similarity(canonical_name_ru, $1) > 0.3 ORDER BY similarity(canonical_name_ru, $1) DESC LIMIT $2 ``` --- ## 2.2 Products ### 2.2.1 Миграция ```sql -- migrations/004_create_products.sql -- +goose Up CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, mapping_id UUID REFERENCES ingredient_mappings(id), name TEXT NOT NULL, quantity DECIMAL(10,2) NOT NULL DEFAULT 1, unit TEXT NOT NULL DEFAULT 'pcs', category TEXT, storage_days INT NOT NULL DEFAULT 7, added_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ GENERATED ALWAYS AS (added_at + (storage_days || ' days')::INTERVAL) STORED ); CREATE INDEX idx_products_user_id ON products(user_id); CREATE INDEX idx_products_expires_at ON products(user_id, expires_at); -- +goose Down DROP TABLE products; ``` ### 2.2.2 Эндпоинты **GET /products** ```json [{ "id": "uuid", "name": "Куриная грудка", "quantity": 500, "unit": "g", "category": "meat", "expires_at": "2026-02-24T00:00:00Z", "days_left": 3, "expiring_soon": true }] ``` Сортировка: `expires_at ASC` (сначала истекающие). `expiring_soon = true` если `days_left <= 3`. **POST /products/batch** Массовое добавление после распознавания (Итерация 3): ```json [{ "name": "Куриная грудка", "quantity": 500, "unit": "g", "mapping_id": "uuid или null" }] ``` --- ## 2.3 Интеграция с рекомендациями Обновить `GET /recommendations`: если у пользователя есть продукты — включать их в промпт. ```go // Если продуктов нет — промпт без них (базовые рекомендации) // Если продукты есть — добавить секцию в промпт: doступные продукты (приоритет — скоро истекают ⚠): - Куриная грудка 500г (истекает завтра ⚠) - Морковь 3 шт - Рис 400г - Яйца 4 шт Предпочтительно использовать эти продукты в рецептах. ``` --- ## 2.4 Flutter: экран продуктов ### ProductsScreen ``` ┌─────────────────────────────────────┐ │ Мои продукты [+ Добавить] │ ├─────────────────────────────────────┤ │ ⚠ Истекает скоро │ │ ┌──────────────────────────────┐ │ │ │ 🥩 Куриная грудка 500 г │ │ │ │ Осталось 1 день │ │ │ └──────────────────────────────┘ │ │ │ │ Всё остальное │ │ ┌──────────────────────────────┐ │ │ │ 🥕 Морковь 3 шт │ │ │ │ Осталось 5 дней │ │ │ └──────────────────────────────┘ │ │ ┌──────────────────────────────┐ │ │ │ 🍚 Рис 400 г │ │ │ │ Осталось 30 дней │ │ │ └──────────────────────────────┘ │ ├─────────────────────────────────────┤ │ [Главная] [Продукты●] [Меню] [Рецепты] [Профиль] │ └─────────────────────────────────────┘ ``` ### Форма добавления - Поле ввода с debounce 300мс → `GET /ingredients/search?q=` - Dropdown с результатами (canonical_name_ru + category) - При выборе: автозаполнение unit, storage_days из mapping - Поля: Количество, Единица (select: г/кг/мл/л/шт) - Кнопка «Добавить» ### Badge на вкладке «Продукты» Количество продуктов с `days_left <= 3` отображается как badge над иконкой.