Files
octopus/VARIANT_STOCK_IMPLEMENTATION.md
Andrey Smakotin 2341cf57c1 feat: Реализовать систему наличия товаров и цены вариантов
Добавлена система управления наличием товаров на трёх уровнях:

1. Product.in_stock (поле БД)
   - Булево значение: есть/нет в наличии
   - Автоматически обновляется при изменении Stock
   - Используется для быстрого поиска и фильтрации товаров

2. Сигналы для синхронизации (inventory/signals.py)
   - При изменении Stock → обновляется Product.in_stock
   - Логика: товар в наличии если есть Stock с quantity_available > 0

3. ProductVariantGroup.in_stock (свойство)
   - Вариант в наличии если хотя бы один из товаров в наличии
   - Динамически рассчитывается по Product.in_stock товаров в группе

4. ProductVariantGroup.price (свойство)
   - Цена по приоритету: берём цену товара с приоритетом 1, если он в наличии
   - Если никто не в наличии: берём максимальную цену из всех товаров
   - Возвращает Decimal или None если группа пуста

Файлы:
- myproject/products/models.py: добавлено поле in_stock и свойства в ProductVariantGroup
- myproject/inventory/signals.py: добавлены сигналы для синхронизации
- myproject/products/migrations/0003_add_product_in_stock.py: миграция для поля in_stock
- VARIANT_STOCK_IMPLEMENTATION.md: полная документация архитектуры
- QUICK_REFERENCE.md: быстрая справка по использованию

Особенности:
✓ Система простая и элегантная (без костылей)
✓ Обратная совместимость не требуется
✓ Высокая производительность (индексирование, минимум JOIN'ов)
✓ Актуальные данные (сигналы гарантируют синхронизацию)
✓ Легко расширяемая (свойства можно менять без миграций)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 23:22:01 +03:00

16 KiB
Raw Blame History

Реализация системы наличия товаров и цены вариантов

Обзор

Реализована система управления наличием товаров (Product) и вычисления цены для групп вариантов (ProductVariantGroup). Система работает на трёх уровнях:

  1. Product — товар имеет поле in_stock (булево значение: есть/нет в наличии)
  2. ProductVariantGroup — группа вариантов с вычисляемыми свойствами in_stock и price
  3. Stock — система складских остатков определяет статус наличия на основе quantity_available > 0

1. Модель Product — добавлено поле in_stock

Изменение в /products/models.py:

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:

Добавлены два сигнала:

@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)

Вспомогательная функция:

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:

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:

# В 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 }}  <!-- True/False -->
{{ variant_group.price }}      <!-- 50.00 -->

4. Архитектурные решения

Почему свойства (properties) а не поля БД?

ProductVariantGroup.in_stock и ProductVariantGroup.price реализованы как свойства (properties), а не как сохраняемые поля:

Преимущества:

  • Всегда актуальны — в любой момент рассчитываются на основе текущих данных
  • Нет дублирования данных — источник истины один (Product.in_stock и Product.sale_price)
  • Без миграций — при изменении логики не нужны миграции БД
  • Простота — чистый и понятный код

⚠️ Недостатки (решены):

  • Производительность — O(N) на каждый вызов, где N = количество товаров в группе
  • Решение: используйте prefetch_related в views:
# Плохо (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: Проверить есть ли товар в наличии

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: Работа с группой вариантов

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: Отображение в шаблоне

{% for variant_group in variant_groups %}
<div class="variant-group">
    <h3>{{ variant_group.name }}</h3>

    {% if variant_group.in_stock %}
        <span class="badge badge-success">В наличии</span>
    {% else %}
        <span class="badge badge-danger">Нет в наличии</span>
    {% endif %}

    <div class="price">
        Цена: {{ variant_group.price }} руб
    </div>

    <ul class="variants">
        {% for item in variant_group.items.all %}
            <li>
                {{ item.product.name }}
                {% if item.product.in_stock %}
                    <span class="in-stock">В наличии</span>
                {% endif %}
            </li>
        {% endfor %}
    </ul>
</div>
{% endfor %}

7. Тестирование

Создан тестовый скрипт: test_variant_stock.py

Скрипт проверяет:

  1. ТЕСТ 1: Обновление Product.in_stock при создании Stock

    • Создаёт товар без наличия (in_stock=False)
    • Добавляет приход товара (создаёт Stock)
    • Проверяет что Product.in_stock автоматически стал True
  2. ТЕСТ 2: Свойство ProductVariantGroup.in_stock

    • Создаёт группу вариантов с несколькими товарами
    • Один товар в наличии
    • Проверяет что вариант.in_stock = True
  3. ТЕСТ 3: Свойство ProductVariantGroup.price

    • Товары с приоритетами 1, 2, 3 и ценами 50, 60, 70 руб
    • Только товар с приоритетом 1 в наличии
    • Проверяет что вариант.price = 50.00 руб
  4. ТЕСТ 4: Цена варианта когда ни один товар не в наличии

    • Все товары не в наличии
    • Цены: 100, 150, 200 руб
    • Проверяет что вариант.price = 200.00 руб (максимальная)

Запуск тестов:

# Активировать окружение
source venv/Scripts/activate

# Запустить тестовый скрипт
python test_variant_stock.py

# Или запустить стандартные Django тесты
cd myproject
python manage.py test inventory -v 2

8. Файлы которые были изменены/созданы

Изменены:

  1. myproject/products/models.py

    • Добавлено поле in_stock в Product
    • Добавлены свойства in_stock и price в ProductVariantGroup
    • Добавлен индекс для in_stock
  2. myproject/inventory/signals.py

    • Добавлены импорты Stock в начало файла
    • Добавлены два сигнала: update_product_in_stock_on_stock_change и update_product_in_stock_on_stock_delete
    • Добавлена вспомогательная функция _update_product_in_stock
  3. myproject/products/migrations/0003_add_product_in_stock.py (создана)

    • Миграция для добавления поля in_stock в Product

Созданы:

  1. test_variant_stock.py
    • Тестовый скрипт для проверки функциональности

9. Резюме

Реализовано:

  1. Product.in_stock — булево поле, автоматически обновляется при изменении остатков
  2. ProductVariantGroup.in_stock — свойство, вариант в наличии если хотя бы один товар в наличии
  3. ProductVariantGroup.price — свойство, цена по приоритету или максимальная если все недоступны
  4. Сигналы — автоматическое обновление Product.in_stock при изменении Stock
  5. Документация — полное описание архитектуры и использования

Особенности:

  • Система простая и элегантная (без костылей)
  • Обратная совместимость не требуется (как вы просили)
  • Высокая производительность (индексирование, минимум JOIN'ов)
  • Актуальные данные (сигналы гарантируют синхронизацию)
  • Легко расширяемая (свойства можно менять без миграций)

Готово к использованию в views и шаблонах!