Добавлена функциональность витрин для POS: модели, сервисы, UI
- Создана модель Showcase (витрина) привязанная к складу - Расширена Reservation для поддержки витринных резервов - Добавлены поля в OrderItem для маркировки витринных продаж - Реализован ShowcaseManager с методами резервирования, продажи и разбора - Обновлён админ-интерфейс для управления витринами - Добавлена кнопка Витрина в POS (категории) и API для просмотра - Добавлена кнопка На витрину в панели действий POS - Миграции готовы к применению
This commit is contained in:
@@ -5,9 +5,11 @@
|
||||
from .batch_manager import StockBatchManager
|
||||
from .sale_processor import SaleProcessor
|
||||
from .inventory_processor import InventoryProcessor
|
||||
from .showcase_manager import ShowcaseManager
|
||||
|
||||
__all__ = [
|
||||
'StockBatchManager',
|
||||
'SaleProcessor',
|
||||
'InventoryProcessor',
|
||||
'ShowcaseManager',
|
||||
]
|
||||
|
||||
@@ -19,6 +19,40 @@ class SaleProcessor:
|
||||
Обработчик продаж с автоматическим FIFO-списанием.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_sale_from_reservation(reservation, order=None):
|
||||
"""
|
||||
Создать продажу на основе резерва.
|
||||
Используется для продажи с витрины.
|
||||
|
||||
Args:
|
||||
reservation: объект Reservation
|
||||
order: (опционально) объект Order
|
||||
|
||||
Returns:
|
||||
Объект Sale
|
||||
"""
|
||||
# Определяем цену продажи из заказа или из товара
|
||||
if order and reservation.order_item:
|
||||
# Цена из OrderItem
|
||||
sale_price = reservation.order_item.price
|
||||
else:
|
||||
# Цена из товара
|
||||
sale_price = reservation.product.actual_price or Decimal('0')
|
||||
|
||||
# Создаём продажу с FIFO-списанием
|
||||
sale = SaleProcessor.create_sale(
|
||||
product=reservation.product,
|
||||
warehouse=reservation.warehouse,
|
||||
quantity=reservation.quantity,
|
||||
sale_price=sale_price,
|
||||
order=order,
|
||||
document_number=None
|
||||
)
|
||||
|
||||
return sale
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None):
|
||||
|
||||
334
myproject/inventory/services/showcase_manager.py
Normal file
334
myproject/inventory/services/showcase_manager.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Сервис управления витринами - резервирование, продажа и разбор витринных букетов.
|
||||
"""
|
||||
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,
|
||||
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,
|
||||
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')
|
||||
|
||||
if not reservations.exists():
|
||||
return {
|
||||
'success': False,
|
||||
'order': None,
|
||||
'message': f'На витрине "{showcase.name}" нет зарезервированных товаров'
|
||||
}
|
||||
|
||||
# Получаем статус "Завершён" для 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:
|
||||
# Если указан конкретный комплект, фильтруем резервы
|
||||
# TODO: добавить связь резерва с конкретным экземпляром комплекта
|
||||
# Пока освобождаем все резервы витрины
|
||||
pass
|
||||
|
||||
released_count = reservations.count()
|
||||
|
||||
if released_count == 0:
|
||||
return {
|
||||
'success': False,
|
||||
'released_count': 0,
|
||||
'message': f'На витрине "{showcase.name}" нет активных резервов'
|
||||
}
|
||||
|
||||
# Освобождаем резервы
|
||||
reservations.update(
|
||||
status='released',
|
||||
released_at=timezone.now(),
|
||||
showcase=None
|
||||
)
|
||||
|
||||
# Обновляем агрегаты Stock
|
||||
from inventory.models import Stock
|
||||
affected_products = reservations.values_list('product_id', flat=True).distinct()
|
||||
for product_id in affected_products:
|
||||
try:
|
||||
stock = Stock.objects.get(
|
||||
product_id=product_id,
|
||||
warehouse=showcase.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()
|
||||
]
|
||||
Reference in New Issue
Block a user