Compare commits

..

9 Commits

Author SHA1 Message Date
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
14 changed files with 844 additions and 538 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,13 +454,66 @@ 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,
warehouse=warehouse,
@@ -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,19 +20,22 @@
<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">
<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>
{% elif document.status == 'confirmed' %}
<span class="badge bg-success ms-2">Проведён</span>
{% elif document.status == 'cancelled' %}
<span class="badge bg-secondary ms-2">Отменён</span>
{% endif %}
</h5>
<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>
{% elif document.status == 'confirmed' %}
<span class="badge bg-success ms-2">Проведён</span>
{% elif document.status == 'cancelled' %}
<span class="badge bg-secondary ms-2">Отменён</span>
{% endif %}
</span>
</button>
{% if document.can_edit %}
<div class="btn-group">
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
@@ -50,65 +53,67 @@
</div>
{% endif %}
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3">
<p class="text-muted small mb-1">Склад</p>
<p class="fw-semibold">{{ document.warehouse.name }}</p>
<div class="collapse" id="document-info-collapse">
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3">
<p class="text-muted small mb-1">Склад</p>
<p class="fw-semibold">{{ document.warehouse.name }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Дата документа</p>
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Тип поступления</p>
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Создан</p>
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
</div>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Дата документа</p>
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Тип поступления</p>
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Создан</p>
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
</div>
</div>
{% if document.supplier_name %}
<div class="mb-3">
<p class="text-muted small mb-1">Поставщик</p>
<p class="fw-semibold">{{ document.supplier_name }}</p>
</div>
{% endif %}
{% if document.notes %}
<div class="mb-3">
<p class="text-muted small mb-1">Примечания</p>
<p>{{ document.notes }}</p>
</div>
{% endif %}
{% if document.confirmed_at %}
<div class="row">
<div class="col-md-6">
<p class="text-muted small mb-1">Проведён</p>
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
{% if document.supplier_name %}
<div class="mb-3">
<p class="text-muted small mb-1">Поставщик</p>
<p class="fw-semibold">{{ document.supplier_name }}</p>
</div>
<div class="col-md-6">
<p class="text-muted small mb-1">Провёл</p>
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
{% endif %}
{% if document.notes %}
<div class="mb-3">
<p class="text-muted small mb-1">Примечания</p>
<p>{{ document.notes }}</p>
</div>
{% endif %}
{% if document.confirmed_at %}
<div class="row">
<div class="col-md-6">
<p class="text-muted small mb-1">Проведён</p>
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
</div>
<div class="col-md-6">
<p class="text-muted small mb-1">Провёл</p>
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
</div>
</div>
{% endif %}
</div>
{% endif %}
</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 %}
<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;">
<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,52 +19,76 @@
</div>
<div class="card-body">
<!-- Информация об инвентаризации - свернута по умолчанию -->
<div class="row mb-4">
<div class="col-md-6">
<h5>Информация</h5>
<table class="table table-borderless">
{% if inventory.document_number %}
<tr>
<th>Номер документа:</th>
<td><strong>{{ inventory.document_number }}</strong></td>
</tr>
{% endif %}
<tr>
<th>Склад:</th>
<td><strong>{{ inventory.warehouse.name }}</strong></td>
</tr>
<tr>
<th>Статус:</th>
<td>
{% if inventory.status == 'draft' %}
<span class="badge bg-secondary fs-6 px-3 py-2">
<i class="bi bi-file-earmark"></i> Черновик
</span>
{% elif inventory.status == 'processing' %}
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
<i class="bi bi-hourglass-split"></i> В обработке
</span>
{% else %}
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-check-circle-fill"></i> Завершена
</span>
{% endif %}
</td>
</tr>
<tr>
<th>Дата:</th>
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
</tr>
{% if inventory.conducted_by %}
<tr>
<th>Провёл:</th>
<td>{{ inventory.conducted_by }}</td>
</tr>
{% endif %}
</table>
<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>
<th>Номер документа:</th>
<td><strong>{{ inventory.document_number }}</strong></td>
</tr>
{% endif %}
<tr>
<th>Склад:</th>
<td><strong>{{ inventory.warehouse.name }}</strong></td>
</tr>
<tr>
<th>Статус:</th>
<td>
{% if inventory.status == 'draft' %}
<span class="badge bg-secondary fs-6 px-3 py-2">
<i class="bi bi-file-earmark"></i> Черновик
</span>
{% elif inventory.status == 'processing' %}
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
<i class="bi bi-hourglass-split"></i> В обработке
</span>
{% else %}
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-check-circle-fill"></i> Завершена
</span>
{% endif %}
</td>
</tr>
<tr>
<th>Дата:</th>
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
</tr>
{% if inventory.conducted_by %}
<tr>
<th>Провёл:</th>
<td>{{ inventory.conducted_by }}</td>
</tr>
{% endif %}
</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' %}
<!-- Информация о созданных документах -->
<div class="alert alert-info mb-4">
@@ -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 {
font-size: 0.875rem;
padding: 0.5rem 0;
}
.breadcrumb-sm {
font-size: 0.875rem;
padding: 0.5rem 0;
}
.table-hover tbody tr:hover {
background-color: #f8f9fa;
}
.table-hover tbody tr:hover {
background-color: #f8f9fa;
}
</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="Просмотр">
<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

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

File diff suppressed because it is too large Load Diff

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

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

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