feat(pos): фиксировать цены товаров в витринных комплектах

- Добавлено поле KitItem.unit_price для хранения зафиксированной цены
- Витринные комплекты больше не обновляются при изменении цен товаров
- Добавлен красный индикатор на карточке если цена неактуальна
- Добавлен warning в модалке редактирования с кнопкой "Актуализировать"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 15:59:44 +03:00
parent 392471ff06
commit 2778796118
6 changed files with 195 additions and 19 deletions

View File

@@ -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 # Товар не используется в комплектах

View File

@@ -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'
});
@@ -1890,12 +1913,92 @@ 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);
alert('Ошибка при загрузке комплекта');
}
}
// Проверка актуальности цен в витринном комплекте
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 = `
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-2 mt-1"></i>
<div class="flex-grow-1">
<strong>Цена неактуальна!</strong><br>
<small class="text-muted">При сохранении комплекта было: <strong>${formatMoney(oldTotalPrice)} руб.</strong></small><br>
<small class="text-muted">Актуальная цена сейчас: <strong>${formatMoney(newTotalPrice)} руб.</strong></small>
<button type="button" class="btn btn-sm btn-warning mt-2" onclick="actualizeKitPrices()">
<i class="bi bi-arrow-clockwise"></i> Пересчитать по актуальным ценам
</button>
</div>
<button type="button" class="btn-close flex-shrink-0" data-bs-dismiss="alert"></button>
`;
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 {

View File

@@ -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
@@ -956,12 +977,21 @@ def get_product_kit_details(request, kit_id):
showcase_id = showcase_reservation.showcase.id if showcase_reservation else None
# Собираем данные о составе
items = [{
# Используем 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(ki.product.actual_price)
} for ki in kit.kit_items.all()]
'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
@@ -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()

View File

@@ -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='Цена за единицу (зафиксированная)'),
),
]

View File

@@ -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 = "Компонент комплекта"

View File

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