Добавлена функциональность витрин для 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()
|
||||
]
|
||||
@@ -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 если цена была изменена вручную при создании заказа"
|
||||
)
|
||||
|
||||
# Витринные продажи
|
||||
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(
|
||||
auto_now_add=True,
|
||||
@@ -699,6 +716,8 @@ class OrderItem(models.Model):
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['product_kit']),
|
||||
models.Index(fields=['is_from_showcase']),
|
||||
models.Index(fields=['showcase']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -14,6 +14,48 @@ function renderCategories() {
|
||||
const grid = document.getElementById('categoryGrid');
|
||||
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');
|
||||
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('addToShowcaseBtn').onclick = () => {
|
||||
alert('Функционал "На витрину" будет реализован позже');
|
||||
};
|
||||
|
||||
// Заглушки для функционала (будет реализовано позже)
|
||||
document.getElementById('checkoutNow').onclick = async () => {
|
||||
alert('Функционал будет подключен позже: создание заказа и списание со склада.');
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2">
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
|
||||
@@ -6,4 +6,5 @@ app_name = 'pos'
|
||||
|
||||
urlpatterns = [
|
||||
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 -*-
|
||||
from django.shortcuts import render
|
||||
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 inventory.models import Showcase, Reservation
|
||||
import json
|
||||
|
||||
|
||||
@@ -52,3 +55,45 @@ def pos_terminal(request):
|
||||
'title': 'POS Terminal',
|
||||
}
|
||||
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