diff --git a/myproject/inventory/migrations/0007_add_showcase_item_model.py b/myproject/inventory/migrations/0007_add_showcase_item_model.py new file mode 100644 index 0000000..715413b --- /dev/null +++ b/myproject/inventory/migrations/0007_add_showcase_item_model.py @@ -0,0 +1,63 @@ +# Generated by Django 5.0.10 on 2025-12-09 04:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0006_reservation_cart_lock_expires_at_and_more'), + ('orders', '0006_transaction_delete_payment_and_more'), + ('products', '0010_alter_product_cost_price'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ShowcaseItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('available', 'Доступен'), ('in_cart', 'В корзине'), ('sold', 'Продан'), ('dismantled', 'Разобран')], db_index=True, default='available', max_length=20, verbose_name='Статус')), + ('sold_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата продажи')), + ('cart_lock_expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Блокировка истекает')), + ('cart_session_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='ID сессии корзины')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлен')), + ('locked_by_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='locked_showcase_items', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем')), + ('product_kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='products.productkit', verbose_name='Шаблон комплекта')), + ('showcase', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='inventory.showcase', verbose_name='Витрина')), + ('sold_order_item', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sold_showcase_item', to='orders.orderitem', verbose_name='Позиция заказа (продажа)')), + ], + options={ + 'verbose_name': 'Экземпляр на витрине', + 'verbose_name_plural': 'Экземпляры на витрине', + }, + ), + migrations.AddField( + model_name='reservation', + name='showcase_item', + field=models.ForeignKey(blank=True, help_text='Для какого физического экземпляра создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcaseitem', verbose_name='Экземпляр на витрине'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['showcase_item'], name='inventory_r_showcas_8cfff5_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + index=models.Index(fields=['showcase', 'status'], name='inventory_s_showcas_116f7f_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + index=models.Index(fields=['product_kit', 'status'], name='inventory_s_product_785870_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + index=models.Index(fields=['status', 'cart_lock_expires_at'], name='inventory_s_status_6acf05_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + index=models.Index(fields=['locked_by_user', 'status'], name='inventory_s_locked__88eac9_idx'), + ), + ] diff --git a/myproject/inventory/migrations/0008_migrate_showcase_kits_to_items.py b/myproject/inventory/migrations/0008_migrate_showcase_kits_to_items.py new file mode 100644 index 0000000..f1b7728 --- /dev/null +++ b/myproject/inventory/migrations/0008_migrate_showcase_kits_to_items.py @@ -0,0 +1,64 @@ +# Generated manually - Data migration for ShowcaseItem + +from django.db import migrations + + +def migrate_showcase_kits_to_items(apps, schema_editor): + """ + Для каждого существующего витринного букета (ProductKit с is_temporary=True и showcase): + 1. Создать ShowcaseItem + 2. Привязать существующие Reservation к этому ShowcaseItem + """ + ProductKit = apps.get_model('products', 'ProductKit') + ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem') + Reservation = apps.get_model('inventory', 'Reservation') + + # Находим все витринные комплекты + showcase_kits = ProductKit.objects.filter( + is_temporary=True, + showcase__isnull=False + ) + + for kit in showcase_kits: + # Создаём ShowcaseItem для каждого существующего витринного букета + showcase_item = ShowcaseItem.objects.create( + showcase=kit.showcase, + product_kit=kit, + status='available' + ) + + # Привязываем существующие резервы к этому ShowcaseItem + Reservation.objects.filter( + product_kit=kit, + showcase=kit.showcase, + status='reserved' + ).update(showcase_item=showcase_item) + + +def reverse_migration(apps, schema_editor): + """ + Откат: удаляем созданные ShowcaseItem и очищаем связи в Reservation + """ + ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem') + Reservation = apps.get_model('inventory', 'Reservation') + + # Очищаем связи в резервах + Reservation.objects.filter(showcase_item__isnull=False).update(showcase_item=None) + + # Удаляем все ShowcaseItem + ShowcaseItem.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0007_add_showcase_item_model'), + ('products', '0001_initial'), # Убедимся что ProductKit существует + ] + + operations = [ + migrations.RunPython( + migrate_showcase_kits_to_items, + reverse_code=reverse_migration + ), + ] diff --git a/myproject/inventory/migrations/0009_fix_showcase_items_status.py b/myproject/inventory/migrations/0009_fix_showcase_items_status.py new file mode 100644 index 0000000..40fb837 --- /dev/null +++ b/myproject/inventory/migrations/0009_fix_showcase_items_status.py @@ -0,0 +1,65 @@ +# Generated manually - Fix ShowcaseItem status for already sold kits + +from django.db import migrations + + +def fix_showcase_items_status(apps, schema_editor): + """ + Исправляем статус ShowcaseItem для уже проданных комплектов. + + Логика: + - Если у ShowcaseItem нет активных резервов (status='reserved') → + это уже проданный/разобранный букет → удаляем ShowcaseItem + """ + ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem') + Reservation = apps.get_model('inventory', 'Reservation') + + # Находим все ShowcaseItem в статусе 'available' + available_items = ShowcaseItem.objects.filter(status='available') + + items_to_delete = [] + + for item in available_items: + # Проверяем есть ли активные резервы для этого экземпляра + has_active_reservations = Reservation.objects.filter( + showcase_item=item, + status='reserved' + ).exists() + + # Если резервы не привязаны к showcase_item, проверяем старым способом + if not has_active_reservations: + has_active_reservations = Reservation.objects.filter( + product_kit=item.product_kit, + showcase=item.showcase, + status='reserved' + ).exists() + + if not has_active_reservations: + # Нет активных резервов - этот букет уже продан/разобран + items_to_delete.append(item.id) + + # Удаляем ShowcaseItem без активных резервов + if items_to_delete: + ShowcaseItem.objects.filter(id__in=items_to_delete).delete() + + +def reverse_migration(apps, schema_editor): + """ + Откат невозможен - удалённые ShowcaseItem не восстановить. + Но это безопасно - они относились к уже проданным букетам. + """ + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0008_migrate_showcase_kits_to_items'), + ] + + operations = [ + migrations.RunPython( + fix_showcase_items_status, + reverse_code=reverse_migration + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index e5e818a..594c0c0 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -450,6 +450,17 @@ class Reservation(models.Model): help_text="Дополнительная идентификация сессии для надежности" ) + # Связь с конкретным экземпляром витринного букета + showcase_item = models.ForeignKey( + 'ShowcaseItem', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='reservations', + verbose_name="Экземпляр на витрине", + help_text="Для какого физического экземпляра создан резерв" + ) + class Meta: verbose_name = "Резервирование" verbose_name_plural = "Резервирования" @@ -463,6 +474,7 @@ class Reservation(models.Model): models.Index(fields=['cart_lock_expires_at']), models.Index(fields=['locked_by_user']), models.Index(fields=['product_kit', 'cart_lock_expires_at']), + models.Index(fields=['showcase_item']), ] def __str__(self): @@ -477,6 +489,152 @@ class Reservation(models.Model): return f"Резерв {self.product.name}: {self.quantity} шт{context} [{self.get_status_display()}]" +class ShowcaseItem(models.Model): + """ + Физический экземпляр комплекта на витрине. + + Один ProductKit (шаблон) -> N ShowcaseItem (экземпляры). + Каждый экземпляр имеет свой набор резервов и может быть продан независимо. + + Защита от двойной продажи: + - sold_order_item = OneToOneField гарантирует что один экземпляр + может быть продан только в один OrderItem (на уровне БД). + """ + showcase = models.ForeignKey( + 'Showcase', + on_delete=models.CASCADE, + related_name='showcase_items', + verbose_name="Витрина" + ) + product_kit = models.ForeignKey( + 'products.ProductKit', + on_delete=models.CASCADE, + related_name='showcase_items', + verbose_name="Шаблон комплекта" + ) + + # Статусы жизненного цикла + STATUS_CHOICES = [ + ('available', 'Доступен'), + ('in_cart', 'В корзине'), + ('sold', 'Продан'), + ('dismantled', 'Разобран'), + ] + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='available', + db_index=True, + verbose_name="Статус" + ) + + # === ЗАЩИТА ОТ ДВОЙНОЙ ПРОДАЖИ === + # OneToOneField гарантирует на уровне БД: 1 ShowcaseItem = max 1 OrderItem + sold_order_item = models.OneToOneField( + 'orders.OrderItem', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='sold_showcase_item', + verbose_name="Позиция заказа (продажа)" + ) + sold_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата продажи" + ) + + # === SOFT LOCK для корзины === + locked_by_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='locked_showcase_items', + verbose_name="Заблокировано пользователем" + ) + cart_lock_expires_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Блокировка истекает" + ) + cart_session_id = models.CharField( + max_length=100, + null=True, + blank=True, + verbose_name="ID сессии корзины" + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлен") + + class Meta: + verbose_name = "Экземпляр на витрине" + verbose_name_plural = "Экземпляры на витрине" + indexes = [ + models.Index(fields=['showcase', 'status']), + models.Index(fields=['product_kit', 'status']), + models.Index(fields=['status', 'cart_lock_expires_at']), + models.Index(fields=['locked_by_user', 'status']), + ] + + def __str__(self): + return f"{self.product_kit.name} #{self.id} ({self.get_status_display()})" + + def lock_for_cart(self, user, session_id=None, duration_minutes=30): + """Заблокировать экземпляр для корзины""" + from datetime import timedelta + + self.status = 'in_cart' + self.locked_by_user = user + self.cart_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes) + self.cart_session_id = session_id + self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at']) + + def release_lock(self): + """Снять блокировку корзины""" + self.status = 'available' + self.locked_by_user = None + self.cart_lock_expires_at = None + self.cart_session_id = None + self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at']) + + def mark_sold(self, order_item): + """ + Пометить как проданный. + OneToOneField автоматически выбросит IntegrityError при повторной продаже. + """ + self.status = 'sold' + self.sold_order_item = order_item # БД защита от дублей! + self.sold_at = timezone.now() + self.locked_by_user = None + self.cart_lock_expires_at = None + self.cart_session_id = None + self.save() + + def is_lock_expired(self): + """Проверить истекла ли блокировка""" + if self.cart_lock_expires_at is None: + return True + return timezone.now() > self.cart_lock_expires_at + + @classmethod + def cleanup_expired_locks(cls): + """Снять все просроченные блокировки (для Celery задачи)""" + expired = cls.objects.filter( + status='in_cart', + cart_lock_expires_at__lt=timezone.now() + ) + count = expired.update( + status='available', + locked_by_user=None, + cart_lock_expires_at=None, + cart_session_id=None + ) + return count + + class Stock(models.Model): """ Агрегированные остатки по товарам и складам. diff --git a/myproject/inventory/services/showcase_manager.py b/myproject/inventory/services/showcase_manager.py index 61b5017..8299a76 100644 --- a/myproject/inventory/services/showcase_manager.py +++ b/myproject/inventory/services/showcase_manager.py @@ -1,12 +1,18 @@ """ Сервис управления витринами - резервирование, продажа и разбор витринных букетов. + +Новая архитектура с ShowcaseItem: +- ProductKit = шаблон (рецепт букета) +- ShowcaseItem = физический экземпляр на витрине +- Каждый ShowcaseItem имеет свой набор Reservation +- Защита от двойной продажи через OneToOneField на sold_order_item """ from decimal import Decimal -from django.db import transaction +from django.db import transaction, IntegrityError from django.utils import timezone from django.core.exceptions import ValidationError -from inventory.models import Showcase, Reservation, Warehouse +from inventory.models import Showcase, Reservation, Warehouse, ShowcaseItem, Stock from products.models import ProductKit from orders.models import Order, OrderItem, OrderStatus from customers.models import Customer @@ -20,17 +26,18 @@ class ShowcaseManager: @staticmethod def reserve_kit_to_showcase(product_kit, showcase, quantity=1): """ - Резервирует комплект на витрину. - Раскладывает комплект на компоненты и создаёт резервы по каждому товару. + Резервирует N экземпляров комплекта на витрину. + Создаёт ShowcaseItem для каждого экземпляра и резервы компонентов. Args: - product_kit: ProductKit - комплект для резервирования + product_kit: ProductKit - шаблон комплекта showcase: Showcase - витрина - quantity: int - количество комплектов (по умолчанию 1) + quantity: int - количество экземпляров (например, 5 одинаковых букетов) Returns: dict: { 'success': bool, + 'showcase_items': list[ShowcaseItem], 'reservations': list[Reservation], 'message': str } @@ -38,100 +45,181 @@ class ShowcaseManager: if not showcase.is_active: return { 'success': False, + 'showcase_items': [], 'reservations': [], 'message': f'Витрина "{showcase.name}" не активна' } warehouse = showcase.warehouse - reservations = [] + showcase_items = [] + all_reservations = [] try: with transaction.atomic(): - # Раскладываем комплект на компоненты kit_items = product_kit.kit_items.all() if not kit_items.exists(): return { 'success': False, + 'showcase_items': [], 'reservations': [], 'message': f'Комплект "{product_kit.name}" не содержит компонентов' } - # Создаём резервы по каждому компоненту - for kit_item in kit_items: - if kit_item.product: - # Обычный товар - component_quantity = kit_item.quantity * quantity - - reservation = Reservation.objects.create( - product=kit_item.product, - warehouse=warehouse, - showcase=showcase, - product_kit=product_kit, - quantity=component_quantity, - status='reserved' - ) - reservations.append(reservation) - - elif kit_item.variant_group: - # Группа вариантов - резервируем первый доступный вариант - # В будущем можно добавить выбор конкретного варианта - variant_items = kit_item.variant_group.items.all() - if variant_items.exists(): - first_variant = variant_items.first() - component_quantity = kit_item.quantity * quantity - + # Создаём N экземпляров + for _ in range(quantity): + # 1. Создаём ShowcaseItem + showcase_item = ShowcaseItem.objects.create( + showcase=showcase, + product_kit=product_kit, + status='available' + ) + showcase_items.append(showcase_item) + + # 2. Создаём резервы ДЛЯ ЭТОГО ЭКЗЕМПЛЯРА + for kit_item in kit_items: + product_to_reserve = None + + if kit_item.product: + product_to_reserve = kit_item.product + elif kit_item.variant_group: + # Группа вариантов - резервируем первый доступный вариант + variant_items = kit_item.variant_group.items.all() + if variant_items.exists(): + product_to_reserve = variant_items.first().product + + if product_to_reserve: reservation = Reservation.objects.create( - product=first_variant.product, + product=product_to_reserve, warehouse=warehouse, showcase=showcase, product_kit=product_kit, - quantity=component_quantity, + showcase_item=showcase_item, # Связь с экземпляром! + quantity=kit_item.quantity, status='reserved' ) - reservations.append(reservation) + all_reservations.append(reservation) - # Обновляем агрегаты Stock для всех затронутых товаров - from inventory.models import Stock - for reservation in reservations: + # Обновляем агрегаты Stock + affected_products = set(r.product_id for r in all_reservations) + for product_id in affected_products: stock, _ = Stock.objects.get_or_create( - product=reservation.product, + product_id=product_id, warehouse=warehouse ) stock.refresh_from_batches() return { 'success': True, - 'reservations': reservations, - 'message': f'Комплект "{product_kit.name}" зарезервирован на витрине "{showcase.name}"' + 'showcase_items': showcase_items, + 'reservations': all_reservations, + 'message': f'Создано {quantity} экз. комплекта "{product_kit.name}" на витрине "{showcase.name}"' } except Exception as e: return { 'success': False, + 'showcase_items': [], 'reservations': [], 'message': f'Ошибка резервирования: {str(e)}' } @staticmethod - def sell_from_showcase(product_kit, showcase, customer, payment_method='cash_to_courier', - custom_price=None, user=None): + def sell_showcase_items(showcase_items, order_item): """ - Продаёт комплект с витрины. - Создаёт Order, OrderItem, конвертирует резервы в Sale. + Продаёт указанные экземпляры с витрины. + Привязывает каждый ShowcaseItem к OrderItem и конвертирует резервы в продажи. Args: - product_kit: ProductKit - комплект для продажи + showcase_items: list[ShowcaseItem] - экземпляры для продажи + order_item: OrderItem - позиция заказа + + Returns: + dict: { + 'success': bool, + 'sold_count': int, + 'message': str + } + + Raises: + IntegrityError: если экземпляр уже был продан (защита на уровне БД) + """ + from inventory.services.sale_processor import SaleProcessor + + sold_count = 0 + order = order_item.order + + try: + with transaction.atomic(): + for showcase_item in showcase_items: + # Проверка статуса перед продажей + if showcase_item.status == 'sold': + raise ValidationError( + f'Экземпляр "{showcase_item}" уже продан' + ) + if showcase_item.status == 'dismantled': + raise ValidationError( + f'Экземпляр "{showcase_item}" был разобран' + ) + + # Помечаем как проданный (OneToOneField защитит от дублей!) + showcase_item.mark_sold(order_item) + + # Конвертируем резервы этого экземпляра в продажи + reservations = Reservation.objects.filter( + showcase_item=showcase_item, + status='reserved' + ) + + for reservation in reservations: + SaleProcessor.create_sale_from_reservation( + reservation=reservation, + order=order + ) + reservation.status = 'converted_to_sale' + reservation.converted_at = timezone.now() + reservation.order_item = order_item + reservation.save() + + sold_count += 1 + + return { + 'success': True, + 'sold_count': sold_count, + 'message': f'Продано {sold_count} экз.' + } + + except IntegrityError as e: + # Защита от двойной продажи сработала на уровне БД + if 'sold_order_item' in str(e) or 'UNIQUE' in str(e): + return { + 'success': False, + 'sold_count': 0, + 'message': 'Один из экземпляров уже был продан. Обновите список витринных букетов.' + } + raise + + @staticmethod + def sell_from_showcase(product_kit, showcase, customer, payment_method='cash_to_courier', + custom_price=None, user=None, quantity=1): + """ + Продаёт N экземпляров комплекта с витрины. + Создаёт Order, OrderItem, выбирает доступные ShowcaseItem и продаёт их. + + Args: + product_kit: ProductKit - шаблон комплекта showcase: Showcase - витрина customer: Customer - покупатель payment_method: str - способ оплаты - custom_price: Decimal - кастомная цена (опционально) + custom_price: Decimal - кастомная цена за единицу (опционально) user: CustomUser - пользователь, выполняющий операцию + quantity: int - количество экземпляров для продажи Returns: dict: { 'success': bool, 'order': Order or None, + 'sold_count': int, 'message': str } """ @@ -139,38 +227,23 @@ class ShowcaseManager: try: with transaction.atomic(): - # Находим резервы для этого комплекта на витрине - # Группируем по product для подсчёта - reservations = Reservation.objects.filter( + # Находим доступные экземпляры этого комплекта на витрине + available_items = ShowcaseItem.objects.select_for_update( + skip_locked=True + ).filter( showcase=showcase, - status='reserved' - ).select_related('product', 'locked_by_user') + product_kit=product_kit, + status='available' + )[:quantity] - if not reservations.exists(): + available_items = list(available_items) + + if len(available_items) < quantity: return { 'success': False, 'order': None, - 'message': f'На витрине "{showcase.name}" нет зарезервированных товаров' - } - - # Проверяем блокировки корзины (Soft Lock) - # Если комплект заблокирован в корзине другого кассира, запрещаем продажу - active_locks = reservations.filter( - cart_lock_expires_at__gt=timezone.now(), - cart_lock_expires_at__isnull=False - ) - - if active_locks.exists(): - lock = active_locks.first() - time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60 - locker_name = lock.locked_by_user.username if lock.locked_by_user else 'неизвестный кассир' - - return { - 'success': False, - 'order': None, - 'message': f'Комплект заблокирован в корзине кассира "{locker_name}". ' - f'Блокировка истечёт через {int(time_left)} мин. ' - f'Дождитесь освобождения или попросите кассира удалить букет из корзины.' + 'sold_count': 0, + 'message': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров' } # Получаем статус "Завершён" для POS-продаж @@ -180,12 +253,11 @@ class ShowcaseManager: ).first() if not completed_status: - # Если нет статуса completed, берём любой положительный completed_status = OrderStatus.objects.filter( is_positive_end=True ).first() - # Создаём заказ (самовывоз с витринного склада) + # Создаём заказ order = Order.objects.create( customer=customer, is_delivery=False, @@ -204,29 +276,18 @@ class ShowcaseManager: order_item = OrderItem.objects.create( order=order, product_kit=product_kit, - quantity=1, + quantity=len(available_items), price=price, is_custom_price=is_custom, is_from_showcase=True, showcase=showcase ) - # Привязываем резервы к OrderItem - reservations.update(order_item=order_item) + # Продаём экземпляры + result = ShowcaseManager.sell_showcase_items(available_items, order_item) - # Конвертируем резервы в продажи - from inventory.services.sale_processor import SaleProcessor - for reservation in reservations: - # Создаём Sale - sale = SaleProcessor.create_sale_from_reservation( - reservation=reservation, - order=order - ) - - # Обновляем статус резерва - reservation.status = 'converted_to_sale' - reservation.converted_at = timezone.now() - reservation.save() + if not result['success']: + raise ValidationError(result['message']) # Пересчитываем итоговую сумму заказа order.calculate_total() @@ -237,24 +298,25 @@ class ShowcaseManager: return { 'success': True, 'order': order, - 'message': f'Заказ #{order.order_number} создан. Продан комплект с витрины "{showcase.name}"' + 'sold_count': result['sold_count'], + 'message': f'Заказ #{order.order_number} создан. Продано {result["sold_count"]} экз. с витрины "{showcase.name}"' } except Exception as e: return { 'success': False, 'order': None, + 'sold_count': 0, 'message': f'Ошибка продажи: {str(e)}' } @staticmethod - def dismantle_from_showcase(showcase, product_kit=None): + def dismantle_showcase_item(showcase_item): """ - Разбирает букет на витрине - освобождает резервы. + Разбирает один экземпляр на витрине - освобождает его резервы. Args: - showcase: Showcase - витрина - product_kit: ProductKit - конкретный комплект (опционально) + showcase_item: ShowcaseItem - экземпляр для разбора Returns: dict: { @@ -263,39 +325,38 @@ class ShowcaseManager: 'message': str } """ + if showcase_item.status == 'sold': + return { + 'success': False, + 'released_count': 0, + 'message': 'Нельзя разобрать проданный экземпляр' + } + try: with transaction.atomic(): - # Находим активные резервы + warehouse = showcase_item.showcase.warehouse + + # Находим резервы этого экземпляра reservations = Reservation.objects.filter( - showcase=showcase, + showcase_item=showcase_item, status='reserved' ) - if product_kit: - # Если указан конкретный комплект, фильтруем только его резервы - reservations = reservations.filter(product_kit=product_kit) - released_count = reservations.count() - - if released_count == 0: - return { - 'success': False, - 'released_count': 0, - 'message': f'На витрине "{showcase.name}" нет активных резервов' - } - - # Сохраняем список затронутых товаров и склад ДО обновления резервов - from inventory.models import Stock affected_products = list(reservations.values_list('product_id', flat=True).distinct()) - warehouse = showcase.warehouse # Освобождаем резервы reservations.update( status='released', released_at=timezone.now(), - showcase=None + showcase=None, + showcase_item=None ) + # Обновляем статус экземпляра + showcase_item.status = 'dismantled' + showcase_item.save() + # Обновляем агрегаты Stock for product_id in affected_products: try: @@ -310,7 +371,7 @@ class ShowcaseManager: return { 'success': True, 'released_count': released_count, - 'message': f'Разобрано {released_count} резервов с витрины "{showcase.name}"' + 'message': f'Экземпляр разобран, освобождено {released_count} резервов' } except Exception as e: @@ -320,16 +381,152 @@ class ShowcaseManager: 'message': f'Ошибка разбора: {str(e)}' } + @staticmethod + def dismantle_from_showcase(showcase, product_kit=None): + """ + Разбирает все экземпляры на витрине (или конкретного комплекта). + Освобождает резервы. + + Args: + showcase: Showcase - витрина + product_kit: ProductKit - конкретный комплект (опционально) + + Returns: + dict: { + 'success': bool, + 'released_count': int, + 'dismantled_items_count': int, + 'message': str + } + """ + try: + with transaction.atomic(): + # Находим экземпляры для разбора + items_qs = ShowcaseItem.objects.filter( + showcase=showcase, + status='available' + ) + + if product_kit: + items_qs = items_qs.filter(product_kit=product_kit) + + items_to_dismantle = list(items_qs) + + if not items_to_dismantle: + return { + 'success': False, + 'released_count': 0, + 'dismantled_items_count': 0, + 'message': f'На витрине "{showcase.name}" нет доступных экземпляров для разбора' + } + + total_released = 0 + for item in items_to_dismantle: + result = ShowcaseManager.dismantle_showcase_item(item) + if result['success']: + total_released += result['released_count'] + + return { + 'success': True, + 'released_count': total_released, + 'dismantled_items_count': len(items_to_dismantle), + 'message': f'Разобрано {len(items_to_dismantle)} экз., освобождено {total_released} резервов' + } + + except Exception as e: + return { + 'success': False, + 'released_count': 0, + 'dismantled_items_count': 0, + 'message': f'Ошибка разбора: {str(e)}' + } + + @staticmethod + def get_showcase_items_for_pos(showcase=None): + """ + Возвращает витринные экземпляры, сгруппированные по шаблону комплекта. + Для использования в POS интерфейсе. + + Args: + showcase: Showcase - конкретная витрина (опционально, если None - все активные) + + Returns: + list: [ + { + 'kit_id': int, + 'kit_name': str, + 'kit_sku': str, + 'price': Decimal, + 'available_count': int, + 'showcase_item_ids': list[int], + 'showcase_id': int, + 'showcase_name': str, + 'type': 'showcase_kit' + }, + ... + ] + """ + from django.db.models import Count + + # Базовый queryset + qs = ShowcaseItem.objects.filter( + status='available', + showcase__is_active=True + ) + + if showcase: + qs = qs.filter(showcase=showcase) + + # Группируем по (product_kit, showcase) + grouped = qs.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') + + result = [] + for item in grouped: + # Получаем IDs всех доступных экземпляров этой группы + item_ids = list(ShowcaseItem.objects.filter( + product_kit_id=item['product_kit_id'], + showcase_id=item['showcase_id'], + status='available' + ).values_list('id', flat=True)) + + # Определяем актуальную цену + price = item['product_kit__sale_price'] or item['product_kit__price'] + + result.append({ + 'kit_id': item['product_kit_id'], + 'kit_name': item['product_kit__name'], + 'kit_sku': item['product_kit__sku'] or '', + 'price': str(price), + 'available_count': item['available_count'], + 'showcase_item_ids': item_ids, + 'showcase_id': item['showcase_id'], + 'showcase_name': item['showcase__name'], + 'type': 'showcase_kit' + }) + + return result + @staticmethod def get_showcase_kits(showcase): """ - Возвращает список комплектов, зарезервированных на витрине. + Возвращает список компонентов, зарезервированных на витрине. + (Для совместимости со старым API) Args: showcase: Showcase Returns: - list: список словарей с информацией о комплектах + list: список словарей с информацией о компонентах """ reservations = Reservation.objects.filter( showcase=showcase, @@ -355,3 +552,110 @@ class ShowcaseManager: } for pid, data in products_dict.items() ] + + @staticmethod + def lock_showcase_items_for_cart(showcase_item_ids, user, session_id=None, duration_minutes=30): + """ + Блокирует указанные экземпляры для корзины. + + Args: + showcase_item_ids: list[int] - ID экземпляров для блокировки + user: User - пользователь (кассир) + session_id: str - ID сессии корзины + duration_minutes: int - длительность блокировки в минутах + + Returns: + dict: { + 'success': bool, + 'locked_item_ids': list[int], + 'lock_expires_at': datetime, + 'message': str + } + """ + from datetime import timedelta + + try: + with transaction.atomic(): + # Выбираем и блокируем экземпляры + items = ShowcaseItem.objects.select_for_update( + skip_locked=True + ).filter( + id__in=showcase_item_ids, + status='available' + ) + + items = list(items) + + if len(items) < len(showcase_item_ids): + # Некоторые экземпляры недоступны + unavailable_count = len(showcase_item_ids) - len(items) + return { + 'success': False, + 'locked_item_ids': [], + 'lock_expires_at': None, + 'message': f'{unavailable_count} экземпляр(ов) недоступны. Возможно, они уже в корзине или проданы.' + } + + lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes) + locked_ids = [] + + for item in items: + item.lock_for_cart(user, session_id, duration_minutes) + locked_ids.append(item.id) + + return { + 'success': True, + 'locked_item_ids': locked_ids, + 'lock_expires_at': lock_expires_at, + 'message': f'Заблокировано {len(locked_ids)} экз.' + } + + except Exception as e: + return { + 'success': False, + 'locked_item_ids': [], + 'lock_expires_at': None, + 'message': f'Ошибка блокировки: {str(e)}' + } + + @staticmethod + def release_showcase_items_from_cart(showcase_item_ids, user): + """ + Снимает блокировку с указанных экземпляров. + + Args: + showcase_item_ids: list[int] - ID экземпляров + user: User - пользователь (только владелец блокировки может снять) + + Returns: + dict: { + 'success': bool, + 'released_count': int, + 'message': str + } + """ + try: + # Снимаем блокировку только для экземпляров, заблокированных этим пользователем + updated = ShowcaseItem.objects.filter( + id__in=showcase_item_ids, + status='in_cart', + locked_by_user=user + ).update( + status='available', + locked_by_user=None, + cart_lock_expires_at=None, + cart_session_id=None + ) + + return { + 'success': True, + 'released_count': updated, + 'message': f'Освобождено {updated} экз.' + } + + except Exception as e: + return { + 'success': False, + 'released_count': 0, + 'message': f'Ошибка освобождения: {str(e)}' + } diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index cd7c5fb..0ea5409 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -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} ${availableCount} шт`; 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() }; diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index adfc5e1..d705893 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -169,7 +169,14 @@ - + + +
+ + + Будет создано указанное количество одинаковых букетов на витрину +
+
diff --git a/myproject/pos/views.py b/myproject/pos/views.py index f06523d..0e8b18f 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -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)