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

- Исправлена логика установки useSalePrice при загрузке данных комплекта
- Исправлено сохранение sale_price при снятии чекбокса 'Установить свою цену'
- Исправлено сохранение измененных цен товаров в составе комплекта (unit_price)
- Добавлен блок предупреждения о неактуальных ценах с функцией пересчета
- Улучшена логика агрегации товаров при сохранении комплекта
This commit is contained in:
2026-01-27 23:57:18 +03:00
parent 0839b0a507
commit 17d85e96d6
4 changed files with 199 additions and 41 deletions

View File

@@ -519,14 +519,74 @@ export class ProductManager {
_renderShowcaseKitBadges(card, item, cart) {
// Кнопка редактирования (только если не заблокирован другим)
if (!item.is_locked || item.locked_by_me) {
// Индикатор неактуальной цены (показываем первым, если есть)
if (item.price_outdated) {
const outdatedBadge = document.createElement('button');
outdatedBadge.className = 'btn btn-sm p-0';
outdatedBadge.style.position = 'absolute';
outdatedBadge.style.top = '8px';
outdatedBadge.style.right = '45px';
outdatedBadge.style.zIndex = '10';
outdatedBadge.style.width = '28px';
outdatedBadge.style.height = '28px';
outdatedBadge.style.borderRadius = '50%';
outdatedBadge.style.display = 'flex';
outdatedBadge.style.alignItems = 'center';
outdatedBadge.style.justifyContent = 'center';
outdatedBadge.style.backgroundColor = '#ff6b6b';
outdatedBadge.style.border = '2px solid #fff';
outdatedBadge.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
outdatedBadge.style.cursor = 'pointer';
outdatedBadge.style.transition = 'all 0.2s ease';
outdatedBadge.title = 'Цена неактуальна - требуется обновление';
outdatedBadge.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-white" style="font-size: 14px;"></i>';
outdatedBadge.onmouseenter = function() {
this.style.transform = 'scale(1.1)';
this.style.boxShadow = '0 3px 6px rgba(0,0,0,0.3)';
};
outdatedBadge.onmouseleave = function() {
this.style.transform = 'scale(1)';
this.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
};
outdatedBadge.onclick = (e) => {
e.stopPropagation();
if (window.showcaseManager) {
window.showcaseManager.openEditModal(item.id);
}
};
card.appendChild(outdatedBadge);
}
// Кнопка редактирования (карандаш)
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-sm btn-outline-primary';
editBtn.className = 'btn btn-sm p-0';
editBtn.style.position = 'absolute';
editBtn.style.top = '5px';
editBtn.style.right = '5px';
editBtn.style.top = '8px';
editBtn.style.right = '8px';
editBtn.style.zIndex = '10';
editBtn.innerHTML = '<i class="bi bi-pencil"></i>';
editBtn.style.width = '32px';
editBtn.style.height = '32px';
editBtn.style.borderRadius = '6px';
editBtn.style.display = 'flex';
editBtn.style.alignItems = 'center';
editBtn.style.justifyContent = 'center';
editBtn.style.backgroundColor = '#4dabf7';
editBtn.style.border = '2px solid #fff';
editBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
editBtn.style.cursor = 'pointer';
editBtn.style.transition = 'all 0.2s ease';
editBtn.title = 'Редактировать комплект';
editBtn.innerHTML = '<i class="bi bi-pencil-fill text-white" style="font-size: 14px;"></i>';
editBtn.onmouseenter = function() {
this.style.backgroundColor = '#339af0';
this.style.transform = 'scale(1.05)';
this.style.boxShadow = '0 3px 6px rgba(0,0,0,0.3)';
};
editBtn.onmouseleave = function() {
this.style.backgroundColor = '#4dabf7';
this.style.transform = 'scale(1)';
this.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
};
editBtn.onclick = (e) => {
e.stopPropagation();
if (window.showcaseManager) {
@@ -534,28 +594,6 @@ export class ProductManager {
}
};
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);
}
}
// Индикация блокировки

View File

@@ -85,6 +85,9 @@ export class ShowcaseManager {
document.getElementById('priceAdjustmentValue')?.addEventListener('input', () => this.updatePriceCalculation());
document.getElementById('useSalePrice')?.addEventListener('change', () => this.updatePriceCalculation());
document.getElementById('salePrice')?.addEventListener('input', () => this.updatePriceCalculation());
// Кнопка пересчета цен
document.getElementById('recalculatePricesBtn')?.addEventListener('click', () => this.recalculatePrices());
}
/**
@@ -157,26 +160,44 @@ export class ShowcaseManager {
// Заполняем временную корзину
this.tempCart.clear();
let hasOutdatedPrices = false;
if (kit.items) {
kit.items.forEach(item => {
const key = `product-${item.product_id}-${item.sales_unit_id || 'base'}`;
const actualPrice = item.actual_catalog_price ? parseFloat(item.actual_catalog_price) : null;
const currentPrice = parseFloat(item.price) || 0;
const isOutdated = item.price_outdated || (actualPrice !== null && Math.abs(currentPrice - actualPrice) > 0.01);
if (isOutdated) {
hasOutdatedPrices = true;
}
this.tempCart.set(key, {
id: item.product_id,
name: item.name || item.product_name, // Сервер отдаёт name
price: parseFloat(item.price) || 0,
price: currentPrice,
qty: parseFloat(item.qty || item.quantity) || 1, // Сервер отдаёт qty
type: 'product',
sales_unit_id: item.sales_unit_id,
unit_name: item.unit_name
unit_name: item.unit_name,
actual_catalog_price: actualPrice, // Сохраняем актуальную цену для пересчета
price_outdated: isOutdated
});
});
}
// Показываем блок предупреждения о неактуальных ценах
this.updatePriceOutdatedWarning(hasOutdatedPrices);
// Заполняем цены
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type || 'none';
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value || 0;
document.getElementById('useSalePrice').checked = kit.use_sale_price || false;
document.getElementById('salePrice').value = kit.sale_price || '';
// Если sale_price установлен, автоматически включаем useSalePrice
const salePriceValue = kit.sale_price || '';
const hasSalePrice = salePriceValue && parseFloat(salePriceValue) > 0;
document.getElementById('useSalePrice').checked = hasSalePrice;
document.getElementById('salePrice').value = salePriceValue;
this.renderTempKitItems();
this.updatePriceCalculation();
@@ -363,6 +384,15 @@ export class ShowcaseManager {
// Обновляем базовую цену
document.getElementById('tempKitBasePrice').textContent = formatMoney(totalBasePrice) + ' руб.';
// Проверяем наличие неактуальных цен и обновляем предупреждение
let hasOutdatedPrices = false;
this.tempCart.forEach((item) => {
if (item.price_outdated || (item.actual_catalog_price !== null && item.actual_catalog_price !== undefined && Math.abs(item.price - item.actual_catalog_price) > 0.01)) {
hasOutdatedPrices = true;
}
});
this.updatePriceOutdatedWarning(hasOutdatedPrices);
}
/**
@@ -416,6 +446,50 @@ export class ShowcaseManager {
document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice);
}
/**
* Обновляет отображение предупреждения о неактуальных ценах
* @param {boolean} hasOutdated - Есть ли неактуальные цены
*/
updatePriceOutdatedWarning(hasOutdated) {
const warningBlock = document.getElementById('priceOutdatedWarning');
if (warningBlock) {
warningBlock.style.display = hasOutdated ? 'block' : 'none';
}
}
/**
* Пересчитывает цены товаров на актуальные из каталога
*/
recalculatePrices() {
let updated = false;
this.tempCart.forEach((item, key) => {
if (item.actual_catalog_price !== null && item.actual_catalog_price !== undefined) {
const oldPrice = item.price;
const newPrice = item.actual_catalog_price;
if (Math.abs(oldPrice - newPrice) > 0.01) {
item.price = newPrice;
item.price_outdated = false;
updated = true;
}
}
});
if (updated) {
// Перерисовываем список товаров и пересчитываем цены
this.renderTempKitItems();
this.updatePriceCalculation();
// Скрываем предупреждение
this.updatePriceOutdatedWarning(false);
showToast('success', 'Цены обновлены на актуальные из каталога');
} else {
showToast('info', 'Все цены уже актуальны');
}
}
/**
* Обрабатывает загрузку фото
*/
@@ -462,6 +536,8 @@ export class ShowcaseManager {
document.getElementById('useSalePrice').checked = false;
document.getElementById('salePrice').value = '';
this.removePhoto();
// Скрываем предупреждение о неактуальных ценах
this.updatePriceOutdatedWarning(false);
}
/**
@@ -537,8 +613,9 @@ export class ShowcaseManager {
formData.append('showcase_created_at', data.createdAt || '');
formData.append('price_adjustment_type', data.adjustmentType);
formData.append('price_adjustment_value', data.adjustmentValue);
formData.append('use_sale_price', data.useSalePrice);
formData.append('sale_price', data.salePrice || 0);
formData.append('use_sale_price', data.useSalePrice ? '1' : '0');
// Если useSalePrice выключен, отправляем пустую строку для явной очистки sale_price на сервере
formData.append('sale_price', data.useSalePrice ? (data.salePrice || '') : '');
if (!this.isEditMode) {
formData.append('quantity', data.quantity);
@@ -550,7 +627,7 @@ export class ShowcaseManager {
items.push({
product_id: item.id,
quantity: item.qty,
price: item.price,
unit_price: item.price, // Используем unit_price для сохранения измененной цены товара
sales_unit_id: item.sales_unit_id || null
});
});

View File

@@ -270,6 +270,20 @@
<strong>Ценообразование</strong>
</div>
<div class="card-body">
<!-- Предупреждение о неактуальных ценах -->
<div class="alert alert-warning mb-2" id="priceOutdatedWarning" style="display: none;">
<div class="d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Цены товаров неактуальны</strong>
<div class="small mt-1">Некоторые товары имеют устаревшие цены. Рекомендуется пересчитать.</div>
</div>
<button type="button" class="btn btn-sm btn-warning" id="recalculatePricesBtn">
<i class="bi bi-arrow-clockwise"></i> Пересчитать
</button>
</div>
</div>
<!-- Базовая цена -->
<div class="mb-2">
<small class="text-muted">Базовая цена (сумма компонентов):</small>

View File

@@ -139,13 +139,14 @@ def get_showcase_kits_for_pos():
status='available'
).values_list('id', flat=True))
# Определяем актуальную цену
# Определяем актуальную цену продажи (sale_price имеет приоритет)
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
# Сравниваем цену продажи с актуальной ценой компонентов
# Если разница больше 0.01, значит цена неактуальна
price_outdated = actual_price > 0 and abs(float(price) - float(actual_price)) > 0.01
showcase_kits.append({
'id': kit_id,
@@ -1030,9 +1031,15 @@ def get_product_kit_details(request, kit_id):
'qty': str(ki.quantity),
'price': str(item_price)
}
# Для временных комплектов добавляем актуальную цену из каталога для сравнения
if kit.is_temporary and ki.unit_price is not None:
# Для временных комплектов всегда добавляем актуальную цену из каталога для сравнения
if kit.is_temporary:
item_data['actual_catalog_price'] = str(ki.product.actual_price)
# Проверяем, неактуальна ли цена (если unit_price установлен и отличается от actual_price)
if ki.unit_price is not None:
price_diff = abs(float(ki.unit_price) - float(ki.product.actual_price))
item_data['price_outdated'] = price_diff > 0.01
else:
item_data['price_outdated'] = False
items.append(item_data)
# Фото (используем миниатюру для быстрой загрузки)
@@ -1099,15 +1106,23 @@ def create_temp_kit_to_showcase(request):
# Парсим items из JSON
items = json.loads(items_json)
# Получаем флаг use_sale_price для явной очистки sale_price
use_sale_price = request.POST.get('use_sale_price', '0') == '1'
# Sale price (опционально)
sale_price = None
if sale_price_str:
# Если use_sale_price = True, обрабатываем sale_price_str
# Если use_sale_price = False, явно устанавливаем sale_price = None
if use_sale_price and sale_price_str:
try:
sale_price = Decimal(str(sale_price_str))
if sale_price <= 0:
sale_price = None
except (ValueError, InvalidOperation):
sale_price = None
else:
# Явно очищаем sale_price, если чекбокс выключен
sale_price = None
# Showcase created at (опционально)
showcase_created_at = None
@@ -1174,6 +1189,9 @@ def create_temp_kit_to_showcase(request):
if product_id in aggregated_items:
aggregated_items[product_id]['quantity'] += quantity
# Если unit_price не был установлен ранее, но есть в текущем элементе, устанавливаем его
if aggregated_items[product_id]['unit_price'] is None and unit_price is not None:
aggregated_items[product_id]['unit_price'] = Decimal(str(unit_price))
else:
aggregated_items[product_id] = {
'quantity': quantity,
@@ -1333,14 +1351,22 @@ def update_product_kit(request, kit_id):
items = json.loads(items_json)
# Получаем флаг use_sale_price для явной очистки sale_price
use_sale_price = request.POST.get('use_sale_price', '0') == '1'
sale_price = None
if sale_price_str:
# Если use_sale_price = True, обрабатываем sale_price_str
# Если use_sale_price = False, явно устанавливаем sale_price = None
if use_sale_price and sale_price_str:
try:
sale_price = Decimal(str(sale_price_str))
if sale_price <= 0:
sale_price = None
except (ValueError, InvalidOperation):
sale_price = None
else:
# Явно очищаем sale_price, если чекбокс выключен
sale_price = None
# Showcase created at (опционально)
showcase_created_at = None
@@ -1381,6 +1407,9 @@ def update_product_kit(request, kit_id):
unit_price = item.get('unit_price')
if product_id in aggregated_items:
aggregated_items[product_id]['quantity'] += quantity
# Если unit_price не был установлен ранее, но есть в текущем элементе, устанавливаем его
if aggregated_items[product_id]['unit_price'] is None and unit_price is not None:
aggregated_items[product_id]['unit_price'] = Decimal(str(unit_price))
else:
aggregated_items[product_id] = {
'quantity': quantity,