Интегрирован компонент поиска товаров в документы списания с фильтром по складу

- Добавлен параметр 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:
2025-12-11 00:02:37 +03:00
parent 542b90c3f1
commit 2e5ebabf22
5 changed files with 138 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@@ -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">
<!-- Заголовок --> <!-- Заголовок -->

View File

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