Добавлена функциональность витрин для POS: модели, сервисы, UI

- Создана модель Showcase (витрина) привязанная к складу
- Расширена Reservation для поддержки витринных резервов
- Добавлены поля в OrderItem для маркировки витринных продаж
- Реализован ShowcaseManager с методами резервирования, продажи и разбора
- Обновлён админ-интерфейс для управления витринами
- Добавлена кнопка Витрина в POS (категории) и API для просмотра
- Добавлена кнопка На витрину в панели действий POS
- Миграции готовы к применению
This commit is contained in:
2025-11-16 21:12:22 +03:00
parent e98bf3cfb4
commit 8f6acfb364
12 changed files with 653 additions and 13 deletions

View File

@@ -7,10 +7,29 @@ from decimal import Decimal
from inventory.models import (
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
Inventory, InventoryLine, Reservation, Stock, StockMovement,
SaleBatchAllocation
SaleBatchAllocation, Showcase
)
# ===== SHOWCASE =====
@admin.register(Showcase)
class ShowcaseAdmin(admin.ModelAdmin):
list_display = ('name', 'warehouse', 'is_active', 'created_at')
list_filter = ('is_active', 'warehouse', 'created_at')
search_fields = ('name', 'warehouse__name')
date_hierarchy = 'created_at'
fieldsets = (
('Основная информация', {
'fields': ('name', 'warehouse', 'description', 'is_active')
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
# ===== WAREHOUSE =====
@admin.register(Warehouse)
class WarehouseAdmin(admin.ModelAdmin):
@@ -269,13 +288,13 @@ class InventoryAdmin(admin.ModelAdmin):
# ===== RESERVATION =====
@admin.register(Reservation)
class ReservationAdmin(admin.ModelAdmin):
list_display = ('product', 'warehouse', 'quantity', 'status_display', 'order_info', 'reserved_at')
list_filter = ('status', 'reserved_at', 'warehouse')
search_fields = ('product__name', 'order_item__order__order_number')
list_display = ('product', 'warehouse', 'quantity', 'status_display', 'context_info', 'reserved_at')
list_filter = ('status', 'reserved_at', 'warehouse', 'showcase')
search_fields = ('product__name', 'order_item__order__order_number', 'showcase__name')
date_hierarchy = 'reserved_at'
fieldsets = (
('Резерв', {
'fields': ('product', 'warehouse', 'quantity', 'status', 'order_item')
'fields': ('product', 'warehouse', 'quantity', 'status', 'order_item', 'showcase')
}),
('Даты', {
'fields': ('reserved_at', 'released_at', 'converted_at')
@@ -296,11 +315,19 @@ class ReservationAdmin(admin.ModelAdmin):
)
status_display.short_description = 'Статус'
def order_info(self, obj):
def context_info(self, obj):
if obj.order_item:
return f"ORD-{obj.order_item.order.order_number}"
return format_html(
'<span style="color: #0066cc;">📎 Заказ ORD-{}</span>',
obj.order_item.order.order_number
)
elif obj.showcase:
return format_html(
'<span style="color: #ff9900;">🌺 Витрина: {}</span>',
obj.showcase.name
)
return "-"
order_info.short_description = 'Заказ'
context_info.short_description = 'Контекст'
# ===== STOCK =====

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.0.10 on 2025-11-16 18:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0002_initial'),
('orders', '0002_initial'),
('products', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Showcase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcases', to='inventory.warehouse', verbose_name='Склад')),
],
options={
'verbose_name': 'Витрина',
'verbose_name_plural': 'Витрины',
'ordering': ['warehouse', 'name'],
},
),
migrations.AddField(
model_name='reservation',
name='showcase',
field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен букет', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['showcase'], name='inventory_r_showcas_bd3508_idx'),
),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['warehouse'], name='inventory_s_warehou_1e4a8a_idx'),
),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['is_active'], name='inventory_s_is_acti_387bfb_idx'),
),
]

View File

@@ -359,10 +359,36 @@ class InventoryLine(models.Model):
super().save(*args, **kwargs)
class Showcase(models.Model):
"""
Витрина - место выкладки собранных букетов/комплектов.
Привязана к конкретному складу для учёта резервов.
"""
name = models.CharField(max_length=200, verbose_name="Название")
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='showcases', verbose_name="Склад")
description = models.TextField(blank=True, null=True, verbose_name="Описание")
is_active = models.BooleanField(default=True, verbose_name="Активна")
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 = "Витрины"
ordering = ['warehouse', 'name']
indexes = [
models.Index(fields=['warehouse']),
models.Index(fields=['is_active']),
]
def __str__(self):
return f"{self.name} ({self.warehouse.name})"
class Reservation(models.Model):
"""
Резервирование товара для заказа.
Отслеживает, какой товар зарезервирован за каким заказом.
Резервирование товара для заказа или витрины.
Отслеживает, какой товар зарезервирован за каким заказом или витриной.
"""
STATUS_CHOICES = [
('reserved', 'Зарезервирован'),
@@ -373,6 +399,10 @@ class Reservation(models.Model):
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
related_name='reservations', verbose_name="Позиция заказа",
null=True, blank=True)
showcase = models.ForeignKey(Showcase, on_delete=models.CASCADE,
related_name='reservations', verbose_name="Витрина",
null=True, blank=True,
help_text="Витрина, на которой выложен букет")
product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='reservations', verbose_name="Товар")
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
@@ -393,11 +423,17 @@ class Reservation(models.Model):
models.Index(fields=['product', 'warehouse']),
models.Index(fields=['status']),
models.Index(fields=['order_item']),
models.Index(fields=['showcase']),
]
def __str__(self):
order_info = f" (заказ {self.order_item.order.order_number})" if self.order_item else ""
return f"Резерв {self.product.name}: {self.quantity} шт{order_info} [{self.get_status_display()}]"
if self.order_item:
context = f" (заказ {self.order_item.order.order_number})"
elif self.showcase:
context = f" (витрина {self.showcase.name})"
else:
context = ""
return f"Резерв {self.product.name}: {self.quantity} шт{context} [{self.get_status_display()}]"
class Stock(models.Model):

View File

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

View File

@@ -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):

View 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()
]