ShowcaseItem: защита от двойной продажи витринных букетов
Новая архитектура: - ShowcaseItem модель - физический экземпляр букета на витрине - OneToOneField(sold_order_item) - БД-уровневая защита от двойной продажи - Поддержка создания нескольких экземпляров одного букета - Возможность продавать N из M доступных (например 2 из 5) Изменения: - inventory/models.py: добавлена модель ShowcaseItem с методами lock/unlock/mark_sold - inventory/services/showcase_manager.py: переработан для работы с ShowcaseItem - pos/views.py: API поддерживает quantity и showcase_item_ids - pos/templates/pos/terminal.html: поле "Сколько букетов создать" - pos/static/pos/js/terminal.js: выбор количества, передача showcase_item_ids Миграции: - 0007: создание модели ShowcaseItem - 0008: data migration существующих букетов - 0009: очистка ShowcaseItem для уже проданных букетов 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -570,9 +570,10 @@ function renderProducts() {
|
||||
const stock = document.createElement('div');
|
||||
stock.className = 'product-stock';
|
||||
|
||||
// Для витринных комплектов показываем название витрины
|
||||
// Для витринных комплектов показываем название витрины И доступное количество
|
||||
if (item.type === 'showcase_kit') {
|
||||
stock.textContent = `🌺 ${item.showcase_name}`;
|
||||
const availableCount = item.available_count || 1;
|
||||
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge bg-success ms-1">${availableCount} шт</span>`;
|
||||
stock.style.color = '#856404';
|
||||
stock.style.fontWeight = 'bold';
|
||||
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
|
||||
@@ -735,14 +736,34 @@ function setupInfiniteScroll() {
|
||||
async function addToCart(item) {
|
||||
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
||||
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock + количество)
|
||||
if (item.type === 'showcase_kit') {
|
||||
// Проверяем: не заблокирован ли уже этим пользователем
|
||||
if (cart.has(cartKey)) {
|
||||
alert('Этот букет уже в вашей корзине.\nВитринные комплекты доступны только в количестве 1 шт.');
|
||||
// Определяем сколько доступно и сколько добавить
|
||||
const availableCount = item.available_count || 1;
|
||||
const currentInCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||
const remainingAvailable = availableCount - currentInCart;
|
||||
|
||||
if (remainingAvailable <= 0) {
|
||||
alert(`Все ${availableCount} экз. этого букета уже в корзине.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если доступно > 1, спрашиваем количество
|
||||
let quantityToAdd = 1;
|
||||
if (remainingAvailable > 1 && !cart.has(cartKey)) {
|
||||
const input = prompt(
|
||||
`Доступно ${availableCount} экз. букета "${item.name}".\n` +
|
||||
`Сколько добавить в корзину? (1-${remainingAvailable})`,
|
||||
'1'
|
||||
);
|
||||
if (input === null) return; // Отмена
|
||||
quantityToAdd = parseInt(input, 10);
|
||||
if (isNaN(quantityToAdd) || quantityToAdd < 1 || quantityToAdd > remainingAvailable) {
|
||||
alert(`Введите число от 1 до ${remainingAvailable}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Пытаемся создать блокировку через API
|
||||
try {
|
||||
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
|
||||
@@ -750,7 +771,8 @@ async function addToCart(item) {
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify({ quantity: quantityToAdd })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -761,16 +783,28 @@ async function addToCart(item) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Успешно заблокировали - добавляем в корзину с qty=1 и флагом max_qty
|
||||
cart.set(cartKey, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: Number(item.price),
|
||||
qty: 1,
|
||||
type: item.type,
|
||||
max_qty: 1, // Флаг: нельзя увеличить количество
|
||||
lock_expires_at: data.lock_expires_at // Время истечения блокировки
|
||||
});
|
||||
// Успешно заблокировали - добавляем/обновляем в корзине
|
||||
const lockedItemIds = data.locked_item_ids || [];
|
||||
|
||||
if (cart.has(cartKey)) {
|
||||
// Добавляем к существующим
|
||||
const existing = cart.get(cartKey);
|
||||
existing.qty += lockedItemIds.length;
|
||||
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
|
||||
existing.max_qty = availableCount;
|
||||
} else {
|
||||
// Создаём новую запись
|
||||
cart.set(cartKey, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: Number(item.price),
|
||||
qty: lockedItemIds.length,
|
||||
type: item.type,
|
||||
max_qty: availableCount, // Максимум = сколько доступно
|
||||
showcase_item_ids: lockedItemIds, // ID заблокированных экземпляров
|
||||
lock_expires_at: data.lock_expires_at
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем список витрины (чтобы показать блокировку)
|
||||
if (isShowcaseView) {
|
||||
@@ -963,12 +997,19 @@ async function removeFromCart(cartKey) {
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку
|
||||
if (item && item.type === 'showcase_kit') {
|
||||
try {
|
||||
// Передаём конкретные showcase_item_ids для снятия блокировки
|
||||
const body = {};
|
||||
if (item.showcase_item_ids && item.showcase_item_ids.length > 0) {
|
||||
body.showcase_item_ids = item.showcase_item_ids;
|
||||
}
|
||||
|
||||
const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -1376,11 +1417,15 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
const useSalePrice = document.getElementById('useSalePrice').checked;
|
||||
const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0;
|
||||
|
||||
// Получаем количество букетов для создания
|
||||
const showcaseKitQuantity = parseInt(document.getElementById('showcaseKitQuantity').value, 10) || 1;
|
||||
|
||||
// Формируем FormData для отправки с файлом
|
||||
const formData = new FormData();
|
||||
formData.append('kit_name', kitName);
|
||||
if (showcaseId) {
|
||||
formData.append('showcase_id', showcaseId);
|
||||
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
|
||||
}
|
||||
formData.append('description', description);
|
||||
formData.append('items', JSON.stringify(items));
|
||||
@@ -1423,19 +1468,21 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
|
||||
if (data.success) {
|
||||
// Успех!
|
||||
const createdCount = data.available_count || 1;
|
||||
const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : '';
|
||||
const successMessage = isEditMode
|
||||
? `✅ ${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.`
|
||||
: `✅ ${data.message}
|
||||
|
||||
Комплект: ${data.kit_name}
|
||||
Цена: ${data.kit_price} руб.
|
||||
Цена: ${data.kit_price} руб.${qtyInfo}
|
||||
Зарезервировано компонентов: ${data.reservations_count}`;
|
||||
|
||||
|
||||
alert(successMessage);
|
||||
|
||||
|
||||
// Очищаем tempCart (изолированное состояние модалки)
|
||||
tempCart.clear();
|
||||
|
||||
|
||||
// Сбрасываем поля формы
|
||||
document.getElementById('tempKitDescription').value = '';
|
||||
document.getElementById('tempKitPhoto').value = '';
|
||||
@@ -1446,6 +1493,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
document.getElementById('useSalePrice').checked = false;
|
||||
document.getElementById('salePrice').value = '';
|
||||
document.getElementById('salePriceBlock').style.display = 'none';
|
||||
document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества
|
||||
|
||||
// Сбрасываем режим редактирования
|
||||
isEditMode = false;
|
||||
@@ -1714,12 +1762,19 @@ async function handleCheckoutSubmit(paymentsData) {
|
||||
const orderData = {
|
||||
customer_id: customer.id,
|
||||
warehouse_id: currentWarehouse.id,
|
||||
items: Array.from(cart.values()).map(item => ({
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
quantity: item.qty,
|
||||
price: item.price
|
||||
})),
|
||||
items: Array.from(cart.values()).map(item => {
|
||||
const itemData = {
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
quantity: item.qty,
|
||||
price: item.price
|
||||
};
|
||||
// Для витринных букетов передаём ID конкретных экземпляров
|
||||
if (item.type === 'showcase_kit' && item.showcase_item_ids) {
|
||||
itemData.showcase_item_ids = item.showcase_item_ids;
|
||||
}
|
||||
return itemData;
|
||||
}),
|
||||
payments: paymentsData,
|
||||
notes: document.getElementById('orderNote').value.trim()
|
||||
};
|
||||
|
||||
@@ -169,7 +169,14 @@
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Количество букетов -->
|
||||
<div class="mb-3">
|
||||
<label for="showcaseKitQuantity" class="form-label">Сколько букетов создать</label>
|
||||
<input type="number" class="form-control" id="showcaseKitQuantity" value="1" min="1" max="99">
|
||||
<small class="text-muted">Будет создано указанное количество одинаковых букетов на витрину</small>
|
||||
</div>
|
||||
|
||||
<!-- Описание -->
|
||||
<div class="mb-3">
|
||||
<label for="tempKitDescription" class="form-label">Описание (опционально)</label>
|
||||
|
||||
@@ -54,107 +54,83 @@ def get_pos_warehouse(request):
|
||||
def get_showcase_kits_for_pos():
|
||||
"""
|
||||
Получает витринные комплекты для отображения в POS.
|
||||
Возвращает список временных комплектов, которые зарезервированы на витринах.
|
||||
Оптимизировано: убраны N+1 запросы, используется один проход по данным.
|
||||
|
||||
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
|
||||
- Группирует экземпляры по (product_kit, showcase)
|
||||
- Возвращает available_count и showcase_item_ids для каждой группы
|
||||
- Позволяет продавать несколько экземпляров одного букета
|
||||
"""
|
||||
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:
|
||||
from inventory.models import ShowcaseItem
|
||||
from django.db.models import Count, Prefetch as DjangoPrefetch
|
||||
|
||||
# Группируем доступные ShowcaseItem по (product_kit, showcase)
|
||||
available_items = ShowcaseItem.objects.filter(
|
||||
status='available',
|
||||
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(
|
||||
available_count=Count('id')
|
||||
).order_by('showcase__name', 'product_kit__name')
|
||||
|
||||
if not available_items:
|
||||
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']))
|
||||
|
||||
|
||||
# Получаем ID всех комплектов для загрузки фото
|
||||
kit_ids = list(set(item['product_kit_id'] for item in available_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:
|
||||
kit_photos[photo.kit_id] = photo.get_thumbnail_url()
|
||||
|
||||
# Формируем результат
|
||||
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
|
||||
})
|
||||
|
||||
for item in available_items:
|
||||
kit_id = item['product_kit_id']
|
||||
showcase_id = item['showcase_id']
|
||||
|
||||
# Получаем IDs всех доступных экземпляров этой группы
|
||||
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': True,
|
||||
'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'],
|
||||
'showcase_item_ids': item_ids
|
||||
})
|
||||
|
||||
return showcase_kits
|
||||
|
||||
|
||||
@@ -477,74 +453,85 @@ def get_showcase_kits_api(request):
|
||||
@require_http_methods(["POST"])
|
||||
def add_showcase_kit_to_cart(request, kit_id):
|
||||
"""
|
||||
API endpoint для добавления витринного комплекта в корзину с блокировкой.
|
||||
Создает soft lock на 30 минут, предотвращая добавление другими кассирами.
|
||||
API endpoint для добавления N экземпляров витринного комплекта в корзину.
|
||||
|
||||
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
|
||||
- Принимает quantity (количество экземпляров)
|
||||
- Блокирует конкретные ShowcaseItem
|
||||
- Возвращает список заблокированных showcase_item_ids
|
||||
|
||||
Payload (JSON): { "quantity": 2 }
|
||||
|
||||
Returns:
|
||||
JSON: {
|
||||
'success': bool,
|
||||
'message': str,
|
||||
'lock_expires_at': ISO datetime (если success=True),
|
||||
'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:
|
||||
# Получаем комплект
|
||||
kit = ProductKit.objects.select_related('showcase').get(
|
||||
# Получаем количество из тела запроса
|
||||
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,
|
||||
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'
|
||||
)
|
||||
# Находим доступные экземпляры этого комплекта
|
||||
available_items = ShowcaseItem.objects.select_for_update(
|
||||
skip_locked=True
|
||||
).filter(
|
||||
product_kit_id=kit_id,
|
||||
status='available',
|
||||
showcase__is_active=True
|
||||
)[:quantity]
|
||||
|
||||
# Проверяем существующие блокировки другими пользователями
|
||||
existing_lock = reservations.filter(
|
||||
cart_lock_expires_at__gt=timezone.now()
|
||||
).exclude(
|
||||
locked_by_user=request.user
|
||||
).first()
|
||||
available_items = list(available_items)
|
||||
|
||||
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
|
||||
if len(available_items) < quantity:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Этот букет уже в корзине кассира "{locked_by_username}". '
|
||||
f'Блокировка истечет через {int(time_left)} мин.'
|
||||
}, status=409) # 409 Conflict
|
||||
'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 = []
|
||||
|
||||
# Обновляем все резервы этого комплекта (теперь атомарно!)
|
||||
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)
|
||||
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'Букет "{kit.name}" добавлен в корзину',
|
||||
'message': f'Добавлено {len(locked_ids)} экз. "{kit.name}" в корзину',
|
||||
'locked_item_ids': locked_ids,
|
||||
'lock_expires_at': lock_expires_at.isoformat(),
|
||||
'locked_until_minutes': 30
|
||||
})
|
||||
@@ -555,6 +542,7 @@ def add_showcase_kit_to_cart(request, kit_id):
|
||||
'error': 'Витринный комплект не найден'
|
||||
}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка добавления витринного букета в корзину: {str(e)}', exc_info=True)
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Ошибка при добавлении в корзину: {str(e)}'
|
||||
@@ -565,16 +553,24 @@ def add_showcase_kit_to_cart(request, kit_id):
|
||||
@require_http_methods(["POST"])
|
||||
def remove_showcase_kit_from_cart(request, kit_id):
|
||||
"""
|
||||
API endpoint для снятия блокировки витринного комплекта при удалении из корзины.
|
||||
Освобождает комплект для добавления другими кассирами.
|
||||
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(
|
||||
@@ -582,27 +578,42 @@ def remove_showcase_kit_from_cart(request, kit_id):
|
||||
is_temporary=True
|
||||
)
|
||||
|
||||
# Снимаем блокировку только для текущего пользователя
|
||||
updated_count = Reservation.objects.filter(
|
||||
# Получаем список 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,
|
||||
locked_by_user=request.user,
|
||||
status='reserved'
|
||||
).update(
|
||||
cart_lock_expires_at=None,
|
||||
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': 'Комплект не был заблокирован вами'
|
||||
'message': 'Экземпляры не были заблокированы вами',
|
||||
'released_count': 0
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'Букет "{kit.name}" удален из корзины. Блокировка снята.',
|
||||
'message': f'Освобождено {updated_count} экз. "{kit.name}"',
|
||||
'released_count': updated_count
|
||||
})
|
||||
|
||||
@@ -612,6 +623,7 @@ def remove_showcase_kit_from_cart(request, kit_id):
|
||||
'error': 'Комплект не найден'
|
||||
}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка снятия блокировки витринного букета: {str(e)}', exc_info=True)
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Ошибка при снятии блокировки: {str(e)}'
|
||||
@@ -865,6 +877,7 @@ def create_temp_kit_to_showcase(request):
|
||||
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)
|
||||
@@ -962,24 +975,32 @@ def create_temp_kit_to_showcase(request):
|
||||
order=0
|
||||
)
|
||||
|
||||
# 5. Резервируем комплект на витрину
|
||||
# 5. Резервируем комплект на витрину (создаём N экземпляров)
|
||||
result = ShowcaseManager.reserve_kit_to_showcase(
|
||||
product_kit=kit,
|
||||
showcase=showcase,
|
||||
quantity=1
|
||||
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}" создан и зарезервирован на витрине "{showcase.name}"',
|
||||
'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'])
|
||||
'reservations_count': len(result['reservations']),
|
||||
'showcase_item_ids': showcase_item_ids,
|
||||
'available_count': created_count
|
||||
})
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
@@ -1328,6 +1349,9 @@ def pos_checkout(request):
|
||||
)
|
||||
|
||||
# 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']
|
||||
@@ -1343,7 +1367,8 @@ def pos_checkout(request):
|
||||
price=price,
|
||||
is_custom_price=False
|
||||
)
|
||||
elif item_type in ['kit', 'showcase_kit']:
|
||||
elif item_type == 'kit':
|
||||
# Обычный комплект (не витринный)
|
||||
kit = ProductKit.objects.get(id=item_id)
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
@@ -1352,6 +1377,40 @@ def pos_checkout(request):
|
||||
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:
|
||||
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.calculate_total()
|
||||
@@ -1374,24 +1433,7 @@ def pos_checkout(request):
|
||||
# 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
|
||||
# 6. Очищаем корзину из Redis
|
||||
from django.core.cache import cache
|
||||
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
|
||||
cache.delete(cart_key)
|
||||
|
||||
Reference in New Issue
Block a user