Compare commits

..

14 Commits

Author SHA1 Message Date
90906944dd feat(pos): глобальный поиск товаров независимо от категории и режима витрины
- При поиске (3+ символа) ищет по всем товарам и комплектам, игнорируя выбранную категорию
- Даже в режиме "Витрина" поиск переключается на глобальный по всем товарам
- Добавлена визуальная индикация: кнопка "Все товары" подсвечивается при активном поиске
- При очистке поиска возвращается к товарам выбранной категории или витрине

Изменения:
- terminal.js: loadItems(), renderProducts(), updateSearchIndicator(), обработчики поиска
- views.py: get_items_api() - игнорирование category_id при search_query
2026-01-27 15:20:07 +03:00
7b6a86bdf2 feat(pos): глобальный поиск товаров независимо от категории
- При поиске (3+ символа) ищет по всем товарам и комплектам, игнорируя выбранную категорию
- Добавлена визуальная индикация: кнопка "Все товары" подсвечивается при активном поиске
- При очистке поиска возвращается к товарам выбранной категории
- Витринные комплекты не участвуют в глобальном поиске

Изменения:
- terminal.js: loadItems(), updateSearchIndicator(), обработчики поиска
- views.py: get_items_api() - игнорирование category_id при search_query
2026-01-27 15:15:27 +03:00
c80e2a5eca refactor(orders): relax delivery address and date requirements for non-draft orders
- Updated order creation and update logic to allow saving orders without a delivery address or date, regardless of order status.
- Removed strict validation for delivery fields in the Delivery model, enabling more flexible order handling.
- Adjusted order form to reflect changes in required fields for delivery information.
2026-01-27 14:19:33 +03:00
2aed6f60e5 feat(pos): enhance product kit price handling and UI interaction
- Updated price aggregation logic in update_product_kit to include unit prices.
- Improved terminal.js to allow inline editing of product prices in the kit.
- Added parsePrice function for consistent price parsing across the application.
- Ensured that the correct price is saved when creating or updating product kits.
2026-01-27 11:31:22 +03:00
2dc397b3ce fix(pos): сохранение изменённой цены товара при создании витринного комплекта
При изменении цены товара в корзине через модалку, витринный комплект
теперь сохраняется с правильной ценой, а не с ценой из каталога:

- terminal.js: передача unit_price при создании витринного комплекта
- views.py: обработка и сохранение unit_price из корзины в KitItem
- kits.py: использование item.unit_price в ProductKit.save() при расчёте base_price
2026-01-27 07:51:34 +03:00
9a7c0728f0 feat(ui): improve product search and inventory interaction
- Добавить двойной клик по товару в поисковике для быстрого добавления
- Добавить автофокус и селект на поле ввода количества при добавлении товара в инвентарь
- Вынести логику поиска товара по ID в отдельный метод _findProductById для переиспользования
2026-01-26 23:40:27 +03:00
67ad0e50ee feat(ui): improve product search and document info section UI
- Collapse incoming document info by default with toggle animation
- Add inline cost price editing in incoming document items
- Make product search picker more compact (smaller inputs, reduced padding)
- Display new inventory lines at the top of the table
- Update product search picker styling for better visual hierarchy
2026-01-26 22:58:20 +03:00
32bc0d2c39 feat(ui): add collapsible section for inventory information with toggle animation
- Wrap inventory information table in Bootstrap collapse component
- Add toggle button with chevron icon indicating collapse state
- Implement JavaScript to animate chevron icon on collapse/expand
- Set inventory information to be collapsed by default for cleaner UI

This change improves the user interface by reducing clutter on the inventory detail page, allowing users to focus on the most important information first and expand additional details as needed.
2026-01-26 21:00:57 +03:00
f140469a56 feat: Add initial views for the orders application. 2026-01-26 19:59:23 +03:00
d947f4eee7 style(ui): increase toast notification delay from 3 to 5 seconds 2026-01-26 18:39:07 +03:00
5700314b10 feat(ui): replace alert notifications with toast messages
Add toast notification functionality using Bootstrap Toasts and update
checkout success/error handling to use toast messages instead of alert boxes.

**Changes:**
- Add `showToast` function to `terminal.js`
- Add toast container and templates to `terminal.html`
- Replace alert() calls in handleCheckoutSubmit with showToast()
2026-01-26 17:44:03 +03:00
b24a0d9f21 feat: Add UI for inventory transfer list and detail views. 2026-01-25 16:44:54 +03:00
034be20a5a feat: add showcase manager service 2026-01-25 15:28:41 +03:00
f75e861bb8 feat: Add new inventory and POS components, including a script to reproduce a POS checkout sale price bug. 2026-01-25 15:26:57 +03:00
19 changed files with 1075 additions and 680 deletions

View File

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

View File

@@ -366,7 +366,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE === # === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
# Проверяем, есть ли уже Sale для этого заказа # Проверяем, есть ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists(): if Sale.objects.filter(order=instance).exists():
logger.info(f"Заказ {instance.order_number}: Sale уже существуют, пропускаем") logger.info(f"Заказ {instance.order_number}: Sale уже существуют, пропускаем")
update_is_returned_flag(instance) update_is_returned_flag(instance)
return return
@@ -376,7 +376,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
previous_status = getattr(instance, '_previous_status', None) previous_status = getattr(instance, '_previous_status', None)
if previous_status and previous_status.is_positive_end: if previous_status and previous_status.is_positive_end:
logger.info( logger.info(
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус " f"Заказ {instance.order_number}: повторный переход в положительный статус "
f"({previous_status.name}{instance.status.name}). Проверяем Sale..." f"({previous_status.name}{instance.status.name}). Проверяем Sale..."
) )
if Sale.objects.filter(order=instance).exists(): if Sale.objects.filter(order=instance).exists():
@@ -454,12 +454,65 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
) )
continue continue
# === РАСЧЕТ ЦЕНЫ ===
# Рассчитываем фактическую стоимость продажи всего комплекта с учетом скидок
# 1. Базовая стоимость позиции
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
# 2. Скидки
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
# Скидка на заказ (распределенная)
instance.refresh_from_db()
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0')
order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
if order_total > 0 and order_discount_amount > 0:
item_order_discount = order_discount_amount * (item_subtotal / order_total)
else:
item_order_discount = Decimal('0')
kit_net_total = item_subtotal - item_discount - item_order_discount
if kit_net_total < 0:
kit_net_total = Decimal('0')
# 3. Суммарная каталожная стоимость всех компонентов (для пропорции)
total_catalog_price = Decimal('0')
for reservation in kit_reservations:
qty = reservation.quantity_base or reservation.quantity
price = reservation.product.actual_price or Decimal('0')
total_catalog_price += price * qty
# 4. Коэффициент распределения
if total_catalog_price > 0:
ratio = kit_net_total / total_catalog_price
else:
# Если каталожная цена 0, распределяем просто по количеству или 0
ratio = Decimal('0')
# Создаем Sale для каждого компонента комплекта # Создаем Sale для каждого компонента комплекта
for reservation in kit_reservations: for reservation in kit_reservations:
try: try:
# Рассчитываем цену продажи компонента пропорционально цене комплекта # Рассчитываем цену продажи компонента пропорционально
# Используем actual_price компонента как цену продажи catalog_price = reservation.product.actual_price or Decimal('0')
component_sale_price = reservation.product.actual_price
if ratio > 0:
# Распределяем реальную выручку
component_sale_price = catalog_price * ratio
else:
# Если выручка 0 или каталожные цены 0
if total_catalog_price == 0 and kit_net_total > 0:
# Крайний случай: товаров на 0 руб, а продали за деньги (услуга?)
# Распределяем равномерно
count = kit_reservations.count()
component_qty = reservation.quantity_base or reservation.quantity
if count > 0 and component_qty > 0:
component_sale_price = (kit_net_total / count) / component_qty
else:
component_sale_price = Decimal('0')
else:
component_sale_price = Decimal('0')
sale = SaleProcessor.create_sale( sale = SaleProcessor.create_sale(
product=reservation.product, product=reservation.product,
@@ -472,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
sales_created.append(sale) sales_created.append(sale)
logger.info( logger.info(
f"✓ Sale создан для компонента комплекта '{kit.name}': " f"✓ Sale создан для компонента комплекта '{kit.name}': "
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. (базовых единиц)" f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. "
f"(цена: {component_sale_price})"
) )
except ValueError as e: except ValueError as e:
logger.error( logger.error(
@@ -548,6 +602,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
else: else:
base_price = price_with_discount base_price = price_with_discount
# LOGGING DEBUG INFO
# print(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
# print(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
# print(f" Item Discount: {item_discount}, Order Discount Share: {item_order_discount}, Total Discount: {total_discount}")
# print(f" Price w/ Discount: {price_with_discount}")
# print(f" Sales Unit: {item.sales_unit}, Conversion: {item.conversion_factor_snapshot}")
# print(f" FINAL BASE PRICE: {base_price}")
# print(f" Sales Unit Object: {item.sales_unit}")
# if item.sales_unit:
# print(f" Sales Unit Conversion: {item.sales_unit.conversion_factor}")
logger.info(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
logger.info(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
logger.info(f" FINAL BASE PRICE: {base_price}")
# Создаем Sale (с автоматическим FIFO-списанием) # Создаем Sale (с автоматическим FIFO-списанием)
sale = SaleProcessor.create_sale( sale = SaleProcessor.create_sale(
product=product, product=product,

View File

@@ -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');

View File

@@ -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 %}

View File

@@ -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 %}

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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 %}

View File

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

View File

@@ -12,6 +12,38 @@ function roundQuantity(value, decimals = 3) {
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
} }
/**
* Показывает toast уведомление в правом верхнем углу
* @param {string} type - 'success' или 'error'
* @param {string} message - Текст сообщения
*/
function showToast(type, message) {
const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast';
const messageId = type === 'success' ? 'toastMessage' : 'errorMessage';
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
const toastElement = document.getElementById(toastId);
const messageElement = document.getElementById(messageId);
// Устанавливаем сообщение
messageElement.textContent = message;
// Добавляем цвет фона
toastElement.classList.add(bgClass, 'text-white');
// Создаём и показываем toast (автоматически скроется через 5 секунд - стандарт Bootstrap)
const toast = new bootstrap.Toast(toastElement, {
delay: 5000,
autohide: true
});
toast.show();
// Убираем класс цвета после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.classList.remove(bgClass, 'text-white');
}, { once: true });
}
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent); const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
let ITEMS = []; // Будем загружать через API let ITEMS = []; // Будем загружать через API
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent); let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent);
@@ -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(); // Для витрины - просто перерисовываем
}
}); });
// Инициализация // Инициализация

View File

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

View File

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

View File

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

View File

@@ -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;
};
/** /**
* Принудительно снять выделение со всех товаров * Принудительно снять выделение со всех товаров
*/ */

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
import os
import sys
import json
import django
from decimal import Decimal
# Setup Django
sys.path.append(os.getcwd())
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django.setup()
from django.test import RequestFactory
from django.contrib.auth import get_user_model
from django.db import connection
from customers.models import Customer
from inventory.models import Warehouse, Sale
from products.models import Product, UnitOfMeasure
from pos.views import pos_checkout
from orders.models import OrderStatus
def run():
# Setup Data
User = get_user_model()
user = User.objects.first()
if not user:
print("No user found")
return
# Create/Get Customer
customer, _ = Customer.objects.get_or_create(
name="Test Customer",
defaults={'phone': '+375291112233'}
)
# Create/Get Warehouse
warehouse, _ = Warehouse.objects.get_or_create(
name="Test Warehouse",
defaults={'is_active': True}
)
# Create product
product, _ = Product.objects.get_or_create(
name="Test Product Debug",
defaults={
'sku': 'DEBUG001',
'buying_price': 10,
'actual_price': 50,
'warehouse': warehouse
}
)
product.actual_price = 50
product.save()
# Ensure OrderStatus exists
OrderStatus.objects.get_or_create(code='completed', is_system=True, defaults={'name': 'Completed', 'is_positive_end': True})
OrderStatus.objects.get_or_create(code='draft', is_system=True, defaults={'name': 'Draft'})
# Prepare Request
factory = RequestFactory()
payload = {
"customer_id": customer.id,
"warehouse_id": warehouse.id,
"items": [
{
"type": "product",
"id": product.id,
"quantity": 1,
"price": 100.00, # Custom price
"quantity_base": 1
}
],
"payments": [
{"payment_method": "cash", "amount": 100.00}
],
"notes": "Debug Sale"
}
request = factory.post(
'/pos/api/checkout/',
data=json.dumps(payload),
content_type='application/json'
)
request.user = user
print("Executing pos_checkout...")
response = pos_checkout(request)
print(f"Response: {response.content}")
# Verify Sale
sales = Sale.objects.filter(product=product).order_by('-id')[:1]
if sales:
sale = sales[0]
print(f"Sale created. ID: {sale.id}")
print(f"Sale Quantity: {sale.quantity}")
print(f"Sale Price: {sale.sale_price}")
if sale.sale_price == 0:
print("FAILURE: Sale price is 0!")
else:
print(f"SUCCESS: Sale price is {sale.sale_price}")
else:
print("FAILURE: No Sale created!")
if __name__ == "__main__":
from django_tenants.utils import schema_context
# Replace with actual schema name if needed, assuming 'public' for now or the default tenant
# Since I don't know the tenant, I'll try to run in the current context.
# But usually need to set schema.
# Let's try to find a tenant.
from tenants.models import Client
tenant = Client.objects.first()
if tenant:
print(f"Running in tenant: {tenant.schema_name}")
with schema_context(tenant.schema_name):
run()
else:
print("No tenant found, running in public?")
run()