Compare commits
7 Commits
2bc70968c3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b24a0d9f21 | |||
| 034be20a5a | |||
| f75e861bb8 | |||
| 5a66d492c8 | |||
| 6cd0a945de | |||
| 41e6c33683 | |||
| bf399996b8 |
@@ -162,8 +162,6 @@ class ShowcaseManager:
|
||||
Raises:
|
||||
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
|
||||
"""
|
||||
from inventory.services.sale_processor import SaleProcessor
|
||||
|
||||
sold_count = 0
|
||||
order = order_item.order
|
||||
|
||||
@@ -207,17 +205,9 @@ class ShowcaseManager:
|
||||
|
||||
# Сначала устанавливаем order_item для правильного определения цены
|
||||
reservation.order_item = order_item
|
||||
reservation.save()
|
||||
|
||||
# Теперь создаём продажу с правильной ценой из OrderItem
|
||||
SaleProcessor.create_sale_from_reservation(
|
||||
reservation=reservation,
|
||||
order=order
|
||||
)
|
||||
|
||||
# Обновляем статус резерва
|
||||
reservation.status = 'converted_to_sale'
|
||||
reservation.converted_at = timezone.now()
|
||||
# ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
|
||||
# Это сделает сигнал create_sale_on_order_completion автоматически.
|
||||
# Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
|
||||
reservation.save()
|
||||
|
||||
sold_count += 1
|
||||
|
||||
@@ -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,12 +454,65 @@ 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,
|
||||
@@ -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,
|
||||
|
||||
@@ -74,10 +74,12 @@
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<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 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">
|
||||
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
||||
</td>
|
||||
@@ -132,9 +134,11 @@
|
||||
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
||||
</a>
|
||||
<!--
|
||||
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash me-1"></i>Удалить
|
||||
</a>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,13 +147,13 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.breadcrumb-sm {
|
||||
.breadcrumb-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -39,9 +39,11 @@
|
||||
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<!--
|
||||
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
-->
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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())\""
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
// Проверяем это не опция "Создать нового клиента"
|
||||
@@ -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;
|
||||
@@ -1817,7 +1817,7 @@ async function openCreateTempKitModal() {
|
||||
// Копируем содержимое cart в tempCart (изолированное состояние модалки)
|
||||
tempCart.clear();
|
||||
cart.forEach((item, key) => {
|
||||
tempCart.set(key, {...item}); // Глубокая копия объекта
|
||||
tempCart.set(key, { ...item }); // Глубокая копия объекта
|
||||
});
|
||||
|
||||
// Генерируем название по умолчанию
|
||||
@@ -1931,7 +1931,7 @@ async function openEditKitModal(kitId) {
|
||||
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}`;
|
||||
@@ -2265,7 +2265,7 @@ function updatePriceCalculations(basePrice = null) {
|
||||
}
|
||||
|
||||
// Обработчики для полей цены
|
||||
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/')) {
|
||||
@@ -2307,7 +2307,7 @@ document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
|
||||
|
||||
// Превью
|
||||
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 = '';
|
||||
@@ -2388,10 +2388,9 @@ 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);
|
||||
}
|
||||
|
||||
// Фото: для редактирования проверяем, удалено ли оно
|
||||
@@ -2650,7 +2649,7 @@ const getCsrfToken = () => {
|
||||
};
|
||||
|
||||
// Сброс режима редактирования при закрытии модального окна
|
||||
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function() {
|
||||
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function () {
|
||||
// Очищаем tempCart (изолированное состояние модалки)
|
||||
tempCart.clear();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -340,17 +340,6 @@ class ProductKit(BaseProductEntity):
|
||||
self.save(update_fields=['is_temporary', 'order'])
|
||||
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):
|
||||
"""
|
||||
Создает снимок текущего состояния комплекта.
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
</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="photoPreview" class="row g-1"></div>
|
||||
</div>
|
||||
@@ -97,7 +98,8 @@
|
||||
<div class="card-body p-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>
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="populateNamesBtn">
|
||||
@@ -111,27 +113,36 @@
|
||||
<!-- Предложения названий -->
|
||||
<div class="name-suggestions">
|
||||
<!-- Строка 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>
|
||||
<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" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
|
||||
<button type="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>
|
||||
<!-- Строка 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>
|
||||
<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" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
|
||||
<button type="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>
|
||||
<!-- Строка 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>
|
||||
<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" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
|
||||
<button type="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>
|
||||
@@ -154,8 +165,10 @@
|
||||
<!-- Базовая цена (отображение) -->
|
||||
<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">
|
||||
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен компонентов:</span>
|
||||
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00 руб.</span>
|
||||
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен
|
||||
компонентов:</span>
|
||||
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00
|
||||
руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,13 +182,15 @@
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,13 +206,15 @@
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,8 +227,10 @@
|
||||
<!-- Итоговая цена -->
|
||||
<div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;">
|
||||
<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 id="finalPriceDisplay" class="fw-bold" style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
|
||||
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая
|
||||
цена:</strong></span>
|
||||
<span id="finalPriceDisplay" class="fw-bold"
|
||||
style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +246,8 @@
|
||||
<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>
|
||||
{{ form.sale_price }}
|
||||
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной цены</small>
|
||||
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной
|
||||
цены</small>
|
||||
{% if form.sale_price.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.sale_price.errors }}</div>
|
||||
{% endif %}
|
||||
@@ -296,7 +316,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
@@ -308,14 +329,14 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Breadcrumbs */
|
||||
.breadcrumb-sm {
|
||||
/* Breadcrumbs */
|
||||
.breadcrumb-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Крупное поле названия */
|
||||
#id_name {
|
||||
/* Крупное поле названия */
|
||||
#id_name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
border: 3px solid #dee2e6;
|
||||
@@ -323,222 +344,229 @@
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
#id_name:focus {
|
||||
#id_name:focus {
|
||||
border-color: #198754;
|
||||
box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.15);
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Описание */
|
||||
#id_description {
|
||||
/* Описание */
|
||||
#id_description {
|
||||
font-size: 0.95rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Компактные чекбоксы */
|
||||
.compact-checkboxes {
|
||||
/* Компактные чекбоксы */
|
||||
.compact-checkboxes {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-checkboxes ul {
|
||||
.compact-checkboxes ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-checkboxes li {
|
||||
.compact-checkboxes li {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-checkboxes label {
|
||||
.compact-checkboxes label {
|
||||
font-weight: normal;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Компонент комплекта */
|
||||
.kititem-form {
|
||||
/* Компонент комплекта */
|
||||
.kititem-form {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.kititem-form:hover {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075) !important;
|
||||
}
|
||||
.kititem-form:hover {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
|
||||
}
|
||||
|
||||
.kititem-form .card-body {
|
||||
.kititem-form .card-body {
|
||||
background: #fafbfc;
|
||||
}
|
||||
}
|
||||
|
||||
.kititem-form input[type="checkbox"][name$="-DELETE"] {
|
||||
.kititem-form input[type="checkbox"][name$="-DELETE"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sticky footer */
|
||||
.sticky-bottom {
|
||||
/* Sticky footer */
|
||||
.sticky-bottom {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1020;
|
||||
}
|
||||
}
|
||||
|
||||
/* Карточки */
|
||||
.card.border-0 {
|
||||
/* Карточки */
|
||||
.card.border-0 {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Лейблы */
|
||||
.form-label.small {
|
||||
/* Лейблы */
|
||||
.form-label.small {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Фото превью */
|
||||
#photoPreview .col-4,
|
||||
#photoPreview .col-md-3,
|
||||
#photoPreview .col-lg-2 {
|
||||
/* Фото превью */
|
||||
#photoPreview .col-4,
|
||||
#photoPreview .col-md-3,
|
||||
#photoPreview .col-lg-2 {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
#photoPreview .card {
|
||||
#photoPreview .card {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
#photoPreview img {
|
||||
#photoPreview img {
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert компактный */
|
||||
.alert-sm {
|
||||
/* Alert компактный */
|
||||
.alert-sm {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация */
|
||||
@keyframes slideIn {
|
||||
/* Анимация */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kititem-form.new-item {
|
||||
.kititem-form.new-item {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Разделитель ИЛИ */
|
||||
.kit-item-separator {
|
||||
/* Разделитель ИЛИ */
|
||||
.kit-item-separator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.kit-item-separator .separator-text {
|
||||
.kit-item-separator .separator-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #adb5bd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.kit-item-separator .separator-help {
|
||||
.kit-item-separator .separator-help {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.kit-item-separator .separator-help:hover {
|
||||
.kit-item-separator .separator-help:hover {
|
||||
color: #0d6efd;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для генератора названий */
|
||||
.cursor-pointer {
|
||||
/* Стили для генератора названий */
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header[data-bs-toggle="collapse"]:hover {
|
||||
.card-header[data-bs-toggle="collapse"]:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header[data-bs-toggle="collapse"] .bi-chevron-down {
|
||||
.card-header[data-bs-toggle="collapse"] .bi-chevron-down {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse.show .card-header[data-bs-toggle="collapse"] .bi-chevron-down {
|
||||
.collapse.show .card-header[data-bs-toggle="collapse"] .bi-chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Кнопки очень маленького размера */
|
||||
.btn-xs {
|
||||
/* Кнопки очень маленького размера */
|
||||
.btn-xs {
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-xs:hover {
|
||||
.btn-xs:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для списка предложений */
|
||||
.name-suggestions {
|
||||
/* Стили для списка предложений */
|
||||
.name-suggestions {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.name-suggestions .text-muted {
|
||||
.name-suggestions .text-muted {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.name-suggestions .border-bottom {
|
||||
.name-suggestions .border-bottom {
|
||||
border-color: #e9ecef !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для полей корректировки цены */
|
||||
#id_increase_percent:disabled,
|
||||
#id_increase_amount:disabled,
|
||||
#id_decrease_percent:disabled,
|
||||
#id_decrease_amount:disabled {
|
||||
/* Стили для полей корректировки цены */
|
||||
#id_increase_percent:disabled,
|
||||
#id_increase_amount:disabled,
|
||||
#id_decrease_percent:disabled,
|
||||
#id_decrease_amount:disabled {
|
||||
background-color: #e9ecef;
|
||||
color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
#id_increase_percent.is-invalid,
|
||||
#id_increase_amount.is-invalid,
|
||||
#id_decrease_percent.is-invalid,
|
||||
#id_decrease_amount.is-invalid {
|
||||
#id_increase_percent.is-invalid,
|
||||
#id_increase_amount.is-invalid,
|
||||
#id_decrease_percent.is-invalid,
|
||||
#id_decrease_amount.is-invalid {
|
||||
border-color: #dc3545;
|
||||
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 991px) {
|
||||
.col-lg-8, .col-lg-4 {
|
||||
/* Адаптивность */
|
||||
@media (max-width: 991px) {
|
||||
|
||||
.col-lg-8,
|
||||
.col-lg-4 {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Select2 инициализация -->
|
||||
{% 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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ==========
|
||||
const increasePercentInput = document.getElementById('id_increase_percent');
|
||||
const increaseAmountInput = document.getElementById('id_increase_amount');
|
||||
@@ -550,6 +578,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const finalPriceDisplay = document.getElementById('finalPriceDisplay');
|
||||
|
||||
let basePrice = 0;
|
||||
let activeUpdates = 0; // Счетчик активных обновлений
|
||||
|
||||
// Кэш цен товаров для быстрого доступа
|
||||
const priceCache = {};
|
||||
@@ -743,6 +772,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Функция для обновления списка единиц продажи при выборе товара
|
||||
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.disabled = true;
|
||||
@@ -761,7 +803,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
if (isNaN(productId) || productId <= 0) {
|
||||
console.warn('updateSalesUnitsOptions: invalid product id', productValue);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,17 +824,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
salesUnitSelect.appendChild(option);
|
||||
});
|
||||
salesUnitSelect.disabled = false;
|
||||
// Обновляем Select2
|
||||
|
||||
// Восстанавливаем значение
|
||||
if (targetValue) {
|
||||
$(salesUnitSelect).val(targetValue).trigger('change');
|
||||
} else {
|
||||
// Обновляем Select2 без значения
|
||||
$(salesUnitSelect).trigger('change');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sales units:', error);
|
||||
}
|
||||
} finally {
|
||||
activeUpdates--; // Завершаем обновление
|
||||
if (activeUpdates === 0) {
|
||||
calculateFinalPrice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем data-product-id и загружаем цену при выборе товара
|
||||
$('[name$="-product"]').on('select2:select', async function() {
|
||||
$('[name$="-product"]').on('select2:select', async function () {
|
||||
const form = $(this).closest('.kititem-form');
|
||||
if (this.value) {
|
||||
// Извлекаем числовой ID из "product_123"
|
||||
@@ -809,9 +862,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (salesUnitSelect) {
|
||||
await updateSalesUnitsOptions(salesUnitSelect, this.value);
|
||||
}
|
||||
calculateFinalPrice();
|
||||
}
|
||||
}).on('select2:unselect', function() {
|
||||
calculateFinalPrice();
|
||||
}).on('select2:unselect', function () {
|
||||
const form = $(this).closest('.kititem-form');
|
||||
// Очищаем список единиц продажи
|
||||
const salesUnitSelect = form.find('[name$="-sales_unit"]')[0];
|
||||
@@ -885,6 +938,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Функция для расчета финальной цены
|
||||
async function calculateFinalPrice() {
|
||||
// Если идут обновления - не считаем, ждем их завершения
|
||||
if (activeUpdates > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем базовую цену (сумма всех компонентов)
|
||||
let newBasePrice = 0;
|
||||
const formsContainer = document.getElementById('kititem-forms');
|
||||
@@ -1060,8 +1118,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Инициальный расчет (асинхронно)
|
||||
calculateFinalPrice();
|
||||
// Инициальный расчет не нужен, так как он выполняется по событиям изменения полей
|
||||
// и после завершения загрузки единиц продажи
|
||||
|
||||
// ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ==========
|
||||
function initSelect2(element, type, preloadedData) {
|
||||
@@ -1072,23 +1130,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProducts = {{ selected_products|default:"{}"|safe }};
|
||||
const selectedVariants = {{ selected_variants|default:"{}"|safe }};
|
||||
const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }};
|
||||
const selectedProducts = JSON.parse(document.getElementById('selected-products-data').textContent || '{}');
|
||||
const selectedVariants = JSON.parse(document.getElementById('selected-variants-data').textContent || '{}');
|
||||
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 preloadedData = selectedProducts[fieldName] || null;
|
||||
initSelect2(this, 'product', preloadedData);
|
||||
// Обработчик уже добавлен выше (строки 673-701)
|
||||
});
|
||||
|
||||
$('[name$="-variant_group"]').each(function() {
|
||||
$('[name$="-variant_group"]').each(function () {
|
||||
const fieldName = $(this).attr('name');
|
||||
const preloadedData = selectedVariants[fieldName] || null;
|
||||
initSelect2(this, 'variant', preloadedData);
|
||||
// При выборе варианта очищаем единицу продажи
|
||||
$(this).on('select2:select', function() {
|
||||
$(this).on('select2:select', function () {
|
||||
const form = $(this).closest('.kititem-form');
|
||||
const salesUnitSelect = form.find('[name$="-sales_unit"]')[0];
|
||||
if (salesUnitSelect) {
|
||||
@@ -1099,7 +1157,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}).on('select2:unselect', calculateFinalPrice);
|
||||
});
|
||||
|
||||
$('[name$="-sales_unit"]').each(function() {
|
||||
$('[name$="-sales_unit"]').each(function () {
|
||||
const fieldName = $(this).attr('name');
|
||||
const preloadedData = selectedSalesUnits[fieldName] || null;
|
||||
initSelect2(this, 'sales_unit', preloadedData);
|
||||
@@ -1169,7 +1227,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (quantityInput) {
|
||||
quantityInput.addEventListener('change', calculateFinalPrice);
|
||||
// Выделяем весь текст при фокусе на поле количества
|
||||
quantityInput.addEventListener('focus', function() {
|
||||
quantityInput.addEventListener('focus', function () {
|
||||
this.select();
|
||||
});
|
||||
}
|
||||
@@ -1238,7 +1296,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
initSelect2(salesUnitSelect, 'sales_unit');
|
||||
|
||||
// Добавляем обработчики для новой формы (как в основном коде)
|
||||
$(productSelect).on('select2:select', async function() {
|
||||
$(productSelect).on('select2:select', async function () {
|
||||
const form = $(this).closest('.kititem-form');
|
||||
if (this.value) {
|
||||
let numericId = this.value;
|
||||
@@ -1252,7 +1310,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
calculateFinalPrice();
|
||||
}).on('select2:unselect', function() {
|
||||
}).on('select2:unselect', function () {
|
||||
if (salesUnitSelect) {
|
||||
salesUnitSelect.innerHTML = '<option value="">---------</option>';
|
||||
$(salesUnitSelect).trigger('change');
|
||||
@@ -1260,7 +1318,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
calculateFinalPrice();
|
||||
});
|
||||
|
||||
$(variantSelect).on('select2:select', function() {
|
||||
$(variantSelect).on('select2:select', function () {
|
||||
if (salesUnitSelect) {
|
||||
salesUnitSelect.innerHTML = '<option value="">---------</option>';
|
||||
$(salesUnitSelect).trigger('change');
|
||||
@@ -1290,7 +1348,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let selectedFiles = [];
|
||||
|
||||
if (photoInput) {
|
||||
photoInput.addEventListener('change', function(e) {
|
||||
photoInput.addEventListener('change', function (e) {
|
||||
selectedFiles = Array.from(e.target.files);
|
||||
|
||||
if (selectedFiles.length > 0) {
|
||||
@@ -1299,7 +1357,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
reader.onload = function (event) {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-4 col-md-3 col-lg-2';
|
||||
col.innerHTML = `
|
||||
@@ -1315,13 +1373,39 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
} else {
|
||||
photoPreviewContainer.style.display = 'none';
|
||||
photoPreviewContainer.style.display = 'none'; // Only hide if no source photos too (will check later)
|
||||
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';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.removePhoto = function(index) {
|
||||
// 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) {
|
||||
selectedFiles.splice(index, 1);
|
||||
const dataTransfer = new DataTransfer();
|
||||
selectedFiles.forEach(file => dataTransfer.items.add(file));
|
||||
@@ -1413,7 +1497,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Обработчик для кнопки "Пополнить базу названиям<D18F><D0BC>"
|
||||
const populateNamesBtn = document.getElementById('populateNamesBtn');
|
||||
if (populateNamesBtn) {
|
||||
populateNamesBtn.addEventListener('click', async function() {
|
||||
populateNamesBtn.addEventListener('click', async function () {
|
||||
const originalHTML = populateNamesBtn.innerHTML;
|
||||
populateNamesBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Пополнение...';
|
||||
populateNamesBtn.disabled = true;
|
||||
@@ -1425,7 +1509,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({'count': 100})
|
||||
body: new URLSearchParams({ 'count': 100 })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -1512,7 +1596,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.btn-take-name').forEach(button => {
|
||||
// Проверяем, был ли уже добавлен обработчик
|
||||
if (!button.dataset.handlerAttached) {
|
||||
button.addEventListener('click', async function() {
|
||||
button.addEventListener('click', async function () {
|
||||
const row = this.closest('.name-row');
|
||||
const nameText = row.querySelector('.name-text').textContent;
|
||||
const nameId = row.getAttribute('data-name-id');
|
||||
@@ -1546,7 +1630,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.btn-delete-name').forEach(button => {
|
||||
// Проверяем, был ли уже добавлен обработчик
|
||||
if (!button.dataset.handlerAttached) {
|
||||
button.addEventListener('click', async function() {
|
||||
button.addEventListener('click', async function () {
|
||||
const row = this.closest('.name-row');
|
||||
const nameId = row.getAttribute('data-name-id');
|
||||
|
||||
@@ -1632,7 +1716,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// Инициализация обработчиков кнопок
|
||||
document.addEventListener('click', function(e) {
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.classList.contains('btn-take-name') || e.target.classList.contains('btn-delete-name')) {
|
||||
attachNameRowHandlers();
|
||||
}
|
||||
@@ -1641,7 +1725,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// ========== ВАЛИДАЦИЯ ПЕРЕД ОТПРАВКОЙ ==========
|
||||
const kitForm = document.querySelector('form[method="post"]');
|
||||
if (kitForm) {
|
||||
kitForm.addEventListener('submit', function(e) {
|
||||
kitForm.addEventListener('submit', function (e) {
|
||||
const formsContainer = document.getElementById('kititem-forms');
|
||||
if (formsContainer) {
|
||||
const allForms = formsContainer.querySelectorAll('.kititem-form');
|
||||
@@ -1671,6 +1755,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
allSelects.forEach(select => select.disabled = false);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -506,6 +506,9 @@
|
||||
<a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary">
|
||||
Отмена
|
||||
</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">
|
||||
<i class="bi bi-check-circle me-1"></i>Сохранить изменения
|
||||
</button>
|
||||
|
||||
@@ -9,9 +9,10 @@ from django.shortcuts import redirect
|
||||
from django.db import transaction, IntegrityError
|
||||
|
||||
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 .utils import handle_photos
|
||||
import os
|
||||
|
||||
|
||||
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||||
@@ -97,6 +98,37 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
form_class = ProductKitForm
|
||||
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):
|
||||
"""
|
||||
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
||||
@@ -132,7 +164,6 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
||||
|
||||
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
|
||||
from ..models import Product, ProductVariantGroup, ProductSalesUnit
|
||||
selected_products = {}
|
||||
selected_variants = {}
|
||||
selected_sales_units = {}
|
||||
@@ -194,9 +225,99 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
context['selected_products'] = selected_products
|
||||
context['selected_variants'] = selected_variants
|
||||
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:
|
||||
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()
|
||||
|
||||
@@ -235,6 +356,48 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
# Обработка фотографий
|
||||
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(
|
||||
self.request,
|
||||
f'Комплект "{self.object.name}" успешно создан!'
|
||||
|
||||
120
myproject/reproduce_issue.py
Normal file
120
myproject/reproduce_issue.py
Normal 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()
|
||||
Reference in New Issue
Block a user