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:
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
Агрегированные остатки по товарам и складам.
|
Агрегированные остатки по товарам и складам.
|
||||||
|
|||||||
@@ -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
|
showcase=showcase,
|
||||||
|
product_kit=product_kit,
|
||||||
|
status='available'
|
||||||
|
)
|
||||||
|
showcase_items.append(showcase_item)
|
||||||
|
|
||||||
reservation = Reservation.objects.create(
|
# 2. Создаём резервы ДЛЯ ЭТОГО ЭКЗЕМПЛЯРА
|
||||||
product=kit_item.product,
|
for kit_item in kit_items:
|
||||||
warehouse=warehouse,
|
product_to_reserve = None
|
||||||
showcase=showcase,
|
|
||||||
product_kit=product_kit,
|
|
||||||
quantity=component_quantity,
|
|
||||||
status='reserved'
|
|
||||||
)
|
|
||||||
reservations.append(reservation)
|
|
||||||
|
|
||||||
elif kit_item.variant_group:
|
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():
|
variant_items = kit_item.variant_group.items.all()
|
||||||
first_variant = variant_items.first()
|
if variant_items.exists():
|
||||||
component_quantity = kit_item.quantity * quantity
|
product_to_reserve = variant_items.first().product
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
if showcase_item.status == 'sold':
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'released_count': 0,
|
||||||
|
'message': 'Нельзя разобрать проданный экземпляр'
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Находим активные резервы
|
warehouse = showcase_item.showcase.warehouse
|
||||||
|
|
||||||
|
# Находим резервы этого экземпляра
|
||||||
reservations = Reservation.objects.filter(
|
reservations = Reservation.objects.filter(
|
||||||
showcase=showcase,
|
showcase_item=showcase_item,
|
||||||
status='reserved'
|
status='reserved'
|
||||||
)
|
)
|
||||||
|
|
||||||
if product_kit:
|
|
||||||
# Если указан конкретный комплект, фильтруем только его резервы
|
|
||||||
reservations = reservations.filter(product_kit=product_kit)
|
|
||||||
|
|
||||||
released_count = reservations.count()
|
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())
|
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)}'
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// Успешно заблокировали - добавляем/обновляем в корзине
|
||||||
cart.set(cartKey, {
|
const lockedItemIds = data.locked_item_ids || [];
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
if (cart.has(cartKey)) {
|
||||||
price: Number(item.price),
|
// Добавляем к существующим
|
||||||
qty: 1,
|
const existing = cart.get(cartKey);
|
||||||
type: item.type,
|
existing.qty += lockedItemIds.length;
|
||||||
max_qty: 1, // Флаг: нельзя увеличить количество
|
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
|
||||||
lock_expires_at: data.lock_expires_at // Время истечения блокировки
|
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) {
|
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 => {
|
||||||
type: item.type,
|
const itemData = {
|
||||||
id: item.id,
|
type: item.type,
|
||||||
quantity: item.qty,
|
id: item.id,
|
||||||
price: item.price
|
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,
|
payments: paymentsData,
|
||||||
notes: document.getElementById('orderNote').value.trim()
|
notes: document.getElementById('orderNote').value.trim()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -54,106 +54,82 @@ 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'енное первое фото
|
showcase_kits.append({
|
||||||
image_url = None
|
'id': kit_id,
|
||||||
if hasattr(kit, 'first_photo_list') and kit.first_photo_list:
|
'name': item['product_kit__name'],
|
||||||
image_url = kit.first_photo_list[0].get_thumbnail_url()
|
'price': str(price),
|
||||||
|
'category_ids': [],
|
||||||
showcase_kits.append({
|
'in_stock': True,
|
||||||
'id': kit.id,
|
'sku': item['product_kit__sku'] or '',
|
||||||
'name': kit.name,
|
'image': kit_photos.get(kit_id),
|
||||||
'price': str(kit.actual_price),
|
'type': 'showcase_kit',
|
||||||
'category_ids': [],
|
'showcase_name': item['showcase__name'],
|
||||||
'in_stock': True,
|
'showcase_id': showcase_id,
|
||||||
'sku': kit.sku or '',
|
# НОВЫЕ ПОЛЯ для поддержки количества
|
||||||
'image': image_url,
|
'available_count': item['available_count'],
|
||||||
'type': 'showcase_kit',
|
'showcase_item_ids': item_ids
|
||||||
'showcase_name': showcase_name,
|
})
|
||||||
'showcase_id': showcase_id
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user