Добавлена функциональность витрин для POS: модели, сервисы, UI
- Создана модель Showcase (витрина) привязанная к складу - Расширена Reservation для поддержки витринных резервов - Добавлены поля в OrderItem для маркировки витринных продаж - Реализован ShowcaseManager с методами резервирования, продажи и разбора - Обновлён админ-интерфейс для управления витринами - Добавлена кнопка Витрина в POS (категории) и API для просмотра - Добавлена кнопка На витрину в панели действий POS - Миграции готовы к применению
This commit is contained in:
@@ -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 =====
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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