feat(products): реализована система единиц продажи на фронтенде

Добавлена полноценная интеграция единиц измерения (UoM) для продажи
товаров в разных единицах с автоматическим пересчётом цен и остатков.

## Основные изменения:

### Backend
- Расширен API поиска товаров (api_views.py): добавлена сериализация sales_units
- Создан новый endpoint get_product_sales_units_api для загрузки единиц с остатками
- Добавлено поле sales_unit в OrderItemForm и SaleForm с валидацией
- Созданы CRUD views для управления единицами продажи (uom_views.py)
- Обновлена ProductForm: использует base_unit вместо устаревшего unit

### Frontend
- Создан модуль sales-units.js с функциями для работы с единицами
- Интегрирован в select2-product-search.js: автозагрузка единиц при выборе товара
- Добавлены контейнеры для единиц в order_form.html и sale_form.html
- Реализовано автоматическое обновление цены при смене единицы продажи
- При выборе базовой единицы цена возвращается к базовой цене товара

### UI
- Добавлены страницы управления единицами продажи в навбар
- Созданы шаблоны: sales_unit_list.html, sales_unit_form.html, sales_unit_delete.html
- Добавлены фильтры по товару, единице, активности и дефолтности

## Исправленные ошибки:
- Порядок инициализации: обработчики устанавливаются ДО триггера события change
- Цена корректно обновляется при выборе единицы продажи
- При выборе "Базовая единица" возвращается базовая цена товара

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 12:35:01 +03:00
parent 5b68f14bb4
commit e831c4fb6e
19 changed files with 1574 additions and 52 deletions

View File

@@ -45,16 +45,33 @@ class WarehouseForm(forms.ModelForm):
class SaleForm(forms.ModelForm):
class Meta:
model = Sale
fields = ['product', 'warehouse', 'quantity', 'sale_price', 'order', 'document_number']
fields = ['product', 'warehouse', 'sales_unit', 'quantity', 'sale_price', 'order', 'document_number']
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'warehouse': forms.Select(attrs={'class': 'form-control'}),
'sales_unit': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
'sale_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'order': forms.Select(attrs={'class': 'form-control'}),
'document_number': forms.TextInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Динамический queryset для sales_unit
if self.instance.pk and self.instance.product:
from products.models import ProductSalesUnit
self.fields['sales_unit'].queryset = ProductSalesUnit.objects.filter(
product=self.instance.product,
is_active=True
).order_by('position', 'id')
else:
from products.models import ProductSalesUnit
self.fields['sales_unit'].queryset = ProductSalesUnit.objects.none()
self.fields['sales_unit'].required = False
def clean_quantity(self):
quantity = self.cleaned_data.get('quantity')
if quantity and quantity <= 0:

View File

@@ -52,6 +52,22 @@
</div>
</div>
<!-- Единица продажи (отображается только для товаров с единицами) -->
<div class="row" id="sales-unit-row" style="display: none;">
<div class="col-md-12 mb-3">
<label for="{{ form.sales_unit.id_for_label }}" class="form-label">
{{ form.sales_unit.label }}
</label>
{{ form.sales_unit }}
<div class="available-qty-display mt-1"></div>
{% if form.sales_unit.errors %}
<div class="invalid-feedback d-block">
{% for error in form.sales_unit.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.quantity.id_for_label }}" class="form-label">
@@ -126,4 +142,83 @@
width: 100%;
}
</style>
{% load static %}
<script src="{% static 'products/js/sales-units.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const productField = document.querySelector('[name="product"]');
const warehouseField = document.querySelector('[name="warehouse"]');
const salesUnitField = document.querySelector('[name="sales_unit"]');
const salePriceField = document.querySelector('[name="sale_price"]');
const salesUnitRow = document.getElementById('sales-unit-row');
const formElement = document.querySelector('form');
// Функция загрузки единиц продажи
async function loadSalesUnits() {
const productId = productField.value;
const warehouseId = warehouseField.value;
// Если товар не выбран - скрываем единицы
if (!productId) {
salesUnitRow.style.display = 'none';
salesUnitField.innerHTML = '<option value="">---------</option>';
return;
}
try {
// Загружаем единицы продажи
const result = await window.SalesUnitsModule.fetchSalesUnits(productId, warehouseId);
if (result.success && result.sales_units && result.sales_units.length > 0) {
// Создаем options для select
const selectHtml = window.SalesUnitsModule.createSalesUnitSelect(result.sales_units);
// Извлекаем options из HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = selectHtml;
const newSelect = tempDiv.querySelector('select');
// Очищаем существующий select и добавляем новые options
salesUnitField.innerHTML = '';
Array.from(newSelect.options).forEach(opt => {
salesUnitField.add(opt.cloneNode(true));
});
// Показываем блок с единицами
salesUnitRow.style.display = 'block';
// Выбираем дефолтную единицу если есть
const defaultUnit = result.sales_units.find(u => u.is_default);
if (defaultUnit) {
salesUnitField.value = defaultUnit.id;
}
// Инициализируем обработчики
window.SalesUnitsModule.initializeSalesUnitHandlers(formElement);
} else {
// Нет единиц - скрываем блок
salesUnitRow.style.display = 'none';
salesUnitField.innerHTML = '<option value="">---------</option>';
}
} catch (error) {
console.error('[Sale Form] Ошибка загрузки единиц продажи:', error);
salesUnitRow.style.display = 'none';
}
}
// Загружаем единицы при изменении товара или склада
if (productField) {
productField.addEventListener('change', loadSalesUnits);
}
if (warehouseField) {
warehouseField.addEventListener('change', loadSalesUnits);
}
// Загружаем единицы при загрузке страницы (если товар уже выбран)
if (productField && productField.value) {
loadSalesUnits();
}
});
</script>
{% endblock %}

View File

@@ -456,13 +456,14 @@ class OrderItemForm(forms.ModelForm):
class Meta:
model = OrderItem
fields = ['product', 'product_kit', 'quantity', 'price', 'is_custom_price']
fields = ['product', 'product_kit', 'sales_unit', 'quantity', 'price', 'is_custom_price']
# ВАЖНО: НЕ включаем 'id' в fields - это предотвращает ошибку валидации
widgets = {
'quantity': forms.NumberInput(attrs={'min': 1, 'value': 1}),
# Скрываем поля product и product_kit - они будут заполняться через JS
'product': forms.HiddenInput(),
'product_kit': forms.HiddenInput(),
'sales_unit': forms.HiddenInput(), # Управляется через JS
'is_custom_price': forms.HiddenInput(),
}
@@ -478,6 +479,9 @@ class OrderItemForm(forms.ModelForm):
self.fields['product'].required = False
self.fields['product_kit'].required = False
# Поле sales_unit опционально (управляется через JS)
self.fields['sales_unit'].required = False
# Поле цены заполняется автоматически, но можно редактировать вручную
self.fields['price'].widget.attrs.update({
'placeholder': 'Цена',
@@ -506,6 +510,7 @@ class OrderItemForm(forms.ModelForm):
cleaned_data = super().clean()
product = cleaned_data.get('product')
product_kit = cleaned_data.get('product_kit')
sales_unit = cleaned_data.get('sales_unit')
quantity = cleaned_data.get('quantity')
# Пустая форма - это нормально (будет удалена)
@@ -525,6 +530,17 @@ class OrderItemForm(forms.ModelForm):
if not quantity or quantity <= 0:
raise forms.ValidationError('Необходимо указать количество больше 0')
# Валидация единицы продажи
if sales_unit:
if product and sales_unit.product_id != product.id:
raise forms.ValidationError('Единица продажи не принадлежит товару')
if quantity:
try:
sales_unit.validate_quantity(quantity)
except ValidationError as e:
raise forms.ValidationError(str(e))
return cleaned_data

View File

@@ -208,6 +208,7 @@
{{ item_form.id }}
{{ item_form.product }} <!-- Hidden field -->
{{ item_form.product_kit }} <!-- Hidden field -->
{{ item_form.sales_unit }} <!-- Hidden field -->
{{ item_form.is_custom_price }} <!-- Hidden field -->
<div class="row align-items-end">
@@ -229,6 +230,9 @@
{% endif %}
</select>
</div>
<!-- Контейнер для единиц продажи (управляется JS) -->
<div class="sales-unit-container mb-2" style="display: none;"></div>
</div>
<div class="col-md-2">
<div class="mb-2">
@@ -295,6 +299,7 @@
<input type="hidden" name="items-__prefix__-id" id="id_items-__prefix__-id">
<input type="hidden" name="items-__prefix__-product" id="id_items-__prefix__-product">
<input type="hidden" name="items-__prefix__-product_kit" id="id_items-__prefix__-product_kit">
<input type="hidden" name="items-__prefix__-sales_unit" id="id_items-__prefix__-sales_unit">
<input type="hidden" name="items-__prefix__-is_custom_price" id="id_items-__prefix__-is_custom_price" value="false">
<div class="row align-items-end">
@@ -308,6 +313,9 @@
<option value=""></option>
</select>
</div>
<!-- Контейнер для единиц продажи (управляется JS) -->
<div class="sales-unit-container mb-2" style="display: none;"></div>
</div>
<div class="col-md-2">
<div class="mb-2">
@@ -1809,7 +1817,8 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<!-- Подключение модуля Select2 для поиска товаров/комплектов -->
<!-- Подключение модулей для работы с единицами продажи и поиском товаров -->
<script src="{% static 'products/js/sales-units.js' %}"></script>
<script src="{% static 'products/js/select2-product-search.js' %}"></script>
<script>

View File

@@ -827,6 +827,20 @@ class ProductCategoryPhotoInline(admin.TabularInline):
)
quality_display.short_description = "Качество"
class ProductSalesUnitInline(admin.TabularInline):
"""Инлайн для единиц продажи товара"""
model = ProductSalesUnit
extra = 0
fields = (
'unit', 'name', 'conversion_factor', 'price', 'sale_price',
'min_quantity', 'quantity_step', 'is_default', 'is_active'
)
autocomplete_fields = ['unit']
verbose_name = "Единица продажи"
verbose_name_plural = "Единицы продажи"
class ProductKitAdminWithItems(ProductKitAdmin):
inlines = [KitItemInline]
@@ -965,19 +979,6 @@ class UnitOfMeasureAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
)
class ProductSalesUnitInline(admin.TabularInline):
"""Инлайн для единиц продажи товара"""
model = ProductSalesUnit
extra = 0
fields = (
'unit', 'name', 'conversion_factor', 'price', 'sale_price',
'min_quantity', 'quantity_step', 'is_default', 'is_active'
)
autocomplete_fields = ['unit']
verbose_name = "Единица продажи"
verbose_name_plural = "Единицы продажи"
@admin.register(ProductSalesUnit)
class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для единиц продажи товаров"""

View File

@@ -4,7 +4,7 @@ from .models import (
Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem,
ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem,
ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute,
ProductAttribute, ProductAttributeValue
ProductAttribute, ProductAttributeValue, ProductSalesUnit, UnitOfMeasure
)
@@ -94,7 +94,7 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
model = Product
fields = [
'name', 'sku', 'description', 'short_description', 'categories',
'tags', 'unit', 'price', 'sale_price', 'status'
'tags', 'base_unit', 'price', 'sale_price', 'status'
]
labels = {
'name': 'Название',
@@ -103,11 +103,14 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
'short_description': 'Краткое описание',
'categories': 'Категории',
'tags': 'Теги',
'unit': 'Единица измерения',
'base_unit': 'Единица измерения',
'price': 'Основная цена',
'sale_price': 'Цена со скидкой',
'status': 'Статус'
}
widgets = {
'base_unit': forms.Select(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -131,9 +134,17 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
})
self.fields['price'].widget.attrs.update({'class': 'form-control'})
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
self.fields['base_unit'].widget.attrs.update({'class': 'form-control'})
self.fields['status'].widget.attrs.update({'class': 'form-control'})
# Фильтруем только активные единицы измерения
from .models import UnitOfMeasure
self.fields['base_unit'].queryset = UnitOfMeasure.objects.filter(
is_active=True
).order_by('position', 'code')
self.fields['base_unit'].required = False
self.fields['base_unit'].help_text = 'Базовая единица для учета товара на складе. На основе этой единицы можно создать единицы продажи.'
def clean_name(self):
"""Валидация уникальности имени для активных товаров"""
name = self.cleaned_data.get('name')
@@ -1026,3 +1037,94 @@ ProductAttributeValueFormSet = inlineformset_factory(
min_num=0,
validate_min=False,
)
class ProductSalesUnitForm(forms.ModelForm):
"""
Форма для создания и редактирования единицы продажи товара
"""
class Meta:
model = ProductSalesUnit
fields = [
'product', 'unit', 'name', 'conversion_factor',
'price', 'sale_price', 'min_quantity', 'quantity_step',
'is_default', 'is_active', 'position'
]
labels = {
'product': 'Товар',
'unit': 'Единица измерения',
'name': 'Название',
'conversion_factor': 'Коэффициент конверсии',
'price': 'Цена продажи',
'sale_price': 'Цена со скидкой',
'min_quantity': 'Минимальное количество',
'quantity_step': 'Шаг количества',
'is_default': 'По умолчанию',
'is_active': 'Активна',
'position': 'Порядок сортировки'
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'unit': forms.Select(attrs={'class': 'form-control'}),
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Например: Ветка большая, Стебель средний'
}),
'conversion_factor': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'min': '0.000001',
'placeholder': '15.0'
}),
'price': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0',
'placeholder': '0.00'
}),
'sale_price': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0',
'placeholder': 'Оставьте пустым если нет скидки'
}),
'min_quantity': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'min': '0.001',
'value': '1'
}),
'quantity_step': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'min': '0.001',
'value': '1'
}),
'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'position': forms.NumberInput(attrs={'class': 'form-control', 'value': '0'}),
}
help_texts = {
'conversion_factor': 'Сколько единиц продажи получается из 1 базовой единицы товара. Например: 15 (из 1 банча получается 15 больших веток)',
'price': 'Цена за одну единицу продажи',
'sale_price': 'Опционально: цена со скидкой (должна быть меньше основной)',
'min_quantity': 'Минимальное количество для заказа',
'quantity_step': 'С каким шагом можно заказывать (например: 0.5, 1)',
'is_default': 'Эта единица будет выбрана по умолчанию при добавлении товара в заказ',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Фильтруем только активные единицы измерения
self.fields['unit'].queryset = UnitOfMeasure.objects.filter(
is_active=True
).order_by('position', 'code')
# Фильтруем только активные товары
self.fields['product'].queryset = Product.objects.filter(
status='active'
).order_by('name')
# Сделать sale_price необязательным
self.fields['sale_price'].required = False

View File

@@ -0,0 +1,249 @@
/**
* Модуль для работы с единицами продажи товаров (Sales Units)
* Управляет загрузкой, отображением и валидацией единиц продажи
*/
(function(window) {
'use strict';
/**
* Загружает единицы продажи для товара с сервера
* @param {number} productId - ID товара
* @param {number|null} warehouseId - ID склада для получения остатков (опционально)
* @returns {Promise<Object>} Промис с данными единиц продажи
*/
async function fetchSalesUnits(productId, warehouseId = null) {
try {
let url = `/products/api/products/${productId}/sales-units/`;
if (warehouseId) {
url += `?warehouse=${warehouseId}`;
}
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Ошибка загрузки единиц продажи');
}
return data;
} catch (error) {
console.error('[SalesUnits] Ошибка при загрузке единиц:', error);
return {success: false, error: error.message, sales_units: []};
}
}
/**
* Создает HTML select элемент с единицами продажи
* @param {Array} salesUnits - Массив единиц продажи
* @param {number|null} selectedId - ID выбранной единицы (опционально)
* @returns {string} HTML строка с select элементом
*/
function createSalesUnitSelect(salesUnits, selectedId = null) {
if (!salesUnits || salesUnits.length === 0) {
return '<select class="form-control" disabled><option>Нет единиц продажи</option></select>';
}
let html = '<select class="form-control sales-unit-selector" name="sales_unit">';
html += '<option value="">Базовая единица</option>';
salesUnits.forEach(unit => {
const selected = (selectedId && unit.id === selectedId) ? 'selected' : '';
const isDefault = unit.is_default ? ' (по умолч.)' : '';
const price = unit.sale_price || unit.price;
html += `<option value="${unit.id}" ${selected}
data-price="${unit.actual_price}"
data-conversion="${unit.conversion_factor}"
data-min-qty="${unit.min_quantity}"
data-step="${unit.quantity_step}"
data-available="${unit.available_quantity || ''}">
${unit.name} (${unit.unit_short_name}) - ${price} руб.${isDefault}
</option>`;
});
html += '</select>';
return html;
}
/**
* Обновляет цену при выборе единицы продажи
* @param {HTMLSelectElement} selectElement - Select элемент с единицами
* @param {HTMLInputElement} priceInput - Input элемент для цены
* @param {HTMLInputElement} isCustomPriceInput - Hidden input для флага кастомной цены
*/
function updatePriceOnUnitChange(selectElement, priceInput, isCustomPriceInput) {
if (!priceInput) {
return;
}
const selectedOption = selectElement.options[selectElement.selectedIndex];
const isManuallyModified = isCustomPriceInput && isCustomPriceInput.value === 'true';
// Если цена была изменена вручную - не обновляем
if (isManuallyModified) {
return;
}
// Если выбрана базовая единица (пустое значение) - возвращаем базовую цену товара
if (!selectedOption || !selectedOption.value) {
const baseProductPrice = priceInput.dataset.baseProductPrice;
if (baseProductPrice) {
priceInput.value = baseProductPrice;
priceInput.dataset.originalPrice = baseProductPrice;
}
return;
}
// Если выбрана единица продажи - устанавливаем её цену
const newPrice = selectedOption.getAttribute('data-price');
if (newPrice) {
priceInput.value = newPrice;
priceInput.dataset.originalPrice = newPrice;
}
}
/**
* Отображает доступное количество товара в выбранной единице
* @param {HTMLSelectElement} selectElement - Select элемент с единицами
* @param {HTMLElement} displayElement - Элемент для отображения остатков
*/
function displayAvailableQuantity(selectElement, displayElement) {
if (!displayElement) {
return;
}
const selectedOption = selectElement.options[selectElement.selectedIndex];
if (!selectedOption || !selectedOption.value) {
displayElement.textContent = '';
return;
}
const availableQty = selectedOption.getAttribute('data-available');
const unitShortName = selectedOption.text.match(/\(([^)]+)\)/)?.[1] || 'шт';
if (availableQty && parseFloat(availableQty) > 0) {
displayElement.innerHTML = `<small class="text-muted">Доступно: ${availableQty} ${unitShortName}</small>`;
} else {
displayElement.textContent = '';
}
}
/**
* Валидирует количество согласно минимуму и шагу единицы продажи
* @param {HTMLInputElement} quantityInput - Input элемент для количества
* @param {HTMLSelectElement} salesUnitSelect - Select элемент с единицами
* @returns {boolean} true если валидация прошла успешно
*/
function validateQuantity(quantityInput, salesUnitSelect) {
const selectedOption = salesUnitSelect.options[salesUnitSelect.selectedIndex];
// Если единица не выбрана - валидируем только положительность
if (!selectedOption || !selectedOption.value) {
return quantityInput.value > 0;
}
const quantity = parseFloat(quantityInput.value);
const minQty = parseFloat(selectedOption.getAttribute('data-min-qty'));
const step = parseFloat(selectedOption.getAttribute('data-step'));
// Проверка минимального количества
if (quantity < minQty) {
alert(`Минимальное количество: ${minQty}`);
return false;
}
// Проверка шага (кратности)
if (step && step > 0) {
const remainder = quantity % step;
// Используем небольшую погрешность для сравнения float
if (remainder > 0.0001 && (step - remainder) > 0.0001) {
alert(`Количество должно быть кратно ${step}`);
return false;
}
}
return true;
}
/**
* Инициализирует обработчики событий для единиц продажи в форме
* @param {HTMLElement} formElement - Элемент формы или родительский контейнер
*/
function initializeSalesUnitHandlers(formElement) {
const salesUnitSelect = formElement.querySelector('.sales-unit-selector');
const priceInput = formElement.querySelector('[name$="-price"], [name="price"]');
const quantityInput = formElement.querySelector('[name$="-quantity"], [name="quantity"]');
const isCustomPriceInput = formElement.querySelector('[name$="-is_custom_price"], [name="is_custom_price"]');
const availableQtyDisplay = formElement.querySelector('.available-qty-display');
if (!salesUnitSelect) {
return;
}
// При смене единицы - обновить цену и остатки
salesUnitSelect.addEventListener('change', function() {
updatePriceOnUnitChange(salesUnitSelect, priceInput, isCustomPriceInput);
displayAvailableQuantity(salesUnitSelect, availableQtyDisplay);
});
// Отследить ручное изменение цены
if (priceInput) {
priceInput.addEventListener('change', function() {
const originalPrice = priceInput.dataset.originalPrice;
if (originalPrice && parseFloat(priceInput.value) !== parseFloat(originalPrice)) {
if (isCustomPriceInput) {
isCustomPriceInput.value = 'true';
}
}
});
}
// Валидация количества при потере фокуса
if (quantityInput) {
quantityInput.addEventListener('blur', function() {
validateQuantity(quantityInput, salesUnitSelect);
});
}
// Начальное отображение остатков и обновление цены если единица уже выбрана
if (salesUnitSelect.value) {
displayAvailableQuantity(salesUnitSelect, availableQtyDisplay);
// Начальное обновление цены (страховка на случай если событие change не сработает)
updatePriceOnUnitChange(salesUnitSelect, priceInput, isCustomPriceInput);
}
}
/**
* Создает и вставляет контейнер для единиц продажи в форму
* @param {HTMLElement} targetElement - Элемент, после которого вставить контейнер
* @returns {HTMLElement} Созданный контейнер
*/
function createSalesUnitContainer(targetElement) {
const container = document.createElement('div');
container.className = 'mb-3 sales-unit-container';
container.style.display = 'none';
container.innerHTML = `
<label class="form-label">Единица продажи</label>
<div class="sales-unit-selector-wrapper"></div>
<div class="available-qty-display mt-1"></div>
`;
targetElement.insertAdjacentElement('afterend', container);
return container;
}
// Экспорт публичного API
window.SalesUnitsModule = {
fetchSalesUnits,
createSalesUnitSelect,
updatePriceOnUnitChange,
displayAvailableQuantity,
validateQuantity,
initializeSalesUnitHandlers,
createSalesUnitContainer
};
console.log('[SalesUnits] Модуль инициализирован');
})(window);

View File

@@ -191,11 +191,24 @@
if (type === 'product') {
if (productField) productField.value = id;
if (kitField) kitField.value = '';
if (priceField) priceField.value = originalPrice;
if (priceField) {
priceField.value = originalPrice;
// Сохраняем базовую цену товара в data-атрибут для возврата при выборе базовой единицы
priceField.dataset.baseProductPrice = originalPrice;
}
// НОВОЕ: Загружаем единицы продажи для товара
loadAndDisplaySalesUnits(id, form, data);
} else if (type === 'kit') {
if (kitField) kitField.value = id;
if (productField) productField.value = '';
if (priceField) priceField.value = originalPrice;
// Скрываем единицы продажи для комплектов
var salesUnitContainer = form.querySelector('.sales-unit-container');
if (salesUnitContainer) {
salesUnitContainer.style.display = 'none';
}
}
// Сохраняем оригинальную цену в data-атрибуте
@@ -232,4 +245,87 @@
});
};
/**
* Загружает и отображает единицы продажи для выбранного товара
* @param {string} productId - ID товара
* @param {HTMLElement} form - Элемент формы
* @param {Object} productData - Данные товара из Select2
*/
async function loadAndDisplaySalesUnits(productId, form, productData) {
// Проверяем наличие модуля SalesUnitsModule
if (typeof window.SalesUnitsModule === 'undefined') {
console.warn('[Select2] SalesUnitsModule не загружен');
return;
}
var salesUnitContainer = form.querySelector('.sales-unit-container');
if (!salesUnitContainer) {
console.warn('[Select2] Контейнер .sales-unit-container не найден в форме');
return;
}
// Показываем индикатор загрузки
salesUnitContainer.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Загрузка...</span></div>';
salesUnitContainer.style.display = 'block';
try {
// Проверяем, есть ли единицы в данных товара (если API уже вернул их)
var salesUnits = productData.sales_units;
// Если единиц нет в данных, загружаем через отдельный API
if (!salesUnits || salesUnits.length === 0) {
var result = await window.SalesUnitsModule.fetchSalesUnits(productId);
if (result.success) {
salesUnits = result.sales_units;
} else {
throw new Error(result.error || 'Не удалось загрузить единицы продажи');
}
}
// Если единиц нет - скрываем контейнер
if (!salesUnits || salesUnits.length === 0) {
salesUnitContainer.style.display = 'none';
return;
}
// Создаем select с единицами
var selectHtml = window.SalesUnitsModule.createSalesUnitSelect(salesUnits);
salesUnitContainer.innerHTML = `
<label class="form-label">Единица продажи</label>
${selectHtml}
<div class="available-qty-display mt-1"></div>
`;
salesUnitContainer.style.display = 'block';
// Находим созданный select и скрытое поле
var salesUnitSelect = salesUnitContainer.querySelector('.sales-unit-selector');
var hiddenSalesUnitField = form.querySelector('[name$="-sales_unit"]');
if (salesUnitSelect && hiddenSalesUnitField) {
// Синхронизируем visible select с hidden field
salesUnitSelect.addEventListener('change', function() {
hiddenSalesUnitField.value = salesUnitSelect.value;
});
}
// СНАЧАЛА инициализируем обработчики из модуля SalesUnitsModule
window.SalesUnitsModule.initializeSalesUnitHandlers(form);
// ПОТОМ выбираем дефолтную единицу и триггерим событие
if (salesUnitSelect && hiddenSalesUnitField) {
var defaultUnit = salesUnits.find(function(u) { return u.is_default; });
if (defaultUnit) {
salesUnitSelect.value = defaultUnit.id;
hiddenSalesUnitField.value = defaultUnit.id;
// Теперь событие сработает ПОСЛЕ установки обработчиков
salesUnitSelect.dispatchEvent(new Event('change'));
}
}
} catch (error) {
console.error('[Select2] Ошибка загрузки единиц продажи:', error);
salesUnitContainer.innerHTML = '<small class="text-danger">Ошибка загрузки единиц продажи</small>';
}
}
})(window);

View File

@@ -181,7 +181,14 @@
</tr>
<tr>
<th>Единица измерения:</th>
<td>{{ product.unit }}</td>
<td>
{% if product.base_unit %}
<span class="badge bg-secondary">{{ product.base_unit.short_name }}</span>
<small class="text-muted ms-2">{{ product.base_unit.name }}</small>
{% else %}
<span class="text-muted">Не указана</span>
{% endif %}
</td>
</tr>
<tr>
<th>Себестоимость:</th>

View File

@@ -506,13 +506,13 @@
<!-- Единица измерения, Основная цена, Цена со скидкой -->
<div class="row mb-3">
<div class="col-md-6">
{{ form.unit.label_tag }}
{{ form.unit }}
{% if form.unit.help_text %}
<small class="form-text text-muted">{{ form.unit.help_text }}</small>
{{ form.base_unit.label_tag }}
{{ form.base_unit }}
{% if form.base_unit.help_text %}
<small class="form-text text-muted">{{ form.base_unit.help_text }}</small>
{% endif %}
{% if form.unit.errors %}
<div class="text-danger">{{ form.unit.errors }}</div>
{% if form.base_unit.errors %}
<div class="text-danger">{{ form.base_unit.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">

View File

@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block title %}Удалить единицу продажи{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Подтверждение удаления</h4>
</div>
<div class="card-body">
<p class="mb-3">Вы действительно хотите удалить единицу продажи?</p>
<div class="alert alert-info">
<h6 class="alert-heading">Информация об единице:</h6>
<dl class="row mb-0">
<dt class="col-sm-4">Товар:</dt>
<dd class="col-sm-8">{{ sales_unit.product.name }}</dd>
<dt class="col-sm-4">Название:</dt>
<dd class="col-sm-8">{{ sales_unit.name }}</dd>
<dt class="col-sm-4">Единица:</dt>
<dd class="col-sm-8">{{ sales_unit.unit.name }} ({{ sales_unit.unit.short_name }})</dd>
<dt class="col-sm-4">Коэффициент:</dt>
<dd class="col-sm-8">{{ sales_unit.conversion_factor }}</dd>
<dt class="col-sm-4">Цена:</dt>
<dd class="col-sm-8">
{% if sales_unit.sale_price %}
<span class="text-decoration-line-through text-muted">{{ sales_unit.price }}</span>
<span class="text-danger fw-bold">{{ sales_unit.sale_price }}</span>
{% else %}
{{ sales_unit.price }}
{% endif %}
руб.
</dd>
</dl>
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-circle"></i> <strong>Внимание!</strong> Это действие нельзя отменить.
</div>
<form method="post" class="d-flex gap-2">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Да, удалить
</button>
<a href="{% url 'products:sales-unit-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,186 @@
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{{ form.non_field_errors }}
</div>
{% endif %}
<!-- Товар и Единица измерения -->
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ form.product.id_for_label }}" class="form-label">
{{ form.product.label }} <span class="text-danger">*</span>
</label>
{{ form.product }}
{% if form.product.help_text %}
<small class="form-text text-muted">{{ form.product.help_text }}</small>
{% endif %}
{% if form.product.errors %}
<div class="text-danger">{{ form.product.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.unit.id_for_label }}" class="form-label">
{{ form.unit.label }} <span class="text-danger">*</span>
</label>
{{ form.unit }}
{% if form.unit.help_text %}
<small class="form-text text-muted">{{ form.unit.help_text }}</small>
{% endif %}
{% if form.unit.errors %}
<div class="text-danger">{{ form.unit.errors }}</div>
{% endif %}
</div>
</div>
<!-- Название -->
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }} <span class="text-danger">*</span>
</label>
{{ form.name }}
{% if form.name.help_text %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
{% endif %}
{% if form.name.errors %}
<div class="text-danger">{{ form.name.errors }}</div>
{% endif %}
</div>
<!-- Коэффициент конверсии -->
<div class="mb-3">
<label for="{{ form.conversion_factor.id_for_label }}" class="form-label">
{{ form.conversion_factor.label }} <span class="text-danger">*</span>
</label>
{{ form.conversion_factor }}
{% if form.conversion_factor.help_text %}
<small class="form-text text-muted d-block">
<i class="bi bi-info-circle"></i> {{ form.conversion_factor.help_text }}
</small>
{% endif %}
{% if form.conversion_factor.errors %}
<div class="text-danger">{{ form.conversion_factor.errors }}</div>
{% endif %}
</div>
<!-- Цена и Цена со скидкой -->
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ form.price.id_for_label }}" class="form-label">
{{ form.price.label }} <span class="text-danger">*</span>
</label>
<div class="input-group">
{{ form.price }}
<span class="input-group-text"></span>
</div>
{% if form.price.help_text %}
<small class="form-text text-muted">{{ form.price.help_text }}</small>
{% endif %}
{% if form.price.errors %}
<div class="text-danger">{{ form.price.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.sale_price.id_for_label }}" class="form-label">
{{ form.sale_price.label }}
</label>
<div class="input-group">
{{ form.sale_price }}
<span class="input-group-text"></span>
</div>
{% if form.sale_price.help_text %}
<small class="form-text text-muted">{{ form.sale_price.help_text }}</small>
{% endif %}
{% if form.sale_price.errors %}
<div class="text-danger">{{ form.sale_price.errors }}</div>
{% endif %}
</div>
</div>
<!-- Минимальное количество и Шаг -->
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ form.min_quantity.id_for_label }}" class="form-label">
{{ form.min_quantity.label }} <span class="text-danger">*</span>
</label>
{{ form.min_quantity }}
{% if form.min_quantity.help_text %}
<small class="form-text text-muted">{{ form.min_quantity.help_text }}</small>
{% endif %}
{% if form.min_quantity.errors %}
<div class="text-danger">{{ form.min_quantity.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.quantity_step.id_for_label }}" class="form-label">
{{ form.quantity_step.label }} <span class="text-danger">*</span>
</label>
{{ form.quantity_step }}
{% if form.quantity_step.help_text %}
<small class="form-text text-muted">{{ form.quantity_step.help_text }}</small>
{% endif %}
{% if form.quantity_step.errors %}
<div class="text-danger">{{ form.quantity_step.errors }}</div>
{% endif %}
</div>
</div>
<!-- Флаги -->
<div class="mb-3">
<div class="form-check mb-2">
{{ form.is_default }}
<label class="form-check-label" for="{{ form.is_default.id_for_label }}">
{{ form.is_default.label }}
</label>
{% if form.is_default.help_text %}
<br><small class="form-text text-muted">{{ form.is_default.help_text }}</small>
{% endif %}
</div>
<div class="form-check mb-2">
{{ form.is_active }}
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
{{ form.is_active.label }}
</label>
</div>
</div>
<!-- Позиция -->
<div class="mb-3">
<label for="{{ form.position.id_for_label }}" class="form-label">
{{ form.position.label }}
</label>
{{ form.position }}
<small class="form-text text-muted">Чем меньше число, тем выше в списке</small>
</div>
<!-- Кнопки -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {{ submit_text }}
</button>
<a href="{% url 'products:sales-unit-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,183 @@
{% extends 'base.html' %}
{% block title %}Единицы продажи товаров{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-11">
<!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h4 class="mb-0"><i class="bi bi-box-seam text-primary"></i> Единицы продажи товаров</h4>
<a href="{% url 'products:sales-unit-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Добавить единицу
</a>
</div>
<!-- Поиск и фильтры -->
<form method="get" class="mb-3">
<div class="row g-2">
<div class="col-md-4">
<input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по товару, артикулу, названию...">
</div>
<div class="col-md-2">
<select class="form-select form-select-sm" name="unit" onchange="this.form.submit()">
<option value="">Все единицы</option>
{% for unit in all_units %}
<option value="{{ unit.id }}" {% if unit_filter == unit.id|stringformat:"s" %}selected{% endif %}>
{{ unit.short_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select class="form-select form-select-sm" name="is_active" onchange="this.form.submit()">
<option value="">Все</option>
<option value="true" {% if is_active_filter == 'true' %}selected{% endif %}>Активные</option>
<option value="false" {% if is_active_filter == 'false' %}selected{% endif %}>Неактивные</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select form-select-sm" name="is_default" onchange="this.form.submit()">
<option value="">Все</option>
<option value="true" {% if is_default_filter == 'true' %}selected{% endif %}>По умолчанию</option>
<option value="false" {% if is_default_filter == 'false' %}selected{% endif %}>Не по умолч.</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-secondary btn-sm w-100">
<i class="bi bi-search"></i> Найти
</button>
</div>
</div>
</form>
<!-- Статистика -->
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i> Всего единиц продажи: <strong>{{ total_sales_units }}</strong>
</div>
<!-- Список единиц продажи -->
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width: 25%;">Товар</th>
<th style="width: 15%;">Название единицы</th>
<th style="width: 10%;" class="text-center">Единица</th>
<th style="width: 10%;" class="text-end">Коэфф.</th>
<th style="width: 12%;" class="text-end">Цена</th>
<th style="width: 8%;" class="text-center">Мин.</th>
<th style="width: 8%;" class="text-center">Шаг</th>
<th style="width: 6%;" class="text-center">По умолч.</th>
<th style="width: 6%;" class="text-center">Статус</th>
<th style="width: 10%;" class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for su in page_obj %}
<tr>
<td>
<a href="{% url 'products:product-detail' su.product.pk %}" class="text-decoration-none">
{{ su.product.name }}
</a>
{% if su.product.sku %}
<br><small class="text-muted">{{ su.product.sku }}</small>
{% endif %}
</td>
<td>{{ su.name }}</td>
<td class="text-center">
<span class="badge bg-secondary">{{ su.unit.short_name }}</span>
</td>
<td class="text-end"><code>{{ su.conversion_factor }}</code></td>
<td class="text-end">
{% if su.sale_price %}
<span class="text-decoration-line-through text-muted">{{ su.price }}</span>
<span class="text-danger fw-bold">{{ su.sale_price }}</span>
{% else %}
{{ su.price }}
{% endif %}
<small class="text-muted">руб.</small>
</td>
<td class="text-center">
<small>{{ su.min_quantity }}</small>
</td>
<td class="text-center">
<small>{{ su.quantity_step }}</small>
</td>
<td class="text-center">
{% if su.is_default %}
<i class="bi bi-check-circle-fill text-success" title="По умолчанию"></i>
{% else %}
<i class="bi bi-circle text-muted" title="Не по умолчанию"></i>
{% endif %}
</td>
<td class="text-center">
{% if su.is_active %}
<span class="badge bg-success">Акт.</span>
{% else %}
<span class="badge bg-secondary">Неакт.</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'products:sales-unit-update' su.pk %}" class="btn btn-outline-secondary btn-sm" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'products:sales-unit-delete' su.pk %}" class="btn btn-outline-danger btn-sm" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if page_obj.has_other_pages %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&laquo;</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&raquo;</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-box-seam fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Единиц продажи пока нет</p>
<p class="small">Добавьте единицы продажи для товаров через форму выше</p>
</div>
{% endif %}
<!-- Ссылка обратно на единицы измерения -->
<div class="mt-4 p-3 bg-light rounded">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><i class="bi bi-rulers"></i> Справочник единиц измерения</h6>
<small class="text-muted">Управление базовыми единицами измерения</small>
</div>
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-primary btn-sm">
Перейти <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,123 @@
{% extends 'base.html' %}
{% block title %}Единицы измерения{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-10">
<!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h4 class="mb-0"><i class="bi bi-rulers text-primary"></i> Единицы измерения</h4>
<a href="{% url 'admin:products_unitofmeasure_add' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Добавить единицу
</a>
</div>
<!-- Поиск и фильтры -->
<form method="get" class="d-flex gap-2 mb-3">
<input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по коду, названию...">
<select class="form-select form-select-sm" name="is_active" style="width: auto;" onchange="this.form.submit()">
<option value="">Все</option>
<option value="true" {% if is_active_filter == 'true' %}selected{% endif %}>Активные</option>
<option value="false" {% if is_active_filter == 'false' %}selected{% endif %}>Неактивные</option>
</select>
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-search"></i></button>
</form>
<!-- Статистика -->
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i> Всего единиц: <strong>{{ total_units }}</strong>
</div>
<!-- Список единиц измерения -->
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th style="width: 100px;">Код</th>
<th>Название</th>
<th style="width: 120px;">Краткое</th>
<th style="width: 100px;" class="text-center">Позиция</th>
<th style="width: 120px;" class="text-center">Использований</th>
<th style="width: 100px;" class="text-center">Статус</th>
<th style="width: 120px;" class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for unit in page_obj %}
<tr>
<td><code>{{ unit.code }}</code></td>
<td>{{ unit.name }}</td>
<td><span class="badge bg-secondary">{{ unit.short_name }}</span></td>
<td class="text-center">{{ unit.position }}</td>
<td class="text-center">
<span class="badge {% if unit.usage_count > 0 %}bg-success{% else %}bg-secondary{% endif %}">
{{ unit.usage_count }}
</span>
</td>
<td class="text-center">
{% if unit.is_active %}
<span class="badge bg-success">Активна</span>
{% else %}
<span class="badge bg-secondary">Неактивна</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'admin:products_unitofmeasure_change' unit.pk %}" class="btn btn-outline-secondary" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if page_obj.has_other_pages %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">&laquo;</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">&raquo;</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-rulers fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Единиц измерения пока нет</p>
</div>
{% endif %}
<!-- Ссылка на единицы продажи -->
<div class="mt-4 p-3 bg-light rounded">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><i class="bi bi-box-seam"></i> Единицы продажи товаров</h6>
<small class="text-muted">Настройка единиц продажи для конкретных товаров</small>
</div>
<a href="{% url 'products:sales-unit-list' %}" class="btn btn-outline-primary btn-sm">
Перейти <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -44,6 +44,7 @@ urlpatterns = [
# API endpoints
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
path('api/products/<int:product_id>/sales-units/', api_views.get_product_sales_units_api, name='api-product-sales-units'),
path('api/kits/temporary/create/', views.create_temporary_kit_api, name='api-temporary-kit-create'),
path('api/tags/create/', api_views.create_tag_api, name='api-tag-create'),
path('api/tags/<int:pk>/toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'),
@@ -110,4 +111,11 @@ urlpatterns = [
path('configurable/<int:pk>/options/add/', views.add_option_to_configurable, name='configurableproduct-add-option'),
path('configurable/<int:pk>/options/<int:option_id>/remove/', views.remove_option_from_configurable, name='configurableproduct-remove-option'),
path('configurable/<int:pk>/options/<int:option_id>/set-default/', views.set_option_as_default, name='configurableproduct-set-default-option'),
# Управление единицами измерения
path('units/', views.unit_of_measure_list, name='unit-list'),
path('sales-units/', views.product_sales_unit_list, name='sales-unit-list'),
path('sales-units/create/', views.product_sales_unit_create, name='sales-unit-create'),
path('sales-units/<int:pk>/edit/', views.product_sales_unit_update, name='sales-unit-update'),
path('sales-units/<int:pk>/delete/', views.product_sales_unit_delete, name='sales-unit-delete'),
]

View File

@@ -111,6 +111,15 @@ from .api_views import search_products_and_variants, validate_kit_cost, create_t
# Каталог
from .catalog_views import CatalogView
# Управление единицами измерения
from .uom_views import (
unit_of_measure_list,
product_sales_unit_list,
product_sales_unit_create,
product_sales_unit_update,
product_sales_unit_delete,
)
__all__ = [
# Утилиты
@@ -204,4 +213,11 @@ __all__ = [
# Каталог
'CatalogView',
# Управление единицами измерения
'unit_of_measure_list',
'product_sales_unit_list',
'product_sales_unit_create',
'product_sales_unit_update',
'product_sales_unit_delete',
]

View File

@@ -104,7 +104,25 @@ def search_products_and_variants(request):
numeric_id = int(item_id)
if item_type == 'product':
product = Product.objects.get(id=numeric_id, status='active')
product = Product.objects.prefetch_related('sales_units__unit').get(id=numeric_id, status='active')
# Сериализация единиц продажи
sales_units_data = []
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
sales_units_data.append({
'id': su.id,
'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price),
'conversion_factor': str(su.conversion_factor),
'min_quantity': str(su.min_quantity),
'quantity_step': str(su.quantity_step),
'is_default': su.is_default
})
return JsonResponse({
'results': [{
'id': f'product_{product.id}',
@@ -113,7 +131,8 @@ def search_products_and_variants(request):
'price': str(product.price) if product.price else None,
'actual_price': str(product.actual_price) if product.actual_price else '0',
'in_stock': product.in_stock,
'type': 'product'
'type': 'product',
'sales_units': sales_units_data
}],
'pagination': {'more': False}
})
@@ -186,29 +205,46 @@ def search_products_and_variants(request):
if search_type in ['all', 'product']:
# Показываем последние добавленные активные товары
products_qs = Product.objects.filter(status='active')
products_qs = Product.objects.filter(status='active').prefetch_related('sales_units__unit')
# Применяем фильтры
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]\
.values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
products = products_qs.order_by('-created_at')[:page_size]
for product in products:
text = product['name']
if product['sku']:
text += f" ({product['sku']})"
text = product.name
if product.sku:
text += f" ({product.sku})"
# Получаем actual_price: приоритет sale_price > price
actual_price = product['sale_price'] if product['sale_price'] else product['price']
actual_price = product.sale_price if product.sale_price else product.price
# Сериализация единиц продажи
sales_units_data = []
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
sales_units_data.append({
'id': su.id,
'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price),
'conversion_factor': str(su.conversion_factor),
'min_quantity': str(su.min_quantity),
'quantity_step': str(su.quantity_step),
'is_default': su.is_default
})
product_results.append({
'id': f"product_{product['id']}",
'id': f"product_{product.id}",
'text': text,
'sku': product['sku'],
'price': str(product['price']) if product['price'] else None,
'sku': product.sku,
'price': str(product.price) if product.price else None,
'actual_price': str(actual_price) if actual_price else '0',
'in_stock': product['in_stock'],
'in_stock': product.in_stock,
'type': 'product',
'photo_url': _get_product_photo_url(product['id'])
'photo_url': _get_product_photo_url(product.id),
'sales_units': sales_units_data
})
if search_type in ['all', 'kit']:
@@ -317,29 +353,50 @@ def search_products_and_variants(request):
# Применяем дополнительные фильтры
products_query = _apply_product_filters(products_query, category_id, tag_id, in_stock_only, warehouse_id)
# Добавляем prefetch для единиц продажи
products_query = products_query.prefetch_related('sales_units__unit')
total_products = products_query.count()
start = (page - 1) * page_size
end = start + page_size
products = products_query[start:end].values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
products = products_query[start:end]
for product in products:
text = product['name']
if product['sku']:
text += f" ({product['sku']})"
text = product.name
if product.sku:
text += f" ({product.sku})"
# Получаем actual_price: приоритет sale_price > price
actual_price = product['sale_price'] if product['sale_price'] else product['price']
actual_price = product.sale_price if product.sale_price else product.price
# Сериализация единиц продажи
sales_units_data = []
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
sales_units_data.append({
'id': su.id,
'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price),
'conversion_factor': str(su.conversion_factor),
'min_quantity': str(su.min_quantity),
'quantity_step': str(su.quantity_step),
'is_default': su.is_default
})
product_results.append({
'id': f"product_{product['id']}",
'id': f"product_{product.id}",
'text': text,
'sku': product['sku'],
'price': str(product['price']) if product['price'] else None,
'sku': product.sku,
'price': str(product.price) if product.price else None,
'actual_price': str(actual_price) if actual_price else '0',
'in_stock': product['in_stock'],
'in_stock': product.in_stock,
'type': 'product',
'photo_url': _get_product_photo_url(product['id'])
'photo_url': _get_product_photo_url(product.id),
'sales_units': sales_units_data
})
has_more = total_products > end
@@ -1302,3 +1359,98 @@ def get_payment_methods(request):
return JsonResponse({
'error': f'Ошибка при загрузке способов оплаты: {str(e)}'
}, status=500)
def get_product_sales_units_api(request, product_id):
"""
API для получения единиц продажи товара с остатками.
GET /api/products/{product_id}/sales-units/?warehouse={id}
Параметры GET:
- warehouse: ID склада для получения остатков (опционально)
Возвращает JSON:
{
"success": true,
"product_id": 123,
"product_name": "Роза красная Freedom 50см",
"sales_units": [
{
"id": 1,
"name": "Ветка большая",
"unit_code": "шт",
"unit_short_name": "шт",
"price": "150.00",
"sale_price": "135.00",
"actual_price": "135.00",
"conversion_factor": "15.000000",
"min_quantity": "1.000",
"quantity_step": "1.000",
"is_default": true,
"available_quantity": "45.000000"
}
]
}
"""
try:
from decimal import Decimal
warehouse_id = request.GET.get('warehouse', '').strip()
# Получаем товар с prefetch единиц продажи
product = Product.objects.prefetch_related('sales_units__unit').get(
id=product_id, status='active'
)
# Получаем остатки со склада
from inventory.models import Stock
available_qty = Decimal('0')
if warehouse_id:
try:
stock = Stock.objects.filter(
product_id=product_id,
warehouse_id=int(warehouse_id)
).first()
if stock:
available_qty = stock.quantity_available - stock.quantity_reserved
except (ValueError, Stock.DoesNotExist):
pass
# Формируем список единиц с пересчётом остатков
sales_units_data = []
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
qty_in_unit = su.convert_from_base(available_qty) if available_qty > 0 else Decimal('0')
sales_units_data.append({
'id': su.id,
'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price),
'conversion_factor': str(su.conversion_factor),
'min_quantity': str(su.min_quantity),
'quantity_step': str(su.quantity_step),
'is_default': su.is_default,
'available_quantity': str(qty_in_unit)
})
return JsonResponse({
'success': True,
'product_id': product.id,
'product_name': product.name,
'sales_units': sales_units_data
})
except Product.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Товар не найден'
}, status=404)
except Exception as e:
logger.error(f'Ошибка загрузки единиц продажи: {str(e)}')
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -0,0 +1,197 @@
"""
Views для управления единицами измерения (Unit of Measure)
"""
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q, Count
from django.core.paginator import Paginator
from django.urls import reverse
from products.models import UnitOfMeasure, ProductSalesUnit
from products.forms import ProductSalesUnitForm
@login_required
def unit_of_measure_list(request):
"""
Список всех единиц измерения с возможностью фильтрации и поиска
"""
# Получаем параметры фильтрации
search_query = request.GET.get('q', '').strip()
is_active_filter = request.GET.get('is_active', '')
# Базовый queryset
units = UnitOfMeasure.objects.all()
# Аннотируем количество использований
units = units.annotate(
usage_count=Count('productsalesunit')
)
# Применяем фильтры
if search_query:
units = units.filter(
Q(code__icontains=search_query) |
Q(name__icontains=search_query) |
Q(short_name__icontains=search_query)
)
if is_active_filter:
units = units.filter(is_active=(is_active_filter == 'true'))
# Сортировка
units = units.order_by('position', 'code')
# Пагинация
paginator = Paginator(units, 50)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'search_query': search_query,
'is_active_filter': is_active_filter,
'total_units': units.count(),
}
return render(request, 'products/uom/unit_list.html', context)
@login_required
def product_sales_unit_list(request):
"""
Список всех единиц продажи товаров с возможностью фильтрации
"""
# Получаем параметры фильтрации
search_query = request.GET.get('q', '').strip()
unit_filter = request.GET.get('unit', '')
is_active_filter = request.GET.get('is_active', '')
is_default_filter = request.GET.get('is_default', '')
# Базовый queryset
sales_units = ProductSalesUnit.objects.select_related(
'product', 'unit'
).all()
# Применяем фильтры
if search_query:
sales_units = sales_units.filter(
Q(product__name__icontains=search_query) |
Q(product__sku__icontains=search_query) |
Q(name__icontains=search_query)
)
if unit_filter:
sales_units = sales_units.filter(unit_id=unit_filter)
if is_active_filter:
sales_units = sales_units.filter(is_active=(is_active_filter == 'true'))
if is_default_filter:
sales_units = sales_units.filter(is_default=(is_default_filter == 'true'))
# Сортировка
sales_units = sales_units.order_by('product__name', 'position')
# Пагинация
paginator = Paginator(sales_units, 50)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Для фильтра единиц
all_units = UnitOfMeasure.objects.filter(is_active=True).order_by('position', 'code')
context = {
'page_obj': page_obj,
'search_query': search_query,
'unit_filter': unit_filter,
'is_active_filter': is_active_filter,
'is_default_filter': is_default_filter,
'all_units': all_units,
'total_sales_units': sales_units.count(),
}
return render(request, 'products/uom/sales_unit_list.html', context)
@login_required
def product_sales_unit_create(request):
"""
Создание новой единицы продажи
"""
if request.method == 'POST':
form = ProductSalesUnitForm(request.POST)
if form.is_valid():
sales_unit = form.save()
messages.success(
request,
f'Единица продажи "{sales_unit.name}" для товара "{sales_unit.product.name}" успешно создана!'
)
return redirect('products:sales-unit-list')
else:
# Предзаполнение товара если передан в параметрах
initial = {}
product_id = request.GET.get('product')
if product_id:
initial['product'] = product_id
form = ProductSalesUnitForm(initial=initial)
context = {
'form': form,
'title': 'Создание единицы продажи',
'submit_text': 'Создать'
}
return render(request, 'products/uom/sales_unit_form.html', context)
@login_required
def product_sales_unit_update(request, pk):
"""
Редактирование единицы продажи
"""
sales_unit = get_object_or_404(ProductSalesUnit, pk=pk)
if request.method == 'POST':
form = ProductSalesUnitForm(request.POST, instance=sales_unit)
if form.is_valid():
sales_unit = form.save()
messages.success(
request,
f'Единица продажи "{sales_unit.name}" успешно обновлена!'
)
return redirect('products:sales-unit-list')
else:
form = ProductSalesUnitForm(instance=sales_unit)
context = {
'form': form,
'sales_unit': sales_unit,
'title': f'Редактирование: {sales_unit.name}',
'submit_text': 'Сохранить'
}
return render(request, 'products/uom/sales_unit_form.html', context)
@login_required
def product_sales_unit_delete(request, pk):
"""
Удаление единицы продажи
"""
sales_unit = get_object_or_404(ProductSalesUnit, pk=pk)
if request.method == 'POST':
product_name = sales_unit.product.name
unit_name = sales_unit.name
sales_unit.delete()
messages.success(
request,
f'Единица продажи "{unit_name}" для товара "{product_name}" успешно удалена!'
)
return redirect('products:sales-unit-list')
context = {
'sales_unit': sales_unit,
}
return render(request, 'products/uom/sales_unit_delete.html', context)

View File

@@ -33,6 +33,9 @@
<li><a class="dropdown-item" href="{% url 'products:tag-list' %}">Теги</a></li>
<li><a class="dropdown-item" href="{% url 'products:variantgroup-list' %}">Варианты (группы)</a></li>
<li><a class="dropdown-item" href="{% url 'products:attribute-list' %}">Атрибуты</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'products:unit-list' %}"><i class="bi bi-rulers"></i> Единицы измерения</a></li>
<li><a class="dropdown-item" href="{% url 'products:sales-unit-list' %}"><i class="bi bi-box-seam"></i> Единицы продажи</a></li>
</ul>
</li>