Добавлена функциональность витрин для 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 (
|
from inventory.models import (
|
||||||
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
|
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
|
||||||
Inventory, InventoryLine, Reservation, Stock, StockMovement,
|
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 =====
|
# ===== WAREHOUSE =====
|
||||||
@admin.register(Warehouse)
|
@admin.register(Warehouse)
|
||||||
class WarehouseAdmin(admin.ModelAdmin):
|
class WarehouseAdmin(admin.ModelAdmin):
|
||||||
@@ -269,13 +288,13 @@ class InventoryAdmin(admin.ModelAdmin):
|
|||||||
# ===== RESERVATION =====
|
# ===== RESERVATION =====
|
||||||
@admin.register(Reservation)
|
@admin.register(Reservation)
|
||||||
class ReservationAdmin(admin.ModelAdmin):
|
class ReservationAdmin(admin.ModelAdmin):
|
||||||
list_display = ('product', 'warehouse', 'quantity', 'status_display', 'order_info', 'reserved_at')
|
list_display = ('product', 'warehouse', 'quantity', 'status_display', 'context_info', 'reserved_at')
|
||||||
list_filter = ('status', 'reserved_at', 'warehouse')
|
list_filter = ('status', 'reserved_at', 'warehouse', 'showcase')
|
||||||
search_fields = ('product__name', 'order_item__order__order_number')
|
search_fields = ('product__name', 'order_item__order__order_number', 'showcase__name')
|
||||||
date_hierarchy = 'reserved_at'
|
date_hierarchy = 'reserved_at'
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Резерв', {
|
('Резерв', {
|
||||||
'fields': ('product', 'warehouse', 'quantity', 'status', 'order_item')
|
'fields': ('product', 'warehouse', 'quantity', 'status', 'order_item', 'showcase')
|
||||||
}),
|
}),
|
||||||
('Даты', {
|
('Даты', {
|
||||||
'fields': ('reserved_at', 'released_at', 'converted_at')
|
'fields': ('reserved_at', 'released_at', 'converted_at')
|
||||||
@@ -296,11 +315,19 @@ class ReservationAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
status_display.short_description = 'Статус'
|
status_display.short_description = 'Статус'
|
||||||
|
|
||||||
def order_info(self, obj):
|
def context_info(self, obj):
|
||||||
if obj.order_item:
|
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 "-"
|
return "-"
|
||||||
order_info.short_description = 'Заказ'
|
context_info.short_description = 'Контекст'
|
||||||
|
|
||||||
|
|
||||||
# ===== STOCK =====
|
# ===== 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)
|
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):
|
class Reservation(models.Model):
|
||||||
"""
|
"""
|
||||||
Резервирование товара для заказа.
|
Резервирование товара для заказа или витрины.
|
||||||
Отслеживает, какой товар зарезервирован за каким заказом.
|
Отслеживает, какой товар зарезервирован за каким заказом или витриной.
|
||||||
"""
|
"""
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('reserved', 'Зарезервирован'),
|
('reserved', 'Зарезервирован'),
|
||||||
@@ -373,6 +399,10 @@ class Reservation(models.Model):
|
|||||||
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
|
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
|
||||||
related_name='reservations', verbose_name="Позиция заказа",
|
related_name='reservations', verbose_name="Позиция заказа",
|
||||||
null=True, blank=True)
|
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,
|
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||||
related_name='reservations', verbose_name="Товар")
|
related_name='reservations', verbose_name="Товар")
|
||||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||||
@@ -393,11 +423,17 @@ class Reservation(models.Model):
|
|||||||
models.Index(fields=['product', 'warehouse']),
|
models.Index(fields=['product', 'warehouse']),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
models.Index(fields=['order_item']),
|
models.Index(fields=['order_item']),
|
||||||
|
models.Index(fields=['showcase']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
order_info = f" (заказ {self.order_item.order.order_number})" if self.order_item else ""
|
if self.order_item:
|
||||||
return f"Резерв {self.product.name}: {self.quantity} шт{order_info} [{self.get_status_display()}]"
|
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):
|
class Stock(models.Model):
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
from .batch_manager import StockBatchManager
|
from .batch_manager import StockBatchManager
|
||||||
from .sale_processor import SaleProcessor
|
from .sale_processor import SaleProcessor
|
||||||
from .inventory_processor import InventoryProcessor
|
from .inventory_processor import InventoryProcessor
|
||||||
|
from .showcase_manager import ShowcaseManager
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'StockBatchManager',
|
'StockBatchManager',
|
||||||
'SaleProcessor',
|
'SaleProcessor',
|
||||||
'InventoryProcessor',
|
'InventoryProcessor',
|
||||||
|
'ShowcaseManager',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,40 @@ class SaleProcessor:
|
|||||||
Обработчик продаж с автоматическим FIFO-списанием.
|
Обработчик продаж с автоматическим 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
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None):
|
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()
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 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', '0003_showcase_reservation_showcase_and_more'),
|
||||||
|
('orders', '0002_initial'),
|
||||||
|
('products', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalorderitem',
|
||||||
|
name='is_from_showcase',
|
||||||
|
field=models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalorderitem',
|
||||||
|
name='showcase',
|
||||||
|
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.showcase', verbose_name='Витрина'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='is_from_showcase',
|
||||||
|
field=models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='showcase',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='inventory.showcase', verbose_name='Витрина'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='orderitem',
|
||||||
|
index=models.Index(fields=['is_from_showcase'], name='orders_orde_is_from_32d8f7_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='orderitem',
|
||||||
|
index=models.Index(fields=['showcase'], name='orders_orde_showcas_aa97bd_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -683,6 +683,23 @@ class OrderItem(models.Model):
|
|||||||
help_text="True если цена была изменена вручную при создании заказа"
|
help_text="True если цена была изменена вручную при создании заказа"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Витринные продажи
|
||||||
|
is_from_showcase = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="С витрины",
|
||||||
|
help_text="True если товар продан с витрины"
|
||||||
|
)
|
||||||
|
|
||||||
|
showcase = models.ForeignKey(
|
||||||
|
'inventory.Showcase',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='order_items',
|
||||||
|
verbose_name="Витрина",
|
||||||
|
help_text="Витрина, с которой был продан товар"
|
||||||
|
)
|
||||||
|
|
||||||
# Временные метки
|
# Временные метки
|
||||||
created_at = models.DateTimeField(
|
created_at = models.DateTimeField(
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
@@ -699,6 +716,8 @@ class OrderItem(models.Model):
|
|||||||
models.Index(fields=['order']),
|
models.Index(fields=['order']),
|
||||||
models.Index(fields=['product']),
|
models.Index(fields=['product']),
|
||||||
models.Index(fields=['product_kit']),
|
models.Index(fields=['product_kit']),
|
||||||
|
models.Index(fields=['is_from_showcase']),
|
||||||
|
models.Index(fields=['showcase']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -14,6 +14,48 @@ function renderCategories() {
|
|||||||
const grid = document.getElementById('categoryGrid');
|
const grid = document.getElementById('categoryGrid');
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
// Кнопка "Витрина" - первая в ряду
|
||||||
|
const showcaseCol = document.createElement('div');
|
||||||
|
showcaseCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
||||||
|
const showcaseCard = document.createElement('div');
|
||||||
|
showcaseCard.className = 'card category-card showcase-card';
|
||||||
|
showcaseCard.style.backgroundColor = '#fff3cd';
|
||||||
|
showcaseCard.style.borderColor = '#ffc107';
|
||||||
|
showcaseCard.onclick = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/pos/api/showcase-items/');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.showcases.length > 0) {
|
||||||
|
let message = '🌺 ВИТРИННЫЕ БУКЕТЫ\n\n';
|
||||||
|
|
||||||
|
data.showcases.forEach(showcase => {
|
||||||
|
message += `● ${showcase.name} (Склад: ${showcase.warehouse})\n`;
|
||||||
|
showcase.items.forEach(item => {
|
||||||
|
message += ` - ${item.product_name}: ${item.quantity} шт\n`;
|
||||||
|
});
|
||||||
|
message += '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
} else {
|
||||||
|
alert('Витрины пусты');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching showcase items:', error);
|
||||||
|
alert('Ошибка загрузки витринных букетов');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const showcaseBody = document.createElement('div');
|
||||||
|
showcaseBody.className = 'card-body';
|
||||||
|
const showcaseName = document.createElement('div');
|
||||||
|
showcaseName.className = 'category-name';
|
||||||
|
showcaseName.innerHTML = '<i class="bi bi-flower1"></i> <strong>ВИТРИНА</strong>';
|
||||||
|
showcaseBody.appendChild(showcaseName);
|
||||||
|
showcaseCard.appendChild(showcaseBody);
|
||||||
|
showcaseCol.appendChild(showcaseCard);
|
||||||
|
grid.appendChild(showcaseCol);
|
||||||
|
|
||||||
// Кнопка "Все"
|
// Кнопка "Все"
|
||||||
const allCol = document.createElement('div');
|
const allCol = document.createElement('div');
|
||||||
allCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
allCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
||||||
@@ -241,6 +283,11 @@ function clearCart() {
|
|||||||
|
|
||||||
document.getElementById('clearCart').onclick = clearCart;
|
document.getElementById('clearCart').onclick = clearCart;
|
||||||
|
|
||||||
|
// Кнопка "На витрину" - функционал будет добавлен позже
|
||||||
|
document.getElementById('addToShowcaseBtn').onclick = () => {
|
||||||
|
alert('Функционал "На витрину" будет реализован позже');
|
||||||
|
};
|
||||||
|
|
||||||
// Заглушки для функционала (будет реализовано позже)
|
// Заглушки для функционала (будет реализовано позже)
|
||||||
document.getElementById('checkoutNow').onclick = async () => {
|
document.getElementById('checkoutNow').onclick = async () => {
|
||||||
alert('Функционал будет подключен позже: создание заказа и списание со склада.');
|
alert('Функционал будет подключен позже: создание заказа и списание со склада.');
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
<button class="btn btn-outline-warning rounded-3 w-100" id="addToShowcaseBtn" style="height: 60px;">
|
||||||
|
<i class="bi bi-flower1"></i><br>На витрину
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ app_name = 'pos'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.pos_terminal, name='terminal'),
|
path('', views.pos_terminal, name='terminal'),
|
||||||
|
path('api/showcase-items/', views.showcase_items_api, name='showcase-items-api'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
from products.models import Product, ProductCategory, ProductKit
|
from products.models import Product, ProductCategory, ProductKit
|
||||||
|
from inventory.models import Showcase, Reservation
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
@@ -52,3 +55,45 @@ def pos_terminal(request):
|
|||||||
'title': 'POS Terminal',
|
'title': 'POS Terminal',
|
||||||
}
|
}
|
||||||
return render(request, 'pos/terminal.html', context)
|
return render(request, 'pos/terminal.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def showcase_items_api(request):
|
||||||
|
"""
|
||||||
|
API endpoint для получения витринных букетов.
|
||||||
|
Возвращает комплекты, зарезервированные на активных витринах.
|
||||||
|
"""
|
||||||
|
# Получаем все активные резервы на витринах
|
||||||
|
showcase_reservations = Reservation.objects.filter(
|
||||||
|
showcase__isnull=False,
|
||||||
|
showcase__is_active=True,
|
||||||
|
status='reserved'
|
||||||
|
).select_related('showcase', 'product').prefetch_related('product__photos')
|
||||||
|
|
||||||
|
# Группируем по витринам
|
||||||
|
showcases_dict = {}
|
||||||
|
for res in showcase_reservations:
|
||||||
|
showcase_id = res.showcase.id
|
||||||
|
if showcase_id not in showcases_dict:
|
||||||
|
showcases_dict[showcase_id] = {
|
||||||
|
'id': showcase_id,
|
||||||
|
'name': res.showcase.name,
|
||||||
|
'warehouse': res.showcase.warehouse.name,
|
||||||
|
'items': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавляем товар в список
|
||||||
|
showcases_dict[showcase_id]['items'].append({
|
||||||
|
'product_id': res.product.id,
|
||||||
|
'product_name': res.product.name,
|
||||||
|
'quantity': str(res.quantity),
|
||||||
|
'image': res.product.photos.first().get_thumbnail_url() if res.product.photos.exists() else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
showcases_list = list(showcases_dict.values())
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'showcases': showcases_list
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user