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:
2025-12-09 23:51:37 +03:00
parent 936d2275e4
commit cfc6ce451e
8 changed files with 1076 additions and 318 deletions

View File

@@ -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'),
),
]

View File

@@ -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
),
]

View File

@@ -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
),
]

View File

@@ -450,6 +450,17 @@ class Reservation(models.Model):
help_text="Дополнительная идентификация сессии для надежности" help_text="Дополнительная идентификация сессии для надежности"
) )
# Связь с конкретным экземпляром витринного букета
showcase_item = models.ForeignKey(
'ShowcaseItem',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='reservations',
verbose_name="Экземпляр на витрине",
help_text="Для какого физического экземпляра создан резерв"
)
class Meta: class Meta:
verbose_name = "Резервирование" verbose_name = "Резервирование"
verbose_name_plural = "Резервирования" verbose_name_plural = "Резервирования"
@@ -463,6 +474,7 @@ class Reservation(models.Model):
models.Index(fields=['cart_lock_expires_at']), models.Index(fields=['cart_lock_expires_at']),
models.Index(fields=['locked_by_user']), models.Index(fields=['locked_by_user']),
models.Index(fields=['product_kit', 'cart_lock_expires_at']), models.Index(fields=['product_kit', 'cart_lock_expires_at']),
models.Index(fields=['showcase_item']),
] ]
def __str__(self): def __str__(self):
@@ -477,6 +489,152 @@ class Reservation(models.Model):
return f"Резерв {self.product.name}: {self.quantity} шт{context} [{self.get_status_display()}]" 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): class Stock(models.Model):
""" """
Агрегированные остатки по товарам и складам. Агрегированные остатки по товарам и складам.

View File

@@ -1,12 +1,18 @@
""" """
Сервис управления витринами - резервирование, продажа и разбор витринных букетов. Сервис управления витринами - резервирование, продажа и разбор витринных букетов.
Новая архитектура с ShowcaseItem:
- ProductKit = шаблон (рецепт букета)
- ShowcaseItem = физический экземпляр на витрине
- Каждый ShowcaseItem имеет свой набор Reservation
- Защита от двойной продажи через OneToOneField на sold_order_item
""" """
from decimal import Decimal from decimal import Decimal
from django.db import transaction from django.db import transaction, IntegrityError
from django.utils import timezone from django.utils import timezone
from django.core.exceptions import ValidationError 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 products.models import ProductKit
from orders.models import Order, OrderItem, OrderStatus from orders.models import Order, OrderItem, OrderStatus
from customers.models import Customer from customers.models import Customer
@@ -20,17 +26,18 @@ class ShowcaseManager:
@staticmethod @staticmethod
def reserve_kit_to_showcase(product_kit, showcase, quantity=1): def reserve_kit_to_showcase(product_kit, showcase, quantity=1):
""" """
Резервирует комплект на витрину. Резервирует N экземпляров комплекта на витрину.
Раскладывает комплект на компоненты и создаёт резервы по каждому товару. Создаёт ShowcaseItem для каждого экземпляра и резервы компонентов.
Args: Args:
product_kit: ProductKit - комплект для резервирования product_kit: ProductKit - шаблон комплекта
showcase: Showcase - витрина showcase: Showcase - витрина
quantity: int - количество комплектов (по умолчанию 1) quantity: int - количество экземпляров (например, 5 одинаковых букетов)
Returns: Returns:
dict: { dict: {
'success': bool, 'success': bool,
'showcase_items': list[ShowcaseItem],
'reservations': list[Reservation], 'reservations': list[Reservation],
'message': str 'message': str
} }
@@ -38,100 +45,181 @@ class ShowcaseManager:
if not showcase.is_active: if not showcase.is_active:
return { return {
'success': False, 'success': False,
'showcase_items': [],
'reservations': [], 'reservations': [],
'message': f'Витрина "{showcase.name}" не активна' 'message': f'Витрина "{showcase.name}" не активна'
} }
warehouse = showcase.warehouse warehouse = showcase.warehouse
reservations = [] showcase_items = []
all_reservations = []
try: try:
with transaction.atomic(): with transaction.atomic():
# Раскладываем комплект на компоненты
kit_items = product_kit.kit_items.all() kit_items = product_kit.kit_items.all()
if not kit_items.exists(): if not kit_items.exists():
return { return {
'success': False, 'success': False,
'showcase_items': [],
'reservations': [], 'reservations': [],
'message': f'Комплект "{product_kit.name}" не содержит компонентов' 'message': f'Комплект "{product_kit.name}" не содержит компонентов'
} }
# Создаём резервы по каждому компоненту # Создаём N экземпляров
for kit_item in kit_items: for _ in range(quantity):
if kit_item.product: # 1. Создаём ShowcaseItem
# Обычный товар showcase_item = ShowcaseItem.objects.create(
component_quantity = kit_item.quantity * quantity
reservation = Reservation.objects.create(
product=kit_item.product,
warehouse=warehouse,
showcase=showcase, showcase=showcase,
product_kit=product_kit, product_kit=product_kit,
quantity=component_quantity, status='available'
status='reserved'
) )
reservations.append(reservation) 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: elif kit_item.variant_group:
# Группа вариантов - резервируем первый доступный вариант # Группа вариантов - резервируем первый доступный вариант
# В будущем можно добавить выбор конкретного варианта
variant_items = kit_item.variant_group.items.all() variant_items = kit_item.variant_group.items.all()
if variant_items.exists(): if variant_items.exists():
first_variant = variant_items.first() product_to_reserve = variant_items.first().product
component_quantity = kit_item.quantity * quantity
if product_to_reserve:
reservation = Reservation.objects.create( reservation = Reservation.objects.create(
product=first_variant.product, product=product_to_reserve,
warehouse=warehouse, warehouse=warehouse,
showcase=showcase, showcase=showcase,
product_kit=product_kit, product_kit=product_kit,
quantity=component_quantity, showcase_item=showcase_item, # Связь с экземпляром!
quantity=kit_item.quantity,
status='reserved' status='reserved'
) )
reservations.append(reservation) all_reservations.append(reservation)
# Обновляем агрегаты Stock для всех затронутых товаров # Обновляем агрегаты Stock
from inventory.models import Stock affected_products = set(r.product_id for r in all_reservations)
for reservation in reservations: for product_id in affected_products:
stock, _ = Stock.objects.get_or_create( stock, _ = Stock.objects.get_or_create(
product=reservation.product, product_id=product_id,
warehouse=warehouse warehouse=warehouse
) )
stock.refresh_from_batches() stock.refresh_from_batches()
return { return {
'success': True, 'success': True,
'reservations': reservations, 'showcase_items': showcase_items,
'message': f'Комплект "{product_kit.name}" зарезервирован на витрине "{showcase.name}"' 'reservations': all_reservations,
'message': f'Создано {quantity} экз. комплекта "{product_kit.name}" на витрине "{showcase.name}"'
} }
except Exception as e: except Exception as e:
return { return {
'success': False, 'success': False,
'showcase_items': [],
'reservations': [], 'reservations': [],
'message': f'Ошибка резервирования: {str(e)}' 'message': f'Ошибка резервирования: {str(e)}'
} }
@staticmethod @staticmethod
def sell_from_showcase(product_kit, showcase, customer, payment_method='cash_to_courier', def sell_showcase_items(showcase_items, order_item):
custom_price=None, user=None):
""" """
Продаёт комплект с витрины. Продаёт указанные экземпляры с витрины.
Создаёт Order, OrderItem, конвертирует резервы в Sale. Привязывает каждый ShowcaseItem к OrderItem и конвертирует резервы в продажи.
Args: 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 - витрина showcase: Showcase - витрина
customer: Customer - покупатель customer: Customer - покупатель
payment_method: str - способ оплаты payment_method: str - способ оплаты
custom_price: Decimal - кастомная цена (опционально) custom_price: Decimal - кастомная цена за единицу (опционально)
user: CustomUser - пользователь, выполняющий операцию user: CustomUser - пользователь, выполняющий операцию
quantity: int - количество экземпляров для продажи
Returns: Returns:
dict: { dict: {
'success': bool, 'success': bool,
'order': Order or None, 'order': Order or None,
'sold_count': int,
'message': str 'message': str
} }
""" """
@@ -139,38 +227,23 @@ class ShowcaseManager:
try: try:
with transaction.atomic(): with transaction.atomic():
# Находим резервы для этого комплекта на витрине # Находим доступные экземпляры этого комплекта на витрине
# Группируем по product для подсчёта available_items = ShowcaseItem.objects.select_for_update(
reservations = Reservation.objects.filter( skip_locked=True
).filter(
showcase=showcase, showcase=showcase,
status='reserved' product_kit=product_kit,
).select_related('product', 'locked_by_user') status='available'
)[:quantity]
if not reservations.exists(): available_items = list(available_items)
if len(available_items) < quantity:
return { return {
'success': False, 'success': False,
'order': None, 'order': None,
'message': f'На витрине "{showcase.name}" нет зарезервированных товаров' 'sold_count': 0,
} 'message': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров'
# Проверяем блокировки корзины (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'Дождитесь освобождения или попросите кассира удалить букет из корзины.'
} }
# Получаем статус "Завершён" для POS-продаж # Получаем статус "Завершён" для POS-продаж
@@ -180,12 +253,11 @@ class ShowcaseManager:
).first() ).first()
if not completed_status: if not completed_status:
# Если нет статуса completed, берём любой положительный
completed_status = OrderStatus.objects.filter( completed_status = OrderStatus.objects.filter(
is_positive_end=True is_positive_end=True
).first() ).first()
# Создаём заказ (самовывоз с витринного склада) # Создаём заказ
order = Order.objects.create( order = Order.objects.create(
customer=customer, customer=customer,
is_delivery=False, is_delivery=False,
@@ -204,29 +276,18 @@ class ShowcaseManager:
order_item = OrderItem.objects.create( order_item = OrderItem.objects.create(
order=order, order=order,
product_kit=product_kit, product_kit=product_kit,
quantity=1, quantity=len(available_items),
price=price, price=price,
is_custom_price=is_custom, is_custom_price=is_custom,
is_from_showcase=True, is_from_showcase=True,
showcase=showcase showcase=showcase
) )
# Привязываем резервы к OrderItem # Продаём экземпляры
reservations.update(order_item=order_item) result = ShowcaseManager.sell_showcase_items(available_items, order_item)
# Конвертируем резервы в продажи if not result['success']:
from inventory.services.sale_processor import SaleProcessor raise ValidationError(result['message'])
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()
# Пересчитываем итоговую сумму заказа # Пересчитываем итоговую сумму заказа
order.calculate_total() order.calculate_total()
@@ -237,24 +298,25 @@ class ShowcaseManager:
return { return {
'success': True, 'success': True,
'order': order, '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: except Exception as e:
return { return {
'success': False, 'success': False,
'order': None, 'order': None,
'sold_count': 0,
'message': f'Ошибка продажи: {str(e)}' 'message': f'Ошибка продажи: {str(e)}'
} }
@staticmethod @staticmethod
def dismantle_from_showcase(showcase, product_kit=None): def dismantle_showcase_item(showcase_item):
""" """
Разбирает букет на витрине - освобождает резервы. Разбирает один экземпляр на витрине - освобождает его резервы.
Args: Args:
showcase: Showcase - витрина showcase_item: ShowcaseItem - экземпляр для разбора
product_kit: ProductKit - конкретный комплект (опционально)
Returns: Returns:
dict: { dict: {
@@ -263,39 +325,38 @@ class ShowcaseManager:
'message': str 'message': str
} }
""" """
try: if showcase_item.status == 'sold':
with transaction.atomic():
# Находим активные резервы
reservations = Reservation.objects.filter(
showcase=showcase,
status='reserved'
)
if product_kit:
# Если указан конкретный комплект, фильтруем только его резервы
reservations = reservations.filter(product_kit=product_kit)
released_count = reservations.count()
if released_count == 0:
return { return {
'success': False, 'success': False,
'released_count': 0, 'released_count': 0,
'message': f'На витрине "{showcase.name}" нет активных резервов' 'message': 'Нельзя разобрать проданный экземпляр'
} }
# Сохраняем список затронутых товаров и склад ДО обновления резервов try:
from inventory.models import Stock with transaction.atomic():
warehouse = showcase_item.showcase.warehouse
# Находим резервы этого экземпляра
reservations = Reservation.objects.filter(
showcase_item=showcase_item,
status='reserved'
)
released_count = reservations.count()
affected_products = list(reservations.values_list('product_id', flat=True).distinct()) affected_products = list(reservations.values_list('product_id', flat=True).distinct())
warehouse = showcase.warehouse
# Освобождаем резервы # Освобождаем резервы
reservations.update( reservations.update(
status='released', status='released',
released_at=timezone.now(), released_at=timezone.now(),
showcase=None showcase=None,
showcase_item=None
) )
# Обновляем статус экземпляра
showcase_item.status = 'dismantled'
showcase_item.save()
# Обновляем агрегаты Stock # Обновляем агрегаты Stock
for product_id in affected_products: for product_id in affected_products:
try: try:
@@ -310,7 +371,7 @@ class ShowcaseManager:
return { return {
'success': True, 'success': True,
'released_count': released_count, 'released_count': released_count,
'message': f'Разобрано {released_count} резервов с витрины "{showcase.name}"' 'message': f'Экземпляр разобран, освобождено {released_count} резервов'
} }
except Exception as e: except Exception as e:
@@ -320,16 +381,152 @@ class ShowcaseManager:
'message': f'Ошибка разбора: {str(e)}' '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 @staticmethod
def get_showcase_kits(showcase): def get_showcase_kits(showcase):
""" """
Возвращает список комплектов, зарезервированных на витрине. Возвращает список компонентов, зарезервированных на витрине.
(Для совместимости со старым API)
Args: Args:
showcase: Showcase showcase: Showcase
Returns: Returns:
list: список словарей с информацией о комплектах list: список словарей с информацией о компонентах
""" """
reservations = Reservation.objects.filter( reservations = Reservation.objects.filter(
showcase=showcase, showcase=showcase,
@@ -355,3 +552,110 @@ class ShowcaseManager:
} }
for pid, data in products_dict.items() 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)}'
}

View File

@@ -570,9 +570,10 @@ function renderProducts() {
const stock = document.createElement('div'); const stock = document.createElement('div');
stock.className = 'product-stock'; stock.className = 'product-stock';
// Для витринных комплектов показываем название витрины // Для витринных комплектов показываем название витрины И доступное количество
if (item.type === 'showcase_kit') { 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.color = '#856404';
stock.style.fontWeight = 'bold'; stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
@@ -735,14 +736,34 @@ function setupInfiniteScroll() {
async function addToCart(item) { async function addToCart(item) {
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1" const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock) // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock + количество)
if (item.type === 'showcase_kit') { if (item.type === 'showcase_kit') {
// Проверяем: не заблокирован ли уже этим пользователем // Определяем сколько доступно и сколько добавить
if (cart.has(cartKey)) { const availableCount = item.available_count || 1;
alert('Этот букет уже в вашей корзине.\nВитринные комплекты доступны только в количестве 1 шт.'); const currentInCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
const remainingAvailable = availableCount - currentInCart;
if (remainingAvailable <= 0) {
alert(`Все ${availableCount} экз. этого букета уже в корзине.`);
return; 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 // Пытаемся создать блокировку через API
try { try {
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, { const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
@@ -750,7 +771,8 @@ async function addToCart(item) {
headers: { headers: {
'X-CSRFToken': getCookie('csrftoken'), 'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} },
body: JSON.stringify({ quantity: quantityToAdd })
}); });
const data = await response.json(); const data = await response.json();
@@ -761,16 +783,28 @@ async function addToCart(item) {
return; return;
} }
// Успешно заблокировали - добавляем в корзину с qty=1 и флагом max_qty // Успешно заблокировали - добавляем/обновляем в корзине
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, { cart.set(cartKey, {
id: item.id, id: item.id,
name: item.name, name: item.name,
price: Number(item.price), price: Number(item.price),
qty: 1, qty: lockedItemIds.length,
type: item.type, type: item.type,
max_qty: 1, // Флаг: нельзя увеличить количество max_qty: availableCount, // Максимум = сколько доступно
lock_expires_at: data.lock_expires_at // Время истечения блокировки showcase_item_ids: lockedItemIds, // ID заблокированных экземпляров
lock_expires_at: data.lock_expires_at
}); });
}
// Обновляем список витрины (чтобы показать блокировку) // Обновляем список витрины (чтобы показать блокировку)
if (isShowcaseView) { if (isShowcaseView) {
@@ -963,12 +997,19 @@ async function removeFromCart(cartKey) {
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку // СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку
if (item && item.type === 'showcase_kit') { if (item && item.type === 'showcase_kit') {
try { 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/`, { const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, {
method: 'POST', method: 'POST',
headers: { headers: {
'X-CSRFToken': getCookie('csrftoken'), 'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} },
body: JSON.stringify(body)
}); });
const data = await response.json(); const data = await response.json();
@@ -1376,11 +1417,15 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
const useSalePrice = document.getElementById('useSalePrice').checked; const useSalePrice = document.getElementById('useSalePrice').checked;
const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0; const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0;
// Получаем количество букетов для создания
const showcaseKitQuantity = parseInt(document.getElementById('showcaseKitQuantity').value, 10) || 1;
// Формируем FormData для отправки с файлом // Формируем FormData для отправки с файлом
const formData = new FormData(); const formData = new FormData();
formData.append('kit_name', kitName); formData.append('kit_name', kitName);
if (showcaseId) { if (showcaseId) {
formData.append('showcase_id', showcaseId); formData.append('showcase_id', showcaseId);
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
} }
formData.append('description', description); formData.append('description', description);
formData.append('items', JSON.stringify(items)); formData.append('items', JSON.stringify(items));
@@ -1423,12 +1468,14 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
if (data.success) { if (data.success) {
// Успех! // Успех!
const createdCount = data.available_count || 1;
const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : '';
const successMessage = isEditMode const successMessage = isEditMode
? `${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.` ? `${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.`
: `${data.message} : `${data.message}
Комплект: ${data.kit_name} Комплект: ${data.kit_name}
Цена: ${data.kit_price} руб. Цена: ${data.kit_price} руб.${qtyInfo}
Зарезервировано компонентов: ${data.reservations_count}`; Зарезервировано компонентов: ${data.reservations_count}`;
alert(successMessage); alert(successMessage);
@@ -1446,6 +1493,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
document.getElementById('useSalePrice').checked = false; document.getElementById('useSalePrice').checked = false;
document.getElementById('salePrice').value = ''; document.getElementById('salePrice').value = '';
document.getElementById('salePriceBlock').style.display = 'none'; document.getElementById('salePriceBlock').style.display = 'none';
document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества
// Сбрасываем режим редактирования // Сбрасываем режим редактирования
isEditMode = false; isEditMode = false;
@@ -1714,12 +1762,19 @@ async function handleCheckoutSubmit(paymentsData) {
const orderData = { const orderData = {
customer_id: customer.id, customer_id: customer.id,
warehouse_id: currentWarehouse.id, warehouse_id: currentWarehouse.id,
items: Array.from(cart.values()).map(item => ({ items: Array.from(cart.values()).map(item => {
const itemData = {
type: item.type, type: item.type,
id: item.id, id: item.id,
quantity: item.qty, quantity: item.qty,
price: item.price 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, payments: paymentsData,
notes: document.getElementById('orderNote').value.trim() notes: document.getElementById('orderNote').value.trim()
}; };

View File

@@ -170,6 +170,13 @@
</select> </select>
</div> </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"> <div class="mb-3">
<label for="tempKitDescription" class="form-label">Описание (опционально)</label> <label for="tempKitDescription" class="form-label">Описание (опционально)</label>

View File

@@ -54,105 +54,81 @@ def get_pos_warehouse(request):
def get_showcase_kits_for_pos(): def get_showcase_kits_for_pos():
""" """
Получает витринные комплекты для отображения в POS. Получает витринные комплекты для отображения в POS.
Возвращает список временных комплектов, которые зарезервированы на витринах.
Оптимизировано: убраны N+1 запросы, используется один проход по данным. НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
- Группирует экземпляры по (product_kit, showcase)
- Возвращает available_count и showcase_item_ids для каждой группы
- Позволяет продавать несколько экземпляров одного букета
""" """
from products.models import ProductKitPhoto from products.models import ProductKitPhoto
from inventory.models import ShowcaseItem
from django.db.models import Count, Prefetch as DjangoPrefetch
# Получаем все зарезервированные товары на витринах # Группируем доступные ShowcaseItem по (product_kit, showcase)
reserved_products = Reservation.objects.filter( available_items = ShowcaseItem.objects.filter(
showcase__isnull=False, status='available',
showcase__is_active=True, showcase__is_active=True
status='reserved' ).select_related(
).values_list('product_id', flat=True).distinct() '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 reserved_products: if not available_items:
return [] return []
# Prefetch для первого фото (thumbnail) # Получаем ID всех комплектов для загрузки фото
first_photo_prefetch = Prefetch( kit_ids = list(set(item['product_kit_id'] for item in available_items))
'photos',
queryset=ProductKitPhoto.objects.order_by('order')[:1],
to_attr='first_photo_list'
)
# Находим комплекты с резервированными компонентами # Загружаем первые фото для комплектов
# ВАЖНО: фильтруем только комплекты, явно привязанные к витрине (showcase__isnull=False) kit_photos = {}
# Это исключает временные комплекты, созданные для заказов (order__isnull=False, showcase=NULL) photos = ProductKitPhoto.objects.filter(
kits_with_showcase_items = ProductKit.objects.filter( kit_id__in=kit_ids
is_temporary=True, ).order_by('kit_id', 'order')
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()
# Получаем все резервы для компонентов комплектов одним запросом for photo in photos:
all_kit_product_ids = set() if photo.kit_id not in kit_photos:
kit_to_product_ids = {} # {kit.id: set(product_ids)} kit_photos[photo.kit_id] = photo.get_thumbnail_url()
for kit in kits_with_showcase_items:
# Используем prefetch'енные kit_items (без дополнительного запроса)
product_ids = {item.product_id for item in kit.kit_items.all()}
kit_to_product_ids[kit.id] = product_ids
all_kit_product_ids.update(product_ids)
# Один запрос для всех резервов
all_reservations = Reservation.objects.filter(
product_id__in=all_kit_product_ids,
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).select_related('showcase').values('product_id', 'showcase_id', 'showcase__name')
# Группируем резервы по product_id
product_to_showcases = {} # {product_id: [(showcase_id, showcase_name), ...]}
for res in all_reservations:
product_id = res['product_id']
if product_id not in product_to_showcases:
product_to_showcases[product_id] = []
product_to_showcases[product_id].append((res['showcase_id'], res['showcase__name']))
# Формируем результат # Формируем результат
showcase_kits = [] showcase_kits = []
for kit in kits_with_showcase_items: for item in available_items:
product_ids = kit_to_product_ids[kit.id] kit_id = item['product_kit_id']
showcase_id = item['showcase_id']
# Находим общую витрину для всех компонентов # Получаем IDs всех доступных экземпляров этой группы
showcases_for_kit = None item_ids = list(ShowcaseItem.objects.filter(
for product_id in product_ids: product_kit_id=kit_id,
showcases = product_to_showcases.get(product_id, []) showcase_id=showcase_id,
if showcases_for_kit is None: status='available'
showcases_for_kit = set(s[0] for s in showcases) ).values_list('id', flat=True))
else:
showcases_for_kit &= set(s[0] for s in showcases)
if showcases_for_kit: # Определяем актуальную цену
# Берём первую витрину price = item['product_kit__sale_price'] or item['product_kit__price']
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({ showcase_kits.append({
'id': kit.id, 'id': kit_id,
'name': kit.name, 'name': item['product_kit__name'],
'price': str(kit.actual_price), 'price': str(price),
'category_ids': [], 'category_ids': [],
'in_stock': True, 'in_stock': True,
'sku': kit.sku or '', 'sku': item['product_kit__sku'] or '',
'image': image_url, 'image': kit_photos.get(kit_id),
'type': 'showcase_kit', 'type': 'showcase_kit',
'showcase_name': showcase_name, 'showcase_name': item['showcase__name'],
'showcase_id': showcase_id 'showcase_id': showcase_id,
# НОВЫЕ ПОЛЯ для поддержки количества
'available_count': item['available_count'],
'showcase_item_ids': item_ids
}) })
return showcase_kits return showcase_kits
@@ -477,74 +453,85 @@ def get_showcase_kits_api(request):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def add_showcase_kit_to_cart(request, kit_id): def add_showcase_kit_to_cart(request, kit_id):
""" """
API endpoint для добавления витринного комплекта в корзину с блокировкой. API endpoint для добавления N экземпляров витринного комплекта в корзину.
Создает soft lock на 30 минут, предотвращая добавление другими кассирами.
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
- Принимает quantity (количество экземпляров)
- Блокирует конкретные ShowcaseItem
- Возвращает список заблокированных showcase_item_ids
Payload (JSON): { "quantity": 2 }
Returns: Returns:
JSON: { JSON: {
'success': bool, 'success': bool,
'message': str, 'message': str,
'lock_expires_at': ISO datetime (если success=True), 'locked_item_ids': list[int],
'lock_expires_at': ISO datetime,
'error': str (если success=False) 'error': str (если success=False)
} }
""" """
from datetime import timedelta from datetime import timedelta
from inventory.models import ShowcaseItem
from inventory.services.showcase_manager import ShowcaseManager
try: 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, id=kit_id,
is_temporary=True, is_temporary=True,
showcase__isnull=False,
status='active' status='active'
) )
# Атомарная проверка и создание блокировки (предотвращает race condition)
with transaction.atomic(): with transaction.atomic():
# Блокируем строки резервов для этого комплекта на уровне БД # Находим доступные экземпляры этого комплекта
# Примечание: нельзя использовать select_related с nullable FK при select_for_update available_items = ShowcaseItem.objects.select_for_update(
reservations = Reservation.objects.select_for_update().filter( skip_locked=True
product_kit=kit, ).filter(
status='reserved' product_kit_id=kit_id,
) status='available',
showcase__is_active=True
)[:quantity]
# Проверяем существующие блокировки другими пользователями available_items = list(available_items)
existing_lock = reservations.filter(
cart_lock_expires_at__gt=timezone.now()
).exclude(
locked_by_user=request.user
).first()
if existing_lock: if len(available_items) < quantity:
# Получаем username отдельным запросом (избегаем outer join с select_for_update)
locked_by_username = existing_lock.locked_by_user.username if existing_lock.locked_by_user else 'другой кассир'
time_left = (existing_lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
'error': f'Этот букет уже в корзине кассира "{locked_by_username}". ' 'error': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров. '
f'Блокировка истечет через {int(time_left)} мин.' f'Возможно, часть уже в корзине другого кассира.'
}, status=409) # 409 Conflict }, status=409)
# Создаем или продлеваем блокировку для текущего пользователя # Блокируем экземпляры
lock_expires_at = timezone.now() + timedelta(minutes=30) lock_expires_at = timezone.now() + timedelta(minutes=30)
session_id = request.session.session_key or '' session_id = request.session.session_key or ''
locked_ids = []
# Обновляем все резервы этого комплекта (теперь атомарно!) for item in available_items:
updated_count = reservations.update( item.lock_for_cart(
cart_lock_expires_at=lock_expires_at, user=request.user,
locked_by_user=request.user, session_id=session_id,
cart_session_id=session_id duration_minutes=30
) )
locked_ids.append(item.id)
if updated_count == 0:
return JsonResponse({
'success': False,
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
}, status=400)
return JsonResponse({ return JsonResponse({
'success': True, '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(), 'lock_expires_at': lock_expires_at.isoformat(),
'locked_until_minutes': 30 'locked_until_minutes': 30
}) })
@@ -555,6 +542,7 @@ def add_showcase_kit_to_cart(request, kit_id):
'error': 'Витринный комплект не найден' 'error': 'Витринный комплект не найден'
}, status=404) }, status=404)
except Exception as e: except Exception as e:
logger.error(f'Ошибка добавления витринного букета в корзину: {str(e)}', exc_info=True)
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
'error': f'Ошибка при добавлении в корзину: {str(e)}' 'error': f'Ошибка при добавлении в корзину: {str(e)}'
@@ -565,16 +553,24 @@ def add_showcase_kit_to_cart(request, kit_id):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def remove_showcase_kit_from_cart(request, kit_id): 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: Returns:
JSON: { JSON: {
'success': bool, 'success': bool,
'message': str, 'message': str,
'released_count': int,
'error': str (если success=False) 'error': str (если success=False)
} }
""" """
from inventory.models import ShowcaseItem
try: try:
# Получаем комплект # Получаем комплект
kit = ProductKit.objects.get( kit = ProductKit.objects.get(
@@ -582,27 +578,42 @@ def remove_showcase_kit_from_cart(request, kit_id):
is_temporary=True is_temporary=True
) )
# Снимаем блокировку только для текущего пользователя # Получаем список ID экземпляров из тела запроса (опционально)
updated_count = Reservation.objects.filter( 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, product_kit=kit,
locked_by_user=request.user, status='in_cart',
status='reserved' locked_by_user=request.user
).update( )
cart_lock_expires_at=None,
# Если указаны конкретные ID - фильтруем только их
if showcase_item_ids:
qs = qs.filter(id__in=showcase_item_ids)
# Снимаем блокировку
updated_count = qs.update(
status='available',
locked_by_user=None, locked_by_user=None,
cart_lock_expires_at=None,
cart_session_id=None cart_session_id=None
) )
if updated_count == 0: if updated_count == 0:
# Комплект не был заблокирован этим пользователем
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'message': 'Комплект не был заблокирован вами' 'message': 'Экземпляры не были заблокированы вами',
'released_count': 0
}) })
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'message': f'Букет "{kit.name}" удален из корзины. Блокировка снята.', 'message': f'Освобождено {updated_count} экз. "{kit.name}"',
'released_count': updated_count 'released_count': updated_count
}) })
@@ -612,6 +623,7 @@ def remove_showcase_kit_from_cart(request, kit_id):
'error': 'Комплект не найден' 'error': 'Комплект не найден'
}, status=404) }, status=404)
except Exception as e: except Exception as e:
logger.error(f'Ошибка снятия блокировки витринного букета: {str(e)}', exc_info=True)
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
'error': f'Ошибка при снятии блокировки: {str(e)}' '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))) price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0)))
sale_price_str = request.POST.get('sale_price', '') sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo') photo_file = request.FILES.get('photo')
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
# Парсим items из JSON # Парсим items из JSON
items = json.loads(items_json) items = json.loads(items_json)
@@ -962,24 +975,32 @@ def create_temp_kit_to_showcase(request):
order=0 order=0
) )
# 5. Резервируем комплект на витрину # 5. Резервируем комплект на витрину (создаём N экземпляров)
result = ShowcaseManager.reserve_kit_to_showcase( result = ShowcaseManager.reserve_kit_to_showcase(
product_kit=kit, product_kit=kit,
showcase=showcase, showcase=showcase,
quantity=1 quantity=showcase_kit_quantity
) )
if not result['success']: if not result['success']:
# Откатываем транзакцию через raise # Откатываем транзакцию через raise
raise Exception(result['message']) 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({ return JsonResponse({
'success': True, '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_id': kit.id,
'kit_name': kit.name, 'kit_name': kit.name,
'kit_price': str(kit.actual_price), '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: except json.JSONDecodeError as e:
@@ -1328,6 +1349,9 @@ def pos_checkout(request):
) )
# 2. Добавляем товары # 2. Добавляем товары
from inventory.models import ShowcaseItem
from inventory.services.showcase_manager import ShowcaseManager
for item_data in items_data: for item_data in items_data:
item_type = item_data['type'] item_type = item_data['type']
item_id = item_data['id'] item_id = item_data['id']
@@ -1343,7 +1367,8 @@ def pos_checkout(request):
price=price, price=price,
is_custom_price=False is_custom_price=False
) )
elif item_type in ['kit', 'showcase_kit']: elif item_type == 'kit':
# Обычный комплект (не витринный)
kit = ProductKit.objects.get(id=item_id) kit = ProductKit.objects.get(id=item_id)
OrderItem.objects.create( OrderItem.objects.create(
order=order, order=order,
@@ -1352,6 +1377,40 @@ def pos_checkout(request):
price=price, price=price,
is_custom_price=False 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. Пересчитываем итоговую стоимость # 3. Пересчитываем итоговую стоимость
order.calculate_total() order.calculate_total()
@@ -1374,24 +1433,7 @@ def pos_checkout(request):
# 5. Обновляем статус оплаты # 5. Обновляем статус оплаты
order.update_payment_status() order.update_payment_status()
# 6. Освобождаем блокировки витринных комплектов # 6. Очищаем корзину из Redis
showcase_kit_ids = [
item_data['id'] for item_data in items_data
if item_data['type'] == 'showcase_kit'
]
if showcase_kit_ids:
Reservation.objects.filter(
product_kit_id__in=showcase_kit_ids,
locked_by_user=request.user,
status='reserved'
).update(
cart_lock_expires_at=None,
locked_by_user=None,
cart_session_id=None
)
# 7. Очищаем корзину из Redis
from django.core.cache import cache from django.core.cache import cache
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}' cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
cache.delete(cart_key) cache.delete(cart_key)