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:
@@ -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):
|
||||
"""Админка для единиц продажи товаров"""
|
||||
|
||||
@@ -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
|
||||
|
||||
249
myproject/products/static/products/js/sales-units.js
Normal file
249
myproject/products/static/products/js/sales-units.js
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
186
myproject/products/templates/products/uom/sales_unit_form.html
Normal file
186
myproject/products/templates/products/uom/sales_unit_form.html
Normal 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 %}
|
||||
183
myproject/products/templates/products/uom/sales_unit_list.html
Normal file
183
myproject/products/templates/products/uom/sales_unit_list.html
Normal 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 %}">«</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 %}">»</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 %}
|
||||
123
myproject/products/templates/products/uom/unit_list.html
Normal file
123
myproject/products/templates/products/uom/unit_list.html
Normal 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 %}">«</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 %}">»</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 %}
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
197
myproject/products/views/uom_views.py
Normal file
197
myproject/products/views/uom_views.py
Normal 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)
|
||||
Reference in New Issue
Block a user