Добавлена функциональность витрин для 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()
]

View File

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

View File

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

View File

@@ -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('Функционал будет подключен позже: создание заказа и списание со склада.');

View File

@@ -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">

View File

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

View File

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