Compare commits

..

12 Commits

Author SHA1 Message Date
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 1006 additions and 661 deletions

View File

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

View File

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

View File

@@ -394,14 +394,21 @@
const emptyMessage = document.getElementById('empty-lines-message');
if (emptyMessage) emptyMessage.remove();
// Добавляем новую строку
// Добавляем новую строку в начало таблицы
const newRow = self.createLineRow(data.line);
tbody.appendChild(newRow);
tbody.insertBefore(newRow, tbody.firstChild);
// Включаем кнопку завершения
const completeBtn = document.getElementById('complete-inventory-btn');
if (completeBtn) completeBtn.disabled = false;
// Фокус на поле ввода количества в новой строке
const quantityInput = newRow.querySelector('.quantity-fact-input');
if (quantityInput) {
quantityInput.focus();
quantityInput.select();
}
this.showNotification('Товар добавлен', 'success');
} else {
this.showNotification('Ошибка: ' + (data.error || 'Не удалось добавить товар'), 'error');

View File

@@ -20,10 +20,12 @@
<div class="row g-3">
<!-- Основной контент - одна колонка -->
<div class="col-12">
<!-- Информация о документе -->
<!-- Информация о документе - свернута по умолчанию -->
<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">
<h5 class="mb-0">
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<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 }}
{% if document.status == 'draft' %}
<span class="badge bg-warning text-dark ms-2">Черновик</span>
@@ -32,7 +34,8 @@
{% elif document.status == 'cancelled' %}
<span class="badge bg-secondary ms-2">Отменён</span>
{% endif %}
</h5>
</span>
</button>
{% if document.can_edit %}
<div class="btn-group">
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
@@ -50,6 +53,7 @@
</div>
{% endif %}
</div>
<div class="collapse" id="document-info-collapse">
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3">
@@ -98,17 +102,18 @@
{% endif %}
</div>
</div>
</div>
<!-- Добавление позиции -->
{% if document.can_edit %}
<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>
</div>
<div class="card-body">
<!-- Компонент поиска товаров -->
<div class="mb-3">
{% 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' %}
<div class="card-body p-2">
<!-- Компонент поиска товаров - компактный -->
<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='150px' %}
</div>
<!-- Информация о выбранном товаре -->
@@ -217,11 +222,19 @@
{% endif %}
</td>
<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 %}
<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"
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
style="display: none; width: 100px; text-align: right; margin-left: auto;">
{% else %}
<span>{{ item.cost_price|floatformat:2 }}</span>
{% endif %}
</td>
<td class="px-3 py-2 text-end" style="width: 120px;">
@@ -271,24 +284,13 @@
</td>
{% if document.can_edit %}
<td class="px-3 py-2 text-end" style="width: 100px;">
<div class="btn-group btn-group-sm item-action-buttons">
<button type="button" class="btn btn-outline-primary btn-edit-item" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-danger"
onclick="if(confirm('Удалить позицию?')) document.getElementById('delete-form-{{ item.id }}').submit();"
title="Удалить">
<i class="bi bi-trash"></i>
</button>
</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"
action="{% url 'inventory:incoming-remove-item' document.pk item.pk %}"
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) {
const productId = String(product.id).replace('product_', '');
@@ -416,160 +434,10 @@ document.addEventListener('DOMContentLoaded', function() {
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() {
@@ -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() {
// Можно реализовать пересчет итогов, если нужно
// Пока оставим как есть, так как сервер возвращает обновленные данные
}
// Инициализация inline редактирования количества
// Инициализация inline редактирования
initInlineQuantityEdit();
initInlineCostPriceEdit();
});
</script>
@@ -745,6 +745,17 @@ document.addEventListener('DOMContentLoaded', function() {
color: #0d6efd !important;
text-decoration: underline;
}
/* Стили для редактируемой цены */
.editable-cost-price {
cursor: pointer;
transition: color 0.2s ease;
}
.editable-cost-price:hover {
color: #0d6efd !important;
text-decoration: underline;
}
</style>
{% endblock %}

View File

@@ -19,9 +19,14 @@
</div>
<div class="card-body">
<!-- Информация об инвентаризации - свернута по умолчанию -->
<div class="row mb-4">
<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">
{% if inventory.document_number %}
<tr>
@@ -64,6 +69,25 @@
</table>
</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' %}
<!-- Информация о созданных документах -->
@@ -100,14 +124,14 @@
<h5>Строки инвентаризации</h5>
<!-- Компонент поиска товаров (только если не завершена) -->
<!-- Компонент поиска товаров - открыт по умолчанию, компактный -->
{% if inventory.status != 'completed' %}
<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>
</div>
<div class="card-body">
{% 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 %}
<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='150px' skip_stock_filter=True %}
</div>
</div>
{% endif %}

View File

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

View File

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

View File

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

View File

@@ -132,35 +132,10 @@ class Delivery(models.Model):
})
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)
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="col-md-6">
<label for="{{ form.address_street.id_for_label }}" class="form-label">
Улица <span class="text-danger">*</span>
Улица
</label>
{{ form.address_street }}
{% if form.address_street.errors %}
@@ -590,7 +590,7 @@
</div>
<div class="col-md-3">
<label for="{{ form.address_building_number.id_for_label }}" class="form-label">
Дом <span class="text-danger">*</span>
Дом
</label>
{{ form.address_building_number }}
{% if form.address_building_number.errors %}

View File

@@ -16,11 +16,18 @@ from inventory.models import Reservation
import json
from django.utils import timezone # Added for default date filter
def order_list(request):
"""
Список всех заказов с фильтрацией и поиском
Использует django-filter для фильтрации данных
"""
# Если параметров нет вообще (первый заход), редиректим на "Сегодня"
if not request.GET:
today = timezone.localdate().isoformat()
return redirect(f'{request.path}?delivery_date_after={today}&delivery_date_before={today}')
# Базовый queryset с оптимизацией запросов
orders = Order.objects.select_related(
'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
@@ -225,7 +232,8 @@ def order_create(request):
address.save()
# Создаем или обновляем Delivery
# Для черновиков создаем Delivery без обязательных проверок, чтобы сохранить адрес
# Ранее для не-черновиков адрес курьерской доставки был обязателен.
# Теперь разрешаем сохранять заказ в любом статусе без адреса.
if is_draft:
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
if address or delivery_type or pickup_warehouse or delivery_date:
@@ -242,23 +250,12 @@ def order_create(request):
}
)
else:
# Для не-черновиков проверяем обязательные поля
if not delivery_type or not delivery_date:
raise ValidationError('Необходимо указать способ доставки и дату доставки')
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.
if address or delivery_type or pickup_warehouse or delivery_date:
delivery = Delivery.objects.create(
order=order,
delivery_type=delivery_type,
delivery_type=delivery_type or Delivery.DELIVERY_TYPE_COURIER,
delivery_date=delivery_date,
time_from=time_from,
time_to=time_to,
@@ -458,7 +455,8 @@ def order_update(request, order_number):
address.save()
# Создаем или обновляем Delivery
# Для черновиков создаем Delivery без обязательных проверок, чтобы сохранить адрес
# Ранее для не-черновиков адрес курьерской доставки был обязателен.
# Теперь разрешаем сохранять заказ в любом статусе без адреса.
if is_draft:
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
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')
}
)
elif hasattr(order, 'delivery'):
# Если все данные доставки очищены, удаляем существующую Delivery
order.delivery.delete()
else:
# Для не-черновиков проверяем обязательные поля
if not delivery_type or not delivery_date:
raise ValidationError('Необходимо указать способ доставки и дату доставки')
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 (если она была).
if not (address or delivery_type or pickup_warehouse or delivery_date):
if hasattr(order, 'delivery'):
order.delivery.delete()
else:
# Создаем или обновляем Delivery с теми данными, что есть.
delivery, created = Delivery.objects.update_or_create(
order=order,
defaults={
'delivery_type': delivery_type,
'delivery_type': delivery_type or Delivery.DELIVERY_TYPE_COURIER,
'delivery_date': delivery_date,
'time_from': time_from,
'time_to': time_to,
@@ -505,8 +495,6 @@ def order_update(request, order_number):
'cost': delivery_cost if delivery_cost else Decimal('0')
}
)
# Пересчитываем итоговую стоимость
order.calculate_total()
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);
}
/**
* Показывает 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);
let ITEMS = []; // Будем загружать через API
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent);
@@ -272,12 +304,12 @@ function initCustomerSelect2() {
url: '/customers/api/search/',
dataType: 'json',
delay: 300,
data: function(params) {
data: function (params) {
return {
q: params.term
};
},
processResults: function(data) {
processResults: function (data) {
return {
results: data.results
};
@@ -289,7 +321,7 @@ function initCustomerSelect2() {
});
// Обработка выбора клиента из списка
$searchInput.on('select2:select', function(e) {
$searchInput.on('select2:select', function (e) {
const data = e.params.data;
// Проверяем это не опция "Создать нового клиента"
@@ -1487,7 +1519,7 @@ function renderCart() {
row.appendChild(deleteBtn);
// Обработчик клика для редактирования товара
row.addEventListener('click', function(e) {
row.addEventListener('click', function (e) {
// Игнорируем клики на кнопки управления количеством и удаления
if (e.target.closest('button') || e.target.closest('input')) {
return;
@@ -1817,7 +1849,7 @@ async function openCreateTempKitModal() {
// Копируем содержимое cart в tempCart (изолированное состояние модалки)
tempCart.clear();
cart.forEach((item, key) => {
tempCart.set(key, {...item}); // Глубокая копия объекта
tempCart.set(key, { ...item }); // Глубокая копия объекта
});
// Генерируем название по умолчанию
@@ -1931,7 +1963,7 @@ async function openEditKitModal(kitId) {
setTimeout(() => {
if (window.ProductSearchPicker) {
const picker = ProductSearchPicker.init('#temp-kit-product-picker', {
onAddSelected: function(product, instance) {
onAddSelected: function (product, instance) {
if (product) {
// Добавляем товар в tempCart
const cartKey = `product-${product.id}`;
@@ -2133,11 +2165,63 @@ function renderTempKitItems() {
// Левая часть: название и цена
const leftDiv = document.createElement('div');
leftDiv.className = 'flex-grow-1';
leftDiv.innerHTML = `
<strong class="small">${item.name}</strong>
<br>
<small class="text-muted">${formatMoney(item.price)} руб. / шт.</small>
`;
// Название товара
const nameSpan = document.createElement('strong');
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');
@@ -2265,7 +2349,7 @@ function updatePriceCalculations(basePrice = null) {
}
// Обработчики для полей цены
document.getElementById('priceAdjustmentType').addEventListener('change', function() {
document.getElementById('priceAdjustmentType').addEventListener('change', function () {
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
if (this.value === 'none') {
adjustmentBlock.style.display = 'none';
@@ -2276,11 +2360,11 @@ document.getElementById('priceAdjustmentType').addEventListener('change', functi
updatePriceCalculations();
});
document.getElementById('priceAdjustmentValue').addEventListener('input', function() {
document.getElementById('priceAdjustmentValue').addEventListener('input', function () {
updatePriceCalculations();
});
document.getElementById('useSalePrice').addEventListener('change', function() {
document.getElementById('useSalePrice').addEventListener('change', function () {
const salePriceBlock = document.getElementById('salePriceBlock');
if (this.checked) {
salePriceBlock.style.display = 'block';
@@ -2291,12 +2375,12 @@ document.getElementById('useSalePrice').addEventListener('change', function() {
updatePriceCalculations();
});
document.getElementById('salePrice').addEventListener('input', function() {
document.getElementById('salePrice').addEventListener('input', function () {
updatePriceCalculations();
});
// Обработчик загрузки фото
document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
document.getElementById('tempKitPhoto').addEventListener('change', function (e) {
const file = e.target.files[0];
if (file) {
if (!file.type.startsWith('image/')) {
@@ -2307,7 +2391,7 @@ document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
// Превью
const reader = new FileReader();
reader.onload = function(event) {
reader.onload = function (event) {
document.getElementById('photoPreviewImg').src = event.target.result;
document.getElementById('photoPreview').style.display = 'block';
};
@@ -2316,7 +2400,7 @@ document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
});
// Удаление фото
document.getElementById('removePhoto').addEventListener('click', function() {
document.getElementById('removePhoto').addEventListener('click', function () {
document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('photoPreviewImg').src = '';
@@ -2347,7 +2431,8 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
if (item.type === 'product') {
items.push({
product_id: item.id,
quantity: item.qty
quantity: item.qty,
unit_price: item.price // Передаём изменённую цену из корзины
});
}
});
@@ -2388,10 +2473,9 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue);
// Если пользователь не задал свою цену, используем вычисленную
const finalSalePrice = useSalePrice ? salePrice : calculatedPrice;
if (finalSalePrice > 0) {
formData.append('sale_price', finalSalePrice);
// Если пользователь явно указал свою цену
if (useSalePrice && salePrice > 0) {
formData.append('sale_price', salePrice);
}
// Фото: для редактирования проверяем, удалено ли оно
@@ -2650,7 +2734,7 @@ const getCsrfToken = () => {
};
// Сброс режима редактирования при закрытии модального окна
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function() {
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function () {
// Очищаем tempCart (изолированное состояние модалки)
tempCart.clear();
@@ -2774,13 +2858,13 @@ document.getElementById('checkoutModal').addEventListener('show.bs.modal', async
});
// Переключение режима оплаты
document.getElementById('singlePaymentMode').addEventListener('click', function() {
document.getElementById('singlePaymentMode').addEventListener('click', function () {
document.getElementById('singlePaymentMode').classList.add('active');
document.getElementById('mixedPaymentMode').classList.remove('active');
reinitPaymentWidget('single');
});
document.getElementById('mixedPaymentMode').addEventListener('click', function() {
document.getElementById('mixedPaymentMode').addEventListener('click', function () {
document.getElementById('mixedPaymentMode').classList.add('active');
document.getElementById('singlePaymentMode').classList.remove('active');
reinitPaymentWidget('mixed');
@@ -3416,8 +3500,8 @@ async function handleCheckoutSubmit(paymentsData) {
if (result.success) {
console.log('✅ Заказ успешно создан:', result);
// Успех
alert(`Заказ #${result.order_number} успешно создан!\nСумма: ${result.total_amount.toFixed(2)} руб.`);
// Показываем toast уведомление
showToast('success', `Заказ #${result.order_number} успешно создан! Сумма: ${result.total_amount.toFixed(2)} руб.`);
// Очищаем корзину
cart.clear();
@@ -3438,12 +3522,12 @@ async function handleCheckoutSubmit(paymentsData) {
}, 500);
} else {
alert('Ошибка: ' + result.error);
showToast('error', 'Ошибка: ' + result.error);
}
} catch (error) {
console.error('Ошибка checkout:', error);
alert('Ошибка при проведении продажи: ' + error.message);
showToast('error', 'Ошибка при проведении продажи: ' + error.message);
} finally {
// Разблокируем кнопку
const btn = document.getElementById('confirmCheckoutBtn');

View File

@@ -729,6 +729,28 @@
<!-- Модалка редактирования товара в корзине -->
{% 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 %}
{% block extra_js %}

View File

@@ -1163,15 +1163,20 @@ def create_temp_kit_to_showcase(request):
}, status=400)
# Агрегируем дубликаты (если один товар добавлен несколько раз)
# Сохраняем также цену из корзины (unit_price)
aggregated_items = {}
for item in items:
product_id = item['product_id']
quantity = Decimal(str(item['quantity']))
unit_price = item.get('unit_price') # Цена из корзины (может быть изменена пользователем)
if product_id in aggregated_items:
aggregated_items[product_id] += quantity
aggregated_items[product_id]['quantity'] += quantity
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():
@@ -1189,13 +1194,15 @@ def create_temp_kit_to_showcase(request):
)
# 2. Создаём KitItem для каждого товара из корзины
for product_id, quantity in aggregated_items.items():
for product_id, item_data in aggregated_items.items():
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(
kit=kit,
product=product,
quantity=quantity,
unit_price=product.actual_price # Фиксируем цену для временного комплекта
quantity=item_data['quantity'],
unit_price=final_price # Фиксируем цену из корзины (с учётом изменений пользователя)
)
# 3. Пересчитываем цену комплекта
@@ -1264,7 +1271,7 @@ def create_temp_kit_to_showcase(request):
f' Название: {request.POST.get("kit_name")}\n'
f' Витрина ID: {request.POST.get("showcase_id")}\n'
f' Товары: {request.POST.get("items")}\n'
f' Пользователь: {request.user.username}\n'
f' Пользователь: {str(request.user)}\n'
f' Ошибка: {str(e)}',
exc_info=True
)
@@ -1364,12 +1371,19 @@ def update_product_kit(request, kit_id):
if len(products) != len(product_ids):
return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400)
# Агрегируем количества
# Агрегируем количества и цены
aggregated_items = {}
for item in items:
product_id = item['product_id']
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():
# Получаем старый состав для сравнения
@@ -1390,7 +1404,7 @@ def update_product_kit(request, kit_id):
for product_id in all_product_ids:
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
if diff > 0 and showcase:
@@ -1429,13 +1443,15 @@ def update_product_kit(request, kit_id):
# Обновляем состав
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]
# Используем переданную цену, если есть, иначе актуальную из каталога
final_price = item_data['unit_price'] if item_data['unit_price'] is not None else product.actual_price
KitItem.objects.create(
kit=kit,
product=product,
quantity=quantity,
unit_price=product.actual_price # Фиксируем актуальную цену
quantity=item_data['quantity'],
unit_price=final_price
)
kit.recalculate_base_price()

View File

@@ -230,6 +230,10 @@ class ProductKit(BaseProductEntity):
qty = item.quantity or Decimal('1')
total += actual_price * qty
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')
qty = item.quantity or Decimal('1')
total += actual_price * qty

View File

@@ -207,6 +207,18 @@
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) {
var self = this;
var product = null;
// Находим товар в списке
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;
}
}
var product = this._findProductById(productId);
if (!product) return;
@@ -473,6 +475,21 @@
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 %}>
<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">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-search text-primary"></i>
</span>
<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:'Поиск товара по названию, артикулу...' }}"
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;">
<i class="bi bi-x-lg"></i>
<i class="bi bi-x"></i>
</button>
</div>
</div>
{% if show_filters|default:True %}
<!-- Фильтры -->
<div class="card-body border-bottom py-2">
<div class="d-flex gap-2 align-items-center flex-wrap">
<!-- Фильтры - компактный вид -->
<div class="card-body border-bottom py-1">
<div class="d-flex gap-1 align-items-center flex-wrap">
{% if categories %}
<!-- Фильтр по категории -->
<select class="form-select form-select-sm product-picker-category" style="width: auto;">
@@ -113,29 +113,29 @@ ProductSearchPicker.init('#writeoff-products', {
</div>
{% 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="spinner-border text-primary" role="status">
<div class="product-picker-loading text-center py-2" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</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 -->
</div>
<!-- Пустой результат -->
<div class="product-picker-empty text-center py-4 text-muted" style="display: none;">
<i class="bi bi-search fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Товары не найдены</p>
<div class="product-picker-empty text-center py-2 text-muted" style="display: none;">
<i class="bi bi-search fs-5 opacity-25"></i>
<p class="mb-0 mt-1 small">Товары не найдены</p>
</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>
<button class="btn btn-primary btn-sm product-picker-add-selected" disabled>

View File

@@ -748,6 +748,16 @@
// Кэш цен товаров для быстрого доступа
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 если необходимо
async function getProductPrice(selectElement) {
// Строгая проверка: нужен валидный element с value
@@ -765,7 +775,7 @@
// Если уже загружена в кэш - возвращаем
if (priceCache[productId] !== undefined) {
const cachedPrice = parseFloat(priceCache[productId]) || 0;
const cachedPrice = parsePrice(priceCache[productId]);
console.log('getProductPrice: from cache', productId, cachedPrice);
return cachedPrice;
}
@@ -776,7 +786,7 @@
const formPrice = form.getAttribute('data-product-price');
const formProductId = form.getAttribute('data-product-id');
if (formPrice && productId.toString() === formProductId) {
const price = parseFloat(formPrice) || 0;
const price = parsePrice(formPrice);
if (price > 0) {
priceCache[productId] = price;
console.log('getProductPrice: from form data', productId, price);
@@ -789,7 +799,7 @@
const selectedOption = $(selectElement).find('option:selected');
let priceData = selectedOption.data('actual_price') || selectedOption.data('price');
if (priceData) {
const price = parseFloat(priceData) || 0;
const price = parsePrice(priceData);
if (price > 0) {
priceCache[productId] = price;
console.log('getProductPrice: from select2 data', productId, price);
@@ -808,7 +818,7 @@
const data = await response.json();
if (data.results && data.results.length > 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) {
priceCache[productId] = price;
console.log('getProductPrice: from API', productId, price);
@@ -868,7 +878,7 @@
// Если уже загружена в кэш - возвращаем
const cacheKey = `variant_${variantGroupId}`;
if (priceCache[cacheKey] !== undefined) {
const cachedPrice = parseFloat(priceCache[cacheKey]) || 0;
const cachedPrice = parsePrice(priceCache[cacheKey]);
console.log('getVariantGroupPrice: from cache', variantGroupId, cachedPrice);
return cachedPrice;
}
@@ -877,7 +887,7 @@
const selectedOption = $(selectElement).find('option:selected');
let priceData = selectedOption.data('actual_price') || selectedOption.data('price');
if (priceData) {
const price = parseFloat(priceData) || 0;
const price = parsePrice(priceData);
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getVariantGroupPrice: from select2 data', variantGroupId, price);
@@ -896,7 +906,7 @@
const data = await response.json();
if (data.results && data.results.length > 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) {
priceCache[cacheKey] = price;
console.log('getVariantGroupPrice: from API', variantGroupId, price);
@@ -940,7 +950,7 @@
// Если уже загружена в кэш - возвращаем
const cacheKey = `sales_unit_${salesUnitId}`;
if (priceCache[cacheKey] !== undefined) {
const cachedPrice = parseFloat(priceCache[cacheKey]) || 0;
const cachedPrice = parsePrice(priceCache[cacheKey]);
return cachedPrice;
}
@@ -949,7 +959,7 @@
if (selectedOption) {
let priceData = selectedOption.dataset.actual_price || selectedOption.dataset.price;
if (priceData) {
const price = parseFloat(priceData) || 0;
const price = parsePrice(priceData);
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getSalesUnitPrice: from standard select option data', salesUnitId, price);
@@ -966,7 +976,7 @@
const itemData = selectedData[0];
const priceData = itemData.actual_price || itemData.price;
if (priceData) {
const price = parseFloat(priceData) || 0;
const price = parsePrice(priceData);
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getSalesUnitPrice: from select2 data', salesUnitId, price);
@@ -988,7 +998,7 @@
if (data.sales_units && data.sales_units.length > 0) {
const salesUnitData = data.sales_units.find(su => su.id == salesUnitId);
if (salesUnitData) {
const price = parseFloat(salesUnitData.actual_price || salesUnitData.price || 0);
const price = parsePrice(salesUnitData.actual_price || salesUnitData.price || 0);
if (price > 0) {
priceCache[cacheKey] = 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()