Интегрирован компонент поиска товаров в документы списания с фильтром по складу
- Добавлен параметр warehouse в API search_products_and_variants - API фильтрует товары по наличию на указанном складе через Stock - Обновлен _apply_product_filters для поддержки warehouse_id - ProductSearchPicker теперь поддерживает data-warehouse-id - Warehouse автоматически передается в AJAX запросы - В WriteOffDocumentDetailView добавлены categories и tags в контекст - Компонент поиска встроен в detail.html с жестким фильтром по складу документа - Single-select режим для выбора одного товара - JS автоматически заполняет select формы при выборе товара - Отображение выбранного товара с фото и артикулом - Автофокус на поле количества после выбора товара - Пользователь видит только товары доступные на складе документа
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Документ списания {{ document.document_number }}{% endblock %}
|
{% block title %}Документ списания {{ document.document_number }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- CSS для компонента поиска -->
|
||||||
|
<link rel="stylesheet" href="{% static 'products/css/product-search-picker.css' %}">
|
||||||
|
|
||||||
<div class="container-fluid px-4 py-3">
|
<div class="container-fluid px-4 py-3">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
@@ -162,12 +166,32 @@
|
|||||||
<!-- Боковая панель: добавление позиции -->
|
<!-- Боковая панель: добавление позиции -->
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
|
<!-- Компонент поиска товаров -->
|
||||||
|
<div class="mb-3">
|
||||||
|
{% include 'products/components/product_search_picker.html' with container_id='writeoff-document-picker' title='Поиск товара для списания' warehouse_id=document.warehouse.id filter_in_stock_only=True categories=categories tags=tags add_button_text='Выбрать товар' multi_select=False show_select_all=False content_height='300px' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма добавления позиции -->
|
||||||
<div class="card border-0 shadow-sm sticky-top" style="top: 1rem;">
|
<div class="card border-0 shadow-sm sticky-top" style="top: 1rem;">
|
||||||
<div class="card-header bg-light py-3">
|
<div class="card-header bg-light py-3">
|
||||||
<h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Добавить товар</h6>
|
<h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Добавить товар</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="{% url 'inventory:writeoff-document-add-item' document.pk %}">
|
<!-- Информация о выбранном товаре -->
|
||||||
|
<div id="selected-product-info" class="alert alert-info mb-3" style="display: none;">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<img id="selected-product-photo" src="" alt="" class="rounded me-2" style="width: 40px; height: 40px; object-fit: cover; display: none;">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong id="selected-product-name" class="d-block"></strong>
|
||||||
|
<small class="text-muted" id="selected-product-sku"></small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-selected-product" title="Очистить">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'inventory:writeoff-document-add-item' document.pk %}" id="add-item-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -176,6 +200,9 @@
|
|||||||
{% if item_form.product.errors %}
|
{% if item_form.product.errors %}
|
||||||
<div class="text-danger small">{{ item_form.product.errors.0 }}</div>
|
<div class="text-danger small">{{ item_form.product.errors.0 }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
<i class="bi bi-info-circle"></i> Используйте поиск выше для удобного выбора
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -206,4 +233,81 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- JS для компонента поиска -->
|
||||||
|
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Элементы формы
|
||||||
|
const productSelect = document.querySelector('#id_product');
|
||||||
|
const quantityInput = document.querySelector('#id_quantity');
|
||||||
|
|
||||||
|
// Элементы отображения выбранного товара
|
||||||
|
const selectedProductInfo = document.getElementById('selected-product-info');
|
||||||
|
const selectedProductName = document.getElementById('selected-product-name');
|
||||||
|
const selectedProductSku = document.getElementById('selected-product-sku');
|
||||||
|
const selectedProductPhoto = document.getElementById('selected-product-photo');
|
||||||
|
const clearSelectedBtn = document.getElementById('clear-selected-product');
|
||||||
|
|
||||||
|
// Инициализация компонента поиска товаров
|
||||||
|
const picker = ProductSearchPicker.init('#writeoff-document-picker', {
|
||||||
|
onAddSelected: function(products, instance) {
|
||||||
|
if (products.length > 0) {
|
||||||
|
selectProduct(products[0]);
|
||||||
|
instance.clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция выбора товара
|
||||||
|
function selectProduct(product) {
|
||||||
|
const productId = String(product.id).replace('product_', '');
|
||||||
|
const productName = product.text || product.name || '';
|
||||||
|
|
||||||
|
// Показываем информацию о выбранном товаре
|
||||||
|
selectedProductName.textContent = productName;
|
||||||
|
selectedProductSku.textContent = product.sku || '';
|
||||||
|
|
||||||
|
if (product.photo_url) {
|
||||||
|
selectedProductPhoto.src = product.photo_url;
|
||||||
|
selectedProductPhoto.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
selectedProductPhoto.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedProductInfo.style.display = 'block';
|
||||||
|
|
||||||
|
// Устанавливаем значение в select формы
|
||||||
|
if (productSelect) {
|
||||||
|
productSelect.value = productId;
|
||||||
|
// Триггерим change для Select2, если он используется
|
||||||
|
const event = new Event('change', { bubbles: true });
|
||||||
|
productSelect.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фокус в поле количества
|
||||||
|
if (quantityInput) {
|
||||||
|
quantityInput.focus();
|
||||||
|
quantityInput.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция очистки выбора товара
|
||||||
|
function clearSelectedProduct() {
|
||||||
|
selectedProductInfo.style.display = 'none';
|
||||||
|
selectedProductName.textContent = '';
|
||||||
|
selectedProductSku.textContent = '';
|
||||||
|
selectedProductPhoto.style.display = 'none';
|
||||||
|
|
||||||
|
if (productSelect) {
|
||||||
|
productSelect.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка выбора товара
|
||||||
|
if (clearSelectedBtn) {
|
||||||
|
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ class WriteOffDocumentDetailView(LoginRequiredMixin, DetailView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['item_form'] = WriteOffDocumentItemForm(document=self.object)
|
context['item_form'] = WriteOffDocumentItemForm(document=self.object)
|
||||||
|
|
||||||
|
# Добавляем категории и теги для компонента поиска товаров
|
||||||
|
from products.models import ProductCategory, ProductTag
|
||||||
|
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
|
||||||
|
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@
|
|||||||
search: '',
|
search: '',
|
||||||
category: '',
|
category: '',
|
||||||
tag: '',
|
tag: '',
|
||||||
inStock: false
|
inStock: false,
|
||||||
|
warehouse: '' // ID склада для фильтрации
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +107,11 @@
|
|||||||
this.state.currentView = initialView;
|
this.state.currentView = initialView;
|
||||||
this._updateViewButtons();
|
this._updateViewButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Инициализация warehouse из data-атрибута
|
||||||
|
if (this.container.dataset.warehouseId) {
|
||||||
|
this.state.filters.warehouse = this.container.dataset.warehouseId;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
if (this.state.filters.inStock) {
|
if (this.state.filters.inStock) {
|
||||||
params.append('in_stock', 'true');
|
params.append('in_stock', 'true');
|
||||||
}
|
}
|
||||||
|
if (this.state.filters.warehouse) {
|
||||||
|
params.append('warehouse', this.state.filters.warehouse);
|
||||||
|
}
|
||||||
|
|
||||||
fetch(this.options.apiUrl + '?' + params.toString())
|
fetch(this.options.apiUrl + '?' + params.toString())
|
||||||
.then(function(response) {
|
.then(function(response) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- multi_select: множественный выбор (default: True)
|
- multi_select: множественный выбор (default: True)
|
||||||
- max_selection: максимальное количество выбранных товаров (default: null)
|
- max_selection: максимальное количество выбранных товаров (default: null)
|
||||||
- filter_in_stock_only: показывать только товары в наличии (default: False)
|
- filter_in_stock_only: показывать только товары в наличии (default: False)
|
||||||
|
- warehouse_id: ID склада для фильтрации товаров (default: None)
|
||||||
- categories: список категорий для фильтра (queryset или list)
|
- categories: список категорий для фильтра (queryset или list)
|
||||||
- tags: список тегов для фильтра (queryset или list)
|
- tags: список тегов для фильтра (queryset или list)
|
||||||
- content_height: высота контейнера с товарами (default: '400px')
|
- content_height: высота контейнера с товарами (default: '400px')
|
||||||
@@ -46,7 +47,8 @@ ProductSearchPicker.init('#writeoff-products', {
|
|||||||
data-api-url="{{ api_url|default:'/products/api/search-products-variants/' }}"
|
data-api-url="{{ api_url|default:'/products/api/search-products-variants/' }}"
|
||||||
data-multi-select="{{ multi_select|default:'true' }}"
|
data-multi-select="{{ multi_select|default:'true' }}"
|
||||||
data-max-selection="{{ max_selection|default:'' }}"
|
data-max-selection="{{ max_selection|default:'' }}"
|
||||||
data-exclude-kits="true">
|
data-exclude-kits="true"
|
||||||
|
{% if warehouse_id %}data-warehouse-id="{{ warehouse_id }}"{% endif %}>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<!-- Заголовок -->
|
<!-- Заголовок -->
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def _get_product_photo_url(product_id):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _apply_product_filters(queryset, category_id=None, tag_id=None, in_stock_only=False):
|
def _apply_product_filters(queryset, category_id=None, tag_id=None, in_stock_only=False, warehouse_id=None):
|
||||||
"""Применяет фильтры к queryset товаров."""
|
"""Применяет фильтры к queryset товаров."""
|
||||||
if category_id:
|
if category_id:
|
||||||
queryset = queryset.filter(categories__id=category_id)
|
queryset = queryset.filter(categories__id=category_id)
|
||||||
@@ -28,6 +28,14 @@ def _apply_product_filters(queryset, category_id=None, tag_id=None, in_stock_onl
|
|||||||
queryset = queryset.filter(tags__id=tag_id)
|
queryset = queryset.filter(tags__id=tag_id)
|
||||||
if in_stock_only:
|
if in_stock_only:
|
||||||
queryset = queryset.filter(in_stock=True)
|
queryset = queryset.filter(in_stock=True)
|
||||||
|
if warehouse_id:
|
||||||
|
# Фильтруем только товары, которые есть на указанном складе с доступным количеством
|
||||||
|
from inventory.models import Stock
|
||||||
|
products_with_stock = Stock.objects.filter(
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
quantity_available__gt=0
|
||||||
|
).values_list('product_id', flat=True)
|
||||||
|
queryset = queryset.filter(id__in=products_with_stock)
|
||||||
return queryset.distinct()
|
return queryset.distinct()
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +52,7 @@ def search_products_and_variants(request):
|
|||||||
- category: ID категории для фильтрации (опционально)
|
- category: ID категории для фильтрации (опционально)
|
||||||
- tag: ID тега для фильтрации (опционально)
|
- tag: ID тега для фильтрации (опционально)
|
||||||
- in_stock: 'true' для фильтрации только товаров в наличии (опционально)
|
- in_stock: 'true' для фильтрации только товаров в наличии (опционально)
|
||||||
|
- warehouse: ID склада для фильтрации только товаров с доступным остатком (опционально)
|
||||||
|
|
||||||
Возвращает JSON в формате Select2 с группировкой:
|
Возвращает JSON в формате Select2 с группировкой:
|
||||||
{
|
{
|
||||||
@@ -156,11 +165,12 @@ def search_products_and_variants(request):
|
|||||||
category_id = request.GET.get('category', '').strip()
|
category_id = request.GET.get('category', '').strip()
|
||||||
tag_id = request.GET.get('tag', '').strip()
|
tag_id = request.GET.get('tag', '').strip()
|
||||||
in_stock_only = request.GET.get('in_stock', '').lower() == 'true'
|
in_stock_only = request.GET.get('in_stock', '').lower() == 'true'
|
||||||
|
warehouse_id = request.GET.get('warehouse', '').strip()
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Проверяем, есть ли дополнительные фильтры
|
# Проверяем, есть ли дополнительные фильтры
|
||||||
has_filters = category_id or tag_id or in_stock_only
|
has_filters = category_id or tag_id or in_stock_only or warehouse_id
|
||||||
|
|
||||||
# Если поиска нет - показываем популярные товары и комплекты
|
# Если поиска нет - показываем популярные товары и комплекты
|
||||||
if not query or len(query) < 2:
|
if not query or len(query) < 2:
|
||||||
@@ -178,7 +188,7 @@ def search_products_and_variants(request):
|
|||||||
# Показываем последние добавленные активные товары
|
# Показываем последние добавленные активные товары
|
||||||
products_qs = Product.objects.filter(status='active')
|
products_qs = Product.objects.filter(status='active')
|
||||||
# Применяем фильтры
|
# Применяем фильтры
|
||||||
products_qs = _apply_product_filters(products_qs, category_id, tag_id, in_stock_only)
|
products_qs = _apply_product_filters(products_qs, category_id, tag_id, in_stock_only, warehouse_id)
|
||||||
products = products_qs.order_by('-created_at')[:page_size]\
|
products = products_qs.order_by('-created_at')[:page_size]\
|
||||||
.values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
.values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
||||||
|
|
||||||
@@ -305,7 +315,7 @@ def search_products_and_variants(request):
|
|||||||
).order_by('-relevance', 'name')
|
).order_by('-relevance', 'name')
|
||||||
|
|
||||||
# Применяем дополнительные фильтры
|
# Применяем дополнительные фильтры
|
||||||
products_query = _apply_product_filters(products_query, category_id, tag_id, in_stock_only)
|
products_query = _apply_product_filters(products_query, category_id, tag_id, in_stock_only, warehouse_id)
|
||||||
|
|
||||||
total_products = products_query.count()
|
total_products = products_query.count()
|
||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
|
|||||||
Reference in New Issue
Block a user