Files
octopus/myproject/pos/views.py
Andrey Smakotin cefd6c98a2 feat: добавлено редактирование витринных комплектов и изолированное состояние tempCart
- Добавлены API endpoints для получения и обновления витринных комплектов
  - GET /pos/api/product-kits/<id>/ - получение деталей комплекта
  - POST /pos/api/product-kits/<id>/update/ - обновление комплекта
- Реализовано редактирование комплектов из POS интерфейса
  - Кнопка редактирования (карандаш) на карточках витринных букетов
  - Модальное окно предзаполняется данными комплекта
  - Поддержка изменения состава, цен, описания и фото
  - Умное управление резервами при изменении состава
- Введено изолированное состояние tempCart для модального окна
  - Основная корзина (cart) больше не затрагивается при редактировании
  - tempCart используется для создания и редактирования комплектов
  - Автоочистка tempCart при закрытии модального окна
- Устранён побочный эффект загрузки состава комплекта в основную корзину
2025-11-16 23:41:27 +03:00

557 lines
23 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
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.utils import timezone
from decimal import Decimal, InvalidOperation
import json
from products.models import Product, ProductCategory, ProductKit, KitItem
from inventory.models import Showcase, Reservation, Warehouse
from inventory.services import ShowcaseManager
def get_showcase_kits_for_pos():
"""
Получает витринные комплекты для отображения в POS.
Возвращает список временных комплектов, которые зарезервированы на витринах.
"""
# Получаем все уникальные комплекты, у которых есть резервы на витринах
showcase_reservations = Reservation.objects.filter(
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).select_related('showcase', 'product').values(
'showcase_id', 'showcase__name'
).distinct()
# Собираем все kit_items, связанные с этими резервами
showcase_kits = []
# Получаем все временные комплекты с резервами на витринах
reserved_products = Reservation.objects.filter(
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).values_list('product_id', flat=True).distinct()
# Находим комплекты, в которых есть эти товары
kits_with_showcase_items = ProductKit.objects.filter(
is_temporary=True,
status='active',
kit_items__product_id__in=reserved_products
).prefetch_related('photos', 'kit_items__product').distinct()
for kit in kits_with_showcase_items:
# Проверяем что все компоненты этого комплекта зарезервированы на одной витрине
kit_product_ids = set(kit.kit_items.values_list('product_id', flat=True))
# Находим резервы для этих товаров
kit_reservations = Reservation.objects.filter(
product_id__in=kit_product_ids,
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).select_related('showcase')
if kit_reservations.exists():
# Берём первую витрину (обычно комплект резервируется на одной)
showcase = kit_reservations.first().showcase
showcase_kits.append({
'id': kit.id,
'name': kit.name,
'price': str(kit.actual_price),
'category_ids': [], # Временные комплекты обычно без категорий
'in_stock': True, # На витрине = в наличии
'sku': kit.sku or '',
'image': kit.photos.first().get_thumbnail_url() if kit.photos.exists() else None,
'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.
Shows categories and all items (products + kits) for quick tap-to-add.
"""
categories_qs = ProductCategory.objects.filter(is_active=True)
# Показываем все товары, не только in_stock
products_qs = Product.objects.all().prefetch_related('categories', 'photos')
# Показываем все комплекты (кроме временных)
kits_qs = ProductKit.objects.filter(is_temporary=False).prefetch_related('categories', 'photos')
categories = [{'id': c.id, 'name': c.name} for c in categories_qs]
# Сериализация товаров
products = [{
'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': p.photos.first().get_thumbnail_url() if p.photos.exists() else None,
'type': 'product'
} for p in products_qs]
# Сериализация комплектов
kits = [{
'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': k.photos.first().get_thumbnail_url() if k.photos.exists() else None,
'type': 'kit'
} for k in kits_qs]
# Получаем витринные комплекты (временные комплекты с резервами на витринах)
showcase_kits_data = get_showcase_kits_for_pos()
# Объединяем все позиции
all_items = products + kits
context = {
'categories_json': json.dumps(categories),
'items_json': json.dumps(all_items),
'showcase_kits_json': json.dumps(showcase_kits_data),
'title': 'POS Terminal',
}
return render(request, 'pos/terminal.html', context)
@login_required
@require_http_methods(["GET"])
def showcase_items_api(request):
"""
API endpoint для получения витринных букетов.
Возвращает комплекты, зарезервированные на активных витринах.
"""
# Получаем все активные резервы на витринах
showcase_reservations = Reservation.objects.filter(
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).select_related('showcase', 'product').prefetch_related('product__photos')
# Группируем по витринам
showcases_dict = {}
for res in showcase_reservations:
showcase_id = res.showcase.id
if showcase_id not in showcases_dict:
showcases_dict[showcase_id] = {
'id': showcase_id,
'name': res.showcase.name,
'warehouse': res.showcase.warehouse.name,
'items': []
}
# Добавляем товар в список
showcases_dict[showcase_id]['items'].append({
'product_id': res.product.id,
'product_name': res.product.name,
'quantity': str(res.quantity),
'image': res.product.photos.first().get_thumbnail_url() if res.product.photos.exists() else None,
})
showcases_list = list(showcases_dict.values())
return JsonResponse({
'success': True,
'showcases': showcases_list
})
@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_warehouse': s.warehouse.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 для получения актуального списка витринных комплектов.
Используется для динамического обновления после создания нового букета.
"""
showcase_kits_data = get_showcase_kits_for_pos()
return JsonResponse({
'success': True,
'items': showcase_kits_data
})
@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
)
# 2. Создаём KitItem для каждого товара из корзины
for product_id, quantity in aggregated_items.items():
KitItem.objects.create(
kit=kit,
product=products[product_id],
quantity=quantity
)
# 3. Пересчитываем цену комплекта
kit.recalculate_base_price()
# 4. Загружаем фото, если есть
if photo_file:
from products.models import ProductKitPhoto
ProductKitPhoto.objects.create(
kit=kit,
image=photo_file,
order=0
)
# 5. Резервируем комплект на витрину
result = ShowcaseManager.reserve_kit_to_showcase(
product_kit=kit,
showcase=showcase,
quantity=1
)
if not result['success']:
# Откатываем транзакцию через raise
raise Exception(result['message'])
return JsonResponse({
'success': True,
'message': f'Временный комплект "{kit_name}" создан и зарезервирован на витрине "{showcase.name}"',
'kit_id': kit.id,
'kit_name': kit.name,
'kit_price': str(kit.actual_price),
'reservations_count': len(result['reservations'])
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный формат данных'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при создании комплекта: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["POST"])
def update_product_kit(request, kit_id):
"""
API endpoint для обновления временного комплекта.
Payload (multipart/form-data):
- kit_name: Новое название
- description: Описание
- items: JSON список [{product_id, quantity}, ...]
- price_adjustment_type, price_adjustment_value, sale_price
- photo: Новое фото (опционально)
- remove_photo: '1' для удаления фото
"""
try:
kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True)
# Получаем данные
kit_name = request.POST.get('kit_name', '').strip()
description = request.POST.get('description', '').strip()
items_json = request.POST.get('items', '[]')
price_adjustment_type = request.POST.get('price_adjustment_type', 'none')
price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0)))
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
remove_photo = request.POST.get('remove_photo', '') == '1'
items = json.loads(items_json)
sale_price = None
if sale_price_str:
try:
sale_price = Decimal(str(sale_price_str))
if sale_price <= 0:
sale_price = None
except (ValueError, InvalidOperation):
sale_price = None
# Валидация
if not kit_name:
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
if not items:
return JsonResponse({'success': False, 'error': 'Состав не может быть пустым'}, status=400)
# Проверяем товары
product_ids = [item['product_id'] for item in items]
products = Product.objects.in_bulk(product_ids)
if len(products) != len(product_ids):
return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400)
# Агрегируем количества
aggregated_items = {}
for item in items:
product_id = item['product_id']
quantity = Decimal(str(item['quantity']))
aggregated_items[product_id] = aggregated_items.get(product_id, Decimal('0')) + quantity
with transaction.atomic():
# Получаем старый состав для сравнения
old_items = {ki.product_id: ki.quantity for ki in kit.kit_items.all()}
# Получаем витрину для резервов
showcase_reservation = Reservation.objects.filter(
product__in=old_items.keys(),
showcase__isnull=False,
status='reserved'
).select_related('showcase').first()
showcase = showcase_reservation.showcase if showcase_reservation else None
# Вычисляем разницу в составе
all_product_ids = set(old_items.keys()) | set(aggregated_items.keys())
for product_id in all_product_ids:
old_qty = old_items.get(product_id, Decimal('0'))
new_qty = aggregated_items.get(product_id, Decimal('0'))
diff = new_qty - old_qty
if diff > 0 and showcase:
# Нужно дозарезервировать
result = ShowcaseManager.reserve_product_to_showcase(
product=products[product_id],
showcase=showcase,
quantity=diff
)
if not result['success']:
raise Exception(f"Недостаточно запасов: {result['message']}")
elif diff < 0 and showcase:
# Нужно освободить резерв
ShowcaseManager.release_showcase_reservation(
product=products[product_id],
showcase=showcase,
quantity=abs(diff)
)
# Обновляем комплект
kit.name = kit_name
kit.description = description
kit.price_adjustment_type = price_adjustment_type
kit.price_adjustment_value = price_adjustment_value
kit.sale_price = sale_price
kit.save()
# Обновляем состав
kit.kit_items.all().delete()
for product_id, quantity in aggregated_items.items():
KitItem.objects.create(
kit=kit,
product=products[product_id],
quantity=quantity
)
kit.recalculate_base_price()
# Обновляем фото
if remove_photo:
kit.photos.all().delete()
if photo_file:
from products.models import ProductKitPhoto
kit.photos.all().delete() # Удаляем старое
ProductKitPhoto.objects.create(kit=kit, image=photo_file, order=0)
return JsonResponse({
'success': True,
'message': f'Комплект "{kit.name}" обновлён',
'kit_id': kit.id,
'kit_name': kit.name,
'kit_price': str(kit.actual_price)
})
except ProductKit.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': 'Неверный формат данных'}, status=400)
except Exception as e:
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)