Обновления и новые функции: изменение шаблона клиента, обновление сигналов инвентаря, добавление снимков наборов и элементов заказа, обновление моделей заказов и продуктов
This commit is contained in:
@@ -260,24 +260,22 @@
|
|||||||
<!-- История заказов -->
|
<!-- История заказов -->
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<button class="btn btn-link w-100 text-start text-decoration-none p-0 d-flex justify-content-between align-items-center"
|
<button class="btn btn-link text-start text-decoration-none p-0 d-flex align-items-center flex-grow-1"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
data-bs-target="#ordersHistoryCollapse"
|
data-bs-target="#ordersHistoryCollapse"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="ordersHistoryCollapse">
|
aria-controls="ordersHistoryCollapse"
|
||||||
<h5 class="mb-0">История заказов</h5>
|
style="border: none; background: none;">
|
||||||
<div>
|
<h5 class="mb-0 me-3">История заказов</h5>
|
||||||
<span class="badge bg-primary me-2">{{ orders_page.paginator.count }}</span>
|
<span class="badge bg-primary">{{ orders_page.paginator.count }}</span>
|
||||||
<a href="{% url 'orders:order-create' %}?customer={{ customer.pk }}"
|
<i class="bi bi-chevron-down ms-auto"></i>
|
||||||
class="btn btn-sm btn-success me-2"
|
|
||||||
onclick="event.stopPropagation();">
|
|
||||||
<i class="bi bi-plus-circle"></i> Новый заказ
|
|
||||||
</a>
|
|
||||||
<i class="bi bi-chevron-down"></i>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
<a href="{% url 'orders:order-create' %}?customer={{ customer.pk }}"
|
||||||
|
class="btn btn-sm btn-success ms-2">
|
||||||
|
<i class="bi bi-plus-circle"></i> Новый заказ
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse" id="ordersHistoryCollapse">
|
<div class="collapse" id="ordersHistoryCollapse">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -59,10 +59,13 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
Процесс:
|
Процесс:
|
||||||
1. Проверяем, новый ли заказ (создан только что)
|
1. Проверяем, новый ли заказ (создан только что)
|
||||||
2. Для каждого товара в заказе создаем Reservation
|
2. Для обычных товаров - создаём резерв напрямую
|
||||||
3. Статус резерва = 'reserved'
|
3. Для комплектов - резервируем компоненты (группируя одинаковые товары)
|
||||||
4. Проверяем на существующие резервы (защита от дубликатов)
|
4. Статус резерва = 'reserved'
|
||||||
|
5. Проверяем на существующие резервы (защита от дубликатов)
|
||||||
"""
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
if not created:
|
if not created:
|
||||||
return # Только для новых заказов
|
return # Только для новых заказов
|
||||||
|
|
||||||
@@ -76,33 +79,63 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
# Для каждого товара в заказе
|
# Для каждого товара в заказе
|
||||||
for item in instance.items.all():
|
for item in instance.items.all():
|
||||||
# Определяем товар (может быть product или product_kit)
|
if item.product:
|
||||||
product = item.product if item.product else item.product_kit
|
# Обычный товар - резервируем как раньше
|
||||||
|
_create_or_update_reservation(item, item.product, warehouse, Decimal(str(item.quantity)))
|
||||||
|
|
||||||
if product:
|
elif item.product_kit and item.kit_snapshot:
|
||||||
# ЗАЩИТА ОТ ДУБЛИКАТОВ: Проверяем, нет ли уже резерва для этой позиции
|
# Комплект - резервируем КОМПОНЕНТЫ из снимка
|
||||||
existing_reservation = Reservation.objects.filter(
|
# Группируем одинаковые товары для создания одного резерва
|
||||||
order_item=item,
|
product_quantities = defaultdict(Decimal)
|
||||||
product=product,
|
|
||||||
warehouse=warehouse
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_reservation:
|
for kit_item in item.kit_snapshot.items.select_related('original_product'):
|
||||||
# Резерв уже существует - обновляем его вместо создания нового
|
if kit_item.original_product:
|
||||||
existing_reservation.quantity = Decimal(str(item.quantity))
|
# Суммируем количество: qty компонента * qty комплектов в заказе
|
||||||
existing_reservation.status = 'reserved'
|
product_quantities[kit_item.original_product_id] += (
|
||||||
existing_reservation.save()
|
kit_item.quantity * Decimal(str(item.quantity))
|
||||||
else:
|
)
|
||||||
# Резерва нет - создаем новый
|
|
||||||
Reservation.objects.create(
|
# Создаём по одному резерву на каждый уникальный товар
|
||||||
order_item=item,
|
from products.models import Product
|
||||||
product=product,
|
for product_id, total_qty in product_quantities.items():
|
||||||
warehouse=warehouse,
|
product = Product.objects.get(pk=product_id)
|
||||||
quantity=Decimal(str(item.quantity)),
|
_create_or_update_reservation(
|
||||||
status='reserved'
|
item, product, warehouse, total_qty, product_kit=item.product_kit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_or_update_reservation(order_item, product, warehouse, quantity, product_kit=None):
|
||||||
|
"""
|
||||||
|
Вспомогательная функция для создания или обновления резерва.
|
||||||
|
"""
|
||||||
|
# Формируем фильтр для поиска существующего резерва
|
||||||
|
filter_kwargs = {
|
||||||
|
'order_item': order_item,
|
||||||
|
'product': product,
|
||||||
|
'warehouse': warehouse,
|
||||||
|
}
|
||||||
|
if product_kit:
|
||||||
|
filter_kwargs['product_kit'] = product_kit
|
||||||
|
|
||||||
|
existing_reservation = Reservation.objects.filter(**filter_kwargs).first()
|
||||||
|
|
||||||
|
if existing_reservation:
|
||||||
|
# Резерв уже существует - обновляем его
|
||||||
|
existing_reservation.quantity = quantity
|
||||||
|
existing_reservation.status = 'reserved'
|
||||||
|
existing_reservation.save()
|
||||||
|
else:
|
||||||
|
# Резерва нет - создаём новый
|
||||||
|
Reservation.objects.create(
|
||||||
|
order_item=order_item,
|
||||||
|
product=product,
|
||||||
|
product_kit=product_kit,
|
||||||
|
warehouse=warehouse,
|
||||||
|
quantity=quantity,
|
||||||
|
status='reserved'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Order)
|
@receiver(post_save, sender=Order)
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||||
@@ -907,47 +940,95 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|||||||
return # Для витринных комплектов не создаем новые резервы
|
return # Для витринных комплектов не создаем новые резервы
|
||||||
|
|
||||||
# Обычный товар или постоянный комплект
|
# Обычный товар или постоянный комплект
|
||||||
# Ищем резерв для этой позиции в ЛЮБОМ статусе (не только 'reserved')
|
# Ищем резервы для этой позиции в ЛЮБОМ статусе (не только 'reserved')
|
||||||
# КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов
|
# КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов
|
||||||
reservation = Reservation.objects.filter(
|
reservations = Reservation.objects.filter(order_item=instance)
|
||||||
order_item=instance
|
|
||||||
).first()
|
if reservations.exists():
|
||||||
|
if instance.product:
|
||||||
|
# Обычный товар - один резерв, обновляем количество напрямую
|
||||||
|
reservation = reservations.first()
|
||||||
|
old_quantity = reservation.quantity
|
||||||
|
reservation.quantity = Decimal(str(instance.quantity))
|
||||||
|
reservation.save(update_fields=['quantity'])
|
||||||
|
|
||||||
if reservation:
|
|
||||||
# Резерв существует - обновляем ТОЛЬКО количество
|
|
||||||
# НЕ меняем статус! (может быть 'converted_to_sale', 'reserved', 'released')
|
|
||||||
old_quantity = reservation.quantity
|
|
||||||
reservation.quantity = Decimal(str(instance.quantity))
|
|
||||||
reservation.save(update_fields=['quantity'])
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"✓ Резерв #{reservation.id} обновлён: quantity {old_quantity} → {reservation.quantity} "
|
|
||||||
f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Резерва нет - создаем новый ТОЛЬКО для обычных товаров (не комплектов)
|
|
||||||
if not instance.product:
|
|
||||||
# Это обычный комплект (не витринный) - не создаем резерв на уровне комплекта
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"ℹ Обычный комплект '{instance.product_kit.name}': резервы управляются на уровне компонентов"
|
f"✓ Резерв #{reservation.id} обновлён: quantity {old_quantity} → {reservation.quantity} "
|
||||||
|
f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
elif instance.product_kit and instance.kit_snapshot:
|
||||||
# Создаем резерв для обычного товара
|
# Комплект - несколько резервов (по одному на компонент)
|
||||||
|
# Обновляем количество каждого резерва пропорционально изменению количества комплектов
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# Собираем ожидаемые количества компонентов
|
||||||
|
product_quantities = defaultdict(Decimal)
|
||||||
|
for kit_item in instance.kit_snapshot.items.select_related('original_product'):
|
||||||
|
if kit_item.original_product:
|
||||||
|
product_quantities[kit_item.original_product_id] = (
|
||||||
|
kit_item.quantity * Decimal(str(instance.quantity))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем каждый резерв
|
||||||
|
for reservation in reservations:
|
||||||
|
expected_qty = product_quantities.get(reservation.product_id, Decimal('0'))
|
||||||
|
if expected_qty > 0:
|
||||||
|
old_quantity = reservation.quantity
|
||||||
|
reservation.quantity = expected_qty
|
||||||
|
reservation.save(update_fields=['quantity'])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Резерв #{reservation.id} ({reservation.product.name}) обновлён: "
|
||||||
|
f"quantity {old_quantity} → {reservation.quantity} "
|
||||||
|
f"(статус: {reservation.status}, OrderItem #{instance.id})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Резерва нет - создаем новый
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||||||
|
|
||||||
if warehouse:
|
if not warehouse:
|
||||||
product = instance.product
|
logger.warning(f"⚠ Не найден склад для резервирования (OrderItem #{instance.id})")
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.product:
|
||||||
|
# Обычный товар - создаем один резерв
|
||||||
reservation = Reservation.objects.create(
|
reservation = Reservation.objects.create(
|
||||||
order_item=instance,
|
order_item=instance,
|
||||||
product=product,
|
product=instance.product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
quantity=Decimal(str(instance.quantity)),
|
quantity=Decimal(str(instance.quantity)),
|
||||||
status='reserved'
|
status='reserved'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✓ Создан новый резерв #{reservation.id}: {product.name}, quantity={reservation.quantity} "
|
f"✓ Создан новый резерв #{reservation.id}: {instance.product.name}, quantity={reservation.quantity} "
|
||||||
|
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif instance.product_kit and instance.kit_snapshot:
|
||||||
|
# Обычный комплект - резервируем КОМПОНЕНТЫ из снимка
|
||||||
|
# Группируем одинаковые товары
|
||||||
|
product_quantities = defaultdict(Decimal)
|
||||||
|
|
||||||
|
for kit_item in instance.kit_snapshot.items.select_related('original_product'):
|
||||||
|
if kit_item.original_product:
|
||||||
|
product_quantities[kit_item.original_product_id] += (
|
||||||
|
kit_item.quantity * Decimal(str(instance.quantity))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаём резервы для компонентов
|
||||||
|
from products.models import Product
|
||||||
|
for product_id, total_qty in product_quantities.items():
|
||||||
|
product = Product.objects.get(pk=product_id)
|
||||||
|
_create_or_update_reservation(
|
||||||
|
instance, product, warehouse, total_qty, product_kit=instance.product_kit
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Комплект '{instance.product_kit.name}': создано {len(product_quantities)} резервов компонентов "
|
||||||
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
76
myproject/orders/migrations/0007_kit_snapshots.py
Normal file
76
myproject/orders/migrations/0007_kit_snapshots.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-12-17 07:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orders', '0006_transaction_delete_payment_and_more'),
|
||||||
|
('products', '0010_alter_product_cost_price'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='KitSnapshot',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||||
|
('sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||||
|
('base_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Базовая цена')),
|
||||||
|
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Итоговая цена')),
|
||||||
|
('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена со скидкой')),
|
||||||
|
('price_adjustment_type', models.CharField(default='none', max_length=20, verbose_name='Тип корректировки')),
|
||||||
|
('price_adjustment_value', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Значение корректировки')),
|
||||||
|
('is_temporary', models.BooleanField(default=False, verbose_name='Временный комплект')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
('original_kit', models.ForeignKey(blank=True, help_text='Ссылка на комплект, с которого создан снимок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='snapshots', to='products.productkit', verbose_name='Оригинальный комплект')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Снимок комплекта',
|
||||||
|
'verbose_name_plural': 'Снимки комплектов',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='KitItemSnapshot',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('product_name', models.CharField(blank=True, max_length=200, verbose_name='Название товара')),
|
||||||
|
('product_sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул товара')),
|
||||||
|
('product_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Цена товара')),
|
||||||
|
('variant_group_name', models.CharField(blank=True, max_length=200, verbose_name='Группа вариантов')),
|
||||||
|
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
|
||||||
|
('kit_snapshot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.kitsnapshot', verbose_name='Снимок комплекта')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Снимок компонента',
|
||||||
|
'verbose_name_plural': 'Снимки компонентов',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalorderitem',
|
||||||
|
name='kit_snapshot',
|
||||||
|
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='kit_snapshot',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='kitsnapshot',
|
||||||
|
index=models.Index(fields=['original_kit'], name='orders_kits_origina_f8d311_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='kitsnapshot',
|
||||||
|
index=models.Index(fields=['created_at'], name='orders_kits_created_70de88_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='kititemsnapshot',
|
||||||
|
index=models.Index(fields=['kit_snapshot'], name='orders_kiti_kit_sna_bf307e_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
39
myproject/orders/migrations/0008_add_item_snapshots.py
Normal file
39
myproject/orders/migrations/0008_add_item_snapshots.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-12-17 11:58
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orders', '0007_kit_snapshots'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalorderitem',
|
||||||
|
name='item_name_snapshot',
|
||||||
|
field=models.CharField(default='', max_length=200, verbose_name='Название на момент заказа'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalorderitem',
|
||||||
|
name='item_sku_snapshot',
|
||||||
|
field=models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='item_name_snapshot',
|
||||||
|
field=models.CharField(default='', max_length=200, verbose_name='Название на момент заказа'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='item_sku_snapshot',
|
||||||
|
field=models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='kit_snapshot',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-12-17 18:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orders', '0008_add_item_snapshots'),
|
||||||
|
('products', '0010_alter_product_cost_price'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='kititemsnapshot',
|
||||||
|
name='original_product',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Ссылка на товар для резервирования на складе', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.product', verbose_name='Оригинальный товар'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,6 +22,7 @@ from .address import Address
|
|||||||
from .order import Order
|
from .order import Order
|
||||||
|
|
||||||
# 4. Зависимые модели
|
# 4. Зависимые модели
|
||||||
|
from .kit_snapshot import KitSnapshot, KitItemSnapshot
|
||||||
from .order_item import OrderItem
|
from .order_item import OrderItem
|
||||||
from .transaction import Transaction
|
from .transaction import Transaction
|
||||||
|
|
||||||
@@ -32,4 +33,6 @@ __all__ = [
|
|||||||
'OrderItem',
|
'OrderItem',
|
||||||
'PaymentMethod',
|
'PaymentMethod',
|
||||||
'Transaction',
|
'Transaction',
|
||||||
|
'KitSnapshot',
|
||||||
|
'KitItemSnapshot',
|
||||||
]
|
]
|
||||||
|
|||||||
163
myproject/orders/models/kit_snapshot.py
Normal file
163
myproject/orders/models/kit_snapshot.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Снапшоты комплектов для сохранения истории заказов.
|
||||||
|
|
||||||
|
При добавлении комплекта (ProductKit) в заказ создается снимок его состояния,
|
||||||
|
чтобы изменения в комплекте не влияли на историю заказов.
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class KitSnapshot(models.Model):
|
||||||
|
"""
|
||||||
|
Снимок комплекта на момент заказа.
|
||||||
|
Сохраняет название, цены и состав комплекта.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Связь с оригинальным комплектом (для аналитики)
|
||||||
|
original_kit = models.ForeignKey(
|
||||||
|
'products.ProductKit',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='snapshots',
|
||||||
|
verbose_name="Оригинальный комплект",
|
||||||
|
help_text="Ссылка на комплект, с которого создан снимок"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Копия основных данных комплекта
|
||||||
|
name = models.CharField(max_length=200, verbose_name="Название")
|
||||||
|
sku = models.CharField(max_length=100, blank=True, verbose_name="Артикул")
|
||||||
|
description = models.TextField(blank=True, verbose_name="Описание")
|
||||||
|
|
||||||
|
# Цены на момент заказа
|
||||||
|
base_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0'),
|
||||||
|
verbose_name="Базовая цена"
|
||||||
|
)
|
||||||
|
price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0'),
|
||||||
|
verbose_name="Итоговая цена"
|
||||||
|
)
|
||||||
|
sale_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Цена со скидкой"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Корректировки цены
|
||||||
|
price_adjustment_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='none',
|
||||||
|
verbose_name="Тип корректировки"
|
||||||
|
)
|
||||||
|
price_adjustment_value = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0'),
|
||||||
|
verbose_name="Значение корректировки"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_temporary = models.BooleanField(default=False, verbose_name="Временный комплект")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Снимок комплекта"
|
||||||
|
verbose_name_plural = "Снимки комплектов"
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['original_kit']),
|
||||||
|
models.Index(fields=['created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
date_str = self.created_at.strftime('%d.%m.%Y %H:%M') if self.created_at else ''
|
||||||
|
return f"Снимок: {self.name} ({date_str})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actual_price(self):
|
||||||
|
"""Финальная цена (sale_price или price)"""
|
||||||
|
if self.sale_price:
|
||||||
|
return self.sale_price
|
||||||
|
return self.price
|
||||||
|
|
||||||
|
def get_total_components_count(self):
|
||||||
|
"""Количество компонентов в комплекте"""
|
||||||
|
return self.items.count()
|
||||||
|
|
||||||
|
|
||||||
|
class KitItemSnapshot(models.Model):
|
||||||
|
"""
|
||||||
|
Снимок компонента комплекта.
|
||||||
|
Сохраняет информацию о товаре и его количестве в комплекте.
|
||||||
|
"""
|
||||||
|
kit_snapshot = models.ForeignKey(
|
||||||
|
KitSnapshot,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='items',
|
||||||
|
verbose_name="Снимок комплекта"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ссылка на оригинальный товар (для резервирования)
|
||||||
|
original_product = models.ForeignKey(
|
||||||
|
'products.Product',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='kit_item_snapshots',
|
||||||
|
verbose_name="Оригинальный товар",
|
||||||
|
help_text="Ссылка на товар для резервирования на складе"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Данные о товаре
|
||||||
|
product_name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Название товара"
|
||||||
|
)
|
||||||
|
product_sku = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Артикул товара"
|
||||||
|
)
|
||||||
|
product_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0'),
|
||||||
|
verbose_name="Цена товара"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если был выбран из группы вариантов
|
||||||
|
variant_group_name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Группа вариантов"
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=3,
|
||||||
|
verbose_name="Количество"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Снимок компонента"
|
||||||
|
verbose_name_plural = "Снимки компонентов"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['kit_snapshot']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
name = self.product_name or self.variant_group_name or "Неизвестный товар"
|
||||||
|
return f"{name} x{self.quantity}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_price(self):
|
||||||
|
"""Стоимость компонента (цена * количество)"""
|
||||||
|
return self.product_price * self.quantity
|
||||||
@@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
|
|||||||
from products.models import Product, ProductKit
|
from products.models import Product, ProductKit
|
||||||
from simple_history.models import HistoricalRecords
|
from simple_history.models import HistoricalRecords
|
||||||
from .order import Order
|
from .order import Order
|
||||||
|
from .kit_snapshot import KitSnapshot
|
||||||
|
|
||||||
|
|
||||||
class OrderItem(models.Model):
|
class OrderItem(models.Model):
|
||||||
@@ -36,6 +37,30 @@ class OrderItem(models.Model):
|
|||||||
verbose_name="Комплект товаров"
|
verbose_name="Комплект товаров"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Снимок комплекта (для сохранения истории состава комплекта на момент заказа)
|
||||||
|
kit_snapshot = models.ForeignKey(
|
||||||
|
KitSnapshot,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='order_items',
|
||||||
|
verbose_name="Снимок комплекта",
|
||||||
|
help_text="Хранит состав комплекта на момент оформления заказа"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Снапшот названия и артикула (для Product и Kit)
|
||||||
|
item_name_snapshot = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
default='',
|
||||||
|
verbose_name="Название на момент заказа"
|
||||||
|
)
|
||||||
|
|
||||||
|
item_sku_snapshot = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Артикул на момент заказа"
|
||||||
|
)
|
||||||
|
|
||||||
quantity = models.PositiveIntegerField(
|
quantity = models.PositiveIntegerField(
|
||||||
default=1,
|
default=1,
|
||||||
verbose_name="Количество"
|
verbose_name="Количество"
|
||||||
@@ -92,12 +117,7 @@ class OrderItem(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
item_name = ""
|
return f"{self.item_name_snapshot} x{self.quantity} в заказе #{self.order.order_number}"
|
||||||
if self.product:
|
|
||||||
item_name = self.product.name
|
|
||||||
elif self.product_kit:
|
|
||||||
item_name = self.product_kit.name
|
|
||||||
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Валидация модели"""
|
"""Валидация модели"""
|
||||||
@@ -116,12 +136,34 @@ class OrderItem(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = not self.pk
|
||||||
|
|
||||||
|
# Определяем, изменился ли комплект (для существующих записей)
|
||||||
|
kit_changed = False
|
||||||
|
if self.pk and self.product_kit:
|
||||||
|
old = OrderItem.objects.filter(pk=self.pk).values('product_kit_id').first()
|
||||||
|
if old and old['product_kit_id'] != self.product_kit_id:
|
||||||
|
kit_changed = True
|
||||||
|
|
||||||
|
# Создаем snapshot для комплекта (новый или при замене)
|
||||||
|
if self.product_kit and (is_new or kit_changed):
|
||||||
|
self.kit_snapshot = self.product_kit.create_snapshot()
|
||||||
|
|
||||||
|
# Сохраняем название и артикул
|
||||||
|
if self.product:
|
||||||
|
self.item_name_snapshot = self.product.name
|
||||||
|
self.item_sku_snapshot = self.product.sku or ''
|
||||||
|
elif self.kit_snapshot:
|
||||||
|
self.item_name_snapshot = self.kit_snapshot.name
|
||||||
|
self.item_sku_snapshot = self.kit_snapshot.sku or ''
|
||||||
|
|
||||||
# Автоматически фиксируем цену при создании, если она не указана
|
# Автоматически фиксируем цену при создании, если она не указана
|
||||||
if not self.price:
|
if not self.price:
|
||||||
if self.product:
|
if self.product:
|
||||||
self.price = self.product.price
|
self.price = self.product.actual_price
|
||||||
elif self.product_kit:
|
elif self.kit_snapshot:
|
||||||
self.price = self.product_kit.price
|
self.price = self.kit_snapshot.actual_price
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_total_price(self):
|
def get_total_price(self):
|
||||||
@@ -130,21 +172,15 @@ class OrderItem(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def item_name(self):
|
def item_name(self):
|
||||||
"""Название товара/комплекта"""
|
"""Название из snapshot"""
|
||||||
if self.product:
|
return self.item_name_snapshot
|
||||||
return self.product.name
|
|
||||||
elif self.product_kit:
|
|
||||||
return self.product_kit.name
|
|
||||||
return "Не указано"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def original_price(self):
|
def original_price(self):
|
||||||
"""Оригинальная цена товара/комплекта из каталога"""
|
"""Оригинальная цена из snapshot"""
|
||||||
if self.product:
|
if self.kit_snapshot:
|
||||||
return self.product.actual_price
|
return self.kit_snapshot.actual_price
|
||||||
elif self.product_kit:
|
return self.price
|
||||||
return self.product_kit.actual_price
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def price_difference(self):
|
def price_difference(self):
|
||||||
|
|||||||
@@ -257,6 +257,50 @@ class ProductKit(BaseProductEntity):
|
|||||||
"""Полное удаление из БД (необратимо!)"""
|
"""Полное удаление из БД (необратимо!)"""
|
||||||
super().delete()
|
super().delete()
|
||||||
|
|
||||||
|
def create_snapshot(self):
|
||||||
|
"""
|
||||||
|
Создает снимок текущего состояния комплекта.
|
||||||
|
Используется при добавлении комплекта в заказ для сохранения истории.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
KitSnapshot: Созданный снимок с компонентами
|
||||||
|
"""
|
||||||
|
from orders.models import KitSnapshot, KitItemSnapshot
|
||||||
|
|
||||||
|
# Создаем снимок комплекта
|
||||||
|
snapshot = KitSnapshot.objects.create(
|
||||||
|
original_kit=self,
|
||||||
|
name=self.name,
|
||||||
|
sku=self.sku or '',
|
||||||
|
description=self.description or '',
|
||||||
|
base_price=self.base_price,
|
||||||
|
price=self.price,
|
||||||
|
sale_price=self.sale_price,
|
||||||
|
price_adjustment_type=self.price_adjustment_type,
|
||||||
|
price_adjustment_value=self.price_adjustment_value,
|
||||||
|
is_temporary=self.is_temporary,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаем снимки компонентов
|
||||||
|
for item in self.kit_items.select_related('product', 'variant_group'):
|
||||||
|
product_price = Decimal('0')
|
||||||
|
if item.product:
|
||||||
|
product_price = item.product.actual_price or Decimal('0')
|
||||||
|
elif item.variant_group:
|
||||||
|
product_price = item.variant_group.price or Decimal('0')
|
||||||
|
|
||||||
|
KitItemSnapshot.objects.create(
|
||||||
|
kit_snapshot=snapshot,
|
||||||
|
original_product=item.product, # Сохраняем ссылку для резервирования
|
||||||
|
product_name=item.product.name if item.product else '',
|
||||||
|
product_sku=item.product.sku if item.product else '',
|
||||||
|
product_price=product_price,
|
||||||
|
variant_group_name=item.variant_group.name if item.variant_group else '',
|
||||||
|
quantity=item.quantity or Decimal('1'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
class KitItem(models.Model):
|
class KitItem(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user