# Реализация системы наличия товаров и цены вариантов ## Обзор Реализована система управления наличием товаров (Product) и вычисления цены для групп вариантов (ProductVariantGroup). Система работает на трёх уровнях: 1. **Product** — товар имеет поле `in_stock` (булево значение: есть/нет в наличии) 2. **ProductVariantGroup** — группа вариантов с вычисляемыми свойствами `in_stock` и `price` 3. **Stock** — система складских остатков определяет статус наличия на основе `quantity_available > 0` --- ## 1. Модель Product — добавлено поле `in_stock` ### Изменение в `/products/models.py`: ```python class Product(models.Model): # ... другие поля ... in_stock = models.BooleanField( default=False, verbose_name="В наличии", db_index=True, help_text="Автоматически обновляется при изменении остатков на складе" ) ``` **Миграция**: `products/migrations/0003_add_product_in_stock.py` ### Особенности: - Поле хранится в БД (для оптимизации поиска и фильтрации) - Индексировано для быстрого поиска товаров в наличии - Обновляется **автоматически** при изменении остатков через сигналы --- ## 2. Сигналы для автоматического обновления `Product.in_stock` ### Изменения в `/inventory/signals.py`: Добавлены два сигнала: ```python @receiver(post_save, sender=Stock) def update_product_in_stock_on_stock_change(sender, instance, created, **kwargs): """ При создании/изменении Stock записи обновляем Product.in_stock. """ _update_product_in_stock(instance.product_id) @receiver(pre_delete, sender=Stock) def update_product_in_stock_on_stock_delete(sender, instance, **kwargs): """ При удалении Stock записи обновляем Product.in_stock. """ _update_product_in_stock(instance.product_id) ``` ### Вспомогательная функция: ```python def _update_product_in_stock(product_id): """ Обновить статус in_stock на основе остатков. Логика: - Товар в наличии (in_stock=True) если существует хотя бы один Stock с quantity_available > 0 (есть свободный остаток на любом складе) - Товар не в наличии (in_stock=False) если нет ни одного Stock с остатком """ product = Product.objects.get(id=product_id) has_stock = Stock.objects.filter( product=product, quantity_available__gt=0 # Свободный остаток > 0 ).exists() if product.in_stock != has_stock: Product.objects.filter(id=product.id).update(in_stock=has_stock) ``` ### Как это работает: 1. **При создании приходного документа (Incoming)**: - Создаётся StockBatch (партия) - Создаётся/обновляется Stock (агрегированный остаток) - Stock.refresh_from_batches() пересчитывает quantity_available - Срабатывает сигнал post_save на Stock - Product.in_stock автоматически обновляется 2. **При продаже (Sale)**: - StockBatchManager.write_off_by_fifo() списывает товар - Stock.quantity_available уменьшается - Срабатывает сигнал post_save на Stock - Product.in_stock автоматически обновляется 3. **При списании (WriteOff)**: - WriteOff модель уменьшает quantity в StockBatch - Stock.refresh_from_batches() пересчитывает остаток - Срабатывает сигнал post_save на Stock - Product.in_stock автоматически обновляется --- ## 3. Модель ProductVariantGroup — свойства `in_stock` и `price` ### Изменения в `/products/models.py`: ```python class ProductVariantGroup(models.Model): # ... существующие поля ... @property def in_stock(self): """ Вариант в наличии, если хотя бы один из его товаров в наличии. Логика: - Проверяет есть ли товар с Product.in_stock=True в этой группе - Возвращает True/False Примеры: - "Роза 50см" в наличии → вариант в наличии - "Роза 60см" нет, но "Роза 70см" есть → вариант в наличии - Все розы отсутствуют → вариант не в наличии """ return self.items.filter(product__in_stock=True).exists() @property def price(self): """ Цена варианта определяется по приоритету товаров. Логика: 1. Идём по товарам в порядке приоритета (priority = 1, 2, 3...) 2. Первый товар в наличии (in_stock=True) → берём его цену 3. Если ни один товар не в наличии → берём максимальную цену из всех товаров Примеры: - Приоритет 1 (роза 50см) в наличии: цена 50.00 руб - Приоритет 1 нет, приоритет 2 (роза 60см) в наличии: цена 60.00 руб - Все недоступны: цена = max(50.00, 60.00, 70.00) = 70.00 руб Возвращает Decimal (цену) или None если группа пуста. """ items = self.items.all().order_by('priority', 'id') if not items.exists(): return None # Ищем первый товар в наличии for item in items: if item.product.in_stock: return item.product.sale_price # Если ни один товар не в наличии - берём самый дорогой max_price = None for item in items: if max_price is None or item.product.sale_price > max_price: max_price = item.product.sale_price return max_price ``` ### Использование в шаблонах и views: ```python # В view variant_group = ProductVariantGroup.objects.get(id=1) # Проверить есть ли вариант в наличии if variant_group.in_stock: # Вариант доступен pass # Получить цену варианта price = variant_group.price # Decimal('50.00') # В шаблоне {{ variant_group.in_stock }} {{ variant_group.price }} ``` --- ## 4. Архитектурные решения ### Почему свойства (properties) а не поля БД? **ProductVariantGroup.in_stock** и **ProductVariantGroup.price** реализованы как **свойства (properties)**, а не как сохраняемые поля: ✅ **Преимущества:** - **Всегда актуальны** — в любой момент рассчитываются на основе текущих данных - **Нет дублирования данных** — источник истины один (Product.in_stock и Product.sale_price) - **Без миграций** — при изменении логики не нужны миграции БД - **Простота** — чистый и понятный код ⚠️ **Недостатки (решены):** - **Производительность** — O(N) на каждый вызов, где N = количество товаров в группе - **Решение**: используйте prefetch_related в views: ```python # Плохо (N+1 queries) for variant_group in groups: print(variant_group.price) # Хорошо (1 query + 1 query для товаров) groups = ProductVariantGroup.objects.prefetch_related('items__product') for variant_group in groups: print(variant_group.price) ``` ### Почему Product.in_stock = поле БД? **Product.in_stock** — это сохраняемое поле в БД: ✅ **Причины:** - **Оптимизация поиска** — можно фильтровать: `Product.objects.filter(in_stock=True)` - **Производительность** — не нужно JOIN'ить Stock при поиске - **Индекс** — ускоряет фильтрацию - **Системная важность** — наличие товара — критичный параметр --- ## 5. Поток данных (Data Flow) ``` Incoming (приход товара) ↓ StockBatch создаётся ↓ Stock создаётся/обновляется ├─ quantity_available пересчитывается └─ post_save сигнал срабатывает ↓ _update_product_in_stock(product_id) ├─ Проверяет есть ли Stock с quantity_available > 0 └─ Product.in_stock обновляется (True/False) ↓ ProductVariantGroup.in_stock (свойство) ├─ Проверяет есть ли товар в группе с Product.in_stock=True └─ Возвращает True/False ProductVariantGroup.price (свойство) ├─ Идёт по товарам по приоритету ├─ Берёт цену первого в наличии └─ Или максимальную цену если никто не в наличии ``` --- ## 6. Примеры использования ### Пример 1: Проверить есть ли товар в наличии ```python from products.models import Product # Получить товар product = Product.objects.get(id=1) # Проверить наличие if product.in_stock: print(f"{product.name} в наличии") else: print(f"{product.name} не в наличии") # Фильтровать товары в наличии in_stock_products = Product.objects.filter(in_stock=True) ``` ### Пример 2: Работа с группой вариантов ```python from products.models import ProductVariantGroup # Получить группу group = ProductVariantGroup.objects.prefetch_related('items__product').get(id=1) # Проверить статус группы print(f"Вариант в наличии: {group.in_stock}") # True/False print(f"Цена варианта: {group.price} руб") # Decimal('50.00') # Получить всю информацию for item in group.items.all().order_by('priority'): status = "✓" if item.product.in_stock else "✗" print(f"{item.priority}. {item.product.name} ({item.product.sale_price}) {status}") ``` ### Пример 3: Отображение в шаблоне ```html {% for variant_group in variant_groups %}