From 27787961186dae2c2cc647a7049d27ec4385b785 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 19 Jan 2026 15:59:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(pos):=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=86=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2=20=D0=B2=20=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено поле KitItem.unit_price для хранения зафиксированной цены - Витринные комплекты больше не обновляются при изменении цен товаров - Добавлен красный индикатор на карточке если цена неактуальна - Добавлен warning в модалке редактирования с кнопкой "Актуализировать" Co-Authored-By: Claude Opus 4.5 --- myproject/inventory/signals.py | 9 +- myproject/pos/static/pos/js/terminal.js | 103 ++++++++++++++++++ myproject/pos/views.py | 60 +++++++--- .../0004_add_unit_price_to_kit_item.py | 18 +++ myproject/products/models/kits.py | 20 +++- myproject/products/services/kit_service.py | 4 + 6 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 myproject/products/migrations/0004_add_unit_price_to_kit_item.py diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 6225674..0ee4c7a 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -1801,8 +1801,13 @@ def update_kit_prices_on_product_change(sender, instance, created, **kwargs): if created: return - # Находим все KitItem с этим товаром - kit_items = KitItem.objects.filter(product=instance) + # Находим все KitItem с этим товаром, исключая временные (витринные) комплекты + # Витринные комплекты имеют зафиксированную цену и не должны обновляться автоматически + kit_items = KitItem.objects.filter( + product=instance + ).select_related('kit').exclude( + kit__is_temporary=True + ) if not kit_items.exists(): return # Товар не используется в комплектах diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index b90bfb1..d7963fa 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -859,6 +859,28 @@ function renderProducts() { openEditKitModal(item.id); }; card.appendChild(editBtn); + + // Индикатор неактуальной цены (красный кружок) + if (item.price_outdated) { + const outdatedBadge = document.createElement('div'); + outdatedBadge.className = 'badge bg-danger'; + outdatedBadge.style.position = 'absolute'; + outdatedBadge.style.top = '5px'; + outdatedBadge.style.right = '45px'; + outdatedBadge.style.zIndex = '10'; + outdatedBadge.style.width = '18px'; + outdatedBadge.style.height = '18px'; + outdatedBadge.style.padding = '0'; + outdatedBadge.style.borderRadius = '50%'; + outdatedBadge.style.display = 'flex'; + outdatedBadge.style.alignItems = 'center'; + outdatedBadge.style.justifyContent = 'center'; + outdatedBadge.style.fontSize = '10px'; + outdatedBadge.style.minWidth = '18px'; + outdatedBadge.title = 'Цена неактуальна'; + outdatedBadge.innerHTML = '!'; + card.appendChild(outdatedBadge); + } } } @@ -1805,6 +1827,7 @@ async function openEditKitModal(kitId) { id: item.product_id, name: item.name, price: Number(item.price), + actual_catalog_price: item.actual_catalog_price ? Number(item.actual_catalog_price) : Number(item.price), qty: Number(item.qty), type: 'product' }); @@ -1889,6 +1912,9 @@ async function openEditKitModal(kitId) { // Открываем модальное окно const modal = new bootstrap.Modal(document.getElementById('createTempKitModal')); modal.show(); + + // Проверяем актуальность цен (сразу после открытия) + checkPricesActual(); } catch (error) { console.error('Error loading kit for edit:', error); @@ -1896,6 +1922,83 @@ async function openEditKitModal(kitId) { } } +// Проверка актуальности цен в витринном комплекте +function checkPricesActual() { + // Удаляем старый warning если есть + const existingWarning = document.getElementById('priceOutdatedWarning'); + if (existingWarning) existingWarning.remove(); + + // Проверяем цены используя actual_catalog_price из tempCart (уже загружен с бэкенда) + const outdatedItems = []; + let oldTotalPrice = 0; + let newTotalPrice = 0; + + tempCart.forEach((item, cartKey) => { + if (item.type === 'product' && item.actual_catalog_price !== undefined) { + const savedPrice = parseFloat(item.price); + const actualPrice = parseFloat(item.actual_catalog_price); + const qty = parseFloat(item.qty) || 1; + + if (Math.abs(savedPrice - actualPrice) > 0.01) { + oldTotalPrice += savedPrice * qty; + newTotalPrice += actualPrice * qty; + outdatedItems.push({ + name: item.name, + old: savedPrice, + new: actualPrice, + qty: qty + }); + } + } + }); + + if (outdatedItems.length > 0) { + showPriceOutdatedWarning(oldTotalPrice, newTotalPrice); + } +} + +// Показать warning о неактуальных ценах +function showPriceOutdatedWarning(oldTotalPrice, newTotalPrice) { + const modalBody = document.querySelector('#createTempKitModal .modal-body'); + + const warning = document.createElement('div'); + warning.id = 'priceOutdatedWarning'; + warning.className = 'alert alert-warning alert-dismissible fade show d-flex align-items-start'; + warning.innerHTML = ` + +
+ Цена неактуальна!
+ При сохранении комплекта было: ${formatMoney(oldTotalPrice)} руб.
+ Актуальная цена сейчас: ${formatMoney(newTotalPrice)} руб. + +
+ + `; + + modalBody.insertBefore(warning, modalBody.firstChild); +} + +// Актуализировать цены в комплекте +function actualizeKitPrices() { + tempCart.forEach((item) => { + if (item.type === 'product' && item.actual_catalog_price !== undefined) { + item.price = item.actual_catalog_price; + // Удаляем actual_catalog_price чтобы не показывался warning снова + delete item.actual_catalog_price; + } + }); + + // Перерисовываем товары и пересчитываем цену + renderTempKitItems(); + updatePriceCalculations(); + + // Убираем warning + const warning = document.getElementById('priceOutdatedWarning'); + if (warning) warning.remove(); +} + // Обновление списка витринных комплектов async function loadShowcaseKits() { try { diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 31a2bba..e8b2535 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -81,6 +81,7 @@ def get_showcase_kits_for_pos(): 'product_kit__sku', 'product_kit__price', 'product_kit__sale_price', + 'product_kit__base_price', 'showcase_id', 'showcase__name' ).annotate( @@ -109,6 +110,19 @@ def get_showcase_kits_for_pos(): thumbnail_url = None kit_photos[photo.kit_id] = thumbnail_url + # Загружаем состав комплектов для проверки актуальности цен + kit_items_data = {} + for ki in KitItem.objects.filter(kit_id__in=kit_ids).select_related('product'): + if ki.kit_id not in kit_items_data: + kit_items_data[ki.kit_id] = [] + kit_items_data[ki.kit_id].append(ki) + + # Считаем актуальные цены для каждого комплекта + kit_actual_prices = {} + for kit_id, items in kit_items_data.items(): + actual_price = sum((ki.product.actual_price or 0) * (ki.quantity or 0) for ki in items) + kit_actual_prices[kit_id] = actual_price + # Формируем результат showcase_kits = [] for item in all_items: @@ -125,6 +139,11 @@ def get_showcase_kits_for_pos(): # Определяем актуальную цену price = item['product_kit__sale_price'] or item['product_kit__price'] + # Проверяем актуальность цены (сравниваем сохранённую цену с актуальной ценой товаров) + actual_price = kit_actual_prices.get(kit_id, Decimal('0')) + base_price = item['product_kit__base_price'] + price_outdated = base_price and abs(float(base_price) - float(actual_price)) > 0.01 + showcase_kits.append({ 'id': kit_id, 'name': item['product_kit__name'], @@ -139,7 +158,9 @@ def get_showcase_kits_for_pos(): # Количества 'available_count': item['available_count'], # Сколько можно добавить 'total_count': item['total_count'], # Всего на витрине (включая в корзине) - 'showcase_item_ids': available_item_ids # IDs только доступных + 'showcase_item_ids': available_item_ids, # IDs только доступных + # Флаг неактуальной цены + 'price_outdated': price_outdated }) return showcase_kits @@ -954,15 +975,24 @@ def get_product_kit_details(request, kit_id): ).select_related('showcase').first() showcase_id = showcase_reservation.showcase.id if showcase_reservation else None - + # Собираем данные о составе - items = [{ - 'product_id': ki.product.id, - 'name': ki.product.name, - 'qty': str(ki.quantity), - 'price': str(ki.product.actual_price) - } for ki in kit.kit_items.all()] - + # Используем unit_price если есть (зафиксированная цена), иначе актуальную цену товара + items = [] + for ki in kit.kit_items.all(): + # Зафиксированная цена или актуальная цена товара + item_price = ki.unit_price if ki.unit_price is not None else ki.product.actual_price + item_data = { + 'product_id': ki.product.id, + 'name': ki.product.name, + 'qty': str(ki.quantity), + 'price': str(item_price) + } + # Для временных комплектов добавляем актуальную цену из каталога для сравнения + if kit.is_temporary and ki.unit_price is not None: + item_data['actual_catalog_price'] = str(ki.product.actual_price) + items.append(item_data) + # Фото (используем миниатюру для быстрой загрузки) photo_url = None if kit.photos.exists(): @@ -1100,10 +1130,12 @@ def create_temp_kit_to_showcase(request): # 2. Создаём KitItem для каждого товара из корзины for product_id, quantity in aggregated_items.items(): + product = products[product_id] KitItem.objects.create( kit=kit, - product=products[product_id], - quantity=quantity + product=product, + quantity=quantity, + unit_price=product.actual_price # Фиксируем цену для временного комплекта ) # 3. Пересчитываем цену комплекта @@ -1318,10 +1350,12 @@ def update_product_kit(request, kit_id): # Обновляем состав kit.kit_items.all().delete() for product_id, quantity in aggregated_items.items(): + product = products[product_id] KitItem.objects.create( kit=kit, - product=products[product_id], - quantity=quantity + product=product, + quantity=quantity, + unit_price=product.actual_price # Фиксируем актуальную цену ) kit.recalculate_base_price() diff --git a/myproject/products/migrations/0004_add_unit_price_to_kit_item.py b/myproject/products/migrations/0004_add_unit_price_to_kit_item.py new file mode 100644 index 0000000..c4e9472 --- /dev/null +++ b/myproject/products/migrations/0004_add_unit_price_to_kit_item.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.10 on 2026-01-19 12:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0003_remove_unit_from_sales_unit'), + ] + + operations = [ + migrations.AddField( + model_name='kititem', + name='unit_price', + field=models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, используется эта цена вместо актуальной цены товара. Применяется для временных витринных комплектов.', max_digits=10, null=True, verbose_name='Цена за единицу (зафиксированная)'), + ), + ] diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 70ce706..ea3a086 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -162,13 +162,17 @@ class ProductKit(BaseProductEntity): total = Decimal('0') for item in self.kit_items.all(): + qty = item.quantity or Decimal('1') if item.product: - actual_price = item.product.actual_price or Decimal('0') - qty = item.quantity or Decimal('1') - total += actual_price * qty + # Используем зафиксированную цену если есть, иначе актуальную цену товара + if item.unit_price is not None: + unit_price = item.unit_price + else: + unit_price = item.product.actual_price or Decimal('0') + total += unit_price * qty elif item.variant_group: + # Для variant_group unit_price не используется (только для продуктов) actual_price = item.variant_group.price or Decimal('0') - qty = item.quantity or Decimal('1') total += actual_price * qty self.base_price = total @@ -395,6 +399,14 @@ class KitItem(models.Model): verbose_name="Группа вариантов" ) quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество") + unit_price = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + verbose_name="Цена за единицу (зафиксированная)", + help_text="Если задана, используется эта цена вместо актуальной цены товара. Применяется для временных витринных комплектов." + ) class Meta: verbose_name = "Компонент комплекта" diff --git a/myproject/products/services/kit_service.py b/myproject/products/services/kit_service.py index bce3a5c..99a9f96 100644 --- a/myproject/products/services/kit_service.py +++ b/myproject/products/services/kit_service.py @@ -111,6 +111,10 @@ def make_kit_permanent(kit: ProductKit) -> bool: kit.is_temporary = False kit.order = None # Отвязываем от заказа kit.save() + + # Очищаем зафиксированные цены - теперь будет использоваться актуальная цена товаров + kit.kit_items.update(unit_price=None) + return True