From f75e861bb84e6422aef63ac8ecef2db5c4dc5884 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 25 Jan 2026 15:26:57 +0300 Subject: [PATCH] feat: Add new inventory and POS components, including a script to reproduce a POS checkout sale price bug. --- .../inventory/services/showcase_manager.py | 21 +- myproject/inventory/signals.py | 81 +++- myproject/myproject/settings.py | 2 + myproject/pos/static/pos/js/terminal.js | 403 +++++++++--------- myproject/reproduce_issue.py | 120 ++++++ 5 files changed, 410 insertions(+), 217 deletions(-) create mode 100644 myproject/reproduce_issue.py diff --git a/myproject/inventory/services/showcase_manager.py b/myproject/inventory/services/showcase_manager.py index 6e09493..422596f 100644 --- a/myproject/inventory/services/showcase_manager.py +++ b/myproject/inventory/services/showcase_manager.py @@ -209,16 +209,19 @@ class ShowcaseManager: reservation.order_item = order_item reservation.save() - # Теперь создаём продажу с правильной ценой из OrderItem - SaleProcessor.create_sale_from_reservation( - reservation=reservation, - order=order - ) + # ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'. + # Это сделает сигнал create_sale_on_order_completion автоматически. + # Таким образом обеспечивается единая точка создания продаж для всех типов товаров. - # Обновляем статус резерва - reservation.status = 'converted_to_sale' - reservation.converted_at = timezone.now() - reservation.save() + # SaleProcessor.create_sale_from_reservation( + # reservation=reservation, + # order=order + # ) + + # Статус резерва остается 'reserved', чтобы сигнал его увидел + # reservation.status = 'converted_to_sale' + # reservation.converted_at = timezone.now() + # reservation.save() sold_count += 1 diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index ec2f4e4..ff476e7 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -366,7 +366,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): # === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE === # Проверяем, есть ли уже Sale для этого заказа if Sale.objects.filter(order=instance).exists(): - logger.info(f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем") + logger.info(f"Заказ {instance.order_number}: Sale уже существуют, пропускаем") update_is_returned_flag(instance) return @@ -376,7 +376,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): previous_status = getattr(instance, '_previous_status', None) if previous_status and previous_status.is_positive_end: logger.info( - f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус " + f"Заказ {instance.order_number}: повторный переход в положительный статус " f"({previous_status.name} → {instance.status.name}). Проверяем Sale..." ) if Sale.objects.filter(order=instance).exists(): @@ -454,13 +454,66 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): ) continue + # === РАСЧЕТ ЦЕНЫ === + # Рассчитываем фактическую стоимость продажи всего комплекта с учетом скидок + # 1. Базовая стоимость позиции + item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity)) + + # 2. Скидки + item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0') + + # Скидка на заказ (распределенная) + instance.refresh_from_db() + order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0') + delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0') + order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0') + + if order_total > 0 and order_discount_amount > 0: + item_order_discount = order_discount_amount * (item_subtotal / order_total) + else: + item_order_discount = Decimal('0') + + kit_net_total = item_subtotal - item_discount - item_order_discount + if kit_net_total < 0: + kit_net_total = Decimal('0') + + # 3. Суммарная каталожная стоимость всех компонентов (для пропорции) + total_catalog_price = Decimal('0') + for reservation in kit_reservations: + qty = reservation.quantity_base or reservation.quantity + price = reservation.product.actual_price or Decimal('0') + total_catalog_price += price * qty + + # 4. Коэффициент распределения + if total_catalog_price > 0: + ratio = kit_net_total / total_catalog_price + else: + # Если каталожная цена 0, распределяем просто по количеству или 0 + ratio = Decimal('0') + # Создаем Sale для каждого компонента комплекта for reservation in kit_reservations: try: - # Рассчитываем цену продажи компонента пропорционально цене комплекта - # Используем actual_price компонента как цену продажи - component_sale_price = reservation.product.actual_price + # Рассчитываем цену продажи компонента пропорционально + catalog_price = reservation.product.actual_price or Decimal('0') + if ratio > 0: + # Распределяем реальную выручку + component_sale_price = catalog_price * ratio + else: + # Если выручка 0 или каталожные цены 0 + if total_catalog_price == 0 and kit_net_total > 0: + # Крайний случай: товаров на 0 руб, а продали за деньги (услуга?) + # Распределяем равномерно + count = kit_reservations.count() + component_qty = reservation.quantity_base or reservation.quantity + if count > 0 and component_qty > 0: + component_sale_price = (kit_net_total / count) / component_qty + else: + component_sale_price = Decimal('0') + else: + component_sale_price = Decimal('0') + sale = SaleProcessor.create_sale( product=reservation.product, warehouse=warehouse, @@ -472,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): sales_created.append(sale) logger.info( f"✓ Sale создан для компонента комплекта '{kit.name}': " - f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. (базовых единиц)" + f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. " + f"(цена: {component_sale_price})" ) except ValueError as e: logger.error( @@ -548,6 +602,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): else: base_price = price_with_discount + # LOGGING DEBUG INFO + # print(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):") + # print(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}") + # print(f" Item Discount: {item_discount}, Order Discount Share: {item_order_discount}, Total Discount: {total_discount}") + # print(f" Price w/ Discount: {price_with_discount}") + # print(f" Sales Unit: {item.sales_unit}, Conversion: {item.conversion_factor_snapshot}") + # print(f" FINAL BASE PRICE: {base_price}") + # print(f" Sales Unit Object: {item.sales_unit}") + # if item.sales_unit: + # print(f" Sales Unit Conversion: {item.sales_unit.conversion_factor}") + + logger.info(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):") + logger.info(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}") + logger.info(f" FINAL BASE PRICE: {base_price}") + # Создаем Sale (с автоматическим FIFO-списанием) sale = SaleProcessor.create_sale( product=product, diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 22e9ce1..2e13230 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY: "ENCRYPTION_KEY not set! Encrypted fields will fail. " "Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" ) + + diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 0665b26..f263565 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -72,15 +72,15 @@ function saveCartToRedis() { }, body: JSON.stringify({ cart: cartObj }) }) - .then(response => response.json()) - .then(data => { - if (!data.success) { - console.error('Ошибка сохранения корзины:', data.error); - } - }) - .catch(error => { - console.error('Ошибка при сохранении корзины в Redis:', error); - }); + .then(response => response.json()) + .then(data => { + if (!data.success) { + console.error('Ошибка сохранения корзины:', data.error); + } + }) + .catch(error => { + console.error('Ошибка при сохранении корзины в Redis:', error); + }); }, 500); // Debounce 500ms } @@ -160,7 +160,7 @@ function updateCustomerDisplay() { // Обновляем видимость кнопок сброса (в корзине и в модалке продажи) [document.getElementById('resetCustomerBtn'), - document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => { + document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => { if (resetBtn) { resetBtn.style.display = isSystemCustomer ? 'none' : 'block'; } @@ -242,18 +242,18 @@ function selectCustomer(customerId, customerName, walletBalance = 0) { 'Content-Type': 'application/json' } }) - .then(response => response.json()) - .then(data => { - if (!data.success) { - console.error('Ошибка сохранения клиента:', data.error); - } else { - // Обновляем баланс из ответа сервера - selectedCustomer.wallet_balance = data.wallet_balance || 0; - } - }) - .catch(error => { - console.error('Ошибка при сохранении клиента в Redis:', error); - }); + .then(response => response.json()) + .then(data => { + if (!data.success) { + console.error('Ошибка сохранения клиента:', data.error); + } else { + // Обновляем баланс из ответа сервера + selectedCustomer.wallet_balance = data.wallet_balance || 0; + } + }) + .catch(error => { + console.error('Ошибка при сохранении клиента в Redis:', error); + }); } /** @@ -272,12 +272,12 @@ function initCustomerSelect2() { url: '/customers/api/search/', dataType: 'json', delay: 300, - data: function(params) { + data: function (params) { return { q: params.term }; }, - processResults: function(data) { + processResults: function (data) { return { results: data.results }; @@ -289,7 +289,7 @@ function initCustomerSelect2() { }); // Обработка выбора клиента из списка - $searchInput.on('select2:select', function(e) { + $searchInput.on('select2:select', function (e) { const data = e.params.data; // Проверяем это не опция "Создать нового клиента" @@ -439,40 +439,40 @@ async function createNewCustomer() { */ async function openProductUnitModal(product) { unitModalProduct = product; - + // Устанавливаем название товара - document.getElementById('unitModalProductName').textContent = + document.getElementById('unitModalProductName').textContent = `${product.name}${product.sku ? ' (' + product.sku + ')' : ''}`; - + // Загружаем единицы продажи try { const response = await fetch( `/products/api/products/${product.id}/sales-units/?warehouse=${currentWarehouse.id}` ); const data = await response.json(); - + if (!data.success || !data.sales_units || data.sales_units.length === 0) { alert('Не удалось загрузить единицы продажи'); return; } - + unitModalSalesUnits = data.sales_units; - + // Отрисовываем список единиц renderUnitSelectionList(); - + // Выбираем единицу по умолчанию или первую const defaultUnit = unitModalSalesUnits.find(u => u.is_default) || unitModalSalesUnits[0]; if (defaultUnit) { selectUnit(defaultUnit); } - + // Открываем модальное окно if (!unitModalInstance) { unitModalInstance = new bootstrap.Modal(document.getElementById('selectProductUnitModal')); } unitModalInstance.show(); - + } catch (error) { console.error('Ошибка загрузки единиц продажи:', error); alert('Ошибка загрузки данных. Попробуйте ещё раз.'); @@ -531,7 +531,7 @@ function renderUnitSelectionList() { */ function selectUnit(unit) { selectedSalesUnit = unit; - + // Обновляем визуальное выделение document.querySelectorAll('.unit-selection-card').forEach(card => { if (card.dataset.unitId === String(unit.id)) { @@ -540,7 +540,7 @@ function selectUnit(unit) { card.classList.remove('selected'); } }); - + // Обновляем отображение выбранной единицы document.getElementById('selectedUnitDisplay').textContent = unit.name; @@ -550,20 +550,20 @@ function selectUnit(unit) { qtyInput.value = roundQuantity(unit.min_quantity, 3); qtyInput.min = unit.min_quantity; qtyInput.step = unit.quantity_step; - + // Устанавливаем цену document.getElementById('unitModalPrice').value = unit.actual_price; - + // Обновляем подсказку const hintEl = document.getElementById('unitQtyHint'); hintEl.textContent = `Мин. ${unit.min_quantity}, шаг ${unit.quantity_step}`; - + // Сбрасываем индикатор изменения цены document.getElementById('priceOverrideIndicator').style.display = 'none'; - + // Пересчитываем итого calculateUnitModalSubtotal(); - + // Валидируем количество validateUnitQuantity(); } @@ -574,12 +574,12 @@ function selectUnit(unit) { */ function validateUnitQuantity() { if (!selectedSalesUnit) return false; - + const qtyInput = document.getElementById('unitModalQuantity'); const qty = parseFloat(qtyInput.value); const errorEl = document.getElementById('unitQtyError'); const confirmBtn = document.getElementById('confirmAddUnitToCart'); - + // Проверка минимального количества if (qty < parseFloat(selectedSalesUnit.min_quantity)) { errorEl.textContent = `Минимальное количество: ${selectedSalesUnit.min_quantity}`; @@ -587,21 +587,21 @@ function validateUnitQuantity() { confirmBtn.disabled = true; return false; } - + // Проверка шага (с учётом погрешности) const step = parseFloat(selectedSalesUnit.quantity_step); const minQty = parseFloat(selectedSalesUnit.min_quantity); const diff = qty - minQty; const remainder = diff % step; const epsilon = 0.0001; - + if (remainder > epsilon && (step - remainder) > epsilon) { errorEl.textContent = `Количество должно быть кратно ${step}`; errorEl.style.display = 'block'; confirmBtn.disabled = true; return false; } - + // Всё ок, скрываем ошибку errorEl.style.display = 'none'; confirmBtn.disabled = false; @@ -617,9 +617,9 @@ function calculateUnitModalSubtotal() { const price = parseFloat(document.getElementById('unitModalPrice').value) || 0; // Округляем до 2 знаков после запятой для корректного отображения const subtotal = Math.round(qty * price * 100) / 100; - + document.getElementById('unitModalSubtotal').textContent = `${formatMoney(subtotal)} руб`; - + // Проверяем изменение цены if (selectedSalesUnit && Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01) { document.getElementById('priceOverrideIndicator').style.display = 'block'; @@ -635,15 +635,15 @@ function addToCartFromModal() { if (!validateUnitQuantity()) { return; } - + const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value); const qty = roundQuantity(qtyRaw, 3); // Округляем количество const price = parseFloat(document.getElementById('unitModalPrice').value); const priceOverridden = Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01; - + // Формируем ключ корзины: product-{id}-{sales_unit_id} const cartKey = `product-${unitModalProduct.id}-${selectedSalesUnit.id}`; - + // Добавляем или обновляем в корзине if (cart.has(cartKey)) { const existing = cart.get(cartKey); @@ -664,16 +664,16 @@ function addToCartFromModal() { price_overridden: priceOverridden }); } - + // Обновляем корзину renderCart(); saveCartToRedis(); - + // Перерисовываем товары для обновления визуального остатка if (!isShowcaseView) { renderProducts(); } - + // Закрываем модальное окно unitModalInstance.hide(); } @@ -729,7 +729,7 @@ async function addProductWithUnitToCart(product, salesUnit, qty = 1) { function renderCategories() { const grid = document.getElementById('categoryGrid'); grid.innerHTML = ''; - + // Кнопка "Витрина" - первая в ряду const showcaseCol = document.createElement('div'); showcaseCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2'; @@ -753,7 +753,7 @@ function renderCategories() { showcaseCard.appendChild(showcaseBody); showcaseCol.appendChild(showcaseCard); grid.appendChild(showcaseCol); - + // Кнопка "Все" const allCol = document.createElement('div'); allCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2'; @@ -776,12 +776,12 @@ function renderCategories() { allCard.appendChild(allBody); allCol.appendChild(allCard); grid.appendChild(allCol); - + // Категории CATEGORIES.forEach(cat => { const col = document.createElement('div'); col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5'; - + const card = document.createElement('div'); card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : ''); card.onclick = async () => { @@ -792,14 +792,14 @@ function renderCategories() { renderCategories(); await loadItems(); // Загрузка через API }; - + const body = document.createElement('div'); body.className = 'card-body'; - + const name = document.createElement('div'); name.className = 'category-name'; name.textContent = cat.name; - + body.appendChild(name); card.appendChild(body); col.appendChild(card); @@ -810,13 +810,13 @@ function renderCategories() { function renderProducts() { const grid = document.getElementById('productGrid'); grid.innerHTML = ''; - + let filtered; - + // Если выбран режим витрины - показываем витринные комплекты if (isShowcaseView) { filtered = showcaseKits; - + // Для витрины — клиентская фильтрация по поиску const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim(); if (searchTerm) { @@ -836,12 +836,12 @@ function renderProducts() { filtered.forEach(item => { const col = document.createElement('div'); col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5'; - + const card = document.createElement('div'); card.className = 'card product-card'; card.style.position = 'relative'; card.onclick = () => addToCart(item); - + // Если это витринный комплект - добавляем кнопку редактирования if (item.type === 'showcase_kit') { // ИНДИКАЦИЯ БЛОКИРОВКИ @@ -914,10 +914,10 @@ function renderProducts() { } } } - + const body = document.createElement('div'); body.className = 'card-body'; - + // Изображение товара/комплекта const imageDiv = document.createElement('div'); imageDiv.className = 'product-image'; @@ -930,18 +930,18 @@ function renderProducts() { } else { imageDiv.innerHTML = ''; } - + // Информация о товаре/комплекте const info = document.createElement('div'); info.className = 'product-info'; - + const name = document.createElement('div'); name.className = 'product-name'; name.textContent = item.name; - + const stock = document.createElement('div'); stock.className = 'product-stock'; - + // Для витринных комплектов показываем количество (доступно/всего) и дней на витрине if (item.type === 'showcase_kit') { const availableCount = item.available_count || 0; @@ -1080,26 +1080,26 @@ function renderProducts() { } } } - + const sku = document.createElement('div'); sku.className = 'product-sku'; - + const skuText = document.createElement('span'); skuText.textContent = item.sku || 'н/д'; - + const priceSpan = document.createElement('span'); priceSpan.className = 'product-price'; // Используем цену из единицы продажи если есть, иначе базовую цену const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price; priceSpan.textContent = `${formatMoney(itemPrice)}`; - + sku.appendChild(skuText); sku.appendChild(priceSpan); - + info.appendChild(name); info.appendChild(stock); info.appendChild(sku); - + body.appendChild(imageDiv); body.appendChild(info); card.appendChild(body); @@ -1111,32 +1111,32 @@ function renderProducts() { // Загрузка товаров через API async function loadItems(append = false) { if (isLoadingItems) return; - + isLoadingItems = true; - + if (!append) { currentPage = 1; ITEMS = []; } - + try { const params = new URLSearchParams({ page: currentPage, page_size: 60 }); - + if (currentCategoryId) { params.append('category_id', currentCategoryId); } - + // Добавляем поисковый запрос, если есть if (currentSearchQuery) { params.append('query', currentSearchQuery); } - + const response = await fetch(`/pos/api/items/?${params}`); const data = await response.json(); - + if (data.success) { if (append) { ITEMS = ITEMS.concat(data.items); @@ -1174,7 +1174,7 @@ function setupInfiniteScroll() { rootMargin: '200px' } ); - + // Наблюдаем за концом грида const sentinel = document.createElement('div'); sentinel.id = 'scroll-sentinel'; @@ -1199,7 +1199,7 @@ async function addToCart(item) { await openProductUnitModal(item); return; } - + const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1" // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock) @@ -1358,7 +1358,7 @@ function renderCart() { if (item.type === 'kit' || item.type === 'showcase_kit') { typeIcon = ' '; } - + // Единица продажи (если есть) let unitInfo = ''; if (item.sales_unit_id && item.unit_name) { @@ -1468,18 +1468,18 @@ function renderCart() { qtyControl.appendChild(qtyInput); qtyControl.appendChild(plusBtn); } - + // Сумма за позицию const itemTotal = document.createElement('div'); itemTotal.className = 'item-total'; itemTotal.textContent = formatMoney(item.price * item.qty); - + // Кнопка удаления const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-sm btn-link text-danger p-0'; deleteBtn.innerHTML = ''; deleteBtn.onclick = () => removeFromCart(cartKey); - + row.appendChild(namePrice); row.appendChild(multiplySign); row.appendChild(qtyControl); @@ -1487,7 +1487,7 @@ function renderCart() { row.appendChild(deleteBtn); // Обработчик клика для редактирования товара - row.addEventListener('click', function(e) { + row.addEventListener('click', function (e) { // Игнорируем клики на кнопки управления количеством и удаления if (e.target.closest('button') || e.target.closest('input')) { return; @@ -1501,10 +1501,10 @@ function renderCart() { }); list.appendChild(row); - + total += item.qty * item.price; }); - + document.getElementById('cartTotal').textContent = formatMoney(total); // Обновляем состояние кнопки "НА ВИТРИНУ" @@ -1785,7 +1785,7 @@ async function openCreateTempKitModal() { alert('Корзина пуста. Добавьте товары перед созданием комплекта.'); return; } - + // Проверяем что в корзине НЕТ витринных комплектов let hasShowcaseKit = false; for (const [cartKey, item] of cart) { @@ -1794,12 +1794,12 @@ async function openCreateTempKitModal() { break; } } - + if (hasShowcaseKit) { alert('⚠️ В корзине уже есть витринный комплект!\n\nНельзя создать новый букет на витрину, пока в корзине находится другой витринный букет.\n\nУдалите витринный букет из корзины или завершите текущую продажу.'); return; } - + // Проверяем что в корзине только товары (не обычные комплекты) let hasKits = false; for (const [cartKey, item] of cart) { @@ -1808,29 +1808,29 @@ async function openCreateTempKitModal() { break; } } - + if (hasKits) { alert('В корзине есть комплекты. Для витрины добавляйте только отдельные товары.'); return; } - + // Копируем содержимое cart в tempCart (изолированное состояние модалки) tempCart.clear(); cart.forEach((item, key) => { - tempCart.set(key, {...item}); // Глубокая копия объекта + tempCart.set(key, { ...item }); // Глубокая копия объекта }); - + // Генерируем название по умолчанию const randomSuffix = Math.floor(Math.random() * 900) + 100; const defaultName = `Витринный букет ${randomSuffix}`; document.getElementById('tempKitName').value = defaultName; - + // Загружаем список витрин await loadShowcases(); - + // Заполняем список товаров из tempCart renderTempKitItems(); - + // Открываем модальное окно const modal = new bootstrap.Modal(document.getElementById('createTempKitModal')); modal.show(); @@ -1842,21 +1842,21 @@ async function openEditKitModal(kitId) { // Загружаем данные комплекта const response = await fetch(`/pos/api/product-kits/${kitId}/`); const data = await response.json(); - + if (!data.success) { alert(`Ошибка: ${data.error}`); return; } - + const kit = data.kit; - + // Устанавливаем режим редактирования isEditMode = true; editingKitId = kitId; - + // Загружаем список витрин await loadShowcases(); - + // Очищаем tempCart и заполняем составом комплекта tempCart.clear(); kit.items.forEach(item => { @@ -1871,7 +1871,7 @@ async function openEditKitModal(kitId) { }); }); renderTempKitItems(); // Отображаем товары в модальном окне - + // Заполняем поля формы document.getElementById('tempKitName').value = kit.name; document.getElementById('tempKitDescription').value = kit.description; @@ -1890,7 +1890,7 @@ async function openEditKitModal(kitId) { document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type; document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value; - + if (kit.sale_price) { document.getElementById('useSalePrice').checked = true; document.getElementById('salePrice').value = kit.sale_price; @@ -1900,12 +1900,12 @@ async function openEditKitModal(kitId) { document.getElementById('salePrice').value = ''; document.getElementById('salePriceBlock').style.display = 'none'; } - + // Выбираем витрину if (kit.showcase_id) { document.getElementById('showcaseSelect').value = kit.showcase_id; } - + // Отображаем фото, если есть if (kit.photo_url) { document.getElementById('photoPreviewImg').src = kit.photo_url; @@ -1913,10 +1913,10 @@ async function openEditKitModal(kitId) { } else { document.getElementById('photoPreview').style.display = 'none'; } - + // Обновляем цены updatePriceCalculations(); - + // Меняем заголовок и кнопку document.getElementById('createTempKitModalLabel').textContent = 'Редактирование витринного букета'; document.getElementById('confirmCreateTempKit').textContent = 'Сохранить изменения'; @@ -1926,12 +1926,12 @@ async function openEditKitModal(kitId) { document.getElementById('writeOffKitBtn').style.display = 'block'; document.getElementById('showcaseKitQuantityBlock').style.display = 'none'; document.getElementById('addProductBlock').style.display = 'block'; - + // Инициализируем компонент поиска товаров setTimeout(() => { if (window.ProductSearchPicker) { const picker = ProductSearchPicker.init('#temp-kit-product-picker', { - onAddSelected: function(product, instance) { + onAddSelected: function (product, instance) { if (product) { // Добавляем товар в tempCart const cartKey = `product-${product.id}`; @@ -1949,10 +1949,10 @@ async function openEditKitModal(kitId) { type: 'product' }); } - + // Обновляем отображение renderTempKitItems(); - + // Очищаем выбор в пикере instance.clearSelection(); } @@ -1967,7 +1967,7 @@ async function openEditKitModal(kitId) { // Проверяем актуальность цен (сразу после открытия) checkPricesActual(); - + } catch (error) { console.error('Error loading kit for edit:', error); alert('Ошибка при загрузке комплекта'); @@ -2084,10 +2084,10 @@ async function loadShowcases() { try { const response = await fetch('/pos/api/get-showcases/'); const data = await response.json(); - + const select = document.getElementById('showcaseSelect'); select.innerHTML = ''; - + if (data.success && data.showcases.length > 0) { let defaultShowcaseId = null; @@ -2120,16 +2120,16 @@ async function loadShowcases() { function renderTempKitItems() { const container = document.getElementById('tempKitItemsList'); container.innerHTML = ''; - + let estimatedTotal = 0; - + tempCart.forEach((item, cartKey) => { // Только товары (не комплекты) if (item.type !== 'product') return; - + const itemDiv = document.createElement('div'); itemDiv.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom'; - + // Левая часть: название и цена const leftDiv = document.createElement('div'); leftDiv.className = 'flex-grow-1'; @@ -2138,11 +2138,11 @@ function renderTempKitItems() {
${formatMoney(item.price)} руб. / шт. `; - + // Правая часть: контролы количества и удаление const rightDiv = document.createElement('div'); rightDiv.className = 'd-flex align-items-center gap-2'; - + // Кнопка минус const minusBtn = document.createElement('button'); minusBtn.className = 'btn btn-sm btn-outline-secondary'; @@ -2156,7 +2156,7 @@ function renderTempKitItems() { } renderTempKitItems(); }; - + // Поле количества const qtyInput = document.createElement('input'); qtyInput.type = 'number'; @@ -2169,7 +2169,7 @@ function renderTempKitItems() { item.qty = Math.max(1, newQty); renderTempKitItems(); }; - + // Кнопка плюс const plusBtn = document.createElement('button'); plusBtn.className = 'btn btn-sm btn-outline-secondary'; @@ -2179,13 +2179,13 @@ function renderTempKitItems() { item.qty++; renderTempKitItems(); }; - + // Сумма за товар const totalDiv = document.createElement('div'); totalDiv.className = 'text-end ms-2'; totalDiv.style.minWidth = '80px'; totalDiv.innerHTML = `${formatMoney(item.qty * item.price)} руб.`; - + // Кнопка удаления const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-sm btn-outline-danger'; @@ -2195,25 +2195,25 @@ function renderTempKitItems() { tempCart.delete(cartKey); renderTempKitItems(); }; - + rightDiv.appendChild(minusBtn); rightDiv.appendChild(qtyInput); rightDiv.appendChild(plusBtn); rightDiv.appendChild(totalDiv); rightDiv.appendChild(deleteBtn); - + itemDiv.appendChild(leftDiv); itemDiv.appendChild(rightDiv); container.appendChild(itemDiv); - + estimatedTotal += item.qty * item.price; }); - + // Если корзина пуста if (tempCart.size === 0) { container.innerHTML = '

Нет товаров

'; } - + // Обновляем все расчеты цен updatePriceCalculations(estimatedTotal); } @@ -2229,14 +2229,14 @@ function updatePriceCalculations(basePrice = null) { } }); } - + // Базовая цена document.getElementById('tempKitBasePrice').textContent = formatMoney(basePrice) + ' руб.'; - + // Корректировка const adjustmentType = document.getElementById('priceAdjustmentType').value; const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0; - + let calculatedPrice = basePrice; if (adjustmentType !== 'none' && adjustmentValue > 0) { if (adjustmentType === 'increase_percent') { @@ -2249,23 +2249,23 @@ function updatePriceCalculations(basePrice = null) { calculatedPrice = Math.max(0, basePrice - adjustmentValue); } } - + document.getElementById('tempKitCalculatedPrice').textContent = formatMoney(calculatedPrice) + ' руб.'; - + // Финальная цена (с учетом sale_price если задана) const useSalePrice = document.getElementById('useSalePrice').checked; const salePrice = parseFloat(document.getElementById('salePrice').value) || 0; - + let finalPrice = calculatedPrice; if (useSalePrice && salePrice > 0) { finalPrice = salePrice; } - + document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice); } // Обработчики для полей цены -document.getElementById('priceAdjustmentType').addEventListener('change', function() { +document.getElementById('priceAdjustmentType').addEventListener('change', function () { const adjustmentBlock = document.getElementById('adjustmentValueBlock'); if (this.value === 'none') { adjustmentBlock.style.display = 'none'; @@ -2276,11 +2276,11 @@ document.getElementById('priceAdjustmentType').addEventListener('change', functi updatePriceCalculations(); }); -document.getElementById('priceAdjustmentValue').addEventListener('input', function() { +document.getElementById('priceAdjustmentValue').addEventListener('input', function () { updatePriceCalculations(); }); -document.getElementById('useSalePrice').addEventListener('change', function() { +document.getElementById('useSalePrice').addEventListener('change', function () { const salePriceBlock = document.getElementById('salePriceBlock'); if (this.checked) { salePriceBlock.style.display = 'block'; @@ -2291,12 +2291,12 @@ document.getElementById('useSalePrice').addEventListener('change', function() { updatePriceCalculations(); }); -document.getElementById('salePrice').addEventListener('input', function() { +document.getElementById('salePrice').addEventListener('input', function () { updatePriceCalculations(); }); // Обработчик загрузки фото -document.getElementById('tempKitPhoto').addEventListener('change', function(e) { +document.getElementById('tempKitPhoto').addEventListener('change', function (e) { const file = e.target.files[0]; if (file) { if (!file.type.startsWith('image/')) { @@ -2304,10 +2304,10 @@ document.getElementById('tempKitPhoto').addEventListener('change', function(e) { this.value = ''; return; } - + // Превью const reader = new FileReader(); - reader.onload = function(event) { + reader.onload = function (event) { document.getElementById('photoPreviewImg').src = event.target.result; document.getElementById('photoPreview').style.display = 'block'; }; @@ -2316,7 +2316,7 @@ document.getElementById('tempKitPhoto').addEventListener('change', function(e) { }); // Удаление фото -document.getElementById('removePhoto').addEventListener('click', function() { +document.getElementById('removePhoto').addEventListener('click', function () { document.getElementById('tempKitPhoto').value = ''; document.getElementById('photoPreview').style.display = 'none'; document.getElementById('photoPreviewImg').src = ''; @@ -2335,12 +2335,12 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { alert('Введите название комплекта'); return; } - + if (!showcaseId && !isEditMode) { alert('Выберите витрину'); return; } - + // Собираем товары из tempCart (изолированное состояние модалки) const items = []; tempCart.forEach((item, cartKey) => { @@ -2351,18 +2351,18 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { }); } }); - + if (items.length === 0) { alert('Нет товаров для создания комплекта'); return; } - + // Получаем данные о ценах const priceAdjustmentType = document.getElementById('priceAdjustmentType').value; const priceAdjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0; const useSalePrice = document.getElementById('useSalePrice').checked; const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0; - + // Получаем количество букетов для создания const showcaseKitQuantity = parseInt(document.getElementById('showcaseKitQuantity').value, 10) || 1; @@ -2388,12 +2388,11 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { formData.append('items', JSON.stringify(items)); formData.append('price_adjustment_type', priceAdjustmentType); formData.append('price_adjustment_value', priceAdjustmentValue); - // Если пользователь не задал свою цену, используем вычисленную - const finalSalePrice = useSalePrice ? salePrice : calculatedPrice; - if (finalSalePrice > 0) { - formData.append('sale_price', finalSalePrice); + // Если пользователь явно указал свою цену + if (useSalePrice && salePrice > 0) { + formData.append('sale_price', salePrice); } - + // Фото: для редактирования проверяем, удалено ли оно if (photoFile) { formData.append('photo', photoFile); @@ -2401,18 +2400,18 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { // Если фото было удалено formData.append('remove_photo', '1'); } - + // Отправляем запрос на сервер const confirmBtn = document.getElementById('confirmCreateTempKit'); confirmBtn.disabled = true; - - const url = isEditMode + + const url = isEditMode ? `/pos/api/product-kits/${editingKitId}/update/` : '/pos/api/create-temp-kit/'; - + const actionText = isEditMode ? 'Сохранение...' : 'Создание...'; confirmBtn.innerHTML = `${actionText}`; - + try { const response = await fetch(url, { method: 'POST', @@ -2422,14 +2421,14 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { }, body: formData }); - + const data = await response.json(); - + if (data.success) { // Успех! const createdCount = data.available_count || 1; const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : ''; - + let successMessage = isEditMode ? `✅ ${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.` : `✅ ${data.message} @@ -2464,24 +2463,24 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { document.getElementById('salePrice').value = ''; document.getElementById('salePriceBlock').style.display = 'none'; document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества - + // Запоминаем, был ли режим редактирования до сброса const wasEditMode = isEditMode; - + // Сбрасываем режим редактирования isEditMode = false; editingKitId = null; - + // Закрываем модальное окно const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); modal.hide(); - + // Если это было СОЗДАНИЕ витринного комплекта из корзины, // очищаем основную корзину POS if (!wasEditMode) { await clearCart(); } - + // Обновляем витринные комплекты и переключаемся на вид витрины isShowcaseView = true; currentCategoryId = null; @@ -2496,7 +2495,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { alert('Ошибка при сохранении комплекта'); } finally { confirmBtn.disabled = false; - const btnText = isEditMode + const btnText = isEditMode ? ' Сохранить изменения' : ' Создать и зарезервировать'; confirmBtn.innerHTML = btnText; @@ -2644,16 +2643,16 @@ const getCsrfToken = () => { if (csrfInput) { return csrfInput.value; } - + // Fallback: пытаемся прочитать из cookie (если CSRF_USE_SESSIONS=False) return getCookie('csrftoken'); }; // Сброс режима редактирования при закрытии модального окна -document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function() { +document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function () { // Очищаем tempCart (изолированное состояние модалки) tempCart.clear(); - + // Сброс режима редактирования при закрытии модального окна if (isEditMode) { // Сбрасываем режим редактирования @@ -2774,13 +2773,13 @@ document.getElementById('checkoutModal').addEventListener('show.bs.modal', async }); // Переключение режима оплаты -document.getElementById('singlePaymentMode').addEventListener('click', function() { +document.getElementById('singlePaymentMode').addEventListener('click', function () { document.getElementById('singlePaymentMode').classList.add('active'); document.getElementById('mixedPaymentMode').classList.remove('active'); reinitPaymentWidget('single'); }); -document.getElementById('mixedPaymentMode').addEventListener('click', function() { +document.getElementById('mixedPaymentMode').addEventListener('click', function () { document.getElementById('mixedPaymentMode').classList.add('active'); document.getElementById('singlePaymentMode').classList.remove('active'); reinitPaymentWidget('mixed'); @@ -3514,9 +3513,9 @@ document.addEventListener('DOMContentLoaded', () => { }); renderCart(); // Отрисовываем восстановленную корзину } - + // ===== ОБРАБОТЧИКИ ДЛЯ МОДАЛКИ ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ ===== - + // Кнопки изменения количества document.getElementById('unitQtyDecrement').addEventListener('click', () => { const input = document.getElementById('unitModalQuantity'); @@ -3526,7 +3525,7 @@ document.addEventListener('DOMContentLoaded', () => { calculateUnitModalSubtotal(); validateUnitQuantity(); }); - + document.getElementById('unitQtyIncrement').addEventListener('click', () => { const input = document.getElementById('unitModalQuantity'); const step = parseFloat(input.step) || 1; @@ -3535,13 +3534,13 @@ document.addEventListener('DOMContentLoaded', () => { calculateUnitModalSubtotal(); validateUnitQuantity(); }); - + // Изменение количества вручную document.getElementById('unitModalQuantity').addEventListener('input', () => { calculateUnitModalSubtotal(); validateUnitQuantity(); }); - + // Округление количества при потере фокуса document.getElementById('unitModalQuantity').addEventListener('blur', (e) => { const rawValue = parseFloat(e.target.value) || 0; @@ -3549,12 +3548,12 @@ document.addEventListener('DOMContentLoaded', () => { calculateUnitModalSubtotal(); validateUnitQuantity(); }); - + // Изменение цены document.getElementById('unitModalPrice').addEventListener('input', () => { calculateUnitModalSubtotal(); }); - + // Кнопка подтверждения добавления в корзину document.getElementById('confirmAddUnitToCart').addEventListener('click', () => { addToCartFromModal(); @@ -3677,16 +3676,16 @@ if (changeWarehouseBtn) { document.addEventListener('click', async (e) => { const warehouseItem = e.target.closest('.warehouse-item'); if (!warehouseItem) return; - + const warehouseId = warehouseItem.dataset.warehouseId; const warehouseName = warehouseItem.dataset.warehouseName; - + // Проверяем, есть ли товары в корзине if (cart.size > 0) { const confirmed = confirm(`При смене склада корзина будет очищена.\n\nПереключиться на склад "${warehouseName}"?`); if (!confirmed) return; } - + try { // Отправляем запрос на смену склада const response = await fetch(`/pos/api/set-warehouse/${warehouseId}/`, { @@ -3695,9 +3694,9 @@ document.addEventListener('click', async (e) => { 'X-CSRFToken': getCsrfToken() } }); - + const data = await response.json(); - + if (data.success) { // Перезагружаем страницу для обновления данных location.reload(); @@ -3718,19 +3717,19 @@ const clearSearchBtn = document.getElementById('clearSearchBtn'); searchInput.addEventListener('input', (e) => { const query = e.target.value.trim(); - + // Показываем/скрываем кнопку очистки if (e.target.value.length > 0) { clearSearchBtn.style.display = 'block'; } else { clearSearchBtn.style.display = 'none'; } - + // Отменяем предыдущий таймер if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); } - + // Если поле пустое — очищаем экран if (query === '') { currentSearchQuery = ''; @@ -3738,19 +3737,19 @@ searchInput.addEventListener('input', (e) => { renderProducts(); // Пустой экран return; } - + // Минимальная длина поиска — 3 символа if (query.length < 3) { // Не реагируем на ввод менее 3 символов return; } - + // Для витрины — мгновенная клиентская фильтрация if (isShowcaseView) { renderProducts(); return; } - + // Для обычных товаров/комплектов — серверный поиск с debounce 300мс searchDebounceTimer = setTimeout(async () => { currentSearchQuery = query; @@ -3845,18 +3844,18 @@ async function createDeferredOrder() { if (result.success) { console.log(`✅ Заказ #${result.order_number} создан (черновик). ShowcaseItem зарезервированы.`); - + // КРИТИЧНО: Очищаем корзину POS (включая витринные ��укеты) cart.clear(); renderCart(); saveCartToRedis(); // Сохраняем пустую корзину в Redis - + // Перезагружаем витрину (чтобы зарезервированные букеты исчезли) if (isShowcaseView) { await refreshShowcaseKits(); renderProducts(); } - + // Открываем форму редактирования в новой вкладке window.open(`/orders/${result.order_number}/edit/`, '_blank'); } else { diff --git a/myproject/reproduce_issue.py b/myproject/reproduce_issue.py new file mode 100644 index 0000000..867e984 --- /dev/null +++ b/myproject/reproduce_issue.py @@ -0,0 +1,120 @@ + +import os +import sys +import json +import django +from decimal import Decimal + +# Setup Django +sys.path.append(os.getcwd()) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") +django.setup() + +from django.test import RequestFactory +from django.contrib.auth import get_user_model +from django.db import connection + +from customers.models import Customer +from inventory.models import Warehouse, Sale +from products.models import Product, UnitOfMeasure +from pos.views import pos_checkout +from orders.models import OrderStatus + +def run(): + # Setup Data + User = get_user_model() + user = User.objects.first() + if not user: + print("No user found") + return + + # Create/Get Customer + customer, _ = Customer.objects.get_or_create( + name="Test Customer", + defaults={'phone': '+375291112233'} + ) + + # Create/Get Warehouse + warehouse, _ = Warehouse.objects.get_or_create( + name="Test Warehouse", + defaults={'is_active': True} + ) + + # Create product + product, _ = Product.objects.get_or_create( + name="Test Product Debug", + defaults={ + 'sku': 'DEBUG001', + 'buying_price': 10, + 'actual_price': 50, + 'warehouse': warehouse + } + ) + product.actual_price = 50 + product.save() + + # Ensure OrderStatus exists + OrderStatus.objects.get_or_create(code='completed', is_system=True, defaults={'name': 'Completed', 'is_positive_end': True}) + OrderStatus.objects.get_or_create(code='draft', is_system=True, defaults={'name': 'Draft'}) + + # Prepare Request + factory = RequestFactory() + + payload = { + "customer_id": customer.id, + "warehouse_id": warehouse.id, + "items": [ + { + "type": "product", + "id": product.id, + "quantity": 1, + "price": 100.00, # Custom price + "quantity_base": 1 + } + ], + "payments": [ + {"payment_method": "cash", "amount": 100.00} + ], + "notes": "Debug Sale" + } + + request = factory.post( + '/pos/api/checkout/', + data=json.dumps(payload), + content_type='application/json' + ) + request.user = user + + print("Executing pos_checkout...") + response = pos_checkout(request) + print(f"Response: {response.content}") + + # Verify Sale + sales = Sale.objects.filter(product=product).order_by('-id')[:1] + if sales: + sale = sales[0] + print(f"Sale created. ID: {sale.id}") + print(f"Sale Quantity: {sale.quantity}") + print(f"Sale Price: {sale.sale_price}") + if sale.sale_price == 0: + print("FAILURE: Sale price is 0!") + else: + print(f"SUCCESS: Sale price is {sale.sale_price}") + else: + print("FAILURE: No Sale created!") + +if __name__ == "__main__": + from django_tenants.utils import schema_context + # Replace with actual schema name if needed, assuming 'public' for now or the default tenant + # Since I don't know the tenant, I'll try to run in the current context. + # But usually need to set schema. + # Let's try to find a tenant. + from tenants.models import Client + tenant = Client.objects.first() + if tenant: + print(f"Running in tenant: {tenant.schema_name}") + with schema_context(tenant.schema_name): + run() + else: + print("No tenant found, running in public?") + run()