Реализована элегантная блокировка витринных букетов при добавлении в корзину, предотвращающая многократную продажу одного физического комплекта. ## Изменения в БД: - Добавлены поля в Reservation: cart_lock_expires_at, locked_by_user, cart_session_id - Созданы индексы для оптимизации запросов блокировок - Миграция 0006: добавление полей Soft Lock ## Backend (pos/views.py): - add_showcase_kit_to_cart: создание блокировки на 30 минут с проверкой конфликтов - remove_showcase_kit_from_cart: снятие блокировки при удалении из корзины - get_showcase_kits_api: возврат статусов блокировок (is_locked, locked_by_me) ## Frontend (terminal.js): - addToCart: AJAX запрос для создания блокировки, запрет qty > 1 - removeFromCart: автоматическое снятие блокировки - renderCart: желтый фон, badge "1 шт (витрина)", скрыты кнопки +/− - UI индикация: зеленый badge "В корзине" (свой), красный "Занят" (чужой) ## Автоматизация (inventory/tasks.py): - cleanup_expired_cart_locks: Celery periodic task (каждые 5 минут) - Автоматическое освобождение истекших блокировок (30 минут timeout) - Логирование очистки для мониторинга ## Маршруты (pos/urls.py): - POST /api/showcase-kits/<id>/add-to-cart/ - создание блокировки - POST /api/showcase-kits/<id>/remove-from-cart/ - снятие блокировки ## Документация: - ЗАПУСК.md: инструкция по запуску Celery Beat Преимущества: ✓ Предотвращает конфликты между кассирами ✓ Автоматическое освобождение при таймауте ✓ Понятный UX с визуальной индикацией ✓ Совместимость с существующей логикой резервирования 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1164 lines
46 KiB
Python
1164 lines
46 KiB
Python
# -*- coding: utf-8 -*-
|
||
from django.shortcuts import render
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.http import JsonResponse
|
||
from django.views.decorators.http import require_http_methods
|
||
from django.db import transaction
|
||
from django.db.models import Prefetch, OuterRef, Subquery, DecimalField
|
||
from django.db.models.functions import Coalesce
|
||
from django.utils import timezone
|
||
from decimal import Decimal, InvalidOperation
|
||
import json
|
||
|
||
from products.models import Product, ProductCategory, ProductKit, KitItem
|
||
from inventory.models import Showcase, Reservation, Warehouse, Stock
|
||
from inventory.services import ShowcaseManager
|
||
|
||
|
||
def get_pos_warehouse(request):
|
||
"""
|
||
Получить текущий склад для POS из сессии или выбрать дефолтный.
|
||
Логика выбора:
|
||
1. Если в сессии есть pos_warehouse_id - используем его
|
||
2. Иначе берем склад с is_default=True
|
||
3. Если нет is_default - берем первый активный
|
||
4. Если нет активных складов - None
|
||
"""
|
||
warehouse_id = request.session.get('pos_warehouse_id')
|
||
|
||
if warehouse_id:
|
||
try:
|
||
return Warehouse.objects.get(id=warehouse_id, is_active=True)
|
||
except Warehouse.DoesNotExist:
|
||
# Склад был удален или деактивирован - сбрасываем сессию
|
||
request.session.pop('pos_warehouse_id', None)
|
||
|
||
# Ищем склад по умолчанию
|
||
warehouse = Warehouse.objects.filter(is_active=True, is_default=True).first()
|
||
|
||
if not warehouse:
|
||
# Берем любой первый активный
|
||
warehouse = Warehouse.objects.filter(is_active=True).first()
|
||
|
||
# Сохраняем в сессию для следующих запросов
|
||
if warehouse:
|
||
request.session['pos_warehouse_id'] = warehouse.id
|
||
|
||
return warehouse
|
||
|
||
|
||
def get_showcase_kits_for_pos():
|
||
"""
|
||
Получает витринные комплекты для отображения в POS.
|
||
Возвращает список временных комплектов, которые зарезервированы на витринах.
|
||
Оптимизировано: убраны N+1 запросы, используется один проход по данным.
|
||
"""
|
||
from products.models import ProductKitPhoto
|
||
|
||
# Получаем все зарезервированные товары на витринах
|
||
reserved_products = Reservation.objects.filter(
|
||
showcase__isnull=False,
|
||
showcase__is_active=True,
|
||
status='reserved'
|
||
).values_list('product_id', flat=True).distinct()
|
||
|
||
if not reserved_products:
|
||
return []
|
||
|
||
# Prefetch для первого фото (thumbnail)
|
||
first_photo_prefetch = Prefetch(
|
||
'photos',
|
||
queryset=ProductKitPhoto.objects.order_by('order')[:1],
|
||
to_attr='first_photo_list'
|
||
)
|
||
|
||
# Находим комплекты с резервированными компонентами
|
||
kits_with_showcase_items = ProductKit.objects.filter(
|
||
is_temporary=True,
|
||
status='active',
|
||
kit_items__product_id__in=reserved_products
|
||
).prefetch_related(
|
||
first_photo_prefetch,
|
||
Prefetch('kit_items', queryset=KitItem.objects.select_related('product'))
|
||
).distinct()
|
||
|
||
# Получаем все резервы для компонентов комплектов одним запросом
|
||
all_kit_product_ids = set()
|
||
kit_to_product_ids = {} # {kit.id: set(product_ids)}
|
||
|
||
for kit in kits_with_showcase_items:
|
||
# Используем prefetch'енные kit_items (без дополнительного запроса)
|
||
product_ids = {item.product_id for item in kit.kit_items.all()}
|
||
kit_to_product_ids[kit.id] = product_ids
|
||
all_kit_product_ids.update(product_ids)
|
||
|
||
# Один запрос для всех резервов
|
||
all_reservations = Reservation.objects.filter(
|
||
product_id__in=all_kit_product_ids,
|
||
showcase__isnull=False,
|
||
showcase__is_active=True,
|
||
status='reserved'
|
||
).select_related('showcase').values('product_id', 'showcase_id', 'showcase__name')
|
||
|
||
# Группируем резервы по product_id
|
||
product_to_showcases = {} # {product_id: [(showcase_id, showcase_name), ...]}
|
||
for res in all_reservations:
|
||
product_id = res['product_id']
|
||
if product_id not in product_to_showcases:
|
||
product_to_showcases[product_id] = []
|
||
product_to_showcases[product_id].append((res['showcase_id'], res['showcase__name']))
|
||
|
||
# Формируем результат
|
||
showcase_kits = []
|
||
for kit in kits_with_showcase_items:
|
||
product_ids = kit_to_product_ids[kit.id]
|
||
|
||
# Находим общую витрину для всех компонентов
|
||
showcases_for_kit = None
|
||
for product_id in product_ids:
|
||
showcases = product_to_showcases.get(product_id, [])
|
||
if showcases_for_kit is None:
|
||
showcases_for_kit = set(s[0] for s in showcases)
|
||
else:
|
||
showcases_for_kit &= set(s[0] for s in showcases)
|
||
|
||
if showcases_for_kit:
|
||
# Берём первую витрину
|
||
showcase_id = list(showcases_for_kit)[0]
|
||
showcase_name = next(
|
||
(s[1] for pid in product_ids for s in product_to_showcases.get(pid, []) if s[0] == showcase_id),
|
||
'Неизвестно'
|
||
)
|
||
|
||
# Используем prefetch'енное первое фото
|
||
image_url = None
|
||
if hasattr(kit, 'first_photo_list') and kit.first_photo_list:
|
||
image_url = kit.first_photo_list[0].get_thumbnail_url()
|
||
|
||
showcase_kits.append({
|
||
'id': kit.id,
|
||
'name': kit.name,
|
||
'price': str(kit.actual_price),
|
||
'category_ids': [],
|
||
'in_stock': True,
|
||
'sku': kit.sku or '',
|
||
'image': image_url,
|
||
'type': 'showcase_kit',
|
||
'showcase_name': showcase_name,
|
||
'showcase_id': showcase_id
|
||
})
|
||
|
||
return showcase_kits
|
||
|
||
|
||
@login_required
|
||
def pos_terminal(request):
|
||
"""
|
||
Tablet-friendly POS screen prototype.
|
||
Товары загружаются прогрессивно через API при клике на категорию.
|
||
Работает только с одним выбранным складом.
|
||
"""
|
||
from customers.models import Customer
|
||
from django.core.cache import cache
|
||
|
||
# Получаем текущий склад для POS
|
||
current_warehouse = get_pos_warehouse(request)
|
||
|
||
if not current_warehouse:
|
||
# Нет активных складов - показываем ошибку
|
||
from django.contrib import messages
|
||
messages.error(request, 'Нет активных складов. Обратитесь к администратору.')
|
||
context = {
|
||
'categories_json': json.dumps([]),
|
||
'items_json': json.dumps([]),
|
||
'showcase_kits_json': json.dumps([]),
|
||
'current_warehouse': None,
|
||
'warehouses': [],
|
||
'title': 'POS Terminal',
|
||
}
|
||
return render(request, 'pos/terminal.html', context)
|
||
|
||
# Получаем или создаём системного клиента
|
||
system_customer, _ = Customer.get_or_create_system_customer()
|
||
|
||
# Пытаемся получить сохраненного клиента из Redis
|
||
selected_customer = None
|
||
redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}'
|
||
cached_customer_data = cache.get(redis_key)
|
||
|
||
if cached_customer_data:
|
||
# Проверяем что клиент еще существует в БД
|
||
try:
|
||
customer = Customer.objects.get(id=cached_customer_data['customer_id'])
|
||
selected_customer = {
|
||
'id': customer.id,
|
||
'name': customer.name
|
||
}
|
||
except Customer.DoesNotExist:
|
||
# Клиент был удален - очищаем кэш
|
||
cache.delete(redis_key)
|
||
|
||
# Если нет сохраненного клиента - используем системного
|
||
if not selected_customer:
|
||
selected_customer = {
|
||
'id': system_customer.id,
|
||
'name': system_customer.name
|
||
}
|
||
|
||
# Пытаемся получить сохраненную корзину из Redis
|
||
cart_redis_key = f'pos:cart:{request.user.id}:{current_warehouse.id}'
|
||
cached_cart_data = cache.get(cart_redis_key)
|
||
cart_data = {}
|
||
|
||
if cached_cart_data:
|
||
# Валидируем товары и комплекты в корзине
|
||
from products.models import Product, ProductKit
|
||
|
||
for cart_key, item in cached_cart_data.items():
|
||
try:
|
||
if item['type'] == 'product':
|
||
# Проверяем что товар существует
|
||
Product.objects.get(id=item['id'])
|
||
cart_data[cart_key] = item
|
||
elif item['type'] in ('kit', 'showcase_kit'):
|
||
# Проверяем что комплект существует
|
||
ProductKit.objects.get(id=item['id'])
|
||
cart_data[cart_key] = item
|
||
except (Product.DoesNotExist, ProductKit.DoesNotExist):
|
||
# Товар или комплект удален - пропускаем
|
||
continue
|
||
|
||
# Загружаем только категории
|
||
categories_qs = ProductCategory.objects.filter(is_active=True)
|
||
categories = [{'id': c.id, 'name': c.name} for c in categories_qs]
|
||
|
||
# Список всех активных складов для модалки выбора
|
||
warehouses = Warehouse.objects.filter(is_active=True).order_by('-is_default', 'name')
|
||
warehouses_list = [{
|
||
'id': w.id,
|
||
'name': w.name,
|
||
'is_default': w.is_default
|
||
} for w in warehouses]
|
||
|
||
context = {
|
||
'categories_json': json.dumps(categories),
|
||
'items_json': json.dumps([]), # Пустой массив - загрузка по API при клике на категорию
|
||
'showcase_kits_json': json.dumps([]), # Пустой массив - загрузка по API
|
||
'current_warehouse': {
|
||
'id': current_warehouse.id,
|
||
'name': current_warehouse.name
|
||
},
|
||
'warehouses': warehouses_list,
|
||
'system_customer': {
|
||
'id': system_customer.id,
|
||
'name': system_customer.name
|
||
},
|
||
'selected_customer': selected_customer, # Текущий выбранный клиент (из Redis или системный)
|
||
'cart_data': json.dumps(cart_data), # Сохраненная корзина из Redis
|
||
'title': 'POS Terminal',
|
||
}
|
||
return render(request, 'pos/terminal.html', context)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def save_cart(request):
|
||
"""
|
||
Сохранить корзину POS в Redis для текущего пользователя и склада.
|
||
TTL: 2 часа (7200 секунд)
|
||
"""
|
||
from django.core.cache import cache
|
||
import json
|
||
|
||
# Получаем текущий склад
|
||
current_warehouse = get_pos_warehouse(request)
|
||
if not current_warehouse:
|
||
return JsonResponse({'success': False, 'error': 'Не выбран активный склад'}, status=400)
|
||
|
||
try:
|
||
# Получаем данные корзины из тела запроса
|
||
body = json.loads(request.body)
|
||
cart_data = body.get('cart', {})
|
||
|
||
# Валидация структуры данных корзины
|
||
if not isinstance(cart_data, dict):
|
||
return JsonResponse({'success': False, 'error': 'Неверный формат данных корзины'}, status=400)
|
||
|
||
# Сохраняем в Redis
|
||
redis_key = f'pos:cart:{request.user.id}:{current_warehouse.id}'
|
||
cache.set(redis_key, cart_data, timeout=7200) # 2 часа
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'items_count': len(cart_data)
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({'success': False, 'error': str(e)}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def set_customer(request, customer_id):
|
||
"""
|
||
Сохранить выбранного клиента в Redis для текущего пользователя и склада.
|
||
TTL: 2 часа (7200 секунд)
|
||
"""
|
||
from customers.models import Customer
|
||
from django.core.cache import cache
|
||
|
||
# Получаем текущий склад
|
||
current_warehouse = get_pos_warehouse(request)
|
||
if not current_warehouse:
|
||
return JsonResponse({'success': False, 'error': 'Не выбран активный склад'}, status=400)
|
||
|
||
# Проверяем, что клиент существует
|
||
try:
|
||
customer = Customer.objects.get(id=customer_id)
|
||
except Customer.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': 'Клиент не найден'}, status=404)
|
||
|
||
# Сохраняем в Redis
|
||
redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}'
|
||
customer_data = {
|
||
'customer_id': customer.id,
|
||
'customer_name': customer.name
|
||
}
|
||
cache.set(redis_key, customer_data, timeout=7200) # 2 часа
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'customer_id': customer.id,
|
||
'customer_name': customer.name
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def set_warehouse(request, warehouse_id):
|
||
"""
|
||
Установить текущий склад для POS.
|
||
Сохраняет выбор в сессию.
|
||
"""
|
||
try:
|
||
warehouse = Warehouse.objects.get(id=warehouse_id, is_active=True)
|
||
request.session['pos_warehouse_id'] = warehouse.id
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'warehouse_id': warehouse.id,
|
||
'warehouse_name': warehouse.name
|
||
})
|
||
except Warehouse.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Склад не найден или неактивен'
|
||
}, status=404)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET"])
|
||
def get_showcases_api(request):
|
||
"""
|
||
API endpoint для получения списка активных витрин.
|
||
Используется для выбора витрины при создании временного комплекта.
|
||
"""
|
||
showcases = Showcase.objects.filter(is_active=True).select_related('warehouse')
|
||
|
||
showcases_data = [{
|
||
'id': s.id,
|
||
'name': s.name,
|
||
'warehouse_name': s.warehouse.name,
|
||
'warehouse_id': s.warehouse.id,
|
||
'is_default': s.is_default # Флаг витрины по умолчанию для автовыбора
|
||
} for s in showcases]
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'showcases': showcases_data
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET"])
|
||
def get_showcase_kits_api(request):
|
||
"""
|
||
API endpoint для получения актуального списка витринных комплектов.
|
||
Используется для динамического обновления после создания нового букета.
|
||
Включает информацию о блокировках в корзинах.
|
||
"""
|
||
from datetime import timedelta
|
||
|
||
showcase_kits_data = get_showcase_kits_for_pos()
|
||
|
||
# Добавляем информацию о блокировках для каждого комплекта
|
||
kit_ids = [kit['id'] for kit in showcase_kits_data]
|
||
|
||
# Получаем активные блокировки (не истекшие)
|
||
active_locks = Reservation.objects.filter(
|
||
product_kit_id__in=kit_ids,
|
||
cart_lock_expires_at__gt=timezone.now(),
|
||
status='reserved'
|
||
).select_related('locked_by_user').values(
|
||
'product_kit_id',
|
||
'locked_by_user_id',
|
||
'locked_by_user__username',
|
||
'cart_lock_expires_at'
|
||
)
|
||
|
||
# Индексируем блокировки по kit_id
|
||
locks_by_kit = {}
|
||
for lock in active_locks:
|
||
kit_id = lock['product_kit_id']
|
||
if kit_id not in locks_by_kit:
|
||
locks_by_kit[kit_id] = lock
|
||
|
||
# Дополняем данные о комплектах информацией о блокировках
|
||
for kit in showcase_kits_data:
|
||
lock_info = locks_by_kit.get(kit['id'])
|
||
|
||
if lock_info:
|
||
is_locked_by_me = lock_info['locked_by_user_id'] == request.user.id
|
||
kit['is_locked'] = True
|
||
kit['locked_by_me'] = is_locked_by_me
|
||
kit['locked_by_user'] = lock_info['locked_by_user__username']
|
||
kit['lock_expires_at'] = lock_info['cart_lock_expires_at'].isoformat()
|
||
else:
|
||
kit['is_locked'] = False
|
||
kit['locked_by_me'] = False
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'items': showcase_kits_data
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def add_showcase_kit_to_cart(request, kit_id):
|
||
"""
|
||
API endpoint для добавления витринного комплекта в корзину с блокировкой.
|
||
Создает soft lock на 30 минут, предотвращая добавление другими кассирами.
|
||
|
||
Returns:
|
||
JSON: {
|
||
'success': bool,
|
||
'message': str,
|
||
'lock_expires_at': ISO datetime (если success=True),
|
||
'error': str (если success=False)
|
||
}
|
||
"""
|
||
from datetime import timedelta
|
||
|
||
try:
|
||
# Получаем комплект
|
||
kit = ProductKit.objects.select_related('showcase').get(
|
||
id=kit_id,
|
||
is_temporary=True,
|
||
showcase__isnull=False,
|
||
status='active'
|
||
)
|
||
|
||
# Проверяем существующие блокировки
|
||
existing_locks = Reservation.objects.filter(
|
||
product_kit=kit,
|
||
cart_lock_expires_at__gt=timezone.now(),
|
||
status='reserved'
|
||
).exclude(
|
||
locked_by_user=request.user
|
||
).select_related('locked_by_user')
|
||
|
||
if existing_locks.exists():
|
||
lock = existing_locks.first()
|
||
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Этот букет уже в корзине кассира "{lock.locked_by_user.username}". '
|
||
f'Блокировка истечет через {int(time_left)} мин.'
|
||
}, status=409) # 409 Conflict
|
||
|
||
# Создаем или продлеваем блокировку для текущего пользователя
|
||
lock_expires_at = timezone.now() + timedelta(minutes=30)
|
||
session_id = request.session.session_key or ''
|
||
|
||
# Обновляем все резервы этого комплекта
|
||
updated_count = Reservation.objects.filter(
|
||
product_kit=kit,
|
||
status='reserved'
|
||
).update(
|
||
cart_lock_expires_at=lock_expires_at,
|
||
locked_by_user=request.user,
|
||
cart_session_id=session_id
|
||
)
|
||
|
||
if updated_count == 0:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
|
||
}, status=400)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Букет "{kit.name}" добавлен в корзину',
|
||
'lock_expires_at': lock_expires_at.isoformat(),
|
||
'locked_until_minutes': 30
|
||
})
|
||
|
||
except ProductKit.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Витринный комплект не найден'
|
||
}, status=404)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка при добавлении в корзину: {str(e)}'
|
||
}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def remove_showcase_kit_from_cart(request, kit_id):
|
||
"""
|
||
API endpoint для снятия блокировки витринного комплекта при удалении из корзины.
|
||
Освобождает комплект для добавления другими кассирами.
|
||
|
||
Returns:
|
||
JSON: {
|
||
'success': bool,
|
||
'message': str,
|
||
'error': str (если success=False)
|
||
}
|
||
"""
|
||
try:
|
||
# Получаем комплект
|
||
kit = ProductKit.objects.get(
|
||
id=kit_id,
|
||
is_temporary=True
|
||
)
|
||
|
||
# Снимаем блокировку только для текущего пользователя
|
||
updated_count = Reservation.objects.filter(
|
||
product_kit=kit,
|
||
locked_by_user=request.user,
|
||
status='reserved'
|
||
).update(
|
||
cart_lock_expires_at=None,
|
||
locked_by_user=None,
|
||
cart_session_id=None
|
||
)
|
||
|
||
if updated_count == 0:
|
||
# Комплект не был заблокирован этим пользователем
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': 'Комплект не был заблокирован вами'
|
||
})
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Букет "{kit.name}" удален из корзины. Блокировка снята.',
|
||
'released_count': updated_count
|
||
})
|
||
|
||
except ProductKit.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Комплект не найден'
|
||
}, status=404)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка при снятии блокировки: {str(e)}'
|
||
}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET"])
|
||
def get_items_api(request):
|
||
"""
|
||
API endpoint для получения товаров и комплектов с пагинацией и поиском.
|
||
Параметры:
|
||
- category_id: ID категории (опционально, для фильтрации)
|
||
- query: поисковый запрос по name или sku (опционально)
|
||
- page: номер страницы (по умолчанию 1)
|
||
- page_size: размер страницы (по умолчанию 60)
|
||
Сортировка по умолчанию: по свободному остатку (available - reserved) DESC
|
||
"""
|
||
from products.models import ProductPhoto, ProductKitPhoto
|
||
from django.core.paginator import Paginator
|
||
from django.db.models import Q
|
||
|
||
# Получаем текущий склад
|
||
current_warehouse = get_pos_warehouse(request)
|
||
if not current_warehouse:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Нет активного склада'
|
||
}, status=400)
|
||
|
||
# Параметры пагинации и поиска
|
||
category_id = request.GET.get('category_id')
|
||
search_query = request.GET.get('query', '').strip()
|
||
page = int(request.GET.get('page', 1))
|
||
page_size = int(request.GET.get('page_size', 60))
|
||
|
||
# Prefetch для первого фото товаров
|
||
first_product_photo = Prefetch(
|
||
'photos',
|
||
queryset=ProductPhoto.objects.order_by('order')[:1],
|
||
to_attr='first_photo_list'
|
||
)
|
||
|
||
# Подзапросы для остатков по текущему складу
|
||
stock_available_subquery = Stock.objects.filter(
|
||
product=OuterRef('pk'),
|
||
warehouse=current_warehouse
|
||
).values('quantity_available')[:1]
|
||
|
||
stock_reserved_subquery = Stock.objects.filter(
|
||
product=OuterRef('pk'),
|
||
warehouse=current_warehouse
|
||
).values('quantity_reserved')[:1]
|
||
|
||
# Фильтруем только активные товары
|
||
products_qs = Product.objects.filter(status='active').annotate(
|
||
available_qty=Coalesce(
|
||
Subquery(stock_available_subquery, output_field=DecimalField()),
|
||
Decimal('0'),
|
||
output_field=DecimalField()
|
||
),
|
||
reserved_qty=Coalesce(
|
||
Subquery(stock_reserved_subquery, output_field=DecimalField()),
|
||
Decimal('0'),
|
||
output_field=DecimalField()
|
||
)
|
||
).prefetch_related(
|
||
'categories',
|
||
first_product_photo
|
||
)
|
||
|
||
# Фильтруем по категории, если указана
|
||
if category_id:
|
||
products_qs = products_qs.filter(categories__id=category_id)
|
||
|
||
# Фильтруем по поисковому запросу (name или sku)
|
||
if search_query:
|
||
products_qs = products_qs.filter(
|
||
Q(name__icontains=search_query) | Q(sku__icontains=search_query)
|
||
)
|
||
|
||
# Сериализуем товары
|
||
products = []
|
||
for p in products_qs:
|
||
image_url = None
|
||
if hasattr(p, 'first_photo_list') and p.first_photo_list:
|
||
image_url = p.first_photo_list[0].get_thumbnail_url()
|
||
|
||
available = p.available_qty
|
||
reserved = p.reserved_qty
|
||
free_qty = available - reserved
|
||
|
||
products.append({
|
||
'id': p.id,
|
||
'name': p.name,
|
||
'price': str(p.actual_price),
|
||
'category_ids': [c.id for c in p.categories.all()],
|
||
'in_stock': p.in_stock,
|
||
'sku': p.sku or '',
|
||
'image': image_url,
|
||
'type': 'product',
|
||
'available_qty': str(available),
|
||
'reserved_qty': str(reserved),
|
||
'free_qty': float(free_qty) # Для сортировки
|
||
})
|
||
|
||
# Prefetch для первого фото комплектов
|
||
first_kit_photo = Prefetch(
|
||
'photos',
|
||
queryset=ProductKitPhoto.objects.order_by('order')[:1],
|
||
to_attr='first_photo_list'
|
||
)
|
||
|
||
# Активные комплекты (не временные)
|
||
kits_qs = ProductKit.objects.filter(
|
||
is_temporary=False,
|
||
status='active'
|
||
).prefetch_related(
|
||
'categories',
|
||
first_kit_photo
|
||
)
|
||
|
||
# Фильтруем комплекты по категории, если указана
|
||
if category_id:
|
||
kits_qs = kits_qs.filter(categories__id=category_id)
|
||
|
||
# Фильтруем комплекты по поисковому запросу (name или sku)
|
||
if search_query:
|
||
kits_qs = kits_qs.filter(
|
||
Q(name__icontains=search_query) | Q(sku__icontains=search_query)
|
||
)
|
||
|
||
# Сериализуем комплекты
|
||
kits = []
|
||
for k in kits_qs:
|
||
image_url = None
|
||
if hasattr(k, 'first_photo_list') and k.first_photo_list:
|
||
image_url = k.first_photo_list[0].get_thumbnail_url()
|
||
|
||
kits.append({
|
||
'id': k.id,
|
||
'name': k.name,
|
||
'price': str(k.actual_price),
|
||
'category_ids': [c.id for c in k.categories.all()],
|
||
'in_stock': False, # Комплекты всегда "Под заказ"
|
||
'sku': k.sku or '',
|
||
'image': image_url,
|
||
'type': 'kit',
|
||
'free_qty': 0 # Для сортировки комплекты всегда внизу
|
||
})
|
||
|
||
# Объединяем и сортируем по free_qty DESC
|
||
all_items = products + kits
|
||
all_items.sort(key=lambda x: x['free_qty'], reverse=True)
|
||
|
||
# Пагинация
|
||
paginator = Paginator(all_items, page_size)
|
||
page_obj = paginator.get_page(page)
|
||
|
||
# Удаляем временное поле free_qty из результата
|
||
items_to_return = []
|
||
for item in page_obj.object_list:
|
||
item_copy = item.copy()
|
||
item_copy.pop('free_qty', None)
|
||
items_to_return.append(item_copy)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'items': items_to_return,
|
||
'has_more': page_obj.has_next(),
|
||
'next_page': page_obj.next_page_number() if page_obj.has_next() else None,
|
||
'total_pages': paginator.num_pages,
|
||
'total_items': paginator.count
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET"])
|
||
def get_product_kit_details(request, kit_id):
|
||
"""
|
||
API endpoint для получения полных данных комплекта для редактирования.
|
||
"""
|
||
try:
|
||
kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id)
|
||
|
||
# Получаем витрину, на которой размещен комплект
|
||
showcase_reservation = Reservation.objects.filter(
|
||
product__in=kit.kit_items.values_list('product_id', flat=True),
|
||
showcase__isnull=False,
|
||
showcase__is_active=True,
|
||
status='reserved'
|
||
).select_related('showcase').first()
|
||
|
||
showcase_id = showcase_reservation.showcase.id if showcase_reservation else None
|
||
|
||
# Собираем данные о составе
|
||
items = [{
|
||
'product_id': ki.product.id,
|
||
'name': ki.product.name,
|
||
'qty': str(ki.quantity),
|
||
'price': str(ki.product.actual_price)
|
||
} for ki in kit.kit_items.all()]
|
||
|
||
# Фото
|
||
photo_url = kit.photos.first().image.url if kit.photos.exists() else None
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'kit': {
|
||
'id': kit.id,
|
||
'name': kit.name,
|
||
'description': kit.description or '',
|
||
'price_adjustment_type': kit.price_adjustment_type,
|
||
'price_adjustment_value': str(kit.price_adjustment_value),
|
||
'sale_price': str(kit.sale_price) if kit.sale_price else '',
|
||
'base_price': str(kit.base_price),
|
||
'final_price': str(kit.actual_price),
|
||
'showcase_id': showcase_id,
|
||
'items': items,
|
||
'photo_url': photo_url
|
||
}
|
||
})
|
||
except ProductKit.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def create_temp_kit_to_showcase(request):
|
||
"""
|
||
API endpoint для создания временного комплекта из корзины POS
|
||
и резервирования его на витрину.
|
||
|
||
Ожидаемый payload (multipart/form-data):
|
||
- kit_name: Название комплекта
|
||
- showcase_id: ID витрины
|
||
- items: JSON список [{product_id, quantity}, ...]
|
||
- description: Описание (опционально)
|
||
- price_adjustment_type: Тип корректировки (опционально)
|
||
- price_adjustment_value: Значение корректировки (опционально)
|
||
- sale_price: Ручная финальная цена (опционально)
|
||
- photo: Файл изображения (опционально)
|
||
"""
|
||
try:
|
||
# Получаем данные из FormData
|
||
kit_name = request.POST.get('kit_name', '').strip()
|
||
showcase_id = request.POST.get('showcase_id')
|
||
description = request.POST.get('description', '').strip()
|
||
items_json = request.POST.get('items', '[]')
|
||
price_adjustment_type = request.POST.get('price_adjustment_type', 'none')
|
||
price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0)))
|
||
sale_price_str = request.POST.get('sale_price', '')
|
||
photo_file = request.FILES.get('photo')
|
||
|
||
# Парсим items из JSON
|
||
items = json.loads(items_json)
|
||
|
||
# Sale price (опционально)
|
||
sale_price = None
|
||
if sale_price_str:
|
||
try:
|
||
sale_price = Decimal(str(sale_price_str))
|
||
if sale_price <= 0:
|
||
sale_price = None
|
||
except (ValueError, InvalidOperation):
|
||
sale_price = None
|
||
|
||
# Валидация
|
||
if not kit_name:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Необходимо указать название комплекта'
|
||
}, status=400)
|
||
|
||
if not showcase_id:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Необходимо выбрать витрину'
|
||
}, status=400)
|
||
|
||
if not items or len(items) == 0:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Корзина пуста. Добавьте товары для создания комплекта'
|
||
}, status=400)
|
||
|
||
# Проверяем что витрина существует и активна
|
||
try:
|
||
showcase = Showcase.objects.select_related('warehouse').get(id=showcase_id, is_active=True)
|
||
except Showcase.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Витрина не найдена или неактивна'
|
||
}, status=404)
|
||
|
||
# Проверяем что все товары из корзины - это Product (не Kit)
|
||
product_ids = [item['product_id'] for item in items]
|
||
products = Product.objects.in_bulk(product_ids)
|
||
|
||
if len(products) != len(product_ids):
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Некоторые товары не найдены'
|
||
}, status=400)
|
||
|
||
# Агрегируем дубликаты (если один товар добавлен несколько раз)
|
||
aggregated_items = {}
|
||
for item in items:
|
||
product_id = item['product_id']
|
||
quantity = Decimal(str(item['quantity']))
|
||
|
||
if product_id in aggregated_items:
|
||
aggregated_items[product_id] += quantity
|
||
else:
|
||
aggregated_items[product_id] = quantity
|
||
|
||
# Создаём временный комплект и резервируем на витрину
|
||
with transaction.atomic():
|
||
# 1. Создаём ProductKit (is_temporary=True)
|
||
kit = ProductKit.objects.create(
|
||
name=kit_name,
|
||
description=description,
|
||
is_temporary=True,
|
||
status='active',
|
||
price_adjustment_type=price_adjustment_type,
|
||
price_adjustment_value=price_adjustment_value,
|
||
sale_price=sale_price,
|
||
showcase=showcase
|
||
)
|
||
|
||
# 2. Создаём KitItem для каждого товара из корзины
|
||
for product_id, quantity in aggregated_items.items():
|
||
KitItem.objects.create(
|
||
kit=kit,
|
||
product=products[product_id],
|
||
quantity=quantity
|
||
)
|
||
|
||
# 3. Пересчитываем цену комплекта
|
||
kit.recalculate_base_price()
|
||
|
||
# 4. Загружаем фото, если есть
|
||
if photo_file:
|
||
from products.models import ProductKitPhoto
|
||
ProductKitPhoto.objects.create(
|
||
kit=kit,
|
||
image=photo_file,
|
||
order=0
|
||
)
|
||
|
||
# 5. Резервируем комплект на витрину
|
||
result = ShowcaseManager.reserve_kit_to_showcase(
|
||
product_kit=kit,
|
||
showcase=showcase,
|
||
quantity=1
|
||
)
|
||
|
||
if not result['success']:
|
||
# Откатываем транзакцию через raise
|
||
raise Exception(result['message'])
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Временный комплект "{kit_name}" создан и зарезервирован на витрине "{showcase.name}"',
|
||
'kit_id': kit.id,
|
||
'kit_name': kit.name,
|
||
'kit_price': str(kit.actual_price),
|
||
'reservations_count': len(result['reservations'])
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Неверный формат данных'
|
||
}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка при создании комплекта: {str(e)}'
|
||
}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def update_product_kit(request, kit_id):
|
||
"""
|
||
API endpoint для обновления временного комплекта.
|
||
|
||
Payload (multipart/form-data):
|
||
- kit_name: Новое название
|
||
- description: Описание
|
||
- items: JSON список [{product_id, quantity}, ...]
|
||
- price_adjustment_type, price_adjustment_value, sale_price
|
||
- photo: Новое фото (опционально)
|
||
- remove_photo: '1' для удаления фото
|
||
"""
|
||
try:
|
||
kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True)
|
||
|
||
# Получаем данные
|
||
kit_name = request.POST.get('kit_name', '').strip()
|
||
description = request.POST.get('description', '').strip()
|
||
items_json = request.POST.get('items', '[]')
|
||
price_adjustment_type = request.POST.get('price_adjustment_type', 'none')
|
||
price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0)))
|
||
sale_price_str = request.POST.get('sale_price', '')
|
||
photo_file = request.FILES.get('photo')
|
||
remove_photo = request.POST.get('remove_photo', '') == '1'
|
||
|
||
items = json.loads(items_json)
|
||
|
||
sale_price = None
|
||
if sale_price_str:
|
||
try:
|
||
sale_price = Decimal(str(sale_price_str))
|
||
if sale_price <= 0:
|
||
sale_price = None
|
||
except (ValueError, InvalidOperation):
|
||
sale_price = None
|
||
|
||
# Валидация
|
||
if not kit_name:
|
||
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
|
||
|
||
if not items:
|
||
return JsonResponse({'success': False, 'error': 'Состав не может быть пустым'}, status=400)
|
||
|
||
# Проверяем товары
|
||
product_ids = [item['product_id'] for item in items]
|
||
products = Product.objects.in_bulk(product_ids)
|
||
|
||
if len(products) != len(product_ids):
|
||
return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400)
|
||
|
||
# Агрегируем количества
|
||
aggregated_items = {}
|
||
for item in items:
|
||
product_id = item['product_id']
|
||
quantity = Decimal(str(item['quantity']))
|
||
aggregated_items[product_id] = aggregated_items.get(product_id, Decimal('0')) + quantity
|
||
|
||
with transaction.atomic():
|
||
# Получаем старый состав для сравнения
|
||
old_items = {ki.product_id: ki.quantity for ki in kit.kit_items.all()}
|
||
|
||
# Получаем витрину для резервов
|
||
showcase_reservation = Reservation.objects.filter(
|
||
product__in=old_items.keys(),
|
||
showcase__isnull=False,
|
||
status='reserved'
|
||
).select_related('showcase').first()
|
||
|
||
showcase = showcase_reservation.showcase if showcase_reservation else None
|
||
|
||
# Вычисляем разницу в составе
|
||
all_product_ids = set(old_items.keys()) | set(aggregated_items.keys())
|
||
|
||
for product_id in all_product_ids:
|
||
old_qty = old_items.get(product_id, Decimal('0'))
|
||
new_qty = aggregated_items.get(product_id, Decimal('0'))
|
||
diff = new_qty - old_qty
|
||
|
||
if diff > 0 and showcase:
|
||
# Нужно дозарезервировать
|
||
result = ShowcaseManager.reserve_product_to_showcase(
|
||
product=products[product_id],
|
||
showcase=showcase,
|
||
quantity=diff
|
||
)
|
||
if not result['success']:
|
||
raise Exception(f"Недостаточно запасов: {result['message']}")
|
||
|
||
elif diff < 0 and showcase:
|
||
# Нужно освободить резерв
|
||
ShowcaseManager.release_showcase_reservation(
|
||
product=products[product_id],
|
||
showcase=showcase,
|
||
quantity=abs(diff)
|
||
)
|
||
|
||
# Обновляем комплект
|
||
kit.name = kit_name
|
||
kit.description = description
|
||
kit.price_adjustment_type = price_adjustment_type
|
||
kit.price_adjustment_value = price_adjustment_value
|
||
kit.sale_price = sale_price
|
||
kit.save()
|
||
|
||
# Обновляем состав
|
||
kit.kit_items.all().delete()
|
||
for product_id, quantity in aggregated_items.items():
|
||
KitItem.objects.create(
|
||
kit=kit,
|
||
product=products[product_id],
|
||
quantity=quantity
|
||
)
|
||
|
||
kit.recalculate_base_price()
|
||
|
||
# Обновляем фото
|
||
if remove_photo:
|
||
kit.photos.all().delete()
|
||
|
||
if photo_file:
|
||
from products.models import ProductKitPhoto
|
||
kit.photos.all().delete() # Удаляем старое
|
||
ProductKitPhoto.objects.create(kit=kit, image=photo_file, order=0)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Комплект "{kit.name}" обновлён',
|
||
'kit_id': kit.id,
|
||
'kit_name': kit.name,
|
||
'kit_price': str(kit.actual_price)
|
||
})
|
||
|
||
except ProductKit.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': 'Неверный формат данных'}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def disassemble_product_kit(request, kit_id):
|
||
"""
|
||
Разбирает витринный комплект: освобождает резервы и устанавливает статус 'discontinued'.
|
||
|
||
Args:
|
||
request: HTTP запрос
|
||
kit_id: ID комплекта для разбора
|
||
|
||
Returns:
|
||
JSON: {
|
||
'success': bool,
|
||
'released_count': int,
|
||
'message': str,
|
||
'error': str (если failed)
|
||
}
|
||
"""
|
||
try:
|
||
# Получаем комплект с витриной (только временные комплекты)
|
||
kit = ProductKit.objects.select_related('showcase').get(id=kit_id, is_temporary=True)
|
||
|
||
# Проверяем, что комплект ещё не разобран
|
||
if kit.status == 'discontinued':
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Комплект уже разобран (статус: Снят)'
|
||
}, status=400)
|
||
|
||
# Проверяем, что у комплекта есть привязанная витрина
|
||
if not kit.showcase:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Комплект не привязан к витрине'
|
||
}, status=400)
|
||
|
||
# Освобождаем резервы и устанавливаем статус
|
||
# ShowcaseManager.dismantle_from_showcase уже использует transaction.atomic()
|
||
result = ShowcaseManager.dismantle_from_showcase(
|
||
showcase=kit.showcase,
|
||
product_kit=kit
|
||
)
|
||
|
||
if not result['success']:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': result['message']
|
||
}, status=400)
|
||
|
||
# Устанавливаем статус комплекта 'discontinued'
|
||
kit.discontinue(user=request.user)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'released_count': result['released_count'],
|
||
'message': f'Комплект "{kit.name}" разобран. Статус изменён на "Снят".'
|
||
})
|
||
|
||
except ProductKit.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Комплект не найден'
|
||
}, status=404)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка при разборе: {str(e)}'
|
||
}, status=500)
|