Files
octopus/myproject/inventory/services/showcase_manager.py
Andrey Smakotin 08a5527ba7 Fix cart lock validation and error handling improvements
## 1. Add cart lock validation to sell_from_showcase()
- Prevent selling showcase kits locked in another cashier's cart
- Check cart_lock_expires_at before allowing direct sales
- Return clear error message with lock holder's name and time remaining
- File: inventory/services/showcase_manager.py

## 2. Improve error handling in POS create_temp_kit_to_showcase()
- Add detailed logging for all error types (JSON, validation, generic)
- Provide user-friendly error messages instead of generic 500
- Log full context (kit name, showcase ID, items, user) for debugging
- Categorize errors: stock issues, integrity, locks, not found
- File: pos/views.py

## 3. Fix critical bug in create_temporary_kit()
- Replace non-existent is_active field with status='active'
- Affects 3 locations: kit creation, product lookup, kit duplication
- This was causing 500 errors when creating temporary kits from order edit
- File: products/services/kit_service.py

## 4. Improve error handling in create_temporary_kit_api()
- Add comprehensive logging for order creation endpoint
- Provide specific error messages for common failure scenarios
- Help diagnose issues when creating kits from order editing UI
- File: products/views/api_views.py

These changes complete the Soft Lock system and fix the 500 error issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 00:24:59 +03:00

358 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сервис управления витринами - резервирование, продажа и разбор витринных букетов.
"""
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import ValidationError
from inventory.models import Showcase, Reservation, Warehouse
from products.models import ProductKit
from orders.models import Order, OrderItem, OrderStatus
from customers.models import Customer
class ShowcaseManager:
"""
Менеджер для работы с витринами и витринными букетами.
"""
@staticmethod
def reserve_kit_to_showcase(product_kit, showcase, quantity=1):
"""
Резервирует комплект на витрину.
Раскладывает комплект на компоненты и создаёт резервы по каждому товару.
Args:
product_kit: ProductKit - комплект для резервирования
showcase: Showcase - витрина
quantity: int - количество комплектов (по умолчанию 1)
Returns:
dict: {
'success': bool,
'reservations': list[Reservation],
'message': str
}
"""
if not showcase.is_active:
return {
'success': False,
'reservations': [],
'message': f'Витрина "{showcase.name}" не активна'
}
warehouse = showcase.warehouse
reservations = []
try:
with transaction.atomic():
# Раскладываем комплект на компоненты
kit_items = product_kit.kit_items.all()
if not kit_items.exists():
return {
'success': False,
'reservations': [],
'message': f'Комплект "{product_kit.name}" не содержит компонентов'
}
# Создаём резервы по каждому компоненту
for kit_item in kit_items:
if kit_item.product:
# Обычный товар
component_quantity = kit_item.quantity * quantity
reservation = Reservation.objects.create(
product=kit_item.product,
warehouse=warehouse,
showcase=showcase,
product_kit=product_kit,
quantity=component_quantity,
status='reserved'
)
reservations.append(reservation)
elif kit_item.variant_group:
# Группа вариантов - резервируем первый доступный вариант
# В будущем можно добавить выбор конкретного варианта
variant_items = kit_item.variant_group.items.all()
if variant_items.exists():
first_variant = variant_items.first()
component_quantity = kit_item.quantity * quantity
reservation = Reservation.objects.create(
product=first_variant.product,
warehouse=warehouse,
showcase=showcase,
product_kit=product_kit,
quantity=component_quantity,
status='reserved'
)
reservations.append(reservation)
# Обновляем агрегаты Stock для всех затронутых товаров
from inventory.models import Stock
for reservation in reservations:
stock, _ = Stock.objects.get_or_create(
product=reservation.product,
warehouse=warehouse
)
stock.refresh_from_batches()
return {
'success': True,
'reservations': reservations,
'message': f'Комплект "{product_kit.name}" зарезервирован на витрине "{showcase.name}"'
}
except Exception as e:
return {
'success': False,
'reservations': [],
'message': f'Ошибка резервирования: {str(e)}'
}
@staticmethod
def sell_from_showcase(product_kit, showcase, customer, payment_method='cash_to_courier',
custom_price=None, user=None):
"""
Продаёт комплект с витрины.
Создаёт Order, OrderItem, конвертирует резервы в Sale.
Args:
product_kit: ProductKit - комплект для продажи
showcase: Showcase - витрина
customer: Customer - покупатель
payment_method: str - способ оплаты
custom_price: Decimal - кастомная цена (опционально)
user: CustomUser - пользователь, выполняющий операцию
Returns:
dict: {
'success': bool,
'order': Order or None,
'message': str
}
"""
warehouse = showcase.warehouse
try:
with transaction.atomic():
# Находим резервы для этого комплекта на витрине
# Группируем по product для подсчёта
reservations = Reservation.objects.filter(
showcase=showcase,
status='reserved'
).select_related('product', 'locked_by_user')
if not reservations.exists():
return {
'success': False,
'order': None,
'message': f'На витрине "{showcase.name}" нет зарезервированных товаров'
}
# Проверяем блокировки корзины (Soft Lock)
# Если комплект заблокирован в корзине другого кассира, запрещаем продажу
active_locks = reservations.filter(
cart_lock_expires_at__gt=timezone.now(),
cart_lock_expires_at__isnull=False
)
if active_locks.exists():
lock = active_locks.first()
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
locker_name = lock.locked_by_user.username if lock.locked_by_user else 'неизвестный кассир'
return {
'success': False,
'order': None,
'message': f'Комплект заблокирован в корзине кассира "{locker_name}". '
f'Блокировка истечёт через {int(time_left)} мин. '
f'Дождитесь освобождения или попросите кассира удалить букет из корзины.'
}
# Получаем статус "Завершён" для POS-продаж
completed_status = OrderStatus.objects.filter(
code='completed',
is_positive_end=True
).first()
if not completed_status:
# Если нет статуса completed, берём любой положительный
completed_status = OrderStatus.objects.filter(
is_positive_end=True
).first()
# Создаём заказ (самовывоз с витринного склада)
order = Order.objects.create(
customer=customer,
is_delivery=False,
pickup_warehouse=warehouse,
status=completed_status,
payment_method=payment_method,
is_paid=True,
modified_by=user
)
# Определяем цену
price = custom_price if custom_price else product_kit.actual_price
is_custom = custom_price is not None
# Создаём позицию заказа
order_item = OrderItem.objects.create(
order=order,
product_kit=product_kit,
quantity=1,
price=price,
is_custom_price=is_custom,
is_from_showcase=True,
showcase=showcase
)
# Привязываем резервы к OrderItem
reservations.update(order_item=order_item)
# Конвертируем резервы в продажи
from inventory.services.sale_processor import SaleProcessor
for reservation in reservations:
# Создаём Sale
sale = SaleProcessor.create_sale_from_reservation(
reservation=reservation,
order=order
)
# Обновляем статус резерва
reservation.status = 'converted_to_sale'
reservation.converted_at = timezone.now()
reservation.save()
# Пересчитываем итоговую сумму заказа
order.calculate_total()
order.amount_paid = order.total_amount
order.update_payment_status()
order.save()
return {
'success': True,
'order': order,
'message': f'Заказ #{order.order_number} создан. Продан комплект с витрины "{showcase.name}"'
}
except Exception as e:
return {
'success': False,
'order': None,
'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,
'message': str
}
"""
try:
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 {
'success': False,
'released_count': 0,
'message': f'На витрине "{showcase.name}" нет активных резервов'
}
# Сохраняем список затронутых товаров и склад ДО обновления резервов
from inventory.models import Stock
affected_products = list(reservations.values_list('product_id', flat=True).distinct())
warehouse = showcase.warehouse
# Освобождаем резервы
reservations.update(
status='released',
released_at=timezone.now(),
showcase=None
)
# Обновляем агрегаты Stock
for product_id in affected_products:
try:
stock = Stock.objects.get(
product_id=product_id,
warehouse=warehouse
)
stock.refresh_from_batches()
except Stock.DoesNotExist:
pass
return {
'success': True,
'released_count': released_count,
'message': f'Разобрано {released_count} резервов с витрины "{showcase.name}"'
}
except Exception as e:
return {
'success': False,
'released_count': 0,
'message': f'Ошибка разбора: {str(e)}'
}
@staticmethod
def get_showcase_kits(showcase):
"""
Возвращает список комплектов, зарезервированных на витрине.
Args:
showcase: Showcase
Returns:
list: список словарей с информацией о комплектах
"""
reservations = Reservation.objects.filter(
showcase=showcase,
status='reserved'
).select_related('product').values('product__id', 'product__name', 'quantity')
# Группируем по товарам
products_dict = {}
for res in reservations:
product_id = res['product__id']
if product_id not in products_dict:
products_dict[product_id] = {
'product_name': res['product__name'],
'quantity': Decimal('0')
}
products_dict[product_id]['quantity'] += res['quantity']
return [
{
'product_id': pid,
'product_name': data['product_name'],
'quantity': data['quantity']
}
for pid, data in products_dict.items()
]