Compare commits

..

9 Commits

Author SHA1 Message Date
d947f4eee7 style(ui): increase toast notification delay from 3 to 5 seconds 2026-01-26 18:39:07 +03:00
5700314b10 feat(ui): replace alert notifications with toast messages
Add toast notification functionality using Bootstrap Toasts and update
checkout success/error handling to use toast messages instead of alert boxes.

**Changes:**
- Add `showToast` function to `terminal.js`
- Add toast container and templates to `terminal.html`
- Replace alert() calls in handleCheckoutSubmit with showToast()
2026-01-26 17:44:03 +03:00
b24a0d9f21 feat: Add UI for inventory transfer list and detail views. 2026-01-25 16:44:54 +03:00
034be20a5a feat: add showcase manager service 2026-01-25 15:28:41 +03:00
f75e861bb8 feat: Add new inventory and POS components, including a script to reproduce a POS checkout sale price bug. 2026-01-25 15:26:57 +03:00
5a66d492c8 feat: Add product kit views. 2026-01-25 00:52:03 +03:00
6cd0a945de feat: Add product kit creation view and its corresponding template. 2026-01-25 00:50:38 +03:00
41e6c33683 feat: Add Product Kit creation and editing functionality with new views and templates. 2026-01-25 00:09:45 +03:00
bf399996b8 fix(products): remove obsolete delete methods from ProductKit
Remove custom delete() and hard_delete() methods that referenced
non-existent is_deleted/deleted_at fields. ProductKit now uses
the correct implementation from BaseProductEntity which uses
status='discontinued' for soft delete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:52:32 +03:00
12 changed files with 1955 additions and 1476 deletions

View File

@@ -162,8 +162,6 @@ class ShowcaseManager:
Raises: Raises:
IntegrityError: если экземпляр уже был продан (защита на уровне БД) IntegrityError: если экземпляр уже был продан (защита на уровне БД)
""" """
from inventory.services.sale_processor import SaleProcessor
sold_count = 0 sold_count = 0
order = order_item.order order = order_item.order
@@ -207,17 +205,9 @@ class ShowcaseManager:
# Сначала устанавливаем order_item для правильного определения цены # Сначала устанавливаем order_item для правильного определения цены
reservation.order_item = order_item reservation.order_item = order_item
reservation.save() # ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
# Это сделает сигнал create_sale_on_order_completion автоматически.
# Теперь создаём продажу с правильной ценой из OrderItem # Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
SaleProcessor.create_sale_from_reservation(
reservation=reservation,
order=order
)
# Обновляем статус резерва
reservation.status = 'converted_to_sale'
reservation.converted_at = timezone.now()
reservation.save() reservation.save()
sold_count += 1 sold_count += 1

View File

@@ -366,7 +366,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE === # === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
# Проверяем, есть ли уже Sale для этого заказа # Проверяем, есть ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists(): 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) update_is_returned_flag(instance)
return return
@@ -376,7 +376,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
previous_status = getattr(instance, '_previous_status', None) previous_status = getattr(instance, '_previous_status', None)
if previous_status and previous_status.is_positive_end: if previous_status and previous_status.is_positive_end:
logger.info( logger.info(
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус " f"Заказ {instance.order_number}: повторный переход в положительный статус "
f"({previous_status.name}{instance.status.name}). Проверяем Sale..." f"({previous_status.name}{instance.status.name}). Проверяем Sale..."
) )
if Sale.objects.filter(order=instance).exists(): if Sale.objects.filter(order=instance).exists():
@@ -454,12 +454,65 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
) )
continue 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 для каждого компонента комплекта # Создаем Sale для каждого компонента комплекта
for reservation in kit_reservations: for reservation in kit_reservations:
try: try:
# Рассчитываем цену продажи компонента пропорционально цене комплекта # Рассчитываем цену продажи компонента пропорционально
# Используем actual_price компонента как цену продажи catalog_price = reservation.product.actual_price or Decimal('0')
component_sale_price = reservation.product.actual_price
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( sale = SaleProcessor.create_sale(
product=reservation.product, product=reservation.product,
@@ -472,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
sales_created.append(sale) sales_created.append(sale)
logger.info( logger.info(
f"✓ Sale создан для компонента комплекта '{kit.name}': " 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: except ValueError as e:
logger.error( logger.error(
@@ -548,6 +602,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
else: else:
base_price = price_with_discount 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 (с автоматическим FIFO-списанием)
sale = SaleProcessor.create_sale( sale = SaleProcessor.create_sale(
product=product, product=product,

View File

@@ -74,10 +74,12 @@
{% for item in items %} {% for item in items %}
<tr> <tr>
<td class="px-3 py-2"> <td class="px-3 py-2">
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a> <a href="{% url 'products:product-detail' item.product.id %}">{{
item.product.name }}</a>
</td> </td>
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td> <td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td> <td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.
</td>
<td class="px-3 py-2"> <td class="px-3 py-2">
<span class="badge bg-secondary">{{ item.batch.id }}</span> <span class="badge bg-secondary">{{ item.batch.id }}</span>
</td> </td>
@@ -132,9 +134,11 @@
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm"> <a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку <i class="bi bi-arrow-left me-1"></i>Вернуться к списку
</a> </a>
<!--
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm"> <a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash me-1"></i>Удалить <i class="bi bi-trash me-1"></i>Удалить
</a> </a>
-->
</div> </div>
</div> </div>
</div> </div>

View File

@@ -39,9 +39,11 @@
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр"> <a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
<!--
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить"> <a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</a> </a>
-->
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY:
"ENCRYPTION_KEY not set! Encrypted fields will fail. " "ENCRYPTION_KEY not set! Encrypted fields will fail. "
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" "Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
) )

View File

@@ -12,6 +12,38 @@ function roundQuantity(value, decimals = 3) {
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
} }
/**
* Показывает toast уведомление в правом верхнем углу
* @param {string} type - 'success' или 'error'
* @param {string} message - Текст сообщения
*/
function showToast(type, message) {
const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast';
const messageId = type === 'success' ? 'toastMessage' : 'errorMessage';
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
const toastElement = document.getElementById(toastId);
const messageElement = document.getElementById(messageId);
// Устанавливаем сообщение
messageElement.textContent = message;
// Добавляем цвет фона
toastElement.classList.add(bgClass, 'text-white');
// Создаём и показываем toast (автоматически скроется через 5 секунд - стандарт Bootstrap)
const toast = new bootstrap.Toast(toastElement, {
delay: 5000,
autohide: true
});
toast.show();
// Убираем класс цвета после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.classList.remove(bgClass, 'text-white');
}, { once: true });
}
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent); const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
let ITEMS = []; // Будем загружать через API let ITEMS = []; // Будем загружать через API
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent); let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent);
@@ -2388,10 +2420,9 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
formData.append('items', JSON.stringify(items)); formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType); formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue); formData.append('price_adjustment_value', priceAdjustmentValue);
// Если пользователь не задал свою цену, используем вычисленную // Если пользователь явно указал свою цену
const finalSalePrice = useSalePrice ? salePrice : calculatedPrice; if (useSalePrice && salePrice > 0) {
if (finalSalePrice > 0) { formData.append('sale_price', salePrice);
formData.append('sale_price', finalSalePrice);
} }
// Фото: для редактирования проверяем, удалено ли оно // Фото: для редактирования проверяем, удалено ли оно
@@ -3416,8 +3447,8 @@ async function handleCheckoutSubmit(paymentsData) {
if (result.success) { if (result.success) {
console.log('✅ Заказ успешно создан:', result); console.log('✅ Заказ успешно создан:', result);
// Успех // Показываем toast уведомление
alert(`Заказ #${result.order_number} успешно создан!\nСумма: ${result.total_amount.toFixed(2)} руб.`); showToast('success', `Заказ #${result.order_number} успешно создан! Сумма: ${result.total_amount.toFixed(2)} руб.`);
// Очищаем корзину // Очищаем корзину
cart.clear(); cart.clear();
@@ -3438,12 +3469,12 @@ async function handleCheckoutSubmit(paymentsData) {
}, 500); }, 500);
} else { } else {
alert('Ошибка: ' + result.error); showToast('error', 'Ошибка: ' + result.error);
} }
} catch (error) { } catch (error) {
console.error('Ошибка checkout:', error); console.error('Ошибка checkout:', error);
alert('Ошибка при проведении продажи: ' + error.message); showToast('error', 'Ошибка при проведении продажи: ' + error.message);
} finally { } finally {
// Разблокируем кнопку // Разблокируем кнопку
const btn = document.getElementById('confirmCheckoutBtn'); const btn = document.getElementById('confirmCheckoutBtn');

View File

@@ -729,6 +729,28 @@
<!-- Модалка редактирования товара в корзине --> <!-- Модалка редактирования товара в корзине -->
{% include 'pos/components/edit_cart_item_modal.html' %} {% include 'pos/components/edit_cart_item_modal.html' %}
<!-- Toast Container для уведомлений -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1060;">
<div id="orderSuccessToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-check-circle-fill text-success me-2 fs-5"></i>
<span id="toastMessage"></span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" style="display: none;"></button>
</div>
</div>
<div id="orderErrorToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-exclamation-circle-fill text-danger me-2 fs-5"></i>
<span id="errorMessage"></span>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}

View File

@@ -340,17 +340,6 @@ class ProductKit(BaseProductEntity):
self.save(update_fields=['is_temporary', 'order']) self.save(update_fields=['is_temporary', 'order'])
return True return True
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=['is_deleted', 'deleted_at'])
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
def create_snapshot(self): def create_snapshot(self):
""" """
Создает снимок текущего состояния комплекта. Создает снимок текущего состояния комплекта.

View File

@@ -76,7 +76,8 @@
</div> </div>
<!-- Загрузка с устройства --> <!-- Загрузка с устройства -->
<input type="file" name="photos" accept="image/*" multiple class="form-control form-control-sm" id="id_photos"> <input type="file" name="photos" accept="image/*" multiple class="form-control form-control-sm"
id="id_photos">
<div id="photoPreviewContainer" class="mt-2" style="display: none;"> <div id="photoPreviewContainer" class="mt-2" style="display: none;">
<div id="photoPreview" class="row g-1"></div> <div id="photoPreview" class="row g-1"></div>
</div> </div>
@@ -97,7 +98,8 @@
<div class="card-body p-3"> <div class="card-body p-3">
<p class="small text-muted mb-3"> <p class="small text-muted mb-3">
Сгенерируйте привлекательное название для вашего букета автоматически Сгенерируйте привлекательное название для вашего букета автоматически
<br><span class="badge bg-secondary mt-1">В базе: <span id="bouquetNamesCount">{{ bouquet_names_count }}</span> названий</span> <br><span class="badge bg-secondary mt-1">В базе: <span id="bouquetNamesCount">{{
bouquet_names_count }}</span> названий</span>
</p> </p>
<div class="d-flex gap-2 mb-4"> <div class="d-flex gap-2 mb-4">
<button type="button" class="btn btn-outline-primary btn-sm" id="populateNamesBtn"> <button type="button" class="btn btn-outline-primary btn-sm" id="populateNamesBtn">
@@ -111,27 +113,36 @@
<!-- Предложения названий --> <!-- Предложения названий -->
<div class="name-suggestions"> <div class="name-suggestions">
<!-- Строка 1 --> <!-- Строка 1 -->
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row" data-name-id=""> <div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
data-name-id="">
<span class="text-muted small name-text">-</span> <span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;"> <div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="button" class="btn btn-success btn-xs btn-take-name">Взять</button> <button type="button"
<button type="button" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button> class="btn btn-success btn-xs btn-take-name">Взять</button>
<button type="button"
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
</div> </div>
</div> </div>
<!-- Строка 2 --> <!-- Строка 2 -->
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row" data-name-id=""> <div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
data-name-id="">
<span class="text-muted small name-text">-</span> <span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;"> <div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="button" class="btn btn-success btn-xs btn-take-name">Взять</button> <button type="button"
<button type="button" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button> class="btn btn-success btn-xs btn-take-name">Взять</button>
<button type="button"
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
</div> </div>
</div> </div>
<!-- Строка 3 --> <!-- Строка 3 -->
<div class="d-flex justify-content-between align-items-center py-2 name-row" data-name-id=""> <div class="d-flex justify-content-between align-items-center py-2 name-row"
data-name-id="">
<span class="text-muted small name-text">-</span> <span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;"> <div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="button" class="btn btn-success btn-xs btn-take-name">Взять</button> <button type="button"
<button type="button" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button> class="btn btn-success btn-xs btn-take-name">Взять</button>
<button type="button"
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
</div> </div>
</div> </div>
</div> </div>
@@ -154,8 +165,10 @@
<!-- Базовая цена (отображение) --> <!-- Базовая цена (отображение) -->
<div class="mb-3 p-2 rounded" style="background: #f8f9fa; border-left: 3px solid #6c757d;"> <div class="mb-3 p-2 rounded" style="background: #f8f9fa; border-left: 3px solid #6c757d;">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен компонентов:</span> <span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00 руб.</span> компонентов:</span>
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00
руб.</span>
</div> </div>
</div> </div>
@@ -169,13 +182,15 @@
<div class="row g-2"> <div class="row g-2">
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_increase_percent" class="form-control" placeholder="%" step="0.01" min="0"> <input type="number" id="id_increase_percent" class="form-control"
placeholder="%" step="0.01" min="0">
<span class="input-group-text">%</span> <span class="input-group-text">%</span>
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_increase_amount" class="form-control" placeholder="руб" step="0.01" min="0"> <input type="number" id="id_increase_amount" class="form-control"
placeholder="руб" step="0.01" min="0">
<span class="input-group-text">руб</span> <span class="input-group-text">руб</span>
</div> </div>
</div> </div>
@@ -191,13 +206,15 @@
<div class="row g-2"> <div class="row g-2">
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_decrease_percent" class="form-control" placeholder="%" step="0.01" min="0"> <input type="number" id="id_decrease_percent" class="form-control"
placeholder="%" step="0.01" min="0">
<span class="input-group-text">%</span> <span class="input-group-text">%</span>
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_decrease_amount" class="form-control" placeholder="руб" step="0.01" min="0"> <input type="number" id="id_decrease_amount" class="form-control"
placeholder="руб" step="0.01" min="0">
<span class="input-group-text">руб</span> <span class="input-group-text">руб</span>
</div> </div>
</div> </div>
@@ -210,8 +227,10 @@
<!-- Итоговая цена --> <!-- Итоговая цена -->
<div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;"> <div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая цена:</strong></span> <span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая
<span id="finalPriceDisplay" class="fw-bold" style="font-size: 1.3rem; color: #198754;">0.00 руб.</span> цена:</strong></span>
<span id="finalPriceDisplay" class="fw-bold"
style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
</div> </div>
</div> </div>
@@ -227,7 +246,8 @@
<h6 class="mb-2 text-muted"><i class="bi bi-tag-discount"></i> Цена со скидкой</h6> <h6 class="mb-2 text-muted"><i class="bi bi-tag-discount"></i> Цена со скидкой</h6>
<label class="form-label small mb-1">{{ form.sale_price.label }}</label> <label class="form-label small mb-1">{{ form.sale_price.label }}</label>
{{ form.sale_price }} {{ form.sale_price }}
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной цены</small> <small class="form-text text-muted">Если указана, будет использоваться вместо расчетной
цены</small>
{% if form.sale_price.errors %} {% if form.sale_price.errors %}
<div class="text-danger small mt-1">{{ form.sale_price.errors }}</div> <div class="text-danger small mt-1">{{ form.sale_price.errors }}</div>
{% endif %} {% endif %}
@@ -296,7 +316,8 @@
</div> </div>
<!-- Sticky Footer --> <!-- Sticky Footer -->
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm"> <div
class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
<a href="{% url 'products:products-list' %}" class="btn btn-outline-secondary"> <a href="{% url 'products:products-list' %}" class="btn btn-outline-secondary">
Отмена Отмена
</a> </a>
@@ -425,6 +446,7 @@
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -528,7 +550,9 @@
/* Адаптивность */ /* Адаптивность */
@media (max-width: 991px) { @media (max-width: 991px) {
.col-lg-8, .col-lg-4 {
.col-lg-8,
.col-lg-4 {
max-width: 100%; max-width: 100%;
} }
} }
@@ -537,6 +561,10 @@
<!-- Select2 инициализация --> <!-- Select2 инициализация -->
{% include 'products/includes/select2-product-init.html' %} {% include 'products/includes/select2-product-init.html' %}
{{ selected_products|default:"{}"|json_script:"selected-products-data" }}
{{ selected_variants|default:"{}"|json_script:"selected-variants-data" }}
{{ selected_sales_units|default:"{}"|json_script:"selected-sales-units-data" }}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ========== // ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ==========
@@ -550,6 +578,7 @@ document.addEventListener('DOMContentLoaded', function() {
const finalPriceDisplay = document.getElementById('finalPriceDisplay'); const finalPriceDisplay = document.getElementById('finalPriceDisplay');
let basePrice = 0; let basePrice = 0;
let activeUpdates = 0; // Счетчик активных обновлений
// Кэш цен товаров для быстрого доступа // Кэш цен товаров для быстрого доступа
const priceCache = {}; const priceCache = {};
@@ -743,6 +772,19 @@ document.addEventListener('DOMContentLoaded', function() {
// Функция для обновления списка единиц продажи при выборе товара // Функция для обновления списка единиц продажи при выборе товара
async function updateSalesUnitsOptions(salesUnitSelect, productValue) { async function updateSalesUnitsOptions(salesUnitSelect, productValue) {
activeUpdates++; // Начинаем обновление
try {
// Сохраняем текущее значение перед очисткой (важно для редактирования и копирования)
let targetValue = salesUnitSelect.value;
// Если значения нет, проверяем preloaded данные (фаллбэк для инициализации)
if (!targetValue) {
const fieldName = salesUnitSelect.name;
if (selectedSalesUnits && selectedSalesUnits[fieldName]) {
targetValue = selectedSalesUnits[fieldName].id;
}
}
// Очищаем текущие опции // Очищаем текущие опции
salesUnitSelect.innerHTML = '<option value="">---------</option>'; salesUnitSelect.innerHTML = '<option value="">---------</option>';
salesUnitSelect.disabled = true; salesUnitSelect.disabled = true;
@@ -761,7 +803,6 @@ document.addEventListener('DOMContentLoaded', function() {
} }
if (isNaN(productId) || productId <= 0) { if (isNaN(productId) || productId <= 0) {
console.warn('updateSalesUnitsOptions: invalid product id', productValue);
return; return;
} }
@@ -783,13 +824,25 @@ document.addEventListener('DOMContentLoaded', function() {
salesUnitSelect.appendChild(option); salesUnitSelect.appendChild(option);
}); });
salesUnitSelect.disabled = false; salesUnitSelect.disabled = false;
// Обновляем Select2
// Восстанавливаем значение
if (targetValue) {
$(salesUnitSelect).val(targetValue).trigger('change');
} else {
// Обновляем Select2 без значения
$(salesUnitSelect).trigger('change'); $(salesUnitSelect).trigger('change');
} }
} }
}
} catch (error) { } catch (error) {
console.error('Error fetching sales units:', error); console.error('Error fetching sales units:', error);
} }
} finally {
activeUpdates--; // Завершаем обновление
if (activeUpdates === 0) {
calculateFinalPrice();
}
}
} }
// Обновляем data-product-id и загружаем цену при выборе товара // Обновляем data-product-id и загружаем цену при выборе товара
@@ -809,8 +862,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (salesUnitSelect) { if (salesUnitSelect) {
await updateSalesUnitsOptions(salesUnitSelect, this.value); await updateSalesUnitsOptions(salesUnitSelect, this.value);
} }
calculateFinalPrice();
} }
calculateFinalPrice();
}).on('select2:unselect', function () { }).on('select2:unselect', function () {
const form = $(this).closest('.kititem-form'); const form = $(this).closest('.kititem-form');
// Очищаем список единиц продажи // Очищаем список единиц продажи
@@ -885,6 +938,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Функция для расчета финальной цены // Функция для расчета финальной цены
async function calculateFinalPrice() { async function calculateFinalPrice() {
// Если идут обновления - не считаем, ждем их завершения
if (activeUpdates > 0) {
return;
}
// Получаем базовую цену (сумма всех компонентов) // Получаем базовую цену (сумма всех компонентов)
let newBasePrice = 0; let newBasePrice = 0;
const formsContainer = document.getElementById('kititem-forms'); const formsContainer = document.getElementById('kititem-forms');
@@ -1060,8 +1118,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// Инициальный расчет (асинхронно) // Инициальный расчет не нужен, так как он выполняется по событиям изменения полей
calculateFinalPrice(); // и после завершения загрузки единиц продажи
// ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ========== // ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ==========
function initSelect2(element, type, preloadedData) { function initSelect2(element, type, preloadedData) {
@@ -1072,9 +1130,9 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
const selectedProducts = {{ selected_products|default:"{}"|safe }}; const selectedProducts = JSON.parse(document.getElementById('selected-products-data').textContent || '{}');
const selectedVariants = {{ selected_variants|default:"{}"|safe }}; const selectedVariants = JSON.parse(document.getElementById('selected-variants-data').textContent || '{}');
const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }}; const selectedSalesUnits = JSON.parse(document.getElementById('selected-sales-units-data').textContent || '{}');
$('[name$="-product"]').each(function () { $('[name$="-product"]').each(function () {
const fieldName = $(this).attr('name'); const fieldName = $(this).attr('name');
@@ -1315,12 +1373,38 @@ document.addEventListener('DOMContentLoaded', function() {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} else { } else {
photoPreviewContainer.style.display = 'none'; photoPreviewContainer.style.display = 'none'; // Only hide if no source photos too (will check later)
photoPreview.innerHTML = ''; photoPreview.innerHTML = '';
// Re-render source photos if they exist and we just cleared new files
if (document.querySelectorAll('.source-photo-item').length > 0) {
photoPreviewContainer.style.display = 'block';
}
} }
}); });
} }
// Render source photos if present
{% if source_photos %}
photoPreviewContainer.style.display = 'block';
{% for photo in source_photos %}
(function () {
const col = document.createElement('div');
col.className = 'col-4 col-md-3 col-lg-2 source-photo-item';
col.innerHTML = `
<div class="card position-relative border-0 shadow-sm">
<img src="{{ photo.image.url }}" class="card-img-top" alt="Source Photo">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1" onclick="this.closest('.col-4').remove();">
<i class="bi bi-x"></i>
</button>
<input type="hidden" name="copied_photos" value="{{ photo.id }}">
</div>
`;
photoPreview.appendChild(col);
})();
{% endfor %}
{% endif %}
window.removePhoto = function (index) { window.removePhoto = function (index) {
selectedFiles.splice(index, 1); selectedFiles.splice(index, 1);
const dataTransfer = new DataTransfer(); const dataTransfer = new DataTransfer();

View File

@@ -506,6 +506,9 @@
<a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary">
Отмена Отмена
</a> </a>
<a href="{% url 'products:productkit-create' %}?copy_from={{ object.pk }}" class="btn btn-warning text-white mx-2">
<i class="bi bi-files me-1"></i>Копировать комплект
</a>
<button type="submit" class="btn btn-primary px-4"> <button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>Сохранить изменения <i class="bi bi-check-circle me-1"></i>Сохранить изменения
</button> </button>

View File

@@ -9,9 +9,10 @@ from django.shortcuts import redirect
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from user_roles.mixins import ManagerOwnerRequiredMixin from user_roles.mixins import ManagerOwnerRequiredMixin
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName, ProductSalesUnit
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
from .utils import handle_photos from .utils import handle_photos
import os
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView): class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
@@ -97,6 +98,37 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
form_class = ProductKitForm form_class = ProductKitForm
template_name = 'products/productkit_create.html' template_name = 'products/productkit_create.html'
def get_initial(self):
initial = super().get_initial()
copy_id = self.request.GET.get('copy_from')
if copy_id:
try:
kit = ProductKit.objects.get(pk=copy_id)
# Generate unique name
base_name = f"{kit.name} (Копия)"
new_name = base_name
counter = 1
while ProductKit.objects.filter(name=new_name).exists():
counter += 1
new_name = f"{base_name} {counter}"
initial.update({
'name': new_name,
'description': kit.description,
'short_description': kit.short_description,
'categories': list(kit.categories.values_list('pk', flat=True)),
'tags': list(kit.tags.values_list('pk', flat=True)),
'sale_price': kit.sale_price,
'price_adjustment_type': kit.price_adjustment_type,
'price_adjustment_value': kit.price_adjustment_value,
'external_category': kit.external_category,
'status': 'active', # Default to active for new kits
})
except ProductKit.DoesNotExist:
pass
return initial
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов. Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
@@ -132,7 +164,6 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem') context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2 # При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {} selected_products = {}
selected_variants = {} selected_variants = {}
selected_sales_units = {} selected_sales_units = {}
@@ -194,9 +225,99 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['selected_products'] = selected_products context['selected_products'] = selected_products
context['selected_variants'] = selected_variants context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units context['selected_sales_units'] = selected_sales_units
else:
# COPY KIT LOGIC
copy_id = self.request.GET.get('copy_from')
initial_items = []
selected_products = {}
selected_variants = {}
selected_sales_units = {}
if copy_id:
try:
source_kit = ProductKit.objects.get(pk=copy_id)
for item in source_kit.kit_items.all():
item_data = {
'quantity': item.quantity,
# Delete flag is false by default
}
form_prefix = f"kititem-{len(initial_items)}"
if item.product:
item_data['product'] = item.product
# Select2 prefill
product = item.product
text = product.name
if product.sku:
text += f" ({product.sku})"
actual_price = product.sale_price if product.sale_price else product.price
selected_products[f"{form_prefix}-product"] = {
'id': product.id,
'text': text,
'price': str(product.price) if product.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.sales_unit:
item_data['sales_unit'] = item.sales_unit
# Select2 prefill
sales_unit = item.sales_unit
text = f"{sales_unit.name} ({sales_unit.product.name})"
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[f"{form_prefix}-sales_unit"] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.variant_group:
item_data['variant_group'] = item.variant_group
# Select2 prefill
variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=item.variant_group.id)
variant_price = variant_group.price or 0
count = variant_group.items.count()
selected_variants[f"{form_prefix}-variant_group"] = {
'id': variant_group.id,
'text': f"{variant_group.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}
initial_items.append(item_data)
except ProductKit.DoesNotExist:
pass
if initial_items:
context['kititem_formset'] = KitItemFormSetCreate(
prefix='kititem',
initial=initial_items
)
context['kititem_formset'].extra = len(initial_items)
else: else:
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem') context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
# Pass Select2 data to context
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
# Pass source photos if copying
if copy_id:
try:
source_kit = ProductKit.objects.prefetch_related('photos').get(pk=copy_id)
photos = source_kit.photos.all().order_by('order')
print(f"DEBUG: Found {photos.count()} source photos for kit {copy_id}")
context['source_photos'] = photos
except ProductKit.DoesNotExist:
print(f"DEBUG: Source kit {copy_id} not found")
pass
# Количество названий букетов в базе # Количество названий букетов в базе
context['bouquet_names_count'] = BouquetName.objects.count() context['bouquet_names_count'] = BouquetName.objects.count()
@@ -235,6 +356,48 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
# Обработка фотографий # Обработка фотографий
handle_photos(self.request, self.object, ProductKitPhoto, 'kit') handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
# Handle copied photos
copied_photo_ids = self.request.POST.getlist('copied_photos')
print(f"DEBUG: copied_photo_ids in POST: {copied_photo_ids}")
if copied_photo_ids:
from django.core.files.base import ContentFile
original_photos = ProductKitPhoto.objects.filter(id__in=copied_photo_ids)
print(f"DEBUG: Found {original_photos.count()} original photos to copy")
# Get max order from existing photos (uploaded via handle_photos)
from django.db.models import Max
max_order = self.object.photos.aggregate(Max('order'))['order__max']
next_order = 0 if max_order is None else max_order + 1
print(f"DEBUG: Starting order for copies: {next_order}")
for photo in original_photos:
try:
# Open the original image file
if photo.image:
print(f"DEBUG: Processing photo {photo.id}: {photo.image.name}")
with photo.image.open('rb') as f:
image_content = f.read()
# Create a new ContentFile
new_image_name = f"copy_{self.object.id}_{os.path.basename(photo.image.name)}"
print(f"DEBUG: New image name: {new_image_name}")
# Create new photo instance
new_photo = ProductKitPhoto(kit=self.object, order=next_order)
# Save the image file (this also saves the model instance)
new_photo.image.save(new_image_name, ContentFile(image_content))
print(f"DEBUG: Successfully saved copy for photo {photo.id}")
next_order += 1
else:
print(f"DEBUG: Photo {photo.id} has no image file")
except Exception as e:
print(f"Error copying photo {photo.id}: {e}")
import traceback
traceback.print_exc()
continue
messages.success( messages.success(
self.request, self.request,
f'Комплект "{self.object.name}" успешно создан!' f'Комплект "{self.object.name}" успешно создан!'

View File

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