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>
This commit is contained in:
2025-10-29 23:22:01 +03:00
parent 6735be9b08
commit 2341cf57c1
5 changed files with 927 additions and 9 deletions

358
QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,358 @@
# Быстрая справка: Наличие товаров и цены вариантов
## В Python коде
### Product (товар)
```python
from products.models import Product
product = Product.objects.get(id=1)
# Проверить есть ли в наличии
if product.in_stock:
print(f"{product.name} - в наличии")
# Получить цену
print(product.sale_price)
# Фильтровать товары в наличии
in_stock = Product.objects.filter(in_stock=True)
out_of_stock = Product.objects.filter(in_stock=False)
```
### ProductVariantGroup (группа вариантов)
```python
from products.models import ProductVariantGroup
group = ProductVariantGroup.objects.prefetch_related('items__product').get(id=1)
# Проверить есть ли в наличии хотя бы один вариант
if group.in_stock:
print(f"Группа '{group.name}' - есть в наличии")
# Получить цену группы
price = group.price # Decimal('50.00')
# Перебрать товары по приоритету
for item in group.items.all().order_by('priority'):
print(f"{item.priority}. {item.product.name}")
if item.product.in_stock:
print(f" -> В наличии ({item.product.sale_price} руб)")
else:
print(f" -> Не в наличии")
```
---
## В шаблонах (HTML)
### Проверка наличия товара
```html
{% if product.in_stock %}
<span class="badge badge-success">В наличии</span>
{% else %}
<span class="badge badge-danger">Нет в наличии</span>
{% endif %}
```
### Отображение цены товара
```html
<div class="price">
{{ product.sale_price }} руб
</div>
```
### Группа вариантов - статус наличия
```html
{% if variant_group.in_stock %}
<p class="text-success">
<strong>Доступно</strong> - есть варианты в наличии
</p>
{% else %}
<p class="text-danger">
<strong>Недоступно</strong> - все варианты закончились
</p>
{% endif %}
```
### Группа вариантов - цена
```html
<div class="variant-price">
Цена: <strong>{{ variant_group.price }} руб</strong>
</div>
```
### Список товаров в группе
```html
<table class="variants">
<thead>
<tr>
<th>Приоритет</th>
<th>Товар</th>
<th>Цена</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for item in variant_group.items.all %}
<tr>
<td>{{ item.priority }}</td>
<td>{{ item.product.name }}</td>
<td>{{ item.product.sale_price }} руб</td>
<td>
{% if item.product.in_stock %}
<span class="badge badge-success">В наличии</span>
{% else %}
<span class="badge badge-secondary">Нет</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
```
### Полный пример - карточка варианта
```html
<div class="variant-card">
<h3>{{ variant_group.name }}</h3>
<div class="status">
{% if variant_group.in_stock %}
<span class="badge badge-success badge-lg">В наличии</span>
{% else %}
<span class="badge badge-danger badge-lg">✗ Нет в наличии</span>
{% endif %}
</div>
<div class="price">
<strong>{{ variant_group.price }} руб</strong>
</div>
<div class="variants-list">
<small class="text-muted">Доступные варианты:</small>
<ul>
{% for item in variant_group.items.all|slice:":3" %}
<li>
{{ item.product.name }}
{% if item.product.in_stock %}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% if variant_group.in_stock %}
<button class="btn btn-primary">Добавить в корзину</button>
{% else %}
<button class="btn btn-secondary" disabled>Недоступно</button>
{% endif %}
</div>
```
---
## В View (запросы к БД)
### Оптимизация запросов
```python
from django.shortcuts import render
from products.models import ProductVariantGroup
def variant_groups_list(request):
# ПРАВИЛЬНО: используем prefetch_related для оптимизации
groups = ProductVariantGroup.objects.prefetch_related(
'items__product'
)
return render(request, 'variants.html', {
'variant_groups': groups
})
```
### Фильтрация товаров в наличии
```python
from products.models import Product
# Получить только товары в наличии
in_stock = Product.objects.filter(in_stock=True)
# Получить только товары без наличия
out_of_stock = Product.objects.filter(in_stock=False)
# Комбинированный фильтр
available = Product.objects.filter(
is_active=True,
in_stock=True
).order_by('name')
```
---
## Логика наличия
### Когда товар считается "в наличии"?
Товар в наличии (Product.in_stock = True) когда:
- Существует запись в Stock с `quantity_available > 0`
- Это может быть на любом из складов
- quantity_available = quantity - reserved (свободный остаток)
### Когда он перестаёт быть в наличии?
- Все Stock записи удалены
- Или всем Stock записям quantity_available = 0 (все проданы или зарезервированы)
### Как это обновляется автоматически?
1. При создании приходного документа (Incoming)
2. При продаже товара (Sale)
3. При списании товара (WriteOff)
4. При изменении резервирования (Reservation)
**Вы не должны вручную обновлять Product.in_stock!**
---
## Цена варианта - логика
### Порядок определения цены ProductVariantGroup:
1. **Есть товары в наличии?**
- Да → берём цену товара с **наименьшим приоритетом** среди доступных
- Пример: приоритет 1 доступен → его цена
2. **Нет товаров в наличии?**
- Все недоступны → берём **максимальную цену** из всех товаров
- Пример: цены 50, 60, 70 → показываем 70 (самая дорогая)
### Пример расчёта:
```
Группа "Роза красная Freedom"
├─ Приоритет 1: Роза 50см, цена 50 руб, в наличии ✓
├─ Приоритет 2: Роза 60см, цена 60 руб, в наличии ✓
└─ Приоритет 3: Роза 70см, цена 70 руб, нет в наличии ✗
Цена группы = 50 руб (первый в наличии)
```
```
Группа "Роза красная Freedom"
├─ Приоритет 1: Роза 50см, цена 50 руб, нет ✗
├─ Приоритет 2: Роза 60см, цена 60 руб, нет ✗
└─ Приоритет 3: Роза 70см, цена 70 руб, нет ✗
Цена группы = 70 руб (максимальная из всех)
```
---
## Типичные ошибки
**Ошибка 1: Попытка обновить Product.in_stock вручную**
```python
# НЕПРАВИЛЬНО!
product.in_stock = True
product.save()
# Это будет перезаписано при следующем изменении Stock
```
**Правильно:**
Система сама обновит Product.in_stock при изменении остатков.
---
**Ошибка 2: Не использовать prefetch_related для вариантов**
```python
# НЕПРАВИЛЬНО (N+1 query problem)!
for group in groups:
price = group.price # Это выполнит запрос для каждой группы!
```
**Правильно:**
```python
groups = ProductVariantGroup.objects.prefetch_related('items__product')
for group in groups:
price = group.price # Всего 2 запроса вместо N+1
```
---
**Ошибка 3: Фильтровать по in_stock на ProductVariantGroup**
```python
# НЕПРАВИЛЬНО!
groups = ProductVariantGroup.objects.filter(in_stock=True)
# in_stock это свойство, а не поле БД
```
**Правильно:**
```python
# Если нужны группы где есть хотя бы один товар в наличии
groups = ProductVariantGroup.objects.filter(
items__product__in_stock=True
).distinct()
# Или отфильтровать в Python
groups = [g for g in groups if g.in_stock]
```
---
## Дополнительные полезные запросы
### Все товары без наличия
```python
from products.models import Product
out_of_stock = Product.objects.filter(in_stock=False)
```
### Группы вариантов где нет ни одного товара в наличии
```python
from django.db.models import Exists, OuterRef
ProductVariantGroup.objects.filter(
~Exists(ProductVariantGroupItem.objects.filter(
variant_group=OuterRef('pk'),
product__in_stock=True
))
)
```
### Товары которые изменили статус наличия за последний час
```python
from django.utils import timezone
from datetime import timedelta
Product.objects.filter(
updated_at__gte=timezone.now() - timedelta(hours=1)
)
```
---
## Помощь и контакты
Если что-то не работает:
1. Проверьте что миграция `0003_add_product_in_stock` применена
2. Убедитесь что сигналы зарегистрированы в `inventory/apps.py`
3. Проверьте логи: есть ли ошибки в сигналах при обновлении Stock
4. Запустите тестовый скрипт: `python test_variant_stock.py`