Compare commits
14 Commits
5a66d492c8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 90906944dd | |||
| 7b6a86bdf2 | |||
| c80e2a5eca | |||
| 2aed6f60e5 | |||
| 2dc397b3ce | |||
| 9a7c0728f0 | |||
| 67ad0e50ee | |||
| 32bc0d2c39 | |||
| f140469a56 | |||
| d947f4eee7 | |||
| 5700314b10 | |||
| b24a0d9f21 | |||
| 034be20a5a | |||
| f75e861bb8 |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -394,14 +394,21 @@
|
|||||||
const emptyMessage = document.getElementById('empty-lines-message');
|
const emptyMessage = document.getElementById('empty-lines-message');
|
||||||
if (emptyMessage) emptyMessage.remove();
|
if (emptyMessage) emptyMessage.remove();
|
||||||
|
|
||||||
// Добавляем новую строку
|
// Добавляем новую строку в начало таблицы
|
||||||
const newRow = self.createLineRow(data.line);
|
const newRow = self.createLineRow(data.line);
|
||||||
tbody.appendChild(newRow);
|
tbody.insertBefore(newRow, tbody.firstChild);
|
||||||
|
|
||||||
// Включаем кнопку завершения
|
// Включаем кнопку завершения
|
||||||
const completeBtn = document.getElementById('complete-inventory-btn');
|
const completeBtn = document.getElementById('complete-inventory-btn');
|
||||||
if (completeBtn) completeBtn.disabled = false;
|
if (completeBtn) completeBtn.disabled = false;
|
||||||
|
|
||||||
|
// Фокус на поле ввода количества в новой строке
|
||||||
|
const quantityInput = newRow.querySelector('.quantity-fact-input');
|
||||||
|
if (quantityInput) {
|
||||||
|
quantityInput.focus();
|
||||||
|
quantityInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
this.showNotification('Товар добавлен', 'success');
|
this.showNotification('Товар добавлен', 'success');
|
||||||
} else {
|
} else {
|
||||||
this.showNotification('Ошибка: ' + (data.error || 'Не удалось добавить товар'), 'error');
|
this.showNotification('Ошибка: ' + (data.error || 'Не удалось добавить товар'), 'error');
|
||||||
|
|||||||
@@ -20,10 +20,12 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Основной контент - одна колонка -->
|
<!-- Основной контент - одна колонка -->
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<!-- Информация о документе -->
|
<!-- Информация о документе - свернута по умолчанию -->
|
||||||
<div class="card border-0 shadow-sm mb-3">
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0">
|
<button class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2" type="button" data-bs-toggle="collapse" data-bs-target="#document-info-collapse" aria-expanded="false" aria-controls="document-info-collapse">
|
||||||
|
<i class="bi bi-chevron-down" id="document-info-collapse-icon"></i>
|
||||||
|
<span>
|
||||||
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
|
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
|
||||||
{% if document.status == 'draft' %}
|
{% if document.status == 'draft' %}
|
||||||
<span class="badge bg-warning text-dark ms-2">Черновик</span>
|
<span class="badge bg-warning text-dark ms-2">Черновик</span>
|
||||||
@@ -32,7 +34,8 @@
|
|||||||
{% elif document.status == 'cancelled' %}
|
{% elif document.status == 'cancelled' %}
|
||||||
<span class="badge bg-secondary ms-2">Отменён</span>
|
<span class="badge bg-secondary ms-2">Отменён</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h5>
|
</span>
|
||||||
|
</button>
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
|
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="collapse" id="document-info-collapse">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -98,17 +102,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Добавление позиции -->
|
<!-- Добавление позиции -->
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<div class="card border-0 shadow-sm mb-3">
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
<div class="card-header bg-light py-3">
|
<div class="card-header bg-light py-1">
|
||||||
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить позицию в документ</h6>
|
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить позицию в документ</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-2">
|
||||||
<!-- Компонент поиска товаров -->
|
<!-- Компонент поиска товаров - компактный -->
|
||||||
<div class="mb-3">
|
<div class="mb-2">
|
||||||
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
|
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар...' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать' content_height='150px' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Информация о выбранном товаре -->
|
<!-- Информация о выбранном товаре -->
|
||||||
@@ -217,11 +222,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||||
<span class="item-cost-price-display">{{ item.cost_price|floatformat:2 }}</span>
|
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
|
<span class="editable-cost-price"
|
||||||
|
data-item-id="{{ item.id }}"
|
||||||
|
data-current-value="{{ item.cost_price }}"
|
||||||
|
title="Закупочная цена (клик для редактирования)"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
{{ item.cost_price|floatformat:2 }}
|
||||||
|
</span>
|
||||||
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
||||||
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
||||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||||
|
{% else %}
|
||||||
|
<span>{{ item.cost_price|floatformat:2 }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||||
@@ -271,24 +284,13 @@
|
|||||||
</td>
|
</td>
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<td class="px-3 py-2 text-end" style="width: 100px;">
|
<td class="px-3 py-2 text-end" style="width: 100px;">
|
||||||
<div class="btn-group btn-group-sm item-action-buttons">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-outline-primary btn-edit-item" title="Редактировать">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-danger"
|
<button type="button" class="btn btn-outline-danger"
|
||||||
onclick="if(confirm('Удалить позицию?')) document.getElementById('delete-form-{{ item.id }}').submit();"
|
onclick="if(confirm('Удалить позицию?')) document.getElementById('delete-form-{{ item.id }}').submit();"
|
||||||
title="Удалить">
|
title="Удалить">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm item-edit-buttons" style="display: none;">
|
|
||||||
<button type="button" class="btn btn-success btn-save-item" title="Сохранить">
|
|
||||||
<i class="bi bi-check-lg"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary btn-cancel-edit" title="Отменить">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="delete-form-{{ item.id }}" method="post"
|
<form id="delete-form-{{ item.id }}" method="post"
|
||||||
action="{% url 'inventory:incoming-remove-item' document.pk item.pk %}"
|
action="{% url 'inventory:incoming-remove-item' document.pk item.pk %}"
|
||||||
style="display: none;">
|
style="display: none;">
|
||||||
@@ -351,6 +353,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Анимация для иконки сворачивания/разворачивания информации о документе
|
||||||
|
const documentInfoCollapse = document.getElementById('document-info-collapse');
|
||||||
|
const documentInfoCollapseIcon = document.getElementById('document-info-collapse-icon');
|
||||||
|
|
||||||
|
if (documentInfoCollapse && documentInfoCollapseIcon) {
|
||||||
|
documentInfoCollapse.addEventListener('show.bs.collapse', function() {
|
||||||
|
documentInfoCollapseIcon.classList.remove('bi-chevron-down');
|
||||||
|
documentInfoCollapseIcon.classList.add('bi-chevron-up');
|
||||||
|
});
|
||||||
|
|
||||||
|
documentInfoCollapse.addEventListener('hide.bs.collapse', function() {
|
||||||
|
documentInfoCollapseIcon.classList.remove('bi-chevron-up');
|
||||||
|
documentInfoCollapseIcon.classList.add('bi-chevron-down');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Функция выбора товара
|
// Функция выбора товара
|
||||||
function selectProduct(product) {
|
function selectProduct(product) {
|
||||||
const productId = String(product.id).replace('product_', '');
|
const productId = String(product.id).replace('product_', '');
|
||||||
@@ -416,160 +434,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
|
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Inline редактирование позиций в таблице
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// Хранилище оригинальных значений при редактировании
|
|
||||||
const originalValues = {};
|
|
||||||
|
|
||||||
// Обработчики для кнопок редактирования
|
|
||||||
document.querySelectorAll('.btn-edit-item').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const row = this.closest('tr');
|
|
||||||
const itemId = row.dataset.itemId;
|
|
||||||
|
|
||||||
// Сохраняем оригинальные значения
|
|
||||||
originalValues[itemId] = {
|
|
||||||
quantity: row.querySelector('.item-quantity-input').value,
|
|
||||||
cost_price: row.querySelector('.item-cost-price-input').value,
|
|
||||||
notes: row.querySelector('.item-notes-input').value
|
|
||||||
};
|
|
||||||
|
|
||||||
// Переключаем в режим редактирования
|
|
||||||
toggleEditMode(row, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обработчики для кнопок сохранения
|
|
||||||
document.querySelectorAll('.btn-save-item').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const row = this.closest('tr');
|
|
||||||
const itemId = row.dataset.itemId;
|
|
||||||
saveItemChanges(itemId, row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обработчики для кнопок отмены
|
|
||||||
document.querySelectorAll('.btn-cancel-edit').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const row = this.closest('tr');
|
|
||||||
const itemId = row.dataset.itemId;
|
|
||||||
|
|
||||||
// Восстанавливаем оригинальные значения
|
|
||||||
if (originalValues[itemId]) {
|
|
||||||
row.querySelector('.item-quantity-input').value = originalValues[itemId].quantity;
|
|
||||||
row.querySelector('.item-cost-price-input').value = originalValues[itemId].cost_price;
|
|
||||||
row.querySelector('.item-notes-input').value = originalValues[itemId].notes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Выходим из режима редактирования
|
|
||||||
toggleEditMode(row, false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Переключение режима редактирования строки
|
|
||||||
*/
|
|
||||||
function toggleEditMode(row, isEditing) {
|
|
||||||
// Переключаем видимость полей отображения/ввода
|
|
||||||
row.querySelectorAll('.item-quantity-display, .item-cost-price-display, .item-notes-display').forEach(el => {
|
|
||||||
el.style.display = isEditing ? 'none' : '';
|
|
||||||
});
|
|
||||||
row.querySelectorAll('.item-quantity-input, .item-cost-price-input, .item-notes-input').forEach(el => {
|
|
||||||
el.style.display = isEditing ? '' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Переключаем видимость кнопок
|
|
||||||
row.querySelector('.item-action-buttons').style.display = isEditing ? 'none' : '';
|
|
||||||
row.querySelector('.item-edit-buttons').style.display = isEditing ? '' : 'none';
|
|
||||||
|
|
||||||
// Фокус на поле количества при входе в режим редактирования
|
|
||||||
if (isEditing) {
|
|
||||||
const qtyInput = row.querySelector('.item-quantity-input');
|
|
||||||
if (qtyInput) {
|
|
||||||
qtyInput.focus();
|
|
||||||
qtyInput.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сохранение изменений позиции
|
|
||||||
*/
|
|
||||||
function saveItemChanges(itemId, row) {
|
|
||||||
const quantity = row.querySelector('.item-quantity-input').value;
|
|
||||||
const costPrice = row.querySelector('.item-cost-price-input').value;
|
|
||||||
const notes = row.querySelector('.item-notes-input').value;
|
|
||||||
|
|
||||||
// Валидация
|
|
||||||
if (!quantity || parseFloat(quantity) <= 0) {
|
|
||||||
alert('Количество должно быть больше нуля');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!costPrice || parseFloat(costPrice) < 0) {
|
|
||||||
alert('Закупочная цена не может быть отрицательной');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем на сервер
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('quantity', quantity);
|
|
||||||
formData.append('cost_price', costPrice);
|
|
||||||
formData.append('notes', notes);
|
|
||||||
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
|
|
||||||
|
|
||||||
// Блокируем кнопки во время сохранения
|
|
||||||
const saveBtn = row.querySelector('.btn-save-item');
|
|
||||||
const cancelBtn = row.querySelector('.btn-cancel-edit');
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
cancelBtn.disabled = true;
|
|
||||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
|
||||||
|
|
||||||
fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
// Обновляем отображение
|
|
||||||
let formattedQty = parseFloat(quantity);
|
|
||||||
if (formattedQty === Math.floor(formattedQty)) {
|
|
||||||
formattedQty = Math.floor(formattedQty).toString();
|
|
||||||
} else {
|
|
||||||
formattedQty = formattedQty.toString().replace('.', ',');
|
|
||||||
}
|
|
||||||
row.querySelector('.item-quantity-display').textContent = formattedQty;
|
|
||||||
row.querySelector('.item-cost-price-display').textContent = parseFloat(costPrice).toFixed(2);
|
|
||||||
row.querySelector('.item-notes-display').textContent = notes || '-';
|
|
||||||
|
|
||||||
// Пересчитываем сумму
|
|
||||||
const totalCost = (parseFloat(quantity) * parseFloat(costPrice)).toFixed(2);
|
|
||||||
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
|
||||||
|
|
||||||
// Выходим из режима редактирования
|
|
||||||
toggleEditMode(row, false);
|
|
||||||
} else {
|
|
||||||
alert('Ошибка: ' + data.error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Произошла ошибка при сохранении');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
cancelBtn.disabled = false;
|
|
||||||
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Inline редактирование количества
|
// Inline редактирование количества и цены
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
function initInlineQuantityEdit() {
|
function initInlineQuantityEdit() {
|
||||||
@@ -708,14 +576,146 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initInlineCostPriceEdit() {
|
||||||
|
// Проверяем, есть ли на странице редактируемые цены
|
||||||
|
const editableCostPrices = document.querySelectorAll('.editable-cost-price');
|
||||||
|
if (editableCostPrices.length === 0) {
|
||||||
|
return; // Нет элементов для редактирования
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик клика на редактируемую цену
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const costPriceSpan = e.target.closest('.editable-cost-price');
|
||||||
|
if (!costPriceSpan) return;
|
||||||
|
|
||||||
|
// Предотвращаем повторное срабатывание, если уже редактируем
|
||||||
|
if (costPriceSpan.querySelector('input')) return;
|
||||||
|
|
||||||
|
const itemId = costPriceSpan.dataset.itemId;
|
||||||
|
const currentValue = costPriceSpan.dataset.currentValue;
|
||||||
|
|
||||||
|
// Сохраняем оригинальный HTML
|
||||||
|
const originalHTML = costPriceSpan.innerHTML;
|
||||||
|
|
||||||
|
// Создаем input для редактирования
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = 'form-control form-control-sm';
|
||||||
|
input.style.width = '100px';
|
||||||
|
input.style.textAlign = 'right';
|
||||||
|
input.value = parseFloat(currentValue).toFixed(2);
|
||||||
|
input.step = '0.01';
|
||||||
|
input.min = '0';
|
||||||
|
input.placeholder = 'Цена';
|
||||||
|
|
||||||
|
// Заменяем содержимое на input
|
||||||
|
costPriceSpan.innerHTML = '';
|
||||||
|
costPriceSpan.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
// Функция сохранения
|
||||||
|
const saveCostPrice = async () => {
|
||||||
|
let newValue = input.value.trim();
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!newValue || parseFloat(newValue) < 0) {
|
||||||
|
alert('Закупочная цена не может быть отрицательной');
|
||||||
|
costPriceSpan.innerHTML = originalHTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, изменилось ли значение
|
||||||
|
if (parseFloat(newValue) === parseFloat(currentValue)) {
|
||||||
|
// Значение не изменилось
|
||||||
|
costPriceSpan.innerHTML = originalHTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем загрузку
|
||||||
|
input.disabled = true;
|
||||||
|
input.style.opacity = '0.5';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем текущие значения других полей
|
||||||
|
const row = costPriceSpan.closest('tr');
|
||||||
|
const quantity = row.querySelector('.item-quantity-input').value;
|
||||||
|
const notes = row.querySelector('.item-notes-input').value;
|
||||||
|
|
||||||
|
const response = await fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
quantity: quantity,
|
||||||
|
cost_price: newValue,
|
||||||
|
notes: notes
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Обновляем отображение
|
||||||
|
const formattedPrice = parseFloat(newValue).toFixed(2);
|
||||||
|
costPriceSpan.textContent = formattedPrice;
|
||||||
|
costPriceSpan.dataset.currentValue = newValue;
|
||||||
|
|
||||||
|
// Пересчитываем сумму
|
||||||
|
const totalCost = (parseFloat(quantity) * parseFloat(newValue)).toFixed(2);
|
||||||
|
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
||||||
|
|
||||||
|
// Обновляем итого
|
||||||
|
updateTotals();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Ошибка при обновлении цены');
|
||||||
|
costPriceSpan.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка сети при обновлении цены');
|
||||||
|
costPriceSpan.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция отмены
|
||||||
|
const cancelEdit = () => {
|
||||||
|
costPriceSpan.innerHTML = originalHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enter - сохранить
|
||||||
|
input.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveCostPrice();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Потеря фокуса - сохранить
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
setTimeout(saveCostPrice, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Функция обновления итоговых сумм
|
// Функция обновления итоговых сумм
|
||||||
function updateTotals() {
|
function updateTotals() {
|
||||||
// Можно реализовать пересчет итогов, если нужно
|
// Можно реализовать пересчет итогов, если нужно
|
||||||
// Пока оставим как есть, так как сервер возвращает обновленные данные
|
// Пока оставим как есть, так как сервер возвращает обновленные данные
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация inline редактирования количества
|
// Инициализация inline редактирования
|
||||||
initInlineQuantityEdit();
|
initInlineQuantityEdit();
|
||||||
|
initInlineCostPriceEdit();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -745,6 +745,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
color: #0d6efd !important;
|
color: #0d6efd !important;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для редактируемой цены */
|
||||||
|
.editable-cost-price {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-cost-price:hover {
|
||||||
|
color: #0d6efd !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<!-- Информация об инвентаризации - свернута по умолчанию -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h5>Информация</h5>
|
<button class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#inventory-info-collapse" aria-expanded="false" aria-controls="inventory-info-collapse">
|
||||||
|
<i class="bi bi-chevron-down" id="info-collapse-icon"></i>
|
||||||
|
<span>Информация</span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse" id="inventory-info-collapse">
|
||||||
<table class="table table-borderless">
|
<table class="table table-borderless">
|
||||||
{% if inventory.document_number %}
|
{% if inventory.document_number %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -64,6 +69,25 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Добавляем простую анимацию для иконки при сворачивании/разворачивании
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const collapseElement = document.getElementById('inventory-info-collapse');
|
||||||
|
const collapseIcon = document.getElementById('info-collapse-icon');
|
||||||
|
|
||||||
|
collapseElement.addEventListener('show.bs.collapse', function() {
|
||||||
|
collapseIcon.classList.remove('bi-chevron-down');
|
||||||
|
collapseIcon.classList.add('bi-chevron-up');
|
||||||
|
});
|
||||||
|
|
||||||
|
collapseElement.addEventListener('hide.bs.collapse', function() {
|
||||||
|
collapseIcon.classList.remove('bi-chevron-up');
|
||||||
|
collapseIcon.classList.add('bi-chevron-down');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% if inventory.status == 'completed' %}
|
{% if inventory.status == 'completed' %}
|
||||||
<!-- Информация о созданных документах -->
|
<!-- Информация о созданных документах -->
|
||||||
@@ -100,14 +124,14 @@
|
|||||||
|
|
||||||
<h5>Строки инвентаризации</h5>
|
<h5>Строки инвентаризации</h5>
|
||||||
|
|
||||||
<!-- Компонент поиска товаров (только если не завершена) -->
|
<!-- Компонент поиска товаров - открыт по умолчанию, компактный -->
|
||||||
{% if inventory.status != 'completed' %}
|
{% if inventory.status != 'completed' %}
|
||||||
<div class="card border-primary mb-4" id="product-search-section">
|
<div class="card border-primary mb-4" id="product-search-section">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light py-1">
|
||||||
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
|
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-2">
|
||||||
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара для инвентаризации...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить товар' content_height='250px' skip_stock_filter=True %}
|
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить' content_height='150px' skip_stock_filter=True %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -143,13 +147,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.breadcrumb-sm {
|
.breadcrumb-sm {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-hover tbody tr:hover {
|
.table-hover tbody tr:hover {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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())\""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,35 +132,10 @@ class Delivery(models.Model):
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Для не-черновиков полная валидация
|
# Для не-черновиков ранее действовала строгая валидация даты и адреса.
|
||||||
|
# В рамках новой логики разрешаем сохранять заказы в любом статусе без адреса
|
||||||
# Проверка: дата доставки обязательна
|
# и без обязательной даты доставки. Сохраняем только базовые проверки
|
||||||
if not self.delivery_date:
|
# непротиворечивости данных.
|
||||||
raise ValidationError({
|
|
||||||
'delivery_date': 'Для не-черновиков дата доставки обязательна'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Проверка: для курьерской доставки должен быть адрес
|
|
||||||
if self.delivery_type == self.DELIVERY_TYPE_COURIER:
|
|
||||||
if not self.address:
|
|
||||||
raise ValidationError({
|
|
||||||
'address': 'Для курьерской доставки необходимо указать адрес'
|
|
||||||
})
|
|
||||||
if self.pickup_warehouse:
|
|
||||||
raise ValidationError({
|
|
||||||
'pickup_warehouse': 'Для курьерской доставки склад не указывается'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Проверка: для самовывоза должен быть склад
|
|
||||||
if self.delivery_type == self.DELIVERY_TYPE_PICKUP:
|
|
||||||
if not self.pickup_warehouse:
|
|
||||||
raise ValidationError({
|
|
||||||
'pickup_warehouse': 'Для самовывоза необходимо указать склад'
|
|
||||||
})
|
|
||||||
if self.address:
|
|
||||||
raise ValidationError({
|
|
||||||
'address': 'Для самовывоза адрес не указывается'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Проверка: время "до" не может быть раньше времени "от" (равные времена разрешены для POS)
|
# Проверка: время "до" не может быть раньше времени "от" (равные времена разрешены для POS)
|
||||||
if self.time_from and self.time_to and self.time_from > self.time_to:
|
if self.time_from and self.time_to and self.time_from > self.time_to:
|
||||||
|
|||||||
@@ -581,7 +581,7 @@
|
|||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="{{ form.address_street.id_for_label }}" class="form-label">
|
<label for="{{ form.address_street.id_for_label }}" class="form-label">
|
||||||
Улица <span class="text-danger">*</span>
|
Улица
|
||||||
</label>
|
</label>
|
||||||
{{ form.address_street }}
|
{{ form.address_street }}
|
||||||
{% if form.address_street.errors %}
|
{% if form.address_street.errors %}
|
||||||
@@ -590,7 +590,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label for="{{ form.address_building_number.id_for_label }}" class="form-label">
|
<label for="{{ form.address_building_number.id_for_label }}" class="form-label">
|
||||||
Дом <span class="text-danger">*</span>
|
Дом
|
||||||
</label>
|
</label>
|
||||||
{{ form.address_building_number }}
|
{{ form.address_building_number }}
|
||||||
{% if form.address_building_number.errors %}
|
{% if form.address_building_number.errors %}
|
||||||
|
|||||||
@@ -16,11 +16,18 @@ from inventory.models import Reservation
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
from django.utils import timezone # Added for default date filter
|
||||||
|
|
||||||
def order_list(request):
|
def order_list(request):
|
||||||
"""
|
"""
|
||||||
Список всех заказов с фильтрацией и поиском
|
Список всех заказов с фильтрацией и поиском
|
||||||
Использует django-filter для фильтрации данных
|
Использует django-filter для фильтрации данных
|
||||||
"""
|
"""
|
||||||
|
# Если параметров нет вообще (первый заход), редиректим на "Сегодня"
|
||||||
|
if not request.GET:
|
||||||
|
today = timezone.localdate().isoformat()
|
||||||
|
return redirect(f'{request.path}?delivery_date_after={today}&delivery_date_before={today}')
|
||||||
|
|
||||||
# Базовый queryset с оптимизацией запросов
|
# Базовый queryset с оптимизацией запросов
|
||||||
orders = Order.objects.select_related(
|
orders = Order.objects.select_related(
|
||||||
'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
|
'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
|
||||||
@@ -225,7 +232,8 @@ def order_create(request):
|
|||||||
address.save()
|
address.save()
|
||||||
|
|
||||||
# Создаем или обновляем Delivery
|
# Создаем или обновляем Delivery
|
||||||
# Для черновиков создаем Delivery без обязательных проверок, чтобы сохранить адрес
|
# Ранее для не-черновиков адрес курьерской доставки был обязателен.
|
||||||
|
# Теперь разрешаем сохранять заказ в любом статусе без адреса.
|
||||||
if is_draft:
|
if is_draft:
|
||||||
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
|
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
|
||||||
if address or delivery_type or pickup_warehouse or delivery_date:
|
if address or delivery_type or pickup_warehouse or delivery_date:
|
||||||
@@ -242,23 +250,12 @@ def order_create(request):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Для не-черновиков проверяем обязательные поля
|
# Для не-черновиков больше не требуем обязательного адреса.
|
||||||
if not delivery_type or not delivery_date:
|
# Если пользователь вообще не указал тип доставки и дату, просто не создаём Delivery.
|
||||||
raise ValidationError('Необходимо указать способ доставки и дату доставки')
|
if address or delivery_type or pickup_warehouse or delivery_date:
|
||||||
|
|
||||||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
|
||||||
# Для курьерской доставки нужен адрес
|
|
||||||
if not address:
|
|
||||||
raise ValidationError('Для курьерской доставки необходимо указать адрес')
|
|
||||||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
|
||||||
# Для самовывоза нужен склад
|
|
||||||
if not pickup_warehouse:
|
|
||||||
raise ValidationError('Для самовывоза необходимо выбрать склад')
|
|
||||||
|
|
||||||
# Создаем Delivery
|
|
||||||
delivery = Delivery.objects.create(
|
delivery = Delivery.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
delivery_type=delivery_type,
|
delivery_type=delivery_type or Delivery.DELIVERY_TYPE_COURIER,
|
||||||
delivery_date=delivery_date,
|
delivery_date=delivery_date,
|
||||||
time_from=time_from,
|
time_from=time_from,
|
||||||
time_to=time_to,
|
time_to=time_to,
|
||||||
@@ -458,7 +455,8 @@ def order_update(request, order_number):
|
|||||||
address.save()
|
address.save()
|
||||||
|
|
||||||
# Создаем или обновляем Delivery
|
# Создаем или обновляем Delivery
|
||||||
# Для черновиков создаем Delivery без обязательных проверок, чтобы сохранить адрес
|
# Ранее для не-черновиков адрес курьерской доставки был обязателен.
|
||||||
|
# Теперь разрешаем сохранять заказ в любом статусе без адреса.
|
||||||
if is_draft:
|
if is_draft:
|
||||||
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
|
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
|
||||||
if address or delivery_type or pickup_warehouse or delivery_date:
|
if address or delivery_type or pickup_warehouse or delivery_date:
|
||||||
@@ -474,29 +472,21 @@ def order_update(request, order_number):
|
|||||||
'cost': delivery_cost if delivery_cost else Decimal('0')
|
'cost': delivery_cost if delivery_cost else Decimal('0')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
elif hasattr(order, 'delivery'):
|
elif hasattr(order, 'delivery'):
|
||||||
|
# Если все данные доставки очищены, удаляем существующую Delivery
|
||||||
order.delivery.delete()
|
order.delivery.delete()
|
||||||
else:
|
else:
|
||||||
# Для не-черновиков проверяем обязательные поля
|
# Для не-черновиков больше не требуем обязательного адреса.
|
||||||
if not delivery_type or not delivery_date:
|
# Если пользователь вообще не указал данные доставки, удаляем Delivery (если она была).
|
||||||
raise ValidationError('Необходимо указать способ доставки и дату доставки')
|
if not (address or delivery_type or pickup_warehouse or delivery_date):
|
||||||
|
if hasattr(order, 'delivery'):
|
||||||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
order.delivery.delete()
|
||||||
# Для курьерской доставки нужен адрес
|
else:
|
||||||
if not address:
|
# Создаем или обновляем Delivery с теми данными, что есть.
|
||||||
raise ValidationError('Для курьерской доставки необходимо указать адрес')
|
|
||||||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
|
||||||
# Для самовывоза нужен склад
|
|
||||||
if not pickup_warehouse:
|
|
||||||
raise ValidationError('Для самовывоза необходимо выбрать склад')
|
|
||||||
|
|
||||||
# Создаем или обновляем Delivery
|
|
||||||
delivery, created = Delivery.objects.update_or_create(
|
delivery, created = Delivery.objects.update_or_create(
|
||||||
order=order,
|
order=order,
|
||||||
defaults={
|
defaults={
|
||||||
'delivery_type': delivery_type,
|
'delivery_type': delivery_type or Delivery.DELIVERY_TYPE_COURIER,
|
||||||
'delivery_date': delivery_date,
|
'delivery_date': delivery_date,
|
||||||
'time_from': time_from,
|
'time_from': time_from,
|
||||||
'time_to': time_to,
|
'time_to': time_to,
|
||||||
@@ -505,8 +495,6 @@ def order_update(request, order_number):
|
|||||||
'cost': delivery_cost if delivery_cost else Decimal('0')
|
'cost': delivery_cost if delivery_cost else Decimal('0')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Пересчитываем итоговую стоимость
|
# Пересчитываем итоговую стоимость
|
||||||
order.calculate_total()
|
order.calculate_total()
|
||||||
order.update_payment_status()
|
order.update_payment_status()
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -272,12 +304,12 @@ function initCustomerSelect2() {
|
|||||||
url: '/customers/api/search/',
|
url: '/customers/api/search/',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
delay: 300,
|
delay: 300,
|
||||||
data: function(params) {
|
data: function (params) {
|
||||||
return {
|
return {
|
||||||
q: params.term
|
q: params.term
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
processResults: function(data) {
|
processResults: function (data) {
|
||||||
return {
|
return {
|
||||||
results: data.results
|
results: data.results
|
||||||
};
|
};
|
||||||
@@ -289,7 +321,7 @@ function initCustomerSelect2() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Обработка выбора клиента из списка
|
// Обработка выбора клиента из списка
|
||||||
$searchInput.on('select2:select', function(e) {
|
$searchInput.on('select2:select', function (e) {
|
||||||
const data = e.params.data;
|
const data = e.params.data;
|
||||||
|
|
||||||
// Проверяем это не опция "Создать нового клиента"
|
// Проверяем это не опция "Создать нового клиента"
|
||||||
@@ -740,6 +772,8 @@ function renderCategories() {
|
|||||||
showcaseCard.onclick = async () => {
|
showcaseCard.onclick = async () => {
|
||||||
isShowcaseView = true;
|
isShowcaseView = true;
|
||||||
currentCategoryId = null;
|
currentCategoryId = null;
|
||||||
|
currentSearchQuery = ''; // Сбрасываем поиск
|
||||||
|
document.getElementById('searchInput').value = ''; // Очищаем поле поиска
|
||||||
await refreshShowcaseKits(); // Загружаем свежие данные
|
await refreshShowcaseKits(); // Загружаем свежие данные
|
||||||
renderCategories();
|
renderCategories();
|
||||||
renderProducts();
|
renderProducts();
|
||||||
@@ -759,6 +793,7 @@ function renderCategories() {
|
|||||||
allCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2';
|
allCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2';
|
||||||
const allCard = document.createElement('div');
|
const allCard = document.createElement('div');
|
||||||
allCard.className = 'card category-card' + (currentCategoryId === null && !isShowcaseView ? ' active' : '');
|
allCard.className = 'card category-card' + (currentCategoryId === null && !isShowcaseView ? ' active' : '');
|
||||||
|
allCard.dataset.categoryId = 'all'; // Для идентификации в updateSearchIndicator
|
||||||
allCard.onclick = async () => {
|
allCard.onclick = async () => {
|
||||||
currentCategoryId = null;
|
currentCategoryId = null;
|
||||||
isShowcaseView = false;
|
isShowcaseView = false;
|
||||||
@@ -813,13 +848,18 @@ function renderProducts() {
|
|||||||
|
|
||||||
let filtered;
|
let filtered;
|
||||||
|
|
||||||
// Если выбран режим витрины - показываем витринные комплекты
|
// Если активен поиск (3+ символов) - показываем результаты поиска по всем товарам
|
||||||
if (isShowcaseView) {
|
// независимо от режима (витрина или обычный)
|
||||||
|
if (currentSearchQuery && currentSearchQuery.length >= 3 && !isShowcaseView) {
|
||||||
|
// Обычный режим - ITEMS уже отфильтрованы на сервере (поиск по всем товарам)
|
||||||
|
filtered = ITEMS;
|
||||||
|
} else if (isShowcaseView) {
|
||||||
|
// Режим витрины - показываем витринные комплекты
|
||||||
filtered = showcaseKits;
|
filtered = showcaseKits;
|
||||||
|
|
||||||
// Для витрины — клиентская фильтрация по поиску
|
// Для витрины — клиентская фильтрация по поиску (только если менее 3 символов)
|
||||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
|
||||||
if (searchTerm) {
|
if (searchTerm && searchTerm.length < 3) {
|
||||||
const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0);
|
const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0);
|
||||||
filtered = filtered.filter(item => {
|
filtered = filtered.filter(item => {
|
||||||
const name = (item.name || '').toLowerCase();
|
const name = (item.name || '').toLowerCase();
|
||||||
@@ -829,7 +869,7 @@ function renderProducts() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Обычный режим - ITEMS уже отфильтрованы на сервере (категория + поиск)
|
// Обычный режим без поиска - ITEMS отфильтрованы по категории
|
||||||
filtered = ITEMS;
|
filtered = ITEMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1125,7 +1165,8 @@ async function loadItems(append = false) {
|
|||||||
page_size: 60
|
page_size: 60
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentCategoryId) {
|
// При активном поиске игнорируем категорию - ищем по всем товарам
|
||||||
|
if (currentCategoryId && !currentSearchQuery) {
|
||||||
params.append('category_id', currentCategoryId);
|
params.append('category_id', currentCategoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,6 +1224,34 @@ function setupInfiniteScroll() {
|
|||||||
observer.observe(sentinel);
|
observer.observe(sentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== ВИЗУАЛЬНАЯ ИНДИКАЦИЯ ГЛОБАЛЬНОГО ПОИСКА =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет визуальную индикацию глобального поиска.
|
||||||
|
* При активном поиске подсвечивает кнопку "Все товары".
|
||||||
|
*/
|
||||||
|
function updateSearchIndicator() {
|
||||||
|
const allCard = document.querySelector('.category-card[data-category-id="all"]');
|
||||||
|
if (!allCard) return;
|
||||||
|
|
||||||
|
if (currentSearchQuery && currentSearchQuery.length >= 3) {
|
||||||
|
// Активен глобальный поиск - подсвечиваем "Все товары"
|
||||||
|
allCard.classList.add('active');
|
||||||
|
allCard.style.backgroundColor = '#e3f2fd'; // Светло-голубой фон
|
||||||
|
allCard.style.borderColor = '#2196f3'; // Синяя рамка
|
||||||
|
allCard.title = 'Идёт поиск по всем товарам';
|
||||||
|
} else {
|
||||||
|
// Поиск неактивен - возвращаем обычный стиль
|
||||||
|
allCard.style.backgroundColor = '';
|
||||||
|
allCard.style.borderColor = '';
|
||||||
|
allCard.title = '';
|
||||||
|
// Активный класс управляется renderCategories
|
||||||
|
if (currentCategoryId !== null) {
|
||||||
|
allCard.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addToCart(item) {
|
async function addToCart(item) {
|
||||||
// ПРОВЕРКА НА НАЛИЧИЕ ЕДИНИЦ ПРОДАЖИ
|
// ПРОВЕРКА НА НАЛИЧИЕ ЕДИНИЦ ПРОДАЖИ
|
||||||
// Если у товара одна единица продажи - добавляем сразу
|
// Если у товара одна единица продажи - добавляем сразу
|
||||||
@@ -1487,7 +1556,7 @@ function renderCart() {
|
|||||||
row.appendChild(deleteBtn);
|
row.appendChild(deleteBtn);
|
||||||
|
|
||||||
// Обработчик клика для редактирования товара
|
// Обработчик клика для редактирования товара
|
||||||
row.addEventListener('click', function(e) {
|
row.addEventListener('click', function (e) {
|
||||||
// Игнорируем клики на кнопки управления количеством и удаления
|
// Игнорируем клики на кнопки управления количеством и удаления
|
||||||
if (e.target.closest('button') || e.target.closest('input')) {
|
if (e.target.closest('button') || e.target.closest('input')) {
|
||||||
return;
|
return;
|
||||||
@@ -1817,7 +1886,7 @@ async function openCreateTempKitModal() {
|
|||||||
// Копируем содержимое cart в tempCart (изолированное состояние модалки)
|
// Копируем содержимое cart в tempCart (изолированное состояние модалки)
|
||||||
tempCart.clear();
|
tempCart.clear();
|
||||||
cart.forEach((item, key) => {
|
cart.forEach((item, key) => {
|
||||||
tempCart.set(key, {...item}); // Глубокая копия объекта
|
tempCart.set(key, { ...item }); // Глубокая копия объекта
|
||||||
});
|
});
|
||||||
|
|
||||||
// Генерируем название по умолчанию
|
// Генерируем название по умолчанию
|
||||||
@@ -1931,7 +2000,7 @@ async function openEditKitModal(kitId) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.ProductSearchPicker) {
|
if (window.ProductSearchPicker) {
|
||||||
const picker = ProductSearchPicker.init('#temp-kit-product-picker', {
|
const picker = ProductSearchPicker.init('#temp-kit-product-picker', {
|
||||||
onAddSelected: function(product, instance) {
|
onAddSelected: function (product, instance) {
|
||||||
if (product) {
|
if (product) {
|
||||||
// Добавляем товар в tempCart
|
// Добавляем товар в tempCart
|
||||||
const cartKey = `product-${product.id}`;
|
const cartKey = `product-${product.id}`;
|
||||||
@@ -2133,11 +2202,63 @@ function renderTempKitItems() {
|
|||||||
// Левая часть: название и цена
|
// Левая часть: название и цена
|
||||||
const leftDiv = document.createElement('div');
|
const leftDiv = document.createElement('div');
|
||||||
leftDiv.className = 'flex-grow-1';
|
leftDiv.className = 'flex-grow-1';
|
||||||
leftDiv.innerHTML = `
|
|
||||||
<strong class="small">${item.name}</strong>
|
// Название товара
|
||||||
<br>
|
const nameSpan = document.createElement('strong');
|
||||||
<small class="text-muted">${formatMoney(item.price)} руб. / шт.</small>
|
nameSpan.className = 'small';
|
||||||
`;
|
nameSpan.textContent = item.name;
|
||||||
|
leftDiv.appendChild(nameSpan);
|
||||||
|
leftDiv.appendChild(document.createElement('br'));
|
||||||
|
|
||||||
|
// Цена с возможностью редактирования
|
||||||
|
const priceContainer = document.createElement('div');
|
||||||
|
priceContainer.className = 'd-inline-flex align-items-center gap-1';
|
||||||
|
|
||||||
|
// Отображение цены (кликабельное)
|
||||||
|
const priceDisplay = document.createElement('small');
|
||||||
|
priceDisplay.className = 'text-muted price-display';
|
||||||
|
priceDisplay.style.cursor = 'pointer';
|
||||||
|
priceDisplay.innerHTML = `<u>${formatMoney(item.price)}</u> руб. / шт.`;
|
||||||
|
priceDisplay.title = 'Кликните для изменения цены';
|
||||||
|
|
||||||
|
// Поле ввода (скрыто по умолчанию)
|
||||||
|
const priceInput = document.createElement('input');
|
||||||
|
priceInput.type = 'number';
|
||||||
|
priceInput.step = '0.01';
|
||||||
|
priceInput.className = 'form-control form-control-sm';
|
||||||
|
priceInput.style.width = '80px';
|
||||||
|
priceInput.style.display = 'none';
|
||||||
|
priceInput.value = item.price;
|
||||||
|
|
||||||
|
// Клик на цену — показать input
|
||||||
|
priceDisplay.onclick = () => {
|
||||||
|
priceDisplay.style.display = 'none';
|
||||||
|
priceInput.style.display = 'inline-block';
|
||||||
|
priceInput.focus();
|
||||||
|
priceInput.select();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Потеря фокуса или Enter — сохранить и скрыть input
|
||||||
|
const savePrice = () => {
|
||||||
|
const newPrice = parseFloat(priceInput.value) || 0;
|
||||||
|
item.price = newPrice;
|
||||||
|
priceDisplay.innerHTML = `<u>${formatMoney(newPrice)}</u> руб. / шт.`;
|
||||||
|
priceInput.style.display = 'none';
|
||||||
|
priceDisplay.style.display = 'inline';
|
||||||
|
renderTempKitItems(); // Пересчёт итогов
|
||||||
|
};
|
||||||
|
|
||||||
|
priceInput.onblur = savePrice;
|
||||||
|
priceInput.onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
savePrice();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
priceContainer.appendChild(priceInput);
|
||||||
|
priceContainer.appendChild(priceDisplay);
|
||||||
|
leftDiv.appendChild(priceContainer);
|
||||||
|
|
||||||
// Правая часть: контролы количества и удаление
|
// Правая часть: контролы количества и удаление
|
||||||
const rightDiv = document.createElement('div');
|
const rightDiv = document.createElement('div');
|
||||||
@@ -2265,7 +2386,7 @@ function updatePriceCalculations(basePrice = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обработчики для полей цены
|
// Обработчики для полей цены
|
||||||
document.getElementById('priceAdjustmentType').addEventListener('change', function() {
|
document.getElementById('priceAdjustmentType').addEventListener('change', function () {
|
||||||
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
|
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
|
||||||
if (this.value === 'none') {
|
if (this.value === 'none') {
|
||||||
adjustmentBlock.style.display = 'none';
|
adjustmentBlock.style.display = 'none';
|
||||||
@@ -2276,11 +2397,11 @@ document.getElementById('priceAdjustmentType').addEventListener('change', functi
|
|||||||
updatePriceCalculations();
|
updatePriceCalculations();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('priceAdjustmentValue').addEventListener('input', function() {
|
document.getElementById('priceAdjustmentValue').addEventListener('input', function () {
|
||||||
updatePriceCalculations();
|
updatePriceCalculations();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('useSalePrice').addEventListener('change', function() {
|
document.getElementById('useSalePrice').addEventListener('change', function () {
|
||||||
const salePriceBlock = document.getElementById('salePriceBlock');
|
const salePriceBlock = document.getElementById('salePriceBlock');
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
salePriceBlock.style.display = 'block';
|
salePriceBlock.style.display = 'block';
|
||||||
@@ -2291,12 +2412,12 @@ document.getElementById('useSalePrice').addEventListener('change', function() {
|
|||||||
updatePriceCalculations();
|
updatePriceCalculations();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('salePrice').addEventListener('input', function() {
|
document.getElementById('salePrice').addEventListener('input', function () {
|
||||||
updatePriceCalculations();
|
updatePriceCalculations();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обработчик загрузки фото
|
// Обработчик загрузки фото
|
||||||
document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
|
document.getElementById('tempKitPhoto').addEventListener('change', function (e) {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
@@ -2307,7 +2428,7 @@ document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
|
|||||||
|
|
||||||
// Превью
|
// Превью
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = function(event) {
|
reader.onload = function (event) {
|
||||||
document.getElementById('photoPreviewImg').src = event.target.result;
|
document.getElementById('photoPreviewImg').src = event.target.result;
|
||||||
document.getElementById('photoPreview').style.display = 'block';
|
document.getElementById('photoPreview').style.display = 'block';
|
||||||
};
|
};
|
||||||
@@ -2316,7 +2437,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('tempKitPhoto').value = '';
|
||||||
document.getElementById('photoPreview').style.display = 'none';
|
document.getElementById('photoPreview').style.display = 'none';
|
||||||
document.getElementById('photoPreviewImg').src = '';
|
document.getElementById('photoPreviewImg').src = '';
|
||||||
@@ -2347,7 +2468,8 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
if (item.type === 'product') {
|
if (item.type === 'product') {
|
||||||
items.push({
|
items.push({
|
||||||
product_id: item.id,
|
product_id: item.id,
|
||||||
quantity: item.qty
|
quantity: item.qty,
|
||||||
|
unit_price: item.price // Передаём изменённую цену из корзины
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2388,10 +2510,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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фото: для редактирования проверяем, удалено ли оно
|
// Фото: для редактирования проверяем, удалено ли оно
|
||||||
@@ -2650,7 +2771,7 @@ const getCsrfToken = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Сброс режима редактирования при закрытии модального окна
|
// Сброс режима редактирования при закрытии модального окна
|
||||||
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function() {
|
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function () {
|
||||||
// Очищаем tempCart (изолированное состояние модалки)
|
// Очищаем tempCart (изолированное состояние модалки)
|
||||||
tempCart.clear();
|
tempCart.clear();
|
||||||
|
|
||||||
@@ -2774,13 +2895,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('singlePaymentMode').classList.add('active');
|
||||||
document.getElementById('mixedPaymentMode').classList.remove('active');
|
document.getElementById('mixedPaymentMode').classList.remove('active');
|
||||||
reinitPaymentWidget('single');
|
reinitPaymentWidget('single');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('mixedPaymentMode').addEventListener('click', function() {
|
document.getElementById('mixedPaymentMode').addEventListener('click', function () {
|
||||||
document.getElementById('mixedPaymentMode').classList.add('active');
|
document.getElementById('mixedPaymentMode').classList.add('active');
|
||||||
document.getElementById('singlePaymentMode').classList.remove('active');
|
document.getElementById('singlePaymentMode').classList.remove('active');
|
||||||
reinitPaymentWidget('mixed');
|
reinitPaymentWidget('mixed');
|
||||||
@@ -3416,8 +3537,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 +3559,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');
|
||||||
@@ -3731,11 +3852,14 @@ searchInput.addEventListener('input', (e) => {
|
|||||||
clearTimeout(searchDebounceTimer);
|
clearTimeout(searchDebounceTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если поле пустое — очищаем экран
|
// Если поле пустое — очищаем поиск и возвращаемся к выбранной категории
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
currentSearchQuery = '';
|
currentSearchQuery = '';
|
||||||
ITEMS = []; // Очистка
|
updateSearchIndicator(); // Обновляем индикацию
|
||||||
renderProducts(); // Пустой экран
|
// Возвращаем товары выбранной категории
|
||||||
|
if (!isShowcaseView) {
|
||||||
|
loadItems();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3745,16 +3869,19 @@ searchInput.addEventListener('input', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для витрины — мгновенная клиентская фильтрация
|
// Для витрины при 3+ символах - переключаемся на глобальный поиск по всем товарам
|
||||||
if (isShowcaseView) {
|
if (isShowcaseView) {
|
||||||
renderProducts();
|
// Выходим из режима витрины для глобального поиска
|
||||||
return;
|
isShowcaseView = false;
|
||||||
|
// Обновляем UI категорий (снимаем выделение с витрины)
|
||||||
|
renderCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для обычных товаров/комплектов — серверный поиск с debounce 300мс
|
// Для обычных товаров/комплектов — серверный поиск с debounce 300мс
|
||||||
searchDebounceTimer = setTimeout(async () => {
|
searchDebounceTimer = setTimeout(async () => {
|
||||||
currentSearchQuery = query;
|
currentSearchQuery = query;
|
||||||
await loadItems(); // Перезагрузка с серверным поиском
|
updateSearchIndicator(); // Обновляем визуальную индикацию
|
||||||
|
await loadItems(); // Перезагрузка с серверным поиском (по всем категориям)
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3771,8 +3898,13 @@ clearSearchBtn.addEventListener('click', () => {
|
|||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
clearSearchBtn.style.display = 'none';
|
clearSearchBtn.style.display = 'none';
|
||||||
currentSearchQuery = '';
|
currentSearchQuery = '';
|
||||||
ITEMS = [];
|
updateSearchIndicator(); // Обновляем индикацию
|
||||||
renderProducts(); // Пустой экран
|
// Возвращаем товары выбранной категории
|
||||||
|
if (!isShowcaseView) {
|
||||||
|
loadItems();
|
||||||
|
} else {
|
||||||
|
renderProducts(); // Для витрины - просто перерисовываем
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Инициализация
|
// Инициализация
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -839,8 +839,9 @@ def get_items_api(request):
|
|||||||
'sales_units' # Загружаем единицы продажи для POS
|
'sales_units' # Загружаем единицы продажи для POS
|
||||||
)
|
)
|
||||||
|
|
||||||
# Фильтруем по категории, если указана
|
# Фильтруем по категории, если указана И нет поискового запроса
|
||||||
if category_id:
|
# При поиске игнорируем категорию - ищем по всем товарам
|
||||||
|
if category_id and not search_query:
|
||||||
products_qs = products_qs.filter(categories__id=category_id)
|
products_qs = products_qs.filter(categories__id=category_id)
|
||||||
|
|
||||||
# Фильтруем по поисковому запросу (name или sku) - разбиваем на токены
|
# Фильтруем по поисковому запросу (name или sku) - разбиваем на токены
|
||||||
@@ -931,8 +932,9 @@ def get_items_api(request):
|
|||||||
first_kit_photo
|
first_kit_photo
|
||||||
)
|
)
|
||||||
|
|
||||||
# Фильтруем комплекты по категории, если указана
|
# Фильтруем комплекты по категории, если указана И нет поискового запроса
|
||||||
if category_id:
|
# При поиске игнорируем категорию - ищем по всем комплектам
|
||||||
|
if category_id and not search_query:
|
||||||
kits_qs = kits_qs.filter(categories__id=category_id)
|
kits_qs = kits_qs.filter(categories__id=category_id)
|
||||||
|
|
||||||
# Фильтруем комплекты по поисковому запросу (name или sku) - разбиваем на токены
|
# Фильтруем комплекты по поисковому запросу (name или sku) - разбиваем на токены
|
||||||
@@ -1163,15 +1165,20 @@ def create_temp_kit_to_showcase(request):
|
|||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Агрегируем дубликаты (если один товар добавлен несколько раз)
|
# Агрегируем дубликаты (если один товар добавлен несколько раз)
|
||||||
|
# Сохраняем также цену из корзины (unit_price)
|
||||||
aggregated_items = {}
|
aggregated_items = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
product_id = item['product_id']
|
product_id = item['product_id']
|
||||||
quantity = Decimal(str(item['quantity']))
|
quantity = Decimal(str(item['quantity']))
|
||||||
|
unit_price = item.get('unit_price') # Цена из корзины (может быть изменена пользователем)
|
||||||
|
|
||||||
if product_id in aggregated_items:
|
if product_id in aggregated_items:
|
||||||
aggregated_items[product_id] += quantity
|
aggregated_items[product_id]['quantity'] += quantity
|
||||||
else:
|
else:
|
||||||
aggregated_items[product_id] = quantity
|
aggregated_items[product_id] = {
|
||||||
|
'quantity': quantity,
|
||||||
|
'unit_price': Decimal(str(unit_price)) if unit_price is not None else None
|
||||||
|
}
|
||||||
|
|
||||||
# Создаём временный комплект и резервируем на витрину
|
# Создаём временный комплект и резервируем на витрину
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -1189,13 +1196,15 @@ def create_temp_kit_to_showcase(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 2. Создаём KitItem для каждого товара из корзины
|
# 2. Создаём KitItem для каждого товара из корзины
|
||||||
for product_id, quantity in aggregated_items.items():
|
for product_id, item_data in aggregated_items.items():
|
||||||
product = products[product_id]
|
product = products[product_id]
|
||||||
|
# Используем цену из корзины, если передана, иначе из каталога
|
||||||
|
final_price = item_data['unit_price'] if item_data['unit_price'] is not None else product.actual_price
|
||||||
KitItem.objects.create(
|
KitItem.objects.create(
|
||||||
kit=kit,
|
kit=kit,
|
||||||
product=product,
|
product=product,
|
||||||
quantity=quantity,
|
quantity=item_data['quantity'],
|
||||||
unit_price=product.actual_price # Фиксируем цену для временного комплекта
|
unit_price=final_price # Фиксируем цену из корзины (с учётом изменений пользователя)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Пересчитываем цену комплекта
|
# 3. Пересчитываем цену комплекта
|
||||||
@@ -1264,7 +1273,7 @@ def create_temp_kit_to_showcase(request):
|
|||||||
f' Название: {request.POST.get("kit_name")}\n'
|
f' Название: {request.POST.get("kit_name")}\n'
|
||||||
f' Витрина ID: {request.POST.get("showcase_id")}\n'
|
f' Витрина ID: {request.POST.get("showcase_id")}\n'
|
||||||
f' Товары: {request.POST.get("items")}\n'
|
f' Товары: {request.POST.get("items")}\n'
|
||||||
f' Пользователь: {request.user.username}\n'
|
f' Пользователь: {str(request.user)}\n'
|
||||||
f' Ошибка: {str(e)}',
|
f' Ошибка: {str(e)}',
|
||||||
exc_info=True
|
exc_info=True
|
||||||
)
|
)
|
||||||
@@ -1364,12 +1373,19 @@ def update_product_kit(request, kit_id):
|
|||||||
if len(products) != len(product_ids):
|
if len(products) != len(product_ids):
|
||||||
return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400)
|
return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400)
|
||||||
|
|
||||||
# Агрегируем количества
|
# Агрегируем количества и цены
|
||||||
aggregated_items = {}
|
aggregated_items = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
product_id = item['product_id']
|
product_id = item['product_id']
|
||||||
quantity = Decimal(str(item['quantity']))
|
quantity = Decimal(str(item['quantity']))
|
||||||
aggregated_items[product_id] = aggregated_items.get(product_id, Decimal('0')) + quantity
|
unit_price = item.get('unit_price')
|
||||||
|
if product_id in aggregated_items:
|
||||||
|
aggregated_items[product_id]['quantity'] += quantity
|
||||||
|
else:
|
||||||
|
aggregated_items[product_id] = {
|
||||||
|
'quantity': quantity,
|
||||||
|
'unit_price': Decimal(str(unit_price)) if unit_price is not None else None
|
||||||
|
}
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Получаем старый состав для сравнения
|
# Получаем старый состав для сравнения
|
||||||
@@ -1390,7 +1406,7 @@ def update_product_kit(request, kit_id):
|
|||||||
|
|
||||||
for product_id in all_product_ids:
|
for product_id in all_product_ids:
|
||||||
old_qty = old_items.get(product_id, Decimal('0'))
|
old_qty = old_items.get(product_id, Decimal('0'))
|
||||||
new_qty = aggregated_items.get(product_id, Decimal('0'))
|
new_qty = aggregated_items.get(product_id, {}).get('quantity', Decimal('0'))
|
||||||
diff = new_qty - old_qty
|
diff = new_qty - old_qty
|
||||||
|
|
||||||
if diff > 0 and showcase:
|
if diff > 0 and showcase:
|
||||||
@@ -1429,13 +1445,15 @@ def update_product_kit(request, kit_id):
|
|||||||
|
|
||||||
# Обновляем состав
|
# Обновляем состав
|
||||||
kit.kit_items.all().delete()
|
kit.kit_items.all().delete()
|
||||||
for product_id, quantity in aggregated_items.items():
|
for product_id, item_data in aggregated_items.items():
|
||||||
product = products[product_id]
|
product = products[product_id]
|
||||||
|
# Используем переданную цену, если есть, иначе актуальную из каталога
|
||||||
|
final_price = item_data['unit_price'] if item_data['unit_price'] is not None else product.actual_price
|
||||||
KitItem.objects.create(
|
KitItem.objects.create(
|
||||||
kit=kit,
|
kit=kit,
|
||||||
product=product,
|
product=product,
|
||||||
quantity=quantity,
|
quantity=item_data['quantity'],
|
||||||
unit_price=product.actual_price # Фиксируем актуальную цену
|
unit_price=final_price
|
||||||
)
|
)
|
||||||
|
|
||||||
kit.recalculate_base_price()
|
kit.recalculate_base_price()
|
||||||
|
|||||||
@@ -230,6 +230,10 @@ class ProductKit(BaseProductEntity):
|
|||||||
qty = item.quantity or Decimal('1')
|
qty = item.quantity or Decimal('1')
|
||||||
total += actual_price * qty
|
total += actual_price * qty
|
||||||
elif item.product:
|
elif item.product:
|
||||||
|
# Используем зафиксированную цену (unit_price) если задана, иначе актуальную цену товара
|
||||||
|
if item.unit_price is not None:
|
||||||
|
actual_price = item.unit_price
|
||||||
|
else:
|
||||||
actual_price = item.product.actual_price or Decimal('0')
|
actual_price = item.product.actual_price or Decimal('0')
|
||||||
qty = item.quantity or Decimal('1')
|
qty = item.quantity or Decimal('1')
|
||||||
total += actual_price * qty
|
total += actual_price * qty
|
||||||
|
|||||||
@@ -207,6 +207,18 @@
|
|||||||
self._toggleProduct(productId);
|
self._toggleProduct(productId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Двойной клик по товару - сразу добавляет в документ
|
||||||
|
this.elements.grid.addEventListener('dblclick', function(e) {
|
||||||
|
var productCard = e.target.closest('.product-picker-item');
|
||||||
|
if (productCard && self.options.onAddSelected) {
|
||||||
|
var productId = productCard.dataset.productId;
|
||||||
|
var product = self._findProductById(productId);
|
||||||
|
if (product) {
|
||||||
|
self.options.onAddSelected(product, self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавить выбранный
|
// Добавить выбранный
|
||||||
@@ -435,17 +447,7 @@
|
|||||||
*/
|
*/
|
||||||
ProductSearchPicker.prototype._toggleProduct = function(productId) {
|
ProductSearchPicker.prototype._toggleProduct = function(productId) {
|
||||||
var self = this;
|
var self = this;
|
||||||
var product = null;
|
var product = this._findProductById(productId);
|
||||||
|
|
||||||
// Находим товар в списке
|
|
||||||
for (var i = 0; i < this.state.products.length; i++) {
|
|
||||||
var p = this.state.products[i];
|
|
||||||
if (String(p.id).replace('product_', '') === productId) {
|
|
||||||
product = p;
|
|
||||||
product.id = productId; // Сохраняем очищенный ID
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!product) return;
|
if (!product) return;
|
||||||
|
|
||||||
@@ -473,6 +475,21 @@
|
|||||||
this._updateSelectionUI();
|
this._updateSelectionUI();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск товара по ID в загруженном списке
|
||||||
|
*/
|
||||||
|
ProductSearchPicker.prototype._findProductById = function(productId) {
|
||||||
|
for (var i = 0; i < this.state.products.length; i++) {
|
||||||
|
var p = this.state.products[i];
|
||||||
|
if (String(p.id).replace('product_', '') === productId) {
|
||||||
|
var product = Object.assign({}, p);
|
||||||
|
product.id = productId; // Сохраняем очищенный ID
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Принудительно снять выделение со всех товаров
|
* Принудительно снять выделение со всех товаров
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,27 +48,27 @@ ProductSearchPicker.init('#writeoff-products', {
|
|||||||
{% if skip_stock_filter %}data-skip-stock-filter="true"{% endif %}>
|
{% if skip_stock_filter %}data-skip-stock-filter="true"{% endif %}>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<!-- Строка поиска -->
|
<!-- Строка поиска - компактный размер -->
|
||||||
<div class="card-header bg-white py-3">
|
<div class="card-header bg-white py-1">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text bg-light border-end-0">
|
<span class="input-group-text bg-light border-end-0">
|
||||||
<i class="bi bi-search text-primary"></i>
|
<i class="bi bi-search text-primary"></i>
|
||||||
</span>
|
</span>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control form-control-lg border-start-0 product-picker-search"
|
class="form-control form-control-sm border-start-0 product-picker-search"
|
||||||
placeholder="{{ title|default:'Поиск товара по названию, артикулу...' }}"
|
placeholder="{{ title|default:'Поиск товара по названию, артикулу...' }}"
|
||||||
style="box-shadow: none;">
|
style="box-shadow: none;">
|
||||||
<button class="btn btn-outline-secondary product-picker-search-clear"
|
<button class="btn btn-outline-secondary btn-sm product-picker-search-clear"
|
||||||
type="button" style="display: none;">
|
type="button" style="display: none;">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_filters|default:True %}
|
{% if show_filters|default:True %}
|
||||||
<!-- Фильтры -->
|
<!-- Фильтры - компактный вид -->
|
||||||
<div class="card-body border-bottom py-2">
|
<div class="card-body border-bottom py-1">
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
<div class="d-flex gap-1 align-items-center flex-wrap">
|
||||||
{% if categories %}
|
{% if categories %}
|
||||||
<!-- Фильтр по категории -->
|
<!-- Фильтр по категории -->
|
||||||
<select class="form-select form-select-sm product-picker-category" style="width: auto;">
|
<select class="form-select form-select-sm product-picker-category" style="width: auto;">
|
||||||
@@ -113,29 +113,29 @@ ProductSearchPicker.init('#writeoff-products', {
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Контент: сетка/список товаров -->
|
<!-- Контент: сетка/список товаров - компактный -->
|
||||||
<div class="card-body product-picker-content" style="max-height: {{ content_height|default:'400px' }}; overflow-y: auto;">
|
<div class="card-body product-picker-content p-1" style="max-height: {{ content_height|default:'400px' }}; overflow-y: auto;">
|
||||||
<!-- Индикатор загрузки -->
|
<!-- Индикатор загрузки -->
|
||||||
<div class="product-picker-loading text-center py-4" style="display: none;">
|
<div class="product-picker-loading text-center py-2" style="display: none;">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
<span class="visually-hidden">Загрузка...</span>
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Сетка товаров -->
|
<!-- Сетка товаров -->
|
||||||
<div class="row g-2 product-picker-grid" data-view="{{ initial_view|default:'list' }}">
|
<div class="row g-1 product-picker-grid" data-view="{{ initial_view|default:'list' }}">
|
||||||
<!-- Товары загружаются через AJAX -->
|
<!-- Товары загружаются через AJAX -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Пустой результат -->
|
<!-- Пустой результат -->
|
||||||
<div class="product-picker-empty text-center py-4 text-muted" style="display: none;">
|
<div class="product-picker-empty text-center py-2 text-muted" style="display: none;">
|
||||||
<i class="bi bi-search fs-1 opacity-25"></i>
|
<i class="bi bi-search fs-5 opacity-25"></i>
|
||||||
<p class="mb-0 mt-2">Товары не найдены</p>
|
<p class="mb-0 mt-1 small">Товары не найдены</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Футер с кнопкой действия -->
|
<!-- Футер с кнопкой действия - компактный -->
|
||||||
<div class="card-footer bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="card-footer bg-white py-1 d-flex justify-content-between align-items-center flex-wrap gap-1">
|
||||||
<div></div>
|
<div></div>
|
||||||
|
|
||||||
<button class="btn btn-primary btn-sm product-picker-add-selected" disabled>
|
<button class="btn btn-primary btn-sm product-picker-add-selected" disabled>
|
||||||
|
|||||||
@@ -748,6 +748,16 @@
|
|||||||
// Кэш цен товаров для быстрого доступа
|
// Кэш цен товаров для быстрого доступа
|
||||||
const priceCache = {};
|
const priceCache = {};
|
||||||
|
|
||||||
|
function parsePrice(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return parseFloat(value.replace(',', '.')) || 0;
|
||||||
|
}
|
||||||
|
return parseFloat(value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Функция для получения цены товара с AJAX если необходимо
|
// Функция для получения цены товара с AJAX если необходимо
|
||||||
async function getProductPrice(selectElement) {
|
async function getProductPrice(selectElement) {
|
||||||
// Строгая проверка: нужен валидный element с value
|
// Строгая проверка: нужен валидный element с value
|
||||||
@@ -765,7 +775,7 @@
|
|||||||
|
|
||||||
// Если уже загружена в кэш - возвращаем
|
// Если уже загружена в кэш - возвращаем
|
||||||
if (priceCache[productId] !== undefined) {
|
if (priceCache[productId] !== undefined) {
|
||||||
const cachedPrice = parseFloat(priceCache[productId]) || 0;
|
const cachedPrice = parsePrice(priceCache[productId]);
|
||||||
console.log('getProductPrice: from cache', productId, cachedPrice);
|
console.log('getProductPrice: from cache', productId, cachedPrice);
|
||||||
return cachedPrice;
|
return cachedPrice;
|
||||||
}
|
}
|
||||||
@@ -776,7 +786,7 @@
|
|||||||
const formPrice = form.getAttribute('data-product-price');
|
const formPrice = form.getAttribute('data-product-price');
|
||||||
const formProductId = form.getAttribute('data-product-id');
|
const formProductId = form.getAttribute('data-product-id');
|
||||||
if (formPrice && productId.toString() === formProductId) {
|
if (formPrice && productId.toString() === formProductId) {
|
||||||
const price = parseFloat(formPrice) || 0;
|
const price = parsePrice(formPrice);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[productId] = price;
|
priceCache[productId] = price;
|
||||||
console.log('getProductPrice: from form data', productId, price);
|
console.log('getProductPrice: from form data', productId, price);
|
||||||
@@ -789,7 +799,7 @@
|
|||||||
const selectedOption = $(selectElement).find('option:selected');
|
const selectedOption = $(selectElement).find('option:selected');
|
||||||
let priceData = selectedOption.data('actual_price') || selectedOption.data('price');
|
let priceData = selectedOption.data('actual_price') || selectedOption.data('price');
|
||||||
if (priceData) {
|
if (priceData) {
|
||||||
const price = parseFloat(priceData) || 0;
|
const price = parsePrice(priceData);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[productId] = price;
|
priceCache[productId] = price;
|
||||||
console.log('getProductPrice: from select2 data', productId, price);
|
console.log('getProductPrice: from select2 data', productId, price);
|
||||||
@@ -808,7 +818,7 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.results && data.results.length > 0) {
|
if (data.results && data.results.length > 0) {
|
||||||
const productData = data.results[0];
|
const productData = data.results[0];
|
||||||
const price = parseFloat(productData.actual_price || productData.price || 0);
|
const price = parsePrice(productData.actual_price || productData.price || 0);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[productId] = price;
|
priceCache[productId] = price;
|
||||||
console.log('getProductPrice: from API', productId, price);
|
console.log('getProductPrice: from API', productId, price);
|
||||||
@@ -868,7 +878,7 @@
|
|||||||
// Если уже загружена в кэш - возвращаем
|
// Если уже загружена в кэш - возвращаем
|
||||||
const cacheKey = `variant_${variantGroupId}`;
|
const cacheKey = `variant_${variantGroupId}`;
|
||||||
if (priceCache[cacheKey] !== undefined) {
|
if (priceCache[cacheKey] !== undefined) {
|
||||||
const cachedPrice = parseFloat(priceCache[cacheKey]) || 0;
|
const cachedPrice = parsePrice(priceCache[cacheKey]);
|
||||||
console.log('getVariantGroupPrice: from cache', variantGroupId, cachedPrice);
|
console.log('getVariantGroupPrice: from cache', variantGroupId, cachedPrice);
|
||||||
return cachedPrice;
|
return cachedPrice;
|
||||||
}
|
}
|
||||||
@@ -877,7 +887,7 @@
|
|||||||
const selectedOption = $(selectElement).find('option:selected');
|
const selectedOption = $(selectElement).find('option:selected');
|
||||||
let priceData = selectedOption.data('actual_price') || selectedOption.data('price');
|
let priceData = selectedOption.data('actual_price') || selectedOption.data('price');
|
||||||
if (priceData) {
|
if (priceData) {
|
||||||
const price = parseFloat(priceData) || 0;
|
const price = parsePrice(priceData);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[cacheKey] = price;
|
priceCache[cacheKey] = price;
|
||||||
console.log('getVariantGroupPrice: from select2 data', variantGroupId, price);
|
console.log('getVariantGroupPrice: from select2 data', variantGroupId, price);
|
||||||
@@ -896,7 +906,7 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.results && data.results.length > 0) {
|
if (data.results && data.results.length > 0) {
|
||||||
const variantData = data.results[0];
|
const variantData = data.results[0];
|
||||||
const price = parseFloat(variantData.actual_price || variantData.price || 0);
|
const price = parsePrice(variantData.actual_price || variantData.price || 0);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[cacheKey] = price;
|
priceCache[cacheKey] = price;
|
||||||
console.log('getVariantGroupPrice: from API', variantGroupId, price);
|
console.log('getVariantGroupPrice: from API', variantGroupId, price);
|
||||||
@@ -940,7 +950,7 @@
|
|||||||
// Если уже загружена в кэш - возвращаем
|
// Если уже загружена в кэш - возвращаем
|
||||||
const cacheKey = `sales_unit_${salesUnitId}`;
|
const cacheKey = `sales_unit_${salesUnitId}`;
|
||||||
if (priceCache[cacheKey] !== undefined) {
|
if (priceCache[cacheKey] !== undefined) {
|
||||||
const cachedPrice = parseFloat(priceCache[cacheKey]) || 0;
|
const cachedPrice = parsePrice(priceCache[cacheKey]);
|
||||||
return cachedPrice;
|
return cachedPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,7 +959,7 @@
|
|||||||
if (selectedOption) {
|
if (selectedOption) {
|
||||||
let priceData = selectedOption.dataset.actual_price || selectedOption.dataset.price;
|
let priceData = selectedOption.dataset.actual_price || selectedOption.dataset.price;
|
||||||
if (priceData) {
|
if (priceData) {
|
||||||
const price = parseFloat(priceData) || 0;
|
const price = parsePrice(priceData);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[cacheKey] = price;
|
priceCache[cacheKey] = price;
|
||||||
console.log('getSalesUnitPrice: from standard select option data', salesUnitId, price);
|
console.log('getSalesUnitPrice: from standard select option data', salesUnitId, price);
|
||||||
@@ -966,7 +976,7 @@
|
|||||||
const itemData = selectedData[0];
|
const itemData = selectedData[0];
|
||||||
const priceData = itemData.actual_price || itemData.price;
|
const priceData = itemData.actual_price || itemData.price;
|
||||||
if (priceData) {
|
if (priceData) {
|
||||||
const price = parseFloat(priceData) || 0;
|
const price = parsePrice(priceData);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[cacheKey] = price;
|
priceCache[cacheKey] = price;
|
||||||
console.log('getSalesUnitPrice: from select2 data', salesUnitId, price);
|
console.log('getSalesUnitPrice: from select2 data', salesUnitId, price);
|
||||||
@@ -988,7 +998,7 @@
|
|||||||
if (data.sales_units && data.sales_units.length > 0) {
|
if (data.sales_units && data.sales_units.length > 0) {
|
||||||
const salesUnitData = data.sales_units.find(su => su.id == salesUnitId);
|
const salesUnitData = data.sales_units.find(su => su.id == salesUnitId);
|
||||||
if (salesUnitData) {
|
if (salesUnitData) {
|
||||||
const price = parseFloat(salesUnitData.actual_price || salesUnitData.price || 0);
|
const price = parsePrice(salesUnitData.actual_price || salesUnitData.price || 0);
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
priceCache[cacheKey] = price;
|
priceCache[cacheKey] = price;
|
||||||
console.log('getSalesUnitPrice: from API', salesUnitId, price);
|
console.log('getSalesUnitPrice: from API', salesUnitId, price);
|
||||||
|
|||||||
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