From 7b32cdcebf4021d17c29b75e8c0323ea4469ec87 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 18 Dec 2025 00:14:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8:=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=88=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D0=B0=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0,=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=D0=BD=D0=B2=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D1=8F,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BD=D0=B8=D0=BC=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B2=20=D0=B8=20=D1=8D=D0=BB=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0,=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/customers/customer_detail.html | 24 ++- myproject/inventory/signals.py | 187 +++++++++++++----- .../orders/migrations/0007_kit_snapshots.py | 76 +++++++ .../migrations/0008_add_item_snapshots.py | 39 ++++ ...d_original_product_to_kit_item_snapshot.py | 20 ++ myproject/orders/models/__init__.py | 3 + myproject/orders/models/kit_snapshot.py | 163 +++++++++++++++ myproject/orders/models/order_item.py | 78 ++++++-- myproject/products/models/kits.py | 44 +++++ 9 files changed, 547 insertions(+), 87 deletions(-) create mode 100644 myproject/orders/migrations/0007_kit_snapshots.py create mode 100644 myproject/orders/migrations/0008_add_item_snapshots.py create mode 100644 myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py create mode 100644 myproject/orders/models/kit_snapshot.py diff --git a/myproject/customers/templates/customers/customer_detail.html b/myproject/customers/templates/customers/customer_detail.html index 005304b..548757e 100644 --- a/myproject/customers/templates/customers/customer_detail.html +++ b/myproject/customers/templates/customers/customer_detail.html @@ -260,24 +260,22 @@
-
- + + Новый заказ +
diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 30ffe12..23f0d68 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -59,10 +59,13 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs): Процесс: 1. Проверяем, новый ли заказ (создан только что) - 2. Для каждого товара в заказе создаем Reservation - 3. Статус резерва = 'reserved' - 4. Проверяем на существующие резервы (защита от дубликатов) + 2. Для обычных товаров - создаём резерв напрямую + 3. Для комплектов - резервируем компоненты (группируя одинаковые товары) + 4. Статус резерва = 'reserved' + 5. Проверяем на существующие резервы (защита от дубликатов) """ + from collections import defaultdict + if not created: return # Только для новых заказов @@ -76,33 +79,63 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs): # Для каждого товара в заказе for item in instance.items.all(): - # Определяем товар (может быть product или product_kit) - product = item.product if item.product else item.product_kit + if item.product: + # Обычный товар - резервируем как раньше + _create_or_update_reservation(item, item.product, warehouse, Decimal(str(item.quantity))) - if product: - # ЗАЩИТА ОТ ДУБЛИКАТОВ: Проверяем, нет ли уже резерва для этой позиции - existing_reservation = Reservation.objects.filter( - order_item=item, - product=product, - warehouse=warehouse - ).first() + elif item.product_kit and item.kit_snapshot: + # Комплект - резервируем КОМПОНЕНТЫ из снимка + # Группируем одинаковые товары для создания одного резерва + product_quantities = defaultdict(Decimal) - if existing_reservation: - # Резерв уже существует - обновляем его вместо создания нового - existing_reservation.quantity = Decimal(str(item.quantity)) - existing_reservation.status = 'reserved' - existing_reservation.save() - else: - # Резерва нет - создаем новый - Reservation.objects.create( - order_item=item, - product=product, - warehouse=warehouse, - quantity=Decimal(str(item.quantity)), - status='reserved' + for kit_item in item.kit_snapshot.items.select_related('original_product'): + if kit_item.original_product: + # Суммируем количество: qty компонента * qty комплектов в заказе + product_quantities[kit_item.original_product_id] += ( + kit_item.quantity * Decimal(str(item.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( + 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) @transaction.atomic 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 # Для витринных комплектов не создаем новые резервы # Обычный товар или постоянный комплект - # Ищем резерв для этой позиции в ЛЮБОМ статусе (не только 'reserved') + # Ищем резервы для этой позиции в ЛЮБОМ статусе (не только 'reserved') # КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов - reservation = Reservation.objects.filter( - order_item=instance - ).first() + reservations = Reservation.objects.filter(order_item=instance) + + 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( - 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() - if warehouse: - product = instance.product + if not warehouse: + logger.warning(f"⚠ Не найден склад для резервирования (OrderItem #{instance.id})") + return + + if instance.product: + # Обычный товар - создаем один резерв reservation = Reservation.objects.create( order_item=instance, - product=product, + product=instance.product, warehouse=warehouse, quantity=Decimal(str(instance.quantity)), status='reserved' ) - + 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})" ) diff --git a/myproject/orders/migrations/0007_kit_snapshots.py b/myproject/orders/migrations/0007_kit_snapshots.py new file mode 100644 index 0000000..0376352 --- /dev/null +++ b/myproject/orders/migrations/0007_kit_snapshots.py @@ -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'), + ), + ] diff --git a/myproject/orders/migrations/0008_add_item_snapshots.py b/myproject/orders/migrations/0008_add_item_snapshots.py new file mode 100644 index 0000000..1bf46f3 --- /dev/null +++ b/myproject/orders/migrations/0008_add_item_snapshots.py @@ -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='Снимок комплекта'), + ), + ] diff --git a/myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py b/myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py new file mode 100644 index 0000000..1f68f20 --- /dev/null +++ b/myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py @@ -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='Оригинальный товар'), + ), + ] diff --git a/myproject/orders/models/__init__.py b/myproject/orders/models/__init__.py index caf26b5..e93ed7c 100644 --- a/myproject/orders/models/__init__.py +++ b/myproject/orders/models/__init__.py @@ -22,6 +22,7 @@ from .address import Address from .order import Order # 4. Зависимые модели +from .kit_snapshot import KitSnapshot, KitItemSnapshot from .order_item import OrderItem from .transaction import Transaction @@ -32,4 +33,6 @@ __all__ = [ 'OrderItem', 'PaymentMethod', 'Transaction', + 'KitSnapshot', + 'KitItemSnapshot', ] diff --git a/myproject/orders/models/kit_snapshot.py b/myproject/orders/models/kit_snapshot.py new file mode 100644 index 0000000..1957338 --- /dev/null +++ b/myproject/orders/models/kit_snapshot.py @@ -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 diff --git a/myproject/orders/models/order_item.py b/myproject/orders/models/order_item.py index e2782d0..7b46d29 100644 --- a/myproject/orders/models/order_item.py +++ b/myproject/orders/models/order_item.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from products.models import Product, ProductKit from simple_history.models import HistoricalRecords from .order import Order +from .kit_snapshot import KitSnapshot class OrderItem(models.Model): @@ -36,6 +37,30 @@ class OrderItem(models.Model): 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( default=1, verbose_name="Количество" @@ -92,12 +117,7 @@ class OrderItem(models.Model): ] def __str__(self): - item_name = "" - 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}" + return f"{self.item_name_snapshot} x{self.quantity} в заказе #{self.order.order_number}" def clean(self): """Валидация модели""" @@ -116,12 +136,34 @@ class OrderItem(models.Model): ) 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 self.product: - self.price = self.product.price - elif self.product_kit: - self.price = self.product_kit.price + self.price = self.product.actual_price + elif self.kit_snapshot: + self.price = self.kit_snapshot.actual_price + super().save(*args, **kwargs) def get_total_price(self): @@ -130,21 +172,15 @@ class OrderItem(models.Model): @property def item_name(self): - """Название товара/комплекта""" - if self.product: - return self.product.name - elif self.product_kit: - return self.product_kit.name - return "Не указано" + """Название из snapshot""" + return self.item_name_snapshot @property def original_price(self): - """Оригинальная цена товара/комплекта из каталога""" - if self.product: - return self.product.actual_price - elif self.product_kit: - return self.product_kit.actual_price - return None + """Оригинальная цена из snapshot""" + if self.kit_snapshot: + return self.kit_snapshot.actual_price + return self.price @property def price_difference(self): diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 8070f3e..6a94fa7 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -257,6 +257,50 @@ class ProductKit(BaseProductEntity): """Полное удаление из БД (необратимо!)""" 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): """