Files
octopus/myproject/pos/views.py
Andrey Smakotin ad7808cd06 Fix: добавлен импорт get_object_or_404 в pos/views.py
Исправлена ошибка 'get_object_or_404 is not defined' при попытке
провести оплату через POS-терминал.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 20:06:59 +03:00

1407 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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.
Возвращает список временных комплектов, которые зарезервированы на витринах.
Оптимизировано: убраны 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'
)
# Находим комплекты с резервированными компонентами
# ВАЖНО: фильтруем только комплекты, явно привязанные к витрине (showcase__isnull=False)
# Это исключает временные комплекты, созданные для заказов (order__isnull=False, showcase=NULL)
kits_with_showcase_items = ProductKit.objects.filter(
is_temporary=True,
status='active',
showcase__isnull=False, # Только витринные комплекты
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:
# Нет активных складов - информация отображается в блоке склада в шаблоне
# Получаем системного клиента для корректного рендеринга 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': {
'id': system_customer.id,
'name': system_customer.name
},
'selected_customer': {
'id': system_customer.id,
'name': system_customer.name
},
'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
}
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'] == 'kit':
# Обычный комплект - только проверяем существование
ProductKit.objects.get(id=item['id'])
cart_data[cart_key] = item
elif item['type'] == 'showcase_kit':
# Витринный комплект - проверяем существование И актуальность блокировки
kit = ProductKit.objects.get(id=item['id'])
# Проверяем, что блокировка всё ещё активна для этого пользователя
has_valid_lock = Reservation.objects.filter(
product_kit=kit,
locked_by_user=request.user,
cart_lock_expires_at__gt=timezone.now(),
status='reserved'
).exists()
if has_valid_lock:
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'
)
# Атомарная проверка и создание блокировки (предотвращает race condition)
with transaction.atomic():
# Блокируем строки резервов для этого комплекта на уровне БД
# Примечание: нельзя использовать select_related с nullable FK при select_for_update
reservations = Reservation.objects.select_for_update().filter(
product_kit=kit,
status='reserved'
)
# Проверяем существующие блокировки другими пользователями
existing_lock = reservations.filter(
cart_lock_expires_at__gt=timezone.now()
).exclude(
locked_by_user=request.user
).first()
if existing_lock:
# Получаем username отдельным запросом (избегаем outer join с select_for_update)
locked_by_username = existing_lock.locked_by_user.username if existing_lock.locked_by_user else 'другой кассир'
time_left = (existing_lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
return JsonResponse({
'success': False,
'error': f'Этот букет уже в корзине кассира "{locked_by_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 = reservations.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 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__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)
@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)
}
"""
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', '')
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. Создаём заказ
order = Order.objects.create(
customer=customer,
is_delivery=False, # POS - всегда самовывоз
pickup_warehouse=warehouse,
status=completed_status, # Сразу "Выполнен"
special_instructions=order_notes,
modified_by=request.user
)
# 2. Добавляем товары
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)
OrderItem.objects.create(
order=order,
product=product,
quantity=quantity,
price=price,
is_custom_price=False
)
elif item_type in ['kit', 'showcase_kit']:
kit = ProductKit.objects.get(id=item_id)
OrderItem.objects.create(
order=order,
product_kit=kit,
quantity=quantity,
price=price,
is_custom_price=False
)
# 3. Пересчитываем итоговую стоимость
order.calculate_total()
# 4. Проводим платежи
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. Освобождаем блокировки витринных комплектов
showcase_kit_ids = [
item_data['id'] for item_data in items_data
if item_data['type'] == 'showcase_kit'
]
if showcase_kit_ids:
Reservation.objects.filter(
product_kit_id__in=showcase_kit_ids,
locked_by_user=request.user,
status='reserved'
).update(
cart_lock_expires_at=None,
locked_by_user=None,
cart_session_id=None
)
# 7. Очищаем корзину из 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)