- Добавить inline-редактирование цен в списке товаров - Оптимизировать карточки товаров в POS-терминале - Рефакторинг моделей единиц измерения - Миграция unit -> base_unit в SalesUnit - Улучшить UI форм создания/редактирования товаров Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2008 lines
84 KiB
Python
2008 lines
84 KiB
Python
# -*- coding: utf-8 -*-
|
||
from django.shortcuts import render, get_object_or_404
|
||
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 django.core.exceptions import ValidationError
|
||
from decimal import Decimal, InvalidOperation
|
||
import json
|
||
import logging
|
||
|
||
from products.models import Product, ProductCategory, ProductKit, KitItem
|
||
from inventory.models import Showcase, Reservation, Warehouse, Stock
|
||
from inventory.services import ShowcaseManager
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
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.
|
||
|
||
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
|
||
- Группирует экземпляры по (product_kit, showcase)
|
||
- Показывает только ДОСТУПНЫЕ букеты (available)
|
||
- В корзине (in_cart) тоже показываем для информации о текущих блокировках
|
||
- Зарезервированные (reserved) НЕ показываем — они заняты под другие заказы
|
||
- Возвращает available_count (сколько можно добавить) и total_count (всего видимых)
|
||
"""
|
||
from products.models import ProductKitPhoto
|
||
from inventory.models import ShowcaseItem
|
||
from django.db.models import Count, Q
|
||
|
||
# Группируем ShowcaseItem по (product_kit, showcase)
|
||
# Включаем только available и in_cart (в корзине текущего пользователя)
|
||
# reserved НЕ показываем - они заняты под отложенные заказы
|
||
all_items = ShowcaseItem.objects.filter(
|
||
status__in=['available', 'in_cart'],
|
||
showcase__is_active=True
|
||
).select_related(
|
||
'product_kit',
|
||
'showcase'
|
||
).values(
|
||
'product_kit_id',
|
||
'product_kit__name',
|
||
'product_kit__sku',
|
||
'product_kit__price',
|
||
'product_kit__sale_price',
|
||
'showcase_id',
|
||
'showcase__name'
|
||
).annotate(
|
||
total_count=Count('id'),
|
||
available_count=Count('id', filter=Q(status='available'))
|
||
).order_by('showcase__name', 'product_kit__name')
|
||
|
||
if not all_items:
|
||
return []
|
||
|
||
# Получаем ID всех комплектов для загрузки фото
|
||
kit_ids = list(set(item['product_kit_id'] for item in all_items))
|
||
|
||
# Загружаем первые фото для комплектов
|
||
kit_photos = {}
|
||
photos = ProductKitPhoto.objects.filter(
|
||
kit_id__in=kit_ids
|
||
).order_by('kit_id', 'order')
|
||
|
||
for photo in photos:
|
||
if photo.kit_id not in kit_photos:
|
||
if photo and photo.image:
|
||
thumbnail_url = photo.get_thumbnail_url()
|
||
# Если миниатюра не найдена, возвращаем None (без фото)
|
||
if not thumbnail_url:
|
||
thumbnail_url = None
|
||
kit_photos[photo.kit_id] = thumbnail_url
|
||
|
||
# Формируем результат
|
||
showcase_kits = []
|
||
for item in all_items:
|
||
kit_id = item['product_kit_id']
|
||
showcase_id = item['showcase_id']
|
||
|
||
# Получаем IDs только ДОСТУПНЫХ экземпляров этой группы
|
||
available_item_ids = list(ShowcaseItem.objects.filter(
|
||
product_kit_id=kit_id,
|
||
showcase_id=showcase_id,
|
||
status='available'
|
||
).values_list('id', flat=True))
|
||
|
||
# Определяем актуальную цену
|
||
price = item['product_kit__sale_price'] or item['product_kit__price']
|
||
|
||
showcase_kits.append({
|
||
'id': kit_id,
|
||
'name': item['product_kit__name'],
|
||
'price': str(price),
|
||
'category_ids': [],
|
||
'in_stock': item['available_count'] > 0, # Есть ли доступные
|
||
'sku': item['product_kit__sku'] or '',
|
||
'image': kit_photos.get(kit_id),
|
||
'type': 'showcase_kit',
|
||
'showcase_name': item['showcase__name'],
|
||
'showcase_id': showcase_id,
|
||
# Количества
|
||
'available_count': item['available_count'], # Сколько можно добавить
|
||
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
|
||
'showcase_item_ids': available_item_ids # IDs только доступных
|
||
})
|
||
|
||
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:
|
||
# Нет активных складов - информация отображается в блоке склада в шаблоне
|
||
# Получаем системного клиента для корректного рендеринга JSON в шаблоне
|
||
system_customer, _ = Customer.get_or_create_system_customer()
|
||
context = {
|
||
'categories_json': json.dumps([]),
|
||
'items_json': json.dumps([]),
|
||
'showcase_kits_json': json.dumps([]),
|
||
'current_warehouse': None,
|
||
'warehouses': [],
|
||
'system_customer_json': json.dumps({
|
||
'id': system_customer.id,
|
||
'name': system_customer.name,
|
||
'wallet_balance': float(system_customer.wallet_balance)
|
||
}),
|
||
'selected_customer_json': json.dumps({
|
||
'id': system_customer.id,
|
||
'name': system_customer.name,
|
||
'wallet_balance': float(system_customer.wallet_balance)
|
||
}),
|
||
'cart_data': json.dumps({}),
|
||
'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,
|
||
'wallet_balance': float(customer.wallet_balance)
|
||
}
|
||
except Customer.DoesNotExist:
|
||
# Клиент был удален - очищаем кэш
|
||
cache.delete(redis_key)
|
||
|
||
# Если нет сохраненного клиента - используем системного
|
||
if not selected_customer:
|
||
selected_customer = {
|
||
'id': system_customer.id,
|
||
'name': system_customer.name,
|
||
'wallet_balance': float(system_customer.wallet_balance)
|
||
}
|
||
|
||
# Пытаемся получить сохраненную корзину из 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
|
||
from inventory.models import ShowcaseItem
|
||
|
||
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'] == 'kit':
|
||
# Обычный комплект - только проверяем существование
|
||
ProductKit.objects.get(id=item['id'])
|
||
cart_data[cart_key] = item
|
||
elif item['type'] == 'showcase_kit':
|
||
# Витринный комплект - проверяем существование И актуальность блокировки
|
||
kit = ProductKit.objects.get(id=item['id'])
|
||
|
||
# Получаем showcase_item_ids из корзины (если есть)
|
||
showcase_item_ids = item.get('showcase_item_ids', [])
|
||
|
||
if showcase_item_ids:
|
||
# Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя
|
||
locked_items = ShowcaseItem.objects.filter(
|
||
id__in=showcase_item_ids,
|
||
product_kit=kit,
|
||
status='in_cart',
|
||
locked_by_user=request.user,
|
||
cart_lock_expires_at__gt=timezone.now()
|
||
)
|
||
|
||
locked_count = locked_items.count()
|
||
|
||
if locked_count > 0:
|
||
# Обновляем количество на актуальное (на случай если часть блокировок истекла)
|
||
item['qty'] = locked_count
|
||
item['showcase_item_ids'] = list(locked_items.values_list('id', flat=True))
|
||
cart_data[cart_key] = item
|
||
# Если ни один не заблокирован - не добавляем в корзину
|
||
# Если showcase_item_ids отсутствует - не добавляем (устаревший формат)
|
||
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
|
||
'categories': categories_qs, # QuerySet для компонента product_search_picker
|
||
'current_warehouse': {
|
||
'id': current_warehouse.id,
|
||
'name': current_warehouse.name
|
||
},
|
||
'warehouses': warehouses_list,
|
||
'system_customer_json': json.dumps({
|
||
'id': system_customer.id,
|
||
'name': system_customer.name,
|
||
'wallet_balance': float(system_customer.wallet_balance)
|
||
}),
|
||
'selected_customer_json': json.dumps(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,
|
||
'wallet_balance': float(customer.wallet_balance)
|
||
}
|
||
cache.set(redis_key, customer_data, timeout=7200) # 2 часа
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'customer_id': customer.id,
|
||
'customer_name': customer.name,
|
||
'wallet_balance': float(customer.wallet_balance)
|
||
})
|
||
|
||
|
||
@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
|
||
from inventory.models import ShowcaseItem
|
||
|
||
# Очищаем только ИСТЁКШИЕ блокировки (cart_lock_expires_at < now)
|
||
expired_locks = ShowcaseItem.objects.filter(
|
||
status='in_cart',
|
||
cart_lock_expires_at__lt=timezone.now()
|
||
)
|
||
expired_count = expired_locks.update(
|
||
status='available',
|
||
locked_by_user=None,
|
||
cart_lock_expires_at=None,
|
||
cart_session_id=None
|
||
)
|
||
if expired_count > 0:
|
||
logger.info(f'Очищено {expired_count} истёкших блокировок ShowcaseItem')
|
||
|
||
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 для добавления N экземпляров витринного комплекта в корзину.
|
||
|
||
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
|
||
- Принимает quantity (количество экземпляров)
|
||
- Блокирует конкретные ShowcaseItem
|
||
- Возвращает список заблокированных showcase_item_ids
|
||
|
||
Payload (JSON): { "quantity": 2 }
|
||
|
||
Returns:
|
||
JSON: {
|
||
'success': bool,
|
||
'message': str,
|
||
'locked_item_ids': list[int],
|
||
'lock_expires_at': ISO datetime,
|
||
'error': str (если success=False)
|
||
}
|
||
"""
|
||
from datetime import timedelta
|
||
from inventory.models import ShowcaseItem
|
||
from inventory.services.showcase_manager import ShowcaseManager
|
||
|
||
try:
|
||
# Получаем количество из тела запроса
|
||
try:
|
||
body = json.loads(request.body) if request.body else {}
|
||
quantity = int(body.get('quantity', 1))
|
||
except (json.JSONDecodeError, ValueError):
|
||
quantity = 1
|
||
|
||
if quantity < 1:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Количество должно быть больше 0'
|
||
}, status=400)
|
||
|
||
# Проверяем что комплект существует
|
||
kit = ProductKit.objects.get(
|
||
id=kit_id,
|
||
is_temporary=True,
|
||
status='active'
|
||
)
|
||
|
||
with transaction.atomic():
|
||
# Находим доступные экземпляры этого комплекта
|
||
available_items = ShowcaseItem.objects.select_for_update(
|
||
skip_locked=True
|
||
).filter(
|
||
product_kit_id=kit_id,
|
||
status='available',
|
||
showcase__is_active=True
|
||
)[:quantity]
|
||
|
||
available_items = list(available_items)
|
||
|
||
if len(available_items) < quantity:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров. '
|
||
f'Возможно, часть уже в корзине другого кассира.'
|
||
}, status=409)
|
||
|
||
# Блокируем экземпляры
|
||
lock_expires_at = timezone.now() + timedelta(minutes=30)
|
||
session_id = request.session.session_key or ''
|
||
locked_ids = []
|
||
|
||
for item in available_items:
|
||
item.lock_for_cart(
|
||
user=request.user,
|
||
session_id=session_id,
|
||
duration_minutes=30
|
||
)
|
||
locked_ids.append(item.id)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Добавлено {len(locked_ids)} экз. "{kit.name}" в корзину',
|
||
'locked_item_ids': locked_ids,
|
||
'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:
|
||
logger.error(f'Ошибка добавления витринного букета в корзину: {str(e)}', exc_info=True)
|
||
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 для снятия блокировки витринных экземпляров при удалении из корзины.
|
||
|
||
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
|
||
- Принимает опционально showcase_item_ids для снятия блокировки с конкретных экземпляров
|
||
- Если не указаны - снимает блокировку со всех экземпляров данного комплекта
|
||
|
||
Payload (JSON): { "showcase_item_ids": [10, 11] } // опционально
|
||
|
||
Returns:
|
||
JSON: {
|
||
'success': bool,
|
||
'message': str,
|
||
'released_count': int,
|
||
'error': str (если success=False)
|
||
}
|
||
"""
|
||
from inventory.models import ShowcaseItem
|
||
|
||
try:
|
||
# Получаем комплект
|
||
kit = ProductKit.objects.get(
|
||
id=kit_id,
|
||
is_temporary=True
|
||
)
|
||
|
||
# Получаем список ID экземпляров из тела запроса (опционально)
|
||
try:
|
||
body = json.loads(request.body) if request.body else {}
|
||
showcase_item_ids = body.get('showcase_item_ids', [])
|
||
except (json.JSONDecodeError, ValueError):
|
||
showcase_item_ids = []
|
||
|
||
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
|
||
qs = ShowcaseItem.objects.filter(
|
||
product_kit=kit,
|
||
status='in_cart',
|
||
locked_by_user=request.user
|
||
)
|
||
|
||
# Если указаны конкретные ID - фильтруем только их
|
||
if showcase_item_ids:
|
||
qs = qs.filter(id__in=showcase_item_ids)
|
||
|
||
# Снимаем блокировку
|
||
updated_count = qs.update(
|
||
status='available',
|
||
locked_by_user=None,
|
||
cart_lock_expires_at=None,
|
||
cart_session_id=None
|
||
)
|
||
|
||
if updated_count == 0:
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': 'Экземпляры не были заблокированы вами',
|
||
'released_count': 0
|
||
})
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Освобождено {updated_count} экз. "{kit.name}"',
|
||
'released_count': updated_count
|
||
})
|
||
|
||
except ProductKit.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Комплект не найден'
|
||
}, status=404)
|
||
except Exception as e:
|
||
logger.error(f'Ошибка снятия блокировки витринного букета: {str(e)}', exc_info=True)
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка при снятии блокировки: {str(e)}'
|
||
}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def release_all_my_showcase_locks(request):
|
||
"""
|
||
API endpoint для сброса ВСЕХ блокировок витринных букетов текущего пользователя.
|
||
Используется при загрузке POS если корзина пустая, чтобы освободить зависшие блокировки.
|
||
"""
|
||
from inventory.models import ShowcaseItem
|
||
|
||
try:
|
||
# Снимаем ВСЕ блокировки текущего пользователя
|
||
updated_count = ShowcaseItem.objects.filter(
|
||
status='in_cart',
|
||
locked_by_user=request.user
|
||
).update(
|
||
status='available',
|
||
locked_by_user=None,
|
||
cart_lock_expires_at=None,
|
||
cart_session_id=None
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Освобождено {updated_count} блокировок',
|
||
'released_count': updated_count
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f'Ошибка сброса блокировок: {str(e)}', exc_info=True)
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 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,
|
||
'sales_units' # Загружаем единицы продажи для POS
|
||
)
|
||
|
||
# Фильтруем по категории, если указана
|
||
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:
|
||
photo = p.first_photo_list[0]
|
||
if photo and photo.image:
|
||
image_url = photo.get_thumbnail_url()
|
||
# Если миниатюра не найдена, возвращаем None (без фото)
|
||
if not image_url:
|
||
image_url = None
|
||
|
||
available = p.available_qty
|
||
reserved = p.reserved_qty
|
||
free_qty = available - reserved
|
||
|
||
# Получаем активные единицы продажи
|
||
active_sales_units = [su for su in p.sales_units.all() if su.is_active]
|
||
sales_units_count = len(active_sales_units)
|
||
has_sales_units = sales_units_count > 0
|
||
|
||
# Находим единицу продажи по умолчанию
|
||
default_sales_unit = None
|
||
available_qty_in_unit = free_qty # Количество в единицах продажи
|
||
price_in_unit = str(p.actual_price) # Цена в единицах продажи
|
||
|
||
if has_sales_units:
|
||
# Ищем единицу с is_default=True или берем первую активную
|
||
default_unit = next((su for su in active_sales_units if su.is_default), active_sales_units[0])
|
||
if default_unit and default_unit.conversion_factor and default_unit.conversion_factor > 0:
|
||
# Конвертируем свободное количество в единицы продажи
|
||
available_qty_in_unit = free_qty * default_unit.conversion_factor
|
||
price_in_unit = str(default_unit.actual_price)
|
||
default_sales_unit = {
|
||
'id': default_unit.id,
|
||
'name': default_unit.name,
|
||
'price': str(default_unit.actual_price),
|
||
'conversion_factor': str(default_unit.conversion_factor),
|
||
'min_quantity': str(default_unit.min_quantity),
|
||
'quantity_step': str(default_unit.quantity_step),
|
||
'is_default': default_unit.is_default
|
||
}
|
||
|
||
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': str(free_qty), # Передаём как строку для сохранения точности
|
||
'free_qty_sort': float(available_qty_in_unit if has_sales_units and default_sales_unit else free_qty), # Для сортировки
|
||
'sales_units_count': sales_units_count,
|
||
'has_sales_units': has_sales_units,
|
||
'default_sales_unit': default_sales_unit,
|
||
'available_qty_in_unit': str(available_qty_in_unit),
|
||
'price_in_unit': price_in_unit
|
||
})
|
||
|
||
# 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:
|
||
photo = k.first_photo_list[0]
|
||
if photo and photo.image:
|
||
image_url = photo.get_thumbnail_url()
|
||
# Если миниатюра не найдена, возвращаем None (без фото)
|
||
if not image_url:
|
||
image_url = None
|
||
|
||
# Рассчитываем доступное количество комплектов на текущем складе
|
||
available_kits = k.calculate_available_quantity(warehouse=current_warehouse)
|
||
|
||
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': available_kits > 0, # Доступен если можно собрать хоть один комплект
|
||
'sku': k.sku or '',
|
||
'image': image_url,
|
||
'type': 'kit',
|
||
'free_qty': str(available_kits), # Количество комплектов которые можно собрать
|
||
'free_qty_sort': float(available_kits) # Для сортировки
|
||
})
|
||
|
||
# Объединяем и сортируем по free_qty_sort DESC
|
||
all_items = products + kits
|
||
all_items.sort(key=lambda x: x['free_qty_sort'], reverse=True)
|
||
|
||
# Пагинация
|
||
paginator = Paginator(all_items, page_size)
|
||
page_obj = paginator.get_page(page)
|
||
|
||
# Удаляем временное поле free_qty_sort из результата (оставляем free_qty как строку)
|
||
items_to_return = []
|
||
for item in page_obj.object_list:
|
||
item_copy = item.copy()
|
||
item_copy.pop('free_qty_sort', None) # Удаляем только free_qty_sort
|
||
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 = None
|
||
if kit.photos.exists():
|
||
first_photo = kit.photos.first()
|
||
if first_photo and first_photo.image:
|
||
photo_url = first_photo.get_thumbnail_url()
|
||
# Если миниатюра не найдена, возвращаем None (без фото)
|
||
if not photo_url:
|
||
photo_url = 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')
|
||
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
|
||
|
||
# Парсим 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. Резервируем комплект на витрину (создаём N экземпляров)
|
||
result = ShowcaseManager.reserve_kit_to_showcase(
|
||
product_kit=kit,
|
||
showcase=showcase,
|
||
quantity=showcase_kit_quantity
|
||
)
|
||
|
||
if not result['success']:
|
||
# Откатываем транзакцию через raise
|
||
raise Exception(result['message'])
|
||
|
||
# Получаем ID созданных ShowcaseItem
|
||
showcase_item_ids = [item.id for item in result.get('showcase_items', [])]
|
||
|
||
created_count = len(showcase_item_ids)
|
||
qty_text = f'{created_count} шт.' if created_count > 1 else ''
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'Букет "{kit_name}" ({qty_text}) создан на витрине "{showcase.name}"' if created_count > 1 else f'Букет "{kit_name}" создан на витрине "{showcase.name}"',
|
||
'kit_id': kit.id,
|
||
'kit_name': kit.name,
|
||
'kit_price': str(kit.actual_price),
|
||
'reservations_count': len(result['reservations']),
|
||
'showcase_item_ids': showcase_item_ids,
|
||
'available_count': created_count,
|
||
'warnings': result.get('warnings')
|
||
})
|
||
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f'JSON decode error при создании временного комплекта: {str(e)}')
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Неверный формат данных корзины'
|
||
}, status=400)
|
||
except Showcase.DoesNotExist:
|
||
logger.warning(f'Попытка создать комплект на несуществующей витрине (ID: {request.POST.get("showcase_id")})')
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Выбранная витрина не найдена'
|
||
}, status=404)
|
||
except ValidationError as e:
|
||
logger.error(f'Validation error при создании временного комплекта: {str(e)}', exc_info=True)
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка валидации: {str(e)}'
|
||
}, status=400)
|
||
except Exception as e:
|
||
# Детальное логирование для диагностики 500 ошибок
|
||
logger.error(
|
||
f'Непредвиденная ошибка при создании временного комплекта:\n'
|
||
f' Название: {request.POST.get("kit_name")}\n'
|
||
f' Витрина ID: {request.POST.get("showcase_id")}\n'
|
||
f' Товары: {request.POST.get("items")}\n'
|
||
f' Пользователь: {request.user.username}\n'
|
||
f' Ошибка: {str(e)}',
|
||
exc_info=True
|
||
)
|
||
|
||
# Проверяем на типичные ошибки и даём понятные сообщения
|
||
error_msg = str(e).lower()
|
||
|
||
if 'недостаточно' in error_msg or 'insufficient' in error_msg or 'stock' in error_msg:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Недостаточно товара на складе. {str(e)}'
|
||
}, status=400)
|
||
elif 'integrity' in error_msg or 'constraint' in error_msg:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Ошибка целостности данных. Проверьте, что все товары существуют и витрина активна.'
|
||
}, status=400)
|
||
elif 'lock' in error_msg or 'blocked' in error_msg or 'заблокирован' in error_msg:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Конфликт блокировки: {str(e)}'
|
||
}, status=409)
|
||
else:
|
||
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_kit=kit,
|
||
showcase__isnull=False,
|
||
status='reserved'
|
||
).select_related('showcase').first()
|
||
|
||
showcase = showcase_reservation.showcase if showcase_reservation else kit.showcase
|
||
|
||
# Вычисляем разницу в составе и собираем информацию о дефиците
|
||
all_product_ids = set(old_items.keys()) | set(aggregated_items.keys())
|
||
stock_warnings = [] # Список товаров с нехваткой остатков
|
||
|
||
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,
|
||
product_kit=kit,
|
||
quantity_per_item=diff,
|
||
)
|
||
# Собираем информацию о дефиците
|
||
if result.get('overdraft', Decimal('0')) > 0:
|
||
stock_warnings.append({
|
||
'product_name': products[product_id].name,
|
||
'overdraft': str(result['overdraft'])
|
||
})
|
||
|
||
elif diff < 0 and showcase:
|
||
# Нужно освободить резерв (на каждый экземпляр)
|
||
ShowcaseManager.release_showcase_reservation(
|
||
product=products[product_id],
|
||
showcase=showcase,
|
||
product_kit=kit,
|
||
quantity_per_item=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),
|
||
'stock_warning': len(stock_warnings) > 0,
|
||
'stock_warnings': stock_warnings
|
||
})
|
||
|
||
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)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def pos_checkout(request):
|
||
"""
|
||
Создать заказ и провести оплату в POS-терминале.
|
||
|
||
Payload (JSON):
|
||
{
|
||
"customer_id": int,
|
||
"warehouse_id": int,
|
||
"items": [
|
||
{"type": "product"|"kit"|"showcase_kit", "id": int, "quantity": float, "price": float},
|
||
...
|
||
],
|
||
"payments": [
|
||
{"payment_method": "cash"|"card"|"online"|"account_balance", "amount": float, "notes": str},
|
||
...
|
||
],
|
||
"notes": str (optional),
|
||
"promo_code": str (optional) - Промокод для скидки
|
||
"manual_discount_id": int (optional) - ID выбранной вручную скидки
|
||
"custom_discount": dict (optional) - Произвольная скидка {"value": float, "is_percent": bool}
|
||
}
|
||
"""
|
||
from orders.models import Order, OrderItem, OrderStatus
|
||
from orders.services.transaction_service import TransactionService
|
||
from customers.models import Customer
|
||
from products.models import Product, ProductKit
|
||
from inventory.models import Warehouse, Reservation
|
||
from django.db import transaction as db_transaction
|
||
from decimal import Decimal
|
||
import json
|
||
|
||
try:
|
||
body = json.loads(request.body)
|
||
|
||
# Валидация
|
||
customer_id = body.get('customer_id')
|
||
warehouse_id = body.get('warehouse_id')
|
||
items_data = body.get('items', [])
|
||
payments_data = body.get('payments', [])
|
||
order_notes = body.get('notes', '')
|
||
promo_code = body.get('promo_code') # Промокод для скидки
|
||
manual_discount_id = body.get('manual_discount_id') # ID выбранной вручную скидки
|
||
custom_discount = body.get('custom_discount') # Произвольная скидка {"value": float, "is_percent": bool}
|
||
|
||
if not customer_id:
|
||
return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400)
|
||
if not warehouse_id:
|
||
return JsonResponse({'success': False, 'error': 'Не указан склад'}, status=400)
|
||
if not items_data:
|
||
return JsonResponse({'success': False, 'error': 'Корзина пуста'}, status=400)
|
||
if not payments_data:
|
||
return JsonResponse({'success': False, 'error': 'Не указаны способы оплаты'}, status=400)
|
||
|
||
# Получаем объекты
|
||
customer = get_object_or_404(Customer, id=customer_id)
|
||
warehouse = get_object_or_404(Warehouse, id=warehouse_id, is_active=True)
|
||
|
||
try:
|
||
completed_status = OrderStatus.objects.get(code='completed', is_system=True)
|
||
except OrderStatus.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Статус "Выполнен" не найден в системе'
|
||
}, status=500)
|
||
|
||
# Атомарная операция
|
||
with db_transaction.atomic():
|
||
# 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk)
|
||
from django.utils import timezone as tz
|
||
from orders.models import Delivery
|
||
now_utc = tz.now() # Текущее время в UTC
|
||
now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk)
|
||
current_time = now_local.time() # Извлекаем время в минском часовом поясе
|
||
|
||
order = Order.objects.create(
|
||
customer=customer,
|
||
status=completed_status, # Сразу "Выполнен"
|
||
special_instructions=order_notes,
|
||
modified_by=request.user
|
||
)
|
||
|
||
# Создаём связанную доставку (самовывоз для POS)
|
||
Delivery.objects.create(
|
||
order=order,
|
||
delivery_type=Delivery.DELIVERY_TYPE_PICKUP, # POS - всегда самовывоз
|
||
pickup_warehouse=warehouse,
|
||
delivery_date=now_local.date(), # Текущая дата в минском часовом поясе
|
||
time_from=current_time, # Текущее время (Минск)
|
||
time_to=current_time, # То же время (точное время)
|
||
cost=0 # Самовывоз бесплатный
|
||
)
|
||
|
||
# 2. Добавляем товары
|
||
from inventory.models import ShowcaseItem
|
||
from inventory.services.showcase_manager import ShowcaseManager
|
||
|
||
for item_data in items_data:
|
||
item_type = item_data['type']
|
||
item_id = item_data['id']
|
||
quantity = Decimal(str(item_data['quantity']))
|
||
price = Decimal(str(item_data['price']))
|
||
|
||
if item_type == 'product':
|
||
product = Product.objects.get(id=item_id)
|
||
# Получаем sales_unit_id если передан
|
||
sales_unit_id = item_data.get('sales_unit_id')
|
||
sales_unit = None
|
||
if sales_unit_id:
|
||
from products.models import ProductSalesUnit
|
||
try:
|
||
sales_unit = ProductSalesUnit.objects.get(id=sales_unit_id, product=product)
|
||
except ProductSalesUnit.DoesNotExist:
|
||
pass
|
||
|
||
OrderItem.objects.create(
|
||
order=order,
|
||
product=product,
|
||
quantity=quantity,
|
||
price=price,
|
||
is_custom_price=False,
|
||
sales_unit=sales_unit
|
||
)
|
||
elif item_type == 'kit':
|
||
# Обычный комплект (не витринный)
|
||
kit = ProductKit.objects.get(id=item_id)
|
||
OrderItem.objects.create(
|
||
order=order,
|
||
product_kit=kit,
|
||
quantity=quantity,
|
||
price=price,
|
||
is_custom_price=False
|
||
)
|
||
elif item_type == 'showcase_kit':
|
||
# Витринный букет - работаем через ShowcaseItem
|
||
kit = ProductKit.objects.get(id=item_id)
|
||
showcase_item_ids = item_data.get('showcase_item_ids', [])
|
||
|
||
if not showcase_item_ids:
|
||
# Обратная совместимость: если showcase_item_ids не передан,
|
||
# ищем заблокированные экземпляры этого комплекта
|
||
showcase_item_ids = list(ShowcaseItem.objects.filter(
|
||
product_kit_id=item_id,
|
||
status='in_cart',
|
||
locked_by_user=request.user
|
||
).values_list('id', flat=True)[:int(quantity)])
|
||
|
||
# Создаём OrderItem
|
||
order_item = OrderItem.objects.create(
|
||
order=order,
|
||
product_kit=kit,
|
||
quantity=len(showcase_item_ids) if showcase_item_ids else int(quantity),
|
||
price=price,
|
||
is_custom_price=False,
|
||
is_from_showcase=True
|
||
)
|
||
|
||
# Продаём экземпляры через ShowcaseManager
|
||
if showcase_item_ids:
|
||
# ShowcaseManager.sell_showcase_items выполнит всю валидацию с блокировкой БД
|
||
showcase_items = list(ShowcaseItem.objects.filter(
|
||
id__in=showcase_item_ids
|
||
))
|
||
|
||
if showcase_items:
|
||
result = ShowcaseManager.sell_showcase_items(showcase_items, order_item)
|
||
if not result['success']:
|
||
raise ValidationError(result['message'])
|
||
|
||
# 3. Пересчитываем итоговую стоимость
|
||
# Обновляем объект заказа из БД, чтобы получить все связанные товары
|
||
order.refresh_from_db()
|
||
order.calculate_total()
|
||
|
||
# 4. Применяем скидки
|
||
if custom_discount:
|
||
# Применяем произвольную скидку
|
||
from decimal import Decimal
|
||
discount_value = Decimal(str(custom_discount.get('value', 0)))
|
||
is_percent = custom_discount.get('is_percent', False)
|
||
|
||
if is_percent:
|
||
# Процентная скидка
|
||
discount_amount = order.subtotal * discount_value / 100
|
||
discount_name = f'Произвольная скидка {discount_value}%'
|
||
else:
|
||
# Фиксированная скидка
|
||
discount_amount = min(discount_value, order.subtotal)
|
||
discount_name = f'Произвольная скидка {discount_value} руб.'
|
||
|
||
# Создаем запись о применении в истории скидок
|
||
from discounts.models import DiscountApplication
|
||
DiscountApplication.objects.create(
|
||
order=order,
|
||
target='order',
|
||
base_amount=order.subtotal,
|
||
discount_amount=discount_amount,
|
||
final_amount=order.subtotal - discount_amount,
|
||
customer=customer,
|
||
applied_by=request.user
|
||
)
|
||
|
||
# Пересчитываем total_amount (использует DiscountApplication)
|
||
order.calculate_total()
|
||
elif manual_discount_id:
|
||
from discounts.services.applier import DiscountApplier
|
||
from discounts.models import Discount
|
||
|
||
try:
|
||
discount = Discount.objects.get(id=manual_discount_id, is_active=True)
|
||
apply_result = DiscountApplier.apply_manual_discount(
|
||
order=order,
|
||
discount=discount,
|
||
user=request.user
|
||
)
|
||
if not apply_result['success']:
|
||
raise ValidationError(apply_result['error'])
|
||
except Discount.DoesNotExist:
|
||
pass
|
||
elif promo_code:
|
||
from discounts.services.applier import DiscountApplier
|
||
apply_result = DiscountApplier.apply_promo_code(
|
||
order=order,
|
||
promo_code=promo_code,
|
||
user=request.user
|
||
)
|
||
if not apply_result['success']:
|
||
raise ValidationError(apply_result['error'])
|
||
else:
|
||
# Применяем автоматические скидки
|
||
from discounts.services.applier import DiscountApplier
|
||
DiscountApplier.apply_auto_discounts(order, user=request.user)
|
||
|
||
# 5. Проводим платежи
|
||
payments_list = []
|
||
for payment_data in payments_data:
|
||
payments_list.append({
|
||
'payment_method': payment_data['payment_method'],
|
||
'amount': Decimal(str(payment_data['amount'])),
|
||
'notes': payment_data.get('notes', f"Оплата POS: {payment_data['payment_method']}")
|
||
})
|
||
|
||
transactions = TransactionService.create_multiple_payments(
|
||
order=order,
|
||
payments_list=payments_list,
|
||
user=request.user
|
||
)
|
||
|
||
# 5. Обновляем статус оплаты
|
||
order.update_payment_status()
|
||
|
||
# 6. Очищаем корзину из Redis
|
||
from django.core.cache import cache
|
||
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
|
||
cache.delete(cart_key)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'order_number': order.order_number,
|
||
'order_id': order.id,
|
||
'total_amount': float(order.total_amount),
|
||
'amount_paid': float(order.amount_paid),
|
||
'amount_due': float(order.amount_due),
|
||
'payments_count': len(transactions),
|
||
'message': f'Заказ #{order.order_number} успешно создан и оплачен'
|
||
})
|
||
|
||
except (Customer.DoesNotExist, Warehouse.DoesNotExist, Product.DoesNotExist, ProductKit.DoesNotExist) as e:
|
||
return JsonResponse({'success': False, 'error': 'Объект не найден'}, status=404)
|
||
except ValidationError as e:
|
||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400)
|
||
except Exception as e:
|
||
logger.error(f'Ошибка при проведении продажи POS: {str(e)}', exc_info=True)
|
||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def create_order_draft(request):
|
||
"""
|
||
Создает черновик заказа из корзины POS и сохраняет в Redis.
|
||
Возвращает токен для передачи в orders/create/.
|
||
|
||
Payload (JSON):
|
||
{
|
||
"customer_id": int,
|
||
"items": [
|
||
{"type": "product"|"kit"|"showcase_kit", "id": int, "quantity": float, "price": float,
|
||
"showcase_item_ids": [int, ...] // для showcase_kit
|
||
},
|
||
...
|
||
]
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"token": "abc123..."
|
||
}
|
||
"""
|
||
from django.core.cache import cache
|
||
import secrets
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
customer_id = data.get('customer_id')
|
||
items = data.get('items', [])
|
||
|
||
if not items:
|
||
return JsonResponse({'success': False, 'error': 'Корзина пуста'}, status=400)
|
||
|
||
# Генерируем уникальный токен
|
||
token = secrets.token_urlsafe(16)
|
||
|
||
# Сохраняем в Redis с TTL 1 час
|
||
cache_key = f'pos_draft:{token}'
|
||
draft_data = {
|
||
'customer_id': customer_id,
|
||
'items': items, # items уже содержат showcase_item_ids для showcase_kit
|
||
}
|
||
cache.set(cache_key, draft_data, timeout=3600) # 1 час
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'token': token
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400)
|
||
except Exception as e:
|
||
logger.error(f'Ошибка при создании черновика заказа: {str(e)}', exc_info=True)
|
||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||
|
||
|
||
# ============================================
|
||
# DISCOUNT API
|
||
# ============================================
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def validate_promo_code(request):
|
||
"""
|
||
Валидировать промокод.
|
||
|
||
Payload JSON:
|
||
{
|
||
'promo_code': str,
|
||
'cart_total': decimal (optional)
|
||
}
|
||
|
||
Returns JSON:
|
||
{
|
||
'success': true/false,
|
||
'promo_code': {...},
|
||
'error': str
|
||
}
|
||
"""
|
||
from discounts.services.validator import DiscountValidator
|
||
from customers.models import Customer
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
promo_code = data.get('promo_code', '')
|
||
cart_total = data.get('cart_total', '0')
|
||
|
||
# Получаем текущего клиента из Redis
|
||
from django.core.cache import cache
|
||
cart_key = f'pos:customer:{request.user.id}'
|
||
customer_id = cache.get(cart_key)
|
||
|
||
customer = None
|
||
if customer_id:
|
||
try:
|
||
customer = Customer.objects.get(id=customer_id)
|
||
except Customer.DoesNotExist:
|
||
pass
|
||
|
||
is_valid, promo, error = DiscountValidator.validate_promo_code(
|
||
promo_code, customer, Decimal(str(cart_total)) if cart_total else None
|
||
)
|
||
|
||
if is_valid:
|
||
return JsonResponse({
|
||
'success': True,
|
||
'promo_code': {
|
||
'code': promo.code,
|
||
'discount_id': promo.discount.id,
|
||
'discount_name': promo.discount.name,
|
||
'discount_type': promo.discount.discount_type,
|
||
'discount_value': float(promo.discount.value),
|
||
'scope': promo.discount.scope,
|
||
}
|
||
})
|
||
else:
|
||
return JsonResponse({'success': False, 'error': error})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400)
|
||
except Exception as e:
|
||
logger.error(f'Ошибка при валидации промокода: {str(e)}', exc_info=True)
|
||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def calculate_cart_discounts(request):
|
||
"""
|
||
Рассчитать скидки для корзины POS.
|
||
Поддерживает комбинирование скидок по combine_mode.
|
||
|
||
Payload JSON:
|
||
{
|
||
'items': [...],
|
||
'promo_code': str (optional),
|
||
'customer_id': int (optional),
|
||
'manual_discount_id': int (optional),
|
||
'skip_auto_discount': bool (optional)
|
||
}
|
||
|
||
Returns JSON:
|
||
{
|
||
'success': true,
|
||
'cart_subtotal': float,
|
||
'order_discounts': [
|
||
{'discount_id': int, 'discount_name': str, 'discount_amount': float, 'combine_mode': str},
|
||
...
|
||
],
|
||
'total_order_discount': float,
|
||
'item_discounts': [
|
||
{
|
||
'cart_index': int,
|
||
'discounts': [
|
||
{'discount_id': int, 'discount_name': str, 'discount_amount': float, 'combine_mode': str},
|
||
...
|
||
],
|
||
'total_discount': float
|
||
},
|
||
...
|
||
],
|
||
'total_discount': float,
|
||
'final_total': float,
|
||
'excluded_by': {'id': int, 'name': str} или None
|
||
}
|
||
"""
|
||
from discounts.services.calculator import DiscountCalculator
|
||
from customers.models import Customer
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
items_data = data.get('items', [])
|
||
promo_code = data.get('promo_code')
|
||
customer_id = data.get('customer_id')
|
||
skip_auto_discount = data.get('skip_auto_discount', False)
|
||
|
||
customer = None
|
||
if customer_id:
|
||
try:
|
||
customer = Customer.objects.get(id=customer_id)
|
||
except Customer.DoesNotExist:
|
||
pass
|
||
|
||
result = DiscountCalculator.calculate_cart_discounts(
|
||
items_data, promo_code, customer, skip_auto_discount=skip_auto_discount
|
||
)
|
||
|
||
# Форматируем item_discounts для JSON (Decimal -> float)
|
||
formatted_item_discounts = []
|
||
for item in result['item_discounts']:
|
||
formatted_discounts = []
|
||
for disc in item['discounts']:
|
||
formatted_discounts.append({
|
||
'discount_id': disc['discount_id'],
|
||
'discount_name': disc['discount_name'],
|
||
'discount_amount': float(disc['discount_amount']),
|
||
'combine_mode': disc['combine_mode']
|
||
})
|
||
formatted_item_discounts.append({
|
||
'cart_index': item['cart_index'],
|
||
'discounts': formatted_discounts,
|
||
'total_discount': float(item['total_discount'])
|
||
})
|
||
|
||
# Форматируем order_discounts для JSON
|
||
formatted_order_discounts = []
|
||
for disc in result['order_discounts']:
|
||
formatted_order_discounts.append({
|
||
'discount_id': disc['discount_id'],
|
||
'discount_name': disc['discount_name'],
|
||
'discount_amount': float(disc['discount_amount']),
|
||
'combine_mode': disc['combine_mode']
|
||
})
|
||
|
||
response_data = {
|
||
'success': True,
|
||
'cart_subtotal': float(result['cart_subtotal']),
|
||
'order_discounts': formatted_order_discounts,
|
||
'total_order_discount': float(result['total_order_discount']),
|
||
'item_discounts': formatted_item_discounts,
|
||
'total_discount': float(result['total_discount']),
|
||
'final_total': float(result['final_total']),
|
||
'excluded_by': result.get('excluded_by')
|
||
}
|
||
|
||
return JsonResponse(response_data)
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400)
|
||
except Exception as e:
|
||
logger.error(f'Ошибка при расчете скидок: {str(e)}', exc_info=True)
|
||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET"])
|
||
def get_available_discounts(request):
|
||
"""
|
||
Возвращает список доступных скидок для ручного применения в POS.
|
||
|
||
Query params:
|
||
- cart_total: сумма корзины (для фильтрации по min_order_amount)
|
||
|
||
Returns JSON:
|
||
{
|
||
'success': true,
|
||
'order_discounts': [
|
||
{
|
||
'id': 1,
|
||
'name': 'Скидка 5%',
|
||
'discount_type': 'percentage',
|
||
'value': 5,
|
||
'min_order_amount': 50
|
||
},
|
||
...
|
||
],
|
||
'auto_order_discount': {...} # если есть автоскидка на заказ
|
||
}
|
||
"""
|
||
from discounts.services.calculator import DiscountCalculator
|
||
|
||
try:
|
||
cart_total = request.GET.get('cart_total', '0')
|
||
cart_total = Decimal(str(cart_total))
|
||
|
||
# Получаем НЕ автоматические скидки на заказ для ручного применения
|
||
order_discounts = DiscountCalculator.get_available_discounts(
|
||
scope='order',
|
||
auto_only=False
|
||
).filter(is_auto=False)
|
||
|
||
# Фильтруем по мин. сумме заказа
|
||
result_discounts = []
|
||
for d in order_discounts:
|
||
if d.min_order_amount and cart_total < d.min_order_amount:
|
||
continue
|
||
result_discounts.append({
|
||
'id': d.id,
|
||
'name': d.name,
|
||
'discount_type': d.discount_type,
|
||
'value': float(d.value),
|
||
'min_order_amount': float(d.min_order_amount) if d.min_order_amount else None,
|
||
'combine_mode': d.combine_mode
|
||
})
|
||
|
||
# Получаем автоматическую скидку (только одну для отображения)
|
||
auto_discounts = DiscountCalculator.get_available_discounts(
|
||
scope='order',
|
||
auto_only=True
|
||
)
|
||
|
||
auto_discount_data = None
|
||
for d in auto_discounts:
|
||
if d.min_order_amount and cart_total < d.min_order_amount:
|
||
continue
|
||
# Рассчитываем сумму
|
||
discount_amount = d.calculate_discount_amount(cart_total)
|
||
auto_discount_data = {
|
||
'id': d.id,
|
||
'name': d.name,
|
||
'discount_type': d.discount_type,
|
||
'value': float(d.value),
|
||
'discount_amount': float(discount_amount)
|
||
}
|
||
break
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'order_discounts': result_discounts,
|
||
'auto_order_discount': auto_discount_data
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f'Ошибка при получении скидок: {str(e)}', exc_info=True)
|
||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|