feat: упростить создание заказов и рефакторинг единиц измерения

- Добавить inline-редактирование цен в списке товаров
- Оптимизировать карточки товаров в POS-терминале
- Рефакторинг моделей единиц измерения
- Миграция unit -> base_unit в SalesUnit
- Улучшить UI форм создания/редактирования товаров

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 03:34:43 +03:00
parent 928b340486
commit 2f1f0621e6
24 changed files with 1079 additions and 227 deletions

View File

@@ -108,21 +108,23 @@ function formatMoney(v) {
* - Баланс кошелька в модальном окне продажи (если оно открыто) * - Баланс кошелька в модальном окне продажи (если оно открыто)
*/ */
function updateCustomerDisplay() { function updateCustomerDisplay() {
// Определяем, системный ли это клиент
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
const displayName = isSystemCustomer ? 'Выбрать клиента' : selectedCustomer.name;
// Обновляем текст кнопки в корзине // Обновляем текст кнопки в корзине
const btnText = document.getElementById('customerSelectBtnText'); const btnText = document.getElementById('customerSelectBtnText');
if (btnText) { if (btnText) {
btnText.textContent = selectedCustomer.name; btnText.textContent = displayName;
} }
// Обновляем текст кнопки в модалке продажи // Обновляем текст кнопки в модалке продажи
const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText'); const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText');
if (checkoutBtnText) { if (checkoutBtnText) {
checkoutBtnText.textContent = selectedCustomer.name; checkoutBtnText.textContent = displayName;
} }
// Обновляем видимость кнопок сброса (в корзине и в модалке продажи) // Обновляем видимость кнопок сброса (в корзине и в модалке продажи)
// Приводим к числу для надёжного сравнения (JSON может вернуть разные типы)
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
[document.getElementById('resetCustomerBtn'), [document.getElementById('resetCustomerBtn'),
document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => { document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => {
@@ -464,10 +466,10 @@ function renderUnitSelectionList() {
if (availableQty > 10) { if (availableQty > 10) {
stockBadgeClass = 'stock-badge-good'; stockBadgeClass = 'stock-badge-good';
stockText = `${availableQty} ${unit.unit_short_name} доступно`; stockText = `${availableQty} шт доступно`;
} else if (availableQty > 0) { } else if (availableQty > 0) {
stockBadgeClass = 'stock-badge-low'; stockBadgeClass = 'stock-badge-low';
stockText = `${availableQty} ${unit.unit_short_name} доступно`; stockText = `${availableQty} шт доступно`;
} }
// Бейдж "По умолчанию" // Бейдж "По умолчанию"
@@ -478,7 +480,6 @@ function renderUnitSelectionList() {
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="unit-name">${unit.name}${defaultBadge}</div> <div class="unit-name">${unit.name}${defaultBadge}</div>
<div class="unit-code text-muted">${unit.unit_code} (${unit.unit_short_name})</div>
</div> </div>
<div class="unit-price">${formatMoney(unit.actual_price)} руб</div> <div class="unit-price">${formatMoney(unit.actual_price)} руб</div>
</div> </div>
@@ -509,7 +510,7 @@ function selectUnit(unit) {
// Обновляем отображение выбранной единицы // Обновляем отображение выбранной единицы
document.getElementById('selectedUnitDisplay').textContent = document.getElementById('selectedUnitDisplay').textContent =
`${unit.name} (${unit.unit_short_name})`; unit.name;
// Устанавливаем минимальное количество и шаг // Устанавливаем минимальное количество и шаг
const qtyInput = document.getElementById('unitModalQuantity'); const qtyInput = document.getElementById('unitModalQuantity');
@@ -626,7 +627,6 @@ function addToCartFromModal() {
type: 'product', type: 'product',
sales_unit_id: selectedSalesUnit.id, sales_unit_id: selectedSalesUnit.id,
unit_name: selectedSalesUnit.name, unit_name: selectedSalesUnit.name,
unit_short_name: selectedSalesUnit.unit_short_name,
quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества
price_overridden: priceOverridden price_overridden: priceOverridden
}); });
@@ -645,6 +645,54 @@ function addToCartFromModal() {
unitModalInstance.hide(); unitModalInstance.hide();
} }
/**
* Добавляет товар с единицей продажи напрямую в корзину (без модального окна)
* Используется для быстрого добавления когда у товара только одна единица продажи
* @param {object} product - Товар из ITEMS
* @param {object} salesUnit - Единица продажи (default_sales_unit)
* @param {number} qty - Количество для добавления
*/
async function addProductWithUnitToCart(product, salesUnit, qty = 1) {
const cartKey = `product-${product.id}-${salesUnit.id}`;
if (cart.has(cartKey)) {
const existing = cart.get(cartKey);
existing.qty = roundQuantity(existing.qty + qty, 3);
} else {
cart.set(cartKey, {
id: product.id,
name: product.name,
price: Number(salesUnit.price),
qty: qty,
type: 'product',
sales_unit_id: salesUnit.id,
unit_name: salesUnit.name,
quantity_step: parseFloat(salesUnit.quantity_step) || 1
});
}
renderCart();
saveCartToRedis();
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView) {
renderProducts();
}
// Фокус на поле количества
setTimeout(() => {
const qtyInputs = document.querySelectorAll('.qty-input');
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
if (!isMobileDevice()) {
qtyInputs[itemIndex].focus();
qtyInputs[itemIndex].select();
}
}
}, 50);
}
function renderCategories() { function renderCategories() {
const grid = document.getElementById('categoryGrid'); const grid = document.getElementById('categoryGrid');
grid.innerHTML = ''; grid.innerHTML = '';
@@ -699,7 +747,7 @@ function renderCategories() {
// Категории // Категории
CATEGORIES.forEach(cat => { CATEGORIES.forEach(cat => {
const col = document.createElement('div'); const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2'; col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5';
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : ''); card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
@@ -752,7 +800,7 @@ function renderProducts() {
filtered.forEach(item => { filtered.forEach(item => {
const col = document.createElement('div'); const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2'; col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5';
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card product-card'; card.className = 'card product-card';
@@ -852,26 +900,75 @@ function renderProducts() {
stock.style.color = '#856404'; stock.style.color = '#856404';
stock.style.fontWeight = 'bold'; stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
// Для обычных товаров показываем остатки: FREE(-RESERVED-IN_CART) // Для обычных товаров показываем остатки
// FREE = доступно для продажи (available - reserved - в корзине) // Если у товара есть единицы продажи - отображаем в единицах продажи
const available = parseFloat(item.available_qty) || 0; const available = parseFloat(item.available_qty) || 0;
const reserved = parseFloat(item.reserved_qty) || 0; const reserved = parseFloat(item.reserved_qty) || 0;
// Вычитаем количество из корзины для визуализации // Используем единицу продажи если есть
if (item.default_sales_unit) {
const unit = item.default_sales_unit;
const conversionFactor = parseFloat(unit.conversion_factor) || 1;
// Вычисляем количество в корзине в единицах продажи
const cartKey = `product-${item.id}-${unit.id}`;
const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
// Свободное количество в единицах продажи
const availableInUnit = parseFloat(item.available_qty_in_unit) || 0;
const reservedInUnit = reserved * conversionFactor;
const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty;
const freeRounded = roundQuantity(freeInUnit, 1); // Округляем для отображения
// Создаём элементы для стилизации
const freeSpan = document.createElement('span');
freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold';
const qtyText = document.createElement('span');
qtyText.textContent = freeRounded;
freeSpan.appendChild(qtyText);
const unitBadge = document.createElement('span');
unitBadge.className = 'badge bg-secondary ms-1';
unitBadge.style.fontSize = '0.7rem';
unitBadge.textContent = unit.name;
freeSpan.appendChild(unitBadge);
// Отображаем корзину если есть
if (inCartBaseQty > 0) {
const suffixSpan = document.createElement('span');
suffixSpan.textContent = ` (${roundQuantity(inCartBaseQty, 1)}🛒)`;
suffixSpan.style.fontSize = '0.85em';
suffixSpan.style.marginLeft = '3px';
stock.appendChild(freeSpan);
stock.appendChild(suffixSpan);
} else {
stock.appendChild(freeSpan);
}
// Цветовая индикация
if (freeInUnit <= 0) {
stock.style.color = '#dc3545'; // Красный
} else if (freeInUnit < 5) {
stock.style.color = '#ffc107'; // Жёлтый
} else {
stock.style.color = '#28a745'; // Зелёный
}
} else {
// Отображение в базовых единицах (старая логика)
const cartKey = `product-${item.id}`; const cartKey = `product-${item.id}`;
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0; const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
const free = available - reserved - inCart; const free = available - reserved - inCart;
const freeRounded = roundQuantity(free, 3); // Округляем для отображения const freeRounded = roundQuantity(free, 3);
// Создаём элементы для стилизации разных размеров
const freeSpan = document.createElement('span'); const freeSpan = document.createElement('span');
freeSpan.textContent = freeRounded; // Используем округлённое значение freeSpan.textContent = freeRounded;
freeSpan.style.fontSize = '1.1em'; freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold'; freeSpan.style.fontWeight = 'bold';
freeSpan.style.fontStyle = 'normal';
// Отображаем резерв и корзину если они есть
const suffixParts = []; const suffixParts = [];
if (reserved > 0) { if (reserved > 0) {
suffixParts.push(`${roundQuantity(reserved, 3)}`); suffixParts.push(`${roundQuantity(reserved, 3)}`);
@@ -885,7 +982,6 @@ function renderProducts() {
suffixSpan.textContent = `(${suffixParts.join(' ')})`; suffixSpan.textContent = `(${suffixParts.join(' ')})`;
suffixSpan.style.fontSize = '0.85em'; suffixSpan.style.fontSize = '0.85em';
suffixSpan.style.marginLeft = '3px'; suffixSpan.style.marginLeft = '3px';
suffixSpan.style.fontStyle = 'normal';
stock.appendChild(freeSpan); stock.appendChild(freeSpan);
stock.appendChild(suffixSpan); stock.appendChild(suffixSpan);
@@ -893,13 +989,13 @@ function renderProducts() {
stock.appendChild(freeSpan); stock.appendChild(freeSpan);
} }
// Цветовая индикация: красный если свободных остатков нет или отрицательные
if (free <= 0) { if (free <= 0) {
stock.style.color = '#dc3545'; // Красный stock.style.color = '#dc3545';
} else if (free < 5) { } else if (free < 5) {
stock.style.color = '#ffc107'; // Жёлтый (мало остатков) stock.style.color = '#ffc107';
} else { } else {
stock.style.color = '#28a745'; // Зелёный (достаточно) stock.style.color = '#28a745';
}
} }
} else { } else {
// Комплекты: показываем доступное количество // Комплекты: показываем доступное количество
@@ -929,7 +1025,9 @@ function renderProducts() {
const priceSpan = document.createElement('span'); const priceSpan = document.createElement('span');
priceSpan.className = 'product-price'; priceSpan.className = 'product-price';
priceSpan.textContent = `${formatMoney(item.price)}`; // Используем цену из единицы продажи если есть, иначе базовую цену
const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price;
priceSpan.textContent = `${formatMoney(itemPrice)}`;
sku.appendChild(skuText); sku.appendChild(skuText);
sku.appendChild(priceSpan); sku.appendChild(priceSpan);
@@ -1022,9 +1120,18 @@ function setupInfiniteScroll() {
} }
async function addToCart(item) { async function addToCart(item) {
// ПРОВЕРКА НА НАЛИЧИЕ НЕСКОЛЬКИХ ЕДИНИЦ ПРОДАЖИ // ПРОВЕРКА НА НАЛИЧИЕ ЕДИНИЦ ПРОДАЖИ
if (item.type === 'product' && item.sales_units_count > 1) { // Если у товара одна единица продажи - добавляем сразу
// Открываем модальное окно выбора единицы // Если несколько - показываем модальное окно выбора
console.log('addToCart:', item.name, 'has_sales_units:', item.has_sales_units, 'sales_units_count:', item.sales_units_count);
if (item.type === 'product' && item.has_sales_units) {
// Если одна единица продажи - добавляем сразу
if (item.sales_units_count === 1 && item.default_sales_unit) {
await addProductWithUnitToCart(item, item.default_sales_unit, 1);
return;
}
// Иначе открываем модальное окно выбора единицы
await openProductUnitModal(item); await openProductUnitModal(item);
return; return;
} }
@@ -1187,8 +1294,10 @@ function renderCart() {
} }
namePrice.innerHTML = ` namePrice.innerHTML = `
<div class="fw-semibold small">${typeIcon}${item.name}${unitInfo}</div> <div class="fw-semibold small">${typeIcon}${item.name}</div>
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / ${item.unit_short_name || 'шт'}</div> <div class="price-unit-row">
<span class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)}</span>${unitInfo}
</div>
`; `;
// Знак умножения // Знак умножения

View File

@@ -16,7 +16,7 @@
<div class="pos-container"> <div class="pos-container">
<div class="row g-3" style="height: 100%;"> <div class="row g-3" style="height: 100%;">
<!-- Products Grid (Left side - 8/12) --> <!-- Products Grid (Left side - 8/12) -->
<div class="col-12 col-md-8" style="display: flex; flex-direction: column; height: 100%;"> <div class="col-12 col-md-7" style="display: flex; flex-direction: column; height: 100%;">
<!-- Search Box --> <!-- Search Box -->
<div class="mb-3"> <div class="mb-3">
<div class="input-group"> <div class="input-group">
@@ -39,7 +39,7 @@
</div> </div>
<!-- Right Panel (4/12) - Fixed --> <!-- Right Panel (4/12) - Fixed -->
<div class="col-12 col-md-4"> <div class="col-12 col-md-5">
<div class="right-panel-fixed d-flex flex-column"> <div class="right-panel-fixed d-flex flex-column">
<!-- Информация о складе (всегда показываем блок для фиксированной позиции) --> <!-- Информация о складе (всегда показываем блок для фиксированной позиции) -->
<div class="card mb-2"> <div class="card mb-2">
@@ -67,11 +67,8 @@
<h6 class="mb-0">Корзина</h6> <h6 class="mb-0">Корзина</h6>
<div class="d-flex gap-1 align-items-center"> <div class="d-flex gap-1 align-items-center">
<button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="customerSelectBtn"> <button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="customerSelectBtn">
<i class="bi bi-person me-1"></i> <i class="bi bi-person"></i>
<div class="d-flex flex-column align-items-start lh-1"> <span id="customerSelectBtnText">Выбрать клиента</span>
<small class="text-muted" style="font-size: 0.65rem;">Клиент</small>
<span id="customerSelectBtnText" class="fw-semibold">Выбрать</span>
</div>
</button> </button>
<a href="#" id="customerProfileLink" class="btn btn-sm btn-outline-secondary" title="Открыть анкету клиента" target="_blank" style="display: none;"> <a href="#" id="customerProfileLink" class="btn btn-sm btn-outline-secondary" title="Открыть анкету клиента" target="_blank" style="display: none;">
<i class="bi bi-box-arrow-up-right"></i> <i class="bi bi-box-arrow-up-right"></i>
@@ -633,11 +630,8 @@
<!-- Цена --> <!-- Цена -->
<div class="mb-3"> <div class="mb-3">
<label for="unitModalPrice" class="form-label">Цена за единицу</label> <label for="unitModalPrice" class="form-label">Цена за единицу</label>
<div class="input-group">
<span class="input-group-text"></span>
<input type="number" class="form-control" id="unitModalPrice" <input type="number" class="form-control" id="unitModalPrice"
value="0" min="0" step="0.01"> value="0" min="0" step="0.01">
</div>
<div id="priceOverrideIndicator" class="text-warning small mt-1" style="display: none;"> <div id="priceOverrideIndicator" class="text-warning small mt-1" style="display: none;">
<i class="bi bi-exclamation-triangle"></i> Цена изменена <i class="bi bi-exclamation-triangle"></i> Цена изменена
</div> </div>

View File

@@ -766,7 +766,8 @@ def get_items_api(request):
) )
).prefetch_related( ).prefetch_related(
'categories', 'categories',
first_product_photo first_product_photo,
'sales_units' # Загружаем единицы продажи для POS
) )
# Фильтруем по категории, если указана # Фильтруем по категории, если указана
@@ -795,10 +796,33 @@ def get_items_api(request):
reserved = p.reserved_qty reserved = p.reserved_qty
free_qty = available - reserved free_qty = available - reserved
# Подсчитываем активные единицы продажи # Получаем активные единицы продажи
sales_units_count = p.sales_units.filter(is_active=True).count() active_sales_units = [su for su in p.sales_units.all() if su.is_active]
sales_units_count = len(active_sales_units)
has_sales_units = sales_units_count > 0 has_sales_units = sales_units_count > 0
# Находим единицу продажи по умолчанию
default_sales_unit = None
available_qty_in_unit = free_qty # Количество в единицах продажи
price_in_unit = str(p.actual_price) # Цена в единицах продажи
if has_sales_units:
# Ищем единицу с is_default=True или берем первую активную
default_unit = next((su for su in active_sales_units if su.is_default), active_sales_units[0])
if default_unit and default_unit.conversion_factor and default_unit.conversion_factor > 0:
# Конвертируем свободное количество в единицы продажи
available_qty_in_unit = free_qty * default_unit.conversion_factor
price_in_unit = str(default_unit.actual_price)
default_sales_unit = {
'id': default_unit.id,
'name': default_unit.name,
'price': str(default_unit.actual_price),
'conversion_factor': str(default_unit.conversion_factor),
'min_quantity': str(default_unit.min_quantity),
'quantity_step': str(default_unit.quantity_step),
'is_default': default_unit.is_default
}
products.append({ products.append({
'id': p.id, 'id': p.id,
'name': p.name, 'name': p.name,
@@ -811,9 +835,12 @@ def get_items_api(request):
'available_qty': str(available), 'available_qty': str(available),
'reserved_qty': str(reserved), 'reserved_qty': str(reserved),
'free_qty': str(free_qty), # Передаём как строку для сохранения точности 'free_qty': str(free_qty), # Передаём как строку для сохранения точности
'free_qty_sort': float(free_qty), # Для сортировки отдельное поле 'free_qty_sort': float(available_qty_in_unit if has_sales_units and default_sales_unit else free_qty), # Для сортировки
'sales_units_count': sales_units_count, 'sales_units_count': sales_units_count,
'has_sales_units': has_sales_units 'has_sales_units': has_sales_units,
'default_sales_unit': default_sales_unit,
'available_qty_in_unit': str(available_qty_in_unit),
'price_in_unit': price_in_unit
}) })
# Prefetch для первого фото комплектов # Prefetch для первого фото комплектов

View File

@@ -408,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
fieldsets = ( fieldsets = (
('Основная информация', { ('Основная информация', {
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'external_category', 'base_unit', 'unit', 'price', 'sale_price') 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'external_category', 'base_unit', 'price', 'sale_price')
}), }),
('Себестоимость', { ('Себестоимость', {
'fields': ('cost_price_details_display',), 'fields': ('cost_price_details_display',),
@@ -834,10 +834,9 @@ class ProductSalesUnitInline(admin.TabularInline):
model = ProductSalesUnit model = ProductSalesUnit
extra = 0 extra = 0
fields = ( fields = (
'unit', 'name', 'conversion_factor', 'price', 'sale_price', 'name', 'conversion_factor', 'price', 'sale_price',
'min_quantity', 'quantity_step', 'is_default', 'is_active' 'min_quantity', 'quantity_step', 'is_default', 'is_active'
) )
autocomplete_fields = ['unit']
verbose_name = "Единица продажи" verbose_name = "Единица продажи"
verbose_name_plural = "Единицы продажи" verbose_name_plural = "Единицы продажи"
@@ -984,18 +983,18 @@ class UnitOfMeasureAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для единиц продажи товаров""" """Админка для единиц продажи товаров"""
list_display = ( list_display = (
'product', 'name', 'unit', 'conversion_factor', 'product', 'name', 'conversion_factor',
'get_price_display', 'min_quantity', 'is_default', 'is_active' 'get_price_display', 'min_quantity', 'is_default', 'is_active'
) )
list_filter = ('is_active', 'is_default', 'unit') list_filter = ('is_active', 'is_default')
search_fields = ('product__name', 'product__sku', 'name') search_fields = ('product__name', 'product__sku', 'name')
autocomplete_fields = ['product', 'unit'] autocomplete_fields = ['product']
list_editable = ('is_default', 'is_active') list_editable = ('is_default', 'is_active')
ordering = ('product', 'position') ordering = ('product', 'position')
fieldsets = ( fieldsets = (
('Товар и единица', { ('Товар и название', {
'fields': ('product', 'unit', 'name') 'fields': ('product', 'name')
}), }),
('Конверсия', { ('Конверсия', {
'fields': ('conversion_factor',), 'fields': ('conversion_factor',),

View File

@@ -156,8 +156,8 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
self.fields['base_unit'].queryset = UnitOfMeasure.objects.filter( self.fields['base_unit'].queryset = UnitOfMeasure.objects.filter(
is_active=True is_active=True
).order_by('position', 'code') ).order_by('position', 'code')
self.fields['base_unit'].required = False self.fields['base_unit'].required = True
self.fields['base_unit'].help_text = 'Базовая единица для учета товара на складе. На основе этой единицы можно создать единицы продажи.' self.fields['base_unit'].help_text = 'Базовая единица хранения и закупки. На её основе создаются единицы продажи.'
# Маркетинговые флаги (switch-стиль) # Маркетинговые флаги (switch-стиль)
for flag_field in ['is_new', 'is_popular', 'is_special']: for flag_field in ['is_new', 'is_popular', 'is_special']:
@@ -1085,13 +1085,12 @@ class ProductSalesUnitForm(forms.ModelForm):
class Meta: class Meta:
model = ProductSalesUnit model = ProductSalesUnit
fields = [ fields = [
'product', 'unit', 'name', 'conversion_factor', 'product', 'name', 'conversion_factor',
'price', 'sale_price', 'min_quantity', 'quantity_step', 'price', 'sale_price', 'min_quantity', 'quantity_step',
'is_default', 'is_active', 'position' 'is_default', 'is_active', 'position'
] ]
labels = { labels = {
'product': 'Товар', 'product': 'Товар',
'unit': 'Единица измерения',
'name': 'Название', 'name': 'Название',
'conversion_factor': 'Коэффициент конверсии', 'conversion_factor': 'Коэффициент конверсии',
'price': 'Цена продажи', 'price': 'Цена продажи',
@@ -1104,7 +1103,6 @@ class ProductSalesUnitForm(forms.ModelForm):
} }
widgets = { widgets = {
'product': forms.Select(attrs={'class': 'form-control'}), 'product': forms.Select(attrs={'class': 'form-control'}),
'unit': forms.Select(attrs={'class': 'form-control'}),
'name': forms.TextInput(attrs={ 'name': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': 'Например: Ветка большая, Стебель средний' 'placeholder': 'Например: Ветка большая, Стебель средний'
@@ -1155,11 +1153,6 @@ class ProductSalesUnitForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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( self.fields['product'].queryset = Product.objects.filter(
status='active' status='active'
@@ -1167,3 +1160,150 @@ class ProductSalesUnitForm(forms.ModelForm):
# Сделать sale_price необязательным # Сделать sale_price необязательным
self.fields['sale_price'].required = False self.fields['sale_price'].required = False
class UnitOfMeasureForm(forms.ModelForm):
"""
Форма для создания и редактирования единицы измерения
"""
class Meta:
model = UnitOfMeasure
fields = ['code', 'name', 'short_name', 'position', 'is_active']
labels = {
'code': 'Код',
'name': 'Название',
'short_name': 'Сокращение',
'position': 'Порядок сортировки',
'is_active': 'Активна',
}
widgets = {
'code': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'шт, кг, банч'
}),
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Штука, Килограмм, Банч'
}),
'short_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'шт., кг., бан.'
}),
'position': forms.NumberInput(attrs={
'class': 'form-control',
'value': '0'
}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
help_texts = {
'code': 'Короткий уникальный код (используется в системе)',
'name': 'Полное название для отображения',
'short_name': 'Сокращённое название для таблиц',
'position': 'Порядок в списках (меньше = выше)',
}
# === INLINE FORMSET ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
class ProductSalesUnitInlineForm(forms.ModelForm):
"""
Форма единицы продажи для inline редактирования в форме товара
"""
class Meta:
model = ProductSalesUnit
fields = [
'name', 'conversion_factor',
'price', 'sale_price', 'min_quantity', 'quantity_step',
'is_default', 'is_active', 'position'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control form-control-sm',
'placeholder': 'Ветка большая'
}),
'conversion_factor': forms.NumberInput(attrs={
'class': 'form-control form-control-sm',
'step': '0.000001',
'min': '0.000001',
'placeholder': '15.0'
}),
'price': forms.NumberInput(attrs={
'class': 'form-control form-control-sm',
'step': '0.01',
'min': '0',
}),
'sale_price': forms.NumberInput(attrs={
'class': 'form-control form-control-sm',
'step': '0.01',
'min': '0',
}),
'min_quantity': forms.NumberInput(attrs={
'class': 'form-control form-control-sm',
'step': '0.001',
'min': '0.001',
'value': '1'
}),
'quantity_step': forms.NumberInput(attrs={
'class': 'form-control form-control-sm',
'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 form-control-sm',
'style': 'width: 60px;'
}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['sale_price'].required = False
def has_changed(self):
"""
Считаем форму неизмененной, если это новая форма без заполненных полей.
Это позволяет избежать ошибок валидации для пустых добавленных форм.
"""
# Если это существующая запись - используем стандартную логику
if self.instance.pk:
return super().has_changed()
# Для новых форм проверяем, есть ли заполненные данные
try:
# Проверяем ключевые поля
cleaned_data = getattr(self, 'cleaned_data', {})
if cleaned_data.get('name'):
return True
if cleaned_data.get('price'):
return True
# Если cleaned_data ещё нет, проверяем raw data
data = self.data if hasattr(self, 'data') else {}
prefix = self.prefix
name_field = f'{prefix}-name'
price_field = f'{prefix}-price'
if data.get(name_field):
return True
if data.get(price_field):
return True
# Форма пустая - считаем неизмененной
return False
except Exception:
# При ошибке используем стандартную логику
return super().has_changed()
# Inline formset для единиц продажи
ProductSalesUnitFormSet = inlineformset_factory(
Product,
ProductSalesUnit,
form=ProductSalesUnitInlineForm,
extra=1,
can_delete=True,
min_num=0,
validate_min=False,
)

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
Миграция данных: перенос значений unit -> base_unit и удаление поля unit.
Этапы:
1. Создать недостающие UnitOfMeasure из старых UNIT_CHOICES
2. Для товаров без base_unit установить соответствующую единицу из справочника
3. Удалить поле unit
"""
from django.db import migrations, models
import django.db.models.deletion
# Старые UNIT_CHOICES для миграции
OLD_UNIT_CHOICES = {
'шт': {'code': 'шт', 'name': 'Штука', 'short_name': 'шт.', 'position': 1},
'м': {'code': 'м', 'name': 'Метр', 'short_name': 'м.', 'position': 2},
'г': {'code': 'г', 'name': 'Грамм', 'short_name': 'г.', 'position': 3},
'л': {'code': 'л', 'name': 'Литр', 'short_name': 'л.', 'position': 4},
'кг': {'code': 'кг', 'name': 'Килограмм', 'short_name': 'кг.', 'position': 5},
}
def migrate_unit_to_base_unit(apps, schema_editor):
"""Перенести значения unit -> base_unit для всех товаров"""
UnitOfMeasure = apps.get_model('products', 'UnitOfMeasure')
Product = apps.get_model('products', 'Product')
# 1. Создать недостающие UnitOfMeasure
for code, data in OLD_UNIT_CHOICES.items():
UnitOfMeasure.objects.get_or_create(
code=code,
defaults={
'name': data['name'],
'short_name': data['short_name'],
'position': data['position'],
'is_active': True,
}
)
# 2. Получить дефолтную единицу (штука)
default_unit = UnitOfMeasure.objects.get(code='шт')
# 3. Для товаров без base_unit - установить из unit или дефолтную
products_without_base_unit = Product.objects.filter(base_unit__isnull=True)
for product in products_without_base_unit:
# Получаем значение старого поля unit
old_unit_code = getattr(product, 'unit', 'шт') or 'шт'
# Находим соответствующую единицу в справочнике
try:
unit = UnitOfMeasure.objects.get(code=old_unit_code)
except UnitOfMeasure.DoesNotExist:
unit = default_unit
product.base_unit = unit
product.save(update_fields=['base_unit'])
print(f"Миграция завершена: обновлено {products_without_base_unit.count()} товаров")
def reverse_migration(apps, schema_editor):
"""Обратная миграция не нужна - поле unit удалено"""
pass
class Migration(migrations.Migration):
# Отключаем атомарность, чтобы избежать конфликта с триггерами PostgreSQL
atomic = False
dependencies = [
('products', '0001_initial'),
]
operations = [
# 1. Сначала делаем base_unit nullable для data migration
# (если он уже nullable - это ничего не изменит)
# 2. Запускаем data migration
migrations.RunPython(migrate_unit_to_base_unit, reverse_migration),
# 3. Удаляем старое поле unit
migrations.RemoveField(
model_name='product',
name='unit',
),
# 4. Делаем base_unit обязательным (NOT NULL)
migrations.AlterField(
model_name='product',
name='base_unit',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='products',
to='products.unitofmeasure',
verbose_name='Базовая единица',
help_text='Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах.',
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated manually
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('products', '0002_migrate_unit_to_base_unit'),
]
operations = [
migrations.RemoveField(
model_name='productsalesunit',
name='unit',
),
]

View File

@@ -16,14 +16,6 @@ class Product(BaseProductEntity):
Базовый товар (цветок, упаковка, аксессуар). Базовый товар (цветок, упаковка, аксессуар).
Наследует общие поля из BaseProductEntity. Наследует общие поля из BaseProductEntity.
""" """
UNIT_CHOICES = [
('шт', 'Штука'),
('м', 'Метр'),
('г', 'Грамм'),
('л', 'Литр'),
('кг', 'Килограмм'),
]
# Специфичные поля Product # Специфичные поля Product
variant_suffix = models.CharField( variant_suffix = models.CharField(
max_length=20, max_length=20,
@@ -53,23 +45,13 @@ class Product(BaseProductEntity):
verbose_name="Группы вариантов" verbose_name="Группы вариантов"
) )
unit = models.CharField( # Базовая единица измерения (единица закупки/хранения)
max_length=10,
choices=UNIT_CHOICES,
default='шт',
verbose_name="Единица измерения (deprecated)"
)
# Новое поле: ссылка на справочник единиц измерения
base_unit = models.ForeignKey( base_unit = models.ForeignKey(
'UnitOfMeasure', 'UnitOfMeasure',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True,
blank=True,
related_name='products', related_name='products',
verbose_name="Базовая единица", verbose_name="Базовая единица",
help_text="Единица хранения и закупки (банч, кг, шт). " help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах."
"Если указана, используется вместо поля 'unit'."
) )
# ЦЕНООБРАЗОВАНИЕ - переименованные поля # ЦЕНООБРАЗОВАНИЕ - переименованные поля
@@ -183,11 +165,9 @@ class Product(BaseProductEntity):
def unit_display(self): def unit_display(self):
""" """
Отображаемое название единицы измерения. Отображаемое название единицы измерения.
Приоритет: base_unit.code > unit Возвращает код базовой единицы.
""" """
if self.base_unit: return self.base_unit.code if self.base_unit else 'шт'
return self.base_unit.code
return self.unit
@property @property
def has_sales_units(self): def has_sales_units(self):

View File

@@ -66,11 +66,6 @@ class ProductSalesUnit(models.Model):
related_name='sales_units', related_name='sales_units',
verbose_name="Товар" verbose_name="Товар"
) )
unit = models.ForeignKey(
UnitOfMeasure,
on_delete=models.PROTECT,
verbose_name="Единица измерения"
)
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
verbose_name="Название", verbose_name="Название",
@@ -135,7 +130,7 @@ class ProductSalesUnit(models.Model):
unique_together = [['product', 'name']] unique_together = [['product', 'name']]
def __str__(self): def __str__(self):
return f"{self.product.name} - {self.name} ({self.unit.code})" return f"{self.product.name} - {self.name}"
def clean(self): def clean(self):
super().clean() super().clean()

View File

@@ -333,10 +333,20 @@ class ProductImporter:
if not unit: if not unit:
unit = 'шт' unit = 'шт'
# Валидация единицы измерения # Находим единицу измерения в справочнике
valid_units = [choice[0] for choice in Product.UNIT_CHOICES] from products.models import UnitOfMeasure
if unit not in valid_units: base_unit = UnitOfMeasure.objects.filter(code=unit, is_active=True).first()
unit = 'шт' # fallback if not base_unit:
# Пробуем найти по названию или короткому имени
base_unit = UnitOfMeasure.objects.filter(
Q(name__iexact=unit) | Q(short_name__iexact=unit),
is_active=True
).first()
if not base_unit:
# Fallback на штуку
base_unit = UnitOfMeasure.objects.filter(code='шт').first()
if not base_unit:
base_unit = UnitOfMeasure.objects.first()
# Пытаемся найти существующего товара # Пытаемся найти существующего товара
existing = None existing = None
@@ -366,7 +376,7 @@ class ProductImporter:
if short_description: if short_description:
existing.short_description = short_description existing.short_description = short_description
if unit: if unit:
existing.unit = unit existing.base_unit = base_unit
existing.price = price existing.price = price
if cost_price is not None: if cost_price is not None:
@@ -406,7 +416,7 @@ class ProductImporter:
sku=sku or None, sku=sku or None,
description=description or "", description=description or "",
short_description=short_description or "", short_description=short_description or "",
unit=unit, base_unit=base_unit,
price=price, price=price,
cost_price=cost_price or 0, # Устанавливаем 0 вместо None (для CostPriceHistory) cost_price=cost_price or 0, # Устанавливаем 0 вместо None (для CostPriceHistory)
sale_price=sale_price, sale_price=sale_price,

View File

@@ -57,7 +57,7 @@
data-min-qty="${unit.min_quantity}" data-min-qty="${unit.min_quantity}"
data-step="${unit.quantity_step}" data-step="${unit.quantity_step}"
data-available="${unit.available_quantity || ''}"> data-available="${unit.available_quantity || ''}">
${unit.name} (${unit.unit_short_name}) - ${price} руб.${isDefault} ${unit.name} - ${price} руб.${isDefault}
</option>`; </option>`;
}); });

View File

@@ -292,7 +292,6 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Название</th> <th>Название</th>
<th>Единица</th>
<th class="text-end">Коэфф.</th> <th class="text-end">Коэфф.</th>
<th class="text-end">Цена</th> <th class="text-end">Цена</th>
<th class="text-center">Мин. кол-во</th> <th class="text-center">Мин. кол-во</th>
@@ -306,9 +305,6 @@
{{ su.name }} {{ su.name }}
{% if su.is_default %}<span class="badge bg-success ms-1">По умолчанию</span>{% endif %} {% if su.is_default %}<span class="badge bg-success ms-1">По умолчанию</span>{% endif %}
</td> </td>
<td>
<span class="badge bg-secondary">{{ su.unit.short_name }}</span>
</td>
<td class="text-end">{{ su.conversion_factor }}</td> <td class="text-end">{{ su.conversion_factor }}</td>
<td class="text-end"> <td class="text-end">
{% if su.sale_price %} {% if su.sale_price %}

View File

@@ -581,6 +581,157 @@
<hr class="my-4"> <hr class="my-4">
<!-- Блок: Единицы продажи -->
{% if sales_unit_formset %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-gradient" style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%);">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0 text-white">
<i class="bi bi-box-seam"></i> Единицы продажи
</h5>
<a href="{% url 'products:unit-list' %}" class="btn btn-sm btn-light" target="_blank">
<i class="bi bi-rulers"></i> Справочник единиц
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Настройте, в каких единицах продается товар (ветка, кг, штука).
Коэффициент указывает, сколько единиц продажи получается из 1 базовой единицы.
</p>
{{ sales_unit_formset.management_form }}
<!-- Шаблон для новых форм (скрыт) -->
<template id="empty-sales-unit-template">
{% with form=sales_unit_formset.empty_form %}
<div class="sales-unit-row border rounded p-3 mb-2">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Ед. измерения</label>
{{ form.unit }}
</div>
<div class="col-md-2">
<label class="form-label small">Название</label>
{{ form.name }}
</div>
<div class="col-md-1">
<label class="form-label small">Коэфф.</label>
{{ form.conversion_factor }}
</div>
<div class="col-md-1">
<label class="form-label small">Цена</label>
{{ form.price }}
</div>
<div class="col-md-1">
<label class="form-label small">Скидка</label>
{{ form.sale_price }}
</div>
<div class="col-md-1">
<label class="form-label small">Мин.кол</label>
{{ form.min_quantity }}
</div>
<div class="col-md-1">
<label class="form-label small">Шаг</label>
{{ form.quantity_step }}
</div>
<div class="col-md-1">
<label class="form-label small">Поз.</label>
{{ form.position }}
</div>
<div class="col-md-1 text-center">
<label class="form-label small d-block">По умолч.</label>
{{ form.is_default }}
</div>
<div class="col-md-1">
<div class="d-flex gap-1">
<div class="form-check" title="Активна">
{{ form.is_active }}
</div>
</div>
</div>
</div>
</div>
{% endwith %}
</template>
<div id="sales-units-container">
{% for form in sales_unit_formset %}
<div class="sales-unit-row border rounded p-3 mb-2 {% if form.instance.pk %}bg-light{% endif %}">
<div class="row g-2 align-items-end">
{% if form.instance.pk %}
<input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}">
{% endif %}
<div class="col-md-2">
<label class="form-label small">Ед. измерения</label>
{{ form.unit }}
</div>
<div class="col-md-2">
<label class="form-label small">Название</label>
{{ form.name }}
</div>
<div class="col-md-1">
<label class="form-label small">Коэфф.</label>
{{ form.conversion_factor }}
</div>
<div class="col-md-1">
<label class="form-label small">Цена</label>
{{ form.price }}
</div>
<div class="col-md-1">
<label class="form-label small">Скидка</label>
{{ form.sale_price }}
</div>
<div class="col-md-1">
<label class="form-label small">Мин.кол</label>
{{ form.min_quantity }}
</div>
<div class="col-md-1">
<label class="form-label small">Шаг</label>
{{ form.quantity_step }}
</div>
<div class="col-md-1">
<label class="form-label small">Поз.</label>
{{ form.position }}
</div>
<div class="col-md-1 text-center">
<label class="form-label small d-block">По умолч.</label>
{{ form.is_default }}
</div>
<div class="col-md-1">
<div class="d-flex gap-1">
<div class="form-check" title="Активна">
{{ form.is_active }}
</div>
{% if form.instance.pk %}
<div class="form-check" title="Удалить">
{{ form.DELETE }}
</div>
{% endif %}
</div>
</div>
</div>
{% if form.errors %}
<div class="text-danger small mt-1">
{% for field, errors in form.errors.items %}
{{ field }}: {{ errors|join:", " }}
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline-success btn-sm mt-2" id="add-sales-unit">
<i class="bi bi-plus-circle"></i> Добавить единицу продажи
</button>
</div>
</div>
{% endif %}
<hr class="my-4">
<!-- Блок 2.5: Информация о наличии (только при редактировании) --> <!-- Блок 2.5: Информация о наличии (только при редактировании) -->
{% if object %} {% if object %}
<div class="mb-4 p-3 bg-info-light rounded border border-info"> <div class="mb-4 p-3 bg-info-light rounded border border-info">
@@ -686,6 +837,58 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}, 250); }, 250);
}); });
// === Динамическое добавление единиц продажи ===
const addButton = document.getElementById('add-sales-unit');
const container = document.getElementById('sales-units-container');
const totalFormsInput = document.querySelector('[name="sales_units-TOTAL_FORMS"]');
if (addButton && container && totalFormsInput) {
addButton.addEventListener('click', function() {
const formCount = parseInt(totalFormsInput.value);
const template = document.getElementById('empty-sales-unit-template');
if (template) {
// Клонируем содержимое шаблона
const newRow = template.content.cloneNode(true);
const rowDiv = newRow.querySelector('.sales-unit-row');
// Обновляем имена и id полей
rowDiv.querySelectorAll('input, select').forEach(input => {
const name = input.getAttribute('name');
const id = input.getAttribute('id');
if (name) {
// Заменяем __prefix__ на текущий индекс
input.setAttribute('name', name.replace('__prefix__', formCount));
}
if (id) {
input.setAttribute('id', id.replace('__prefix__', formCount));
}
// Устанавливаем значения по умолчанию
if (input.type === 'checkbox') {
if (input.name.includes('is_active')) {
input.checked = true;
} else {
input.checked = false;
}
} else if (input.type !== 'hidden') {
if (input.name.includes('min_quantity') || input.name.includes('quantity_step')) {
input.value = '1';
} else if (input.name.includes('position')) {
input.value = formCount;
} else if (input.name.includes('conversion_factor')) {
input.value = '1';
}
}
});
container.appendChild(rowDiv);
totalFormsInput.value = formCount + 1;
}
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -22,9 +22,6 @@
<dt class="col-sm-4">Название:</dt> <dt class="col-sm-4">Название:</dt>
<dd class="col-sm-8">{{ sales_unit.name }}</dd> <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> <dt class="col-sm-4">Коэффициент:</dt>
<dd class="col-sm-8">{{ sales_unit.conversion_factor }}</dd> <dd class="col-sm-8">{{ sales_unit.conversion_factor }}</dd>

View File

@@ -20,9 +20,8 @@
</div> </div>
{% endif %} {% endif %}
<!-- Товар и Единица измерения --> <!-- Товар -->
<div class="row mb-3"> <div class="mb-3">
<div class="col-md-6">
<label for="{{ form.product.id_for_label }}" class="form-label"> <label for="{{ form.product.id_for_label }}" class="form-label">
{{ form.product.label }} <span class="text-danger">*</span> {{ form.product.label }} <span class="text-danger">*</span>
</label> </label>
@@ -34,19 +33,6 @@
<div class="text-danger">{{ form.product.errors }}</div> <div class="text-danger">{{ form.product.errors }}</div>
{% endif %} {% endif %}
</div> </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"> <div class="mb-3">

View File

@@ -17,19 +17,9 @@
<!-- Поиск и фильтры --> <!-- Поиск и фильтры -->
<form method="get" class="mb-3"> <form method="get" class="mb-3">
<div class="row g-2"> <div class="row g-2">
<div class="col-md-4"> <div class="col-md-6">
<input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по товару, артикулу, названию..."> <input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по товару, артикулу, названию...">
</div> </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"> <div class="col-md-2">
<select class="form-select form-select-sm" name="is_active" onchange="this.form.submit()"> <select class="form-select form-select-sm" name="is_active" onchange="this.form.submit()">
<option value="">Все</option> <option value="">Все</option>
@@ -63,15 +53,14 @@
<table class="table table-hover table-sm align-middle"> <table class="table table-hover table-sm align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width: 25%;">Товар</th> <th style="width: 28%;">Товар</th>
<th style="width: 15%;">Название единицы</th> <th style="width: 18%;">Название единицы</th>
<th style="width: 10%;" class="text-center">Единица</th>
<th style="width: 10%;" class="text-end">Коэфф.</th> <th style="width: 10%;" class="text-end">Коэфф.</th>
<th style="width: 12%;" 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: 8%;" class="text-center">Шаг</th> <th style="width: 8%;" class="text-center">Шаг</th>
<th style="width: 6%;" class="text-center">По умолч.</th> <th style="width: 8%;" class="text-center">По умолч.</th>
<th style="width: 6%;" class="text-center">Статус</th> <th style="width: 8%;" class="text-center">Статус</th>
<th style="width: 10%;" class="text-end">Действия</th> <th style="width: 10%;" class="text-end">Действия</th>
</tr> </tr>
</thead> </thead>
@@ -87,9 +76,6 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ su.name }}</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"><code>{{ su.conversion_factor }}</code></td>
<td class="text-end"> <td class="text-end">
{% if su.sale_price %} {% if su.sale_price %}
@@ -142,7 +128,7 @@
<ul class="pagination pagination-sm justify-content-center mb-0"> <ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <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> <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 %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&laquo;</a>
</li> </li>
{% endif %} {% endif %}
<li class="page-item active"> <li class="page-item active">
@@ -150,7 +136,7 @@
</li> </li>
{% if page_obj.has_next %} {% if page_obj.has_next %}
<li class="page-item"> <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> <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 %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&raquo;</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@@ -0,0 +1,67 @@
{% 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="d-flex align-items-center justify-content-between mb-4">
<h4 class="mb-0"><i class="bi bi-trash text-danger"></i> Удаление единицы измерения</h4>
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> К списку
</a>
</div>
<div class="card">
<div class="card-body">
{% if can_delete %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
Вы уверены, что хотите удалить единицу измерения <strong>"{{ unit.name }}"</strong> ({{ unit.code }})?
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</form>
{% else %}
<div class="alert alert-danger">
<i class="bi bi-x-circle"></i>
<strong>Невозможно удалить единицу измерения "{{ unit.name }}"</strong>
</div>
<p>Эта единица измерения используется в:</p>
<ul>
{% if products_using > 0 %}
<li><strong>{{ products_using }}</strong> товарах (как базовая единица)</li>
{% endif %}
{% if sales_units_using > 0 %}
<li><strong>{{ sales_units_using }}</strong> единицах продажи</li>
{% endif %}
</ul>
<p class="text-muted">
Перед удалением необходимо переназначить эти товары и единицы продажи на другую единицу измерения.
</p>
<div class="d-flex justify-content-start">
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Назад к списку
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h4 class="mb-0"><i class="bi bi-rulers text-primary"></i> {{ title }}</h4>
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> К списку
</a>
</div>
<!-- Форма -->
<div class="card">
<div class="card-body">
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.code.id_for_label }}" class="form-label">
Код <span class="text-danger">*</span>
</label>
{{ form.code }}
{% if form.code.help_text %}
<div class="form-text">{{ form.code.help_text }}</div>
{% endif %}
{% if form.code.errors %}
<div class="invalid-feedback d-block">
{% for error in form.code.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.short_name.id_for_label }}" class="form-label">
Сокращение
</label>
{{ form.short_name }}
{% if form.short_name.help_text %}
<div class="form-text">{{ form.short_name.help_text }}</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
Название <span class="text-danger">*</span>
</label>
{{ form.name }}
{% if form.name.help_text %}
<div class="form-text">{{ form.name.help_text }}</div>
{% endif %}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{% for error in form.name.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.position.id_for_label }}" class="form-label">
Позиция
</label>
{{ form.position }}
{% if form.position.help_text %}
<div class="form-text">{{ form.position.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check">
{{ form.is_active }}
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
Активна
</label>
</div>
</div>
</div>
<hr>
<div class="d-flex justify-content-between">
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ submit_text }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -9,7 +9,7 @@
<!-- Заголовок --> <!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4"> <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> <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"> <a href="{% url 'products:unit-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Добавить единицу <i class="bi bi-plus-circle"></i> Добавить единицу
</a> </a>
</div> </div>
@@ -53,9 +53,12 @@
<td><span class="badge bg-secondary">{{ unit.short_name }}</span></td> <td><span class="badge bg-secondary">{{ unit.short_name }}</span></td>
<td class="text-center">{{ unit.position }}</td> <td class="text-center">{{ unit.position }}</td>
<td class="text-center"> <td class="text-center">
<span class="badge {% if unit.usage_count > 0 %}bg-success{% else %}bg-secondary{% endif %}"> <span class="badge {% if unit.usage_count > 0 %}bg-success{% else %}bg-secondary{% endif %}" title="Единицы продажи">
{{ unit.usage_count }} {{ unit.usage_count }}
</span> </span>
<span class="badge {% if unit.products_count > 0 %}bg-primary{% else %}bg-secondary{% endif %}" title="Товаров">
{{ unit.products_count }}
</span>
</td> </td>
<td class="text-center"> <td class="text-center">
{% if unit.is_active %} {% if unit.is_active %}
@@ -66,9 +69,12 @@
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<a href="{% url 'admin:products_unitofmeasure_change' unit.pk %}" class="btn btn-outline-secondary" title="Изменить"> <a href="{% url 'products:unit-update' unit.pk %}" class="btn btn-outline-secondary" title="Изменить">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</a> </a>
<a href="{% url 'products:unit-delete' unit.pk %}" class="btn btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -115,8 +115,13 @@ urlpatterns = [
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>/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('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('units/', views.unit_of_measure_list, name='unit-list'),
path('units/create/', views.unit_of_measure_create, name='unit-create'),
path('units/<int:pk>/edit/', views.unit_of_measure_update, name='unit-update'),
path('units/<int:pk>/delete/', views.unit_of_measure_delete, name='unit-delete'),
# Единицы продажи (устаревшие отдельные страницы, для совместимости)
path('sales-units/', views.product_sales_unit_list, name='sales-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/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>/edit/', views.product_sales_unit_update, name='sales-unit-update'),

View File

@@ -122,6 +122,9 @@ from .catalog_views import CatalogView
# Управление единицами измерения # Управление единицами измерения
from .uom_views import ( from .uom_views import (
unit_of_measure_list, unit_of_measure_list,
unit_of_measure_create,
unit_of_measure_update,
unit_of_measure_delete,
product_sales_unit_list, product_sales_unit_list,
product_sales_unit_create, product_sales_unit_create,
product_sales_unit_update, product_sales_unit_update,
@@ -228,6 +231,9 @@ __all__ = [
# Управление единицами измерения # Управление единицами измерения
'unit_of_measure_list', 'unit_of_measure_list',
'unit_of_measure_create',
'unit_of_measure_update',
'unit_of_measure_delete',
'product_sales_unit_list', 'product_sales_unit_list',
'product_sales_unit_create', 'product_sales_unit_create',
'product_sales_unit_update', 'product_sales_unit_update',

View File

@@ -114,7 +114,7 @@ def search_products_and_variants(request):
numeric_id = int(item_id) numeric_id = int(item_id)
if item_type == 'product': if item_type == 'product':
product = Product.objects.prefetch_related('sales_units__unit').get(id=numeric_id, status='active') product = Product.objects.prefetch_related('sales_units').get(id=numeric_id, status='active')
# Сериализация единиц продажи # Сериализация единиц продажи
sales_units_data = [] sales_units_data = []
@@ -122,8 +122,6 @@ def search_products_and_variants(request):
sales_units_data.append({ sales_units_data.append({
'id': su.id, 'id': su.id,
'name': su.name, 'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price), 'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None, 'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price), 'actual_price': str(su.actual_price),
@@ -216,7 +214,7 @@ def search_products_and_variants(request):
if search_type in ['all', 'product']: if search_type in ['all', 'product']:
# Показываем последние добавленные активные товары # Показываем последние добавленные активные товары
products_qs = Product.objects.filter(status='active').prefetch_related('sales_units__unit') products_qs = Product.objects.filter(status='active').prefetch_related('sales_units')
# Применяем фильтры # Применяем фильтры
products_qs = _apply_product_filters(products_qs, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter) products_qs = _apply_product_filters(products_qs, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter)
products = products_qs.order_by('-created_at')[:page_size] products = products_qs.order_by('-created_at')[:page_size]
@@ -235,8 +233,6 @@ def search_products_and_variants(request):
sales_units_data.append({ sales_units_data.append({
'id': su.id, 'id': su.id,
'name': su.name, 'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price), 'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None, 'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price), 'actual_price': str(su.actual_price),
@@ -365,7 +361,7 @@ def search_products_and_variants(request):
products_query = _apply_product_filters(products_query, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter) products_query = _apply_product_filters(products_query, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter)
# Добавляем prefetch для единиц продажи # Добавляем prefetch для единиц продажи
products_query = products_query.prefetch_related('sales_units__unit') products_query = products_query.prefetch_related('sales_units')
total_products = products_query.count() total_products = products_query.count()
start = (page - 1) * page_size start = (page - 1) * page_size
@@ -387,8 +383,6 @@ def search_products_and_variants(request):
sales_units_data.append({ sales_units_data.append({
'id': su.id, 'id': su.id,
'name': su.name, 'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price), 'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None, 'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price), 'actual_price': str(su.actual_price),
@@ -1422,7 +1416,7 @@ def get_product_sales_units_api(request, product_id):
warehouse_id = request.GET.get('warehouse', '').strip() warehouse_id = request.GET.get('warehouse', '').strip()
# Получаем товар с prefetch единиц продажи # Получаем товар с prefetch единиц продажи
product = Product.objects.prefetch_related('sales_units__unit').get( product = Product.objects.prefetch_related('sales_units').get(
id=product_id, status='active' id=product_id, status='active'
) )
@@ -1448,8 +1442,6 @@ def get_product_sales_units_api(request, product_id):
sales_units_data.append({ sales_units_data.append({
'id': su.id, 'id': su.id,
'name': su.name, 'name': su.name,
'unit_code': su.unit.code,
'unit_short_name': su.unit.short_name,
'price': str(su.price), 'price': str(su.price),
'sale_price': str(su.sale_price) if su.sale_price else None, 'sale_price': str(su.sale_price) if su.sale_price else None,
'actual_price': str(su.actual_price), 'actual_price': str(su.actual_price),

View File

@@ -10,7 +10,7 @@ from django.db.models.functions import Coalesce
from itertools import chain from itertools import chain
from ..models import Product, ProductCategory, ProductTag, ProductKit from ..models import Product, ProductCategory, ProductTag, ProductKit
from ..forms import ProductForm from ..forms import ProductForm, ProductSalesUnitFormSet
from .utils import handle_photos from .utils import handle_photos
from ..models import ProductPhoto from ..models import ProductPhoto
from user_roles.mixins import ManagerOwnerRequiredMixin from user_roles.mixins import ManagerOwnerRequiredMixin
@@ -110,11 +110,36 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
def get_success_url(self): def get_success_url(self):
return reverse_lazy('products:products-list') return reverse_lazy('products:products-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['sales_unit_formset'] = ProductSalesUnitFormSet(
self.request.POST,
prefix='sales_units'
)
else:
context['sales_unit_formset'] = ProductSalesUnitFormSet(prefix='sales_units')
return context
def form_valid(self, form): def form_valid(self, form):
from django.db import IntegrityError from django.db import IntegrityError
context = self.get_context_data()
sales_unit_formset = context['sales_unit_formset']
try: try:
response = super().form_valid(form) # Сначала сохраняем товар
self.object = form.save()
# Затем сохраняем единицы продажи
if sales_unit_formset.is_valid():
sales_unit_formset.instance = self.object
sales_unit_formset.save()
else:
# Если formset невалиден, показываем ошибки
for error in sales_unit_formset.errors:
if error:
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
# Обработка загрузки фотографий # Обработка загрузки фотографий
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product') photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
@@ -127,7 +152,7 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
messages.error(self.request, error) messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!') messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
return response return super().form_valid(form)
except IntegrityError as e: except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints # Обработка ошибки дублирования slug'а или других unique constraints
@@ -161,7 +186,7 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
'photos', 'photos',
'categories', 'categories',
'tags', 'tags',
'sales_units__unit' 'sales_units'
).annotate( ).annotate(
total_available=total_available, total_available=total_available,
total_reserved=total_reserved, total_reserved=total_reserved,
@@ -199,13 +224,40 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
# Добавляем фотографии товара в контекст # Добавляем фотографии товара в контекст
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at') context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count() context['photos_count'] = self.object.photos.count()
# Добавляем formset единиц продажи
if self.request.POST:
context['sales_unit_formset'] = ProductSalesUnitFormSet(
self.request.POST,
instance=self.object,
prefix='sales_units'
)
else:
context['sales_unit_formset'] = ProductSalesUnitFormSet(
instance=self.object,
prefix='sales_units'
)
return context return context
def form_valid(self, form): def form_valid(self, form):
from django.db import IntegrityError from django.db import IntegrityError
context = self.get_context_data()
sales_unit_formset = context['sales_unit_formset']
try: try:
response = super().form_valid(form) # Сначала сохраняем товар
self.object = form.save()
# Затем сохраняем единицы продажи
if sales_unit_formset.is_valid():
sales_unit_formset.instance = self.object
sales_unit_formset.save()
else:
# Если formset невалиден, показываем ошибки
for error in sales_unit_formset.errors:
if error:
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
# Обработка загрузки фотографий # Обработка загрузки фотографий
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product') photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
@@ -218,7 +270,7 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
messages.error(self.request, error) messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!') messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
return response return super().form_valid(form)
except IntegrityError as e: except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints # Обработка ошибки дублирования slug'а или других unique constraints

View File

@@ -9,7 +9,7 @@ from django.core.paginator import Paginator
from django.urls import reverse from django.urls import reverse
from products.models import UnitOfMeasure, ProductSalesUnit from products.models import UnitOfMeasure, ProductSalesUnit
from products.forms import ProductSalesUnitForm from products.forms import ProductSalesUnitForm, UnitOfMeasureForm
@login_required @login_required
@@ -26,7 +26,8 @@ def unit_of_measure_list(request):
# Аннотируем количество использований # Аннотируем количество использований
units = units.annotate( units = units.annotate(
usage_count=Count('productsalesunit') usage_count=Count('productsalesunit'),
products_count=Count('products')
) )
# Применяем фильтры # Применяем фильтры
@@ -58,6 +59,89 @@ def unit_of_measure_list(request):
return render(request, 'products/uom/unit_list.html', context) return render(request, 'products/uom/unit_list.html', context)
@login_required
def unit_of_measure_create(request):
"""
Создание новой единицы измерения
"""
if request.method == 'POST':
form = UnitOfMeasureForm(request.POST)
if form.is_valid():
unit = form.save()
messages.success(request, f'Единица измерения "{unit.name}" успешно создана!')
return redirect('products:unit-list')
else:
form = UnitOfMeasureForm()
context = {
'form': form,
'title': 'Создание единицы измерения',
'submit_text': 'Создать'
}
return render(request, 'products/uom/unit_form.html', context)
@login_required
def unit_of_measure_update(request, pk):
"""
Редактирование единицы измерения
"""
unit = get_object_or_404(UnitOfMeasure, pk=pk)
if request.method == 'POST':
form = UnitOfMeasureForm(request.POST, instance=unit)
if form.is_valid():
unit = form.save()
messages.success(request, f'Единица измерения "{unit.name}" успешно обновлена!')
return redirect('products:unit-list')
else:
form = UnitOfMeasureForm(instance=unit)
context = {
'form': form,
'unit': unit,
'title': f'Редактирование: {unit.name}',
'submit_text': 'Сохранить'
}
return render(request, 'products/uom/unit_form.html', context)
@login_required
def unit_of_measure_delete(request, pk):
"""
Удаление единицы измерения
"""
unit = get_object_or_404(UnitOfMeasure, pk=pk)
# Проверяем использование
products_using = unit.products.count()
sales_units_using = unit.productsalesunit_set.count()
can_delete = products_using == 0 and sales_units_using == 0
if request.method == 'POST':
if can_delete:
name = unit.name
unit.delete()
messages.success(request, f'Единица измерения "{name}" успешно удалена!')
return redirect('products:unit-list')
else:
messages.error(
request,
f'Невозможно удалить единицу измерения "{unit.name}". '
f'Она используется в {products_using} товарах и {sales_units_using} единицах продажи.'
)
return redirect('products:unit-list')
context = {
'unit': unit,
'can_delete': can_delete,
'products_using': products_using,
'sales_units_using': sales_units_using,
}
return render(request, 'products/uom/unit_delete.html', context)
@login_required @login_required
def product_sales_unit_list(request): def product_sales_unit_list(request):
""" """
@@ -65,14 +149,11 @@ def product_sales_unit_list(request):
""" """
# Получаем параметры фильтрации # Получаем параметры фильтрации
search_query = request.GET.get('q', '').strip() search_query = request.GET.get('q', '').strip()
unit_filter = request.GET.get('unit', '')
is_active_filter = request.GET.get('is_active', '') is_active_filter = request.GET.get('is_active', '')
is_default_filter = request.GET.get('is_default', '') is_default_filter = request.GET.get('is_default', '')
# Базовый queryset # Базовый queryset
sales_units = ProductSalesUnit.objects.select_related( sales_units = ProductSalesUnit.objects.select_related('product').all()
'product', 'unit'
).all()
# Применяем фильтры # Применяем фильтры
if search_query: if search_query:
@@ -82,9 +163,6 @@ def product_sales_unit_list(request):
Q(name__icontains=search_query) Q(name__icontains=search_query)
) )
if unit_filter:
sales_units = sales_units.filter(unit_id=unit_filter)
if is_active_filter: if is_active_filter:
sales_units = sales_units.filter(is_active=(is_active_filter == 'true')) sales_units = sales_units.filter(is_active=(is_active_filter == 'true'))
@@ -99,16 +177,11 @@ def product_sales_unit_list(request):
page_number = request.GET.get('page') page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
# Для фильтра единиц
all_units = UnitOfMeasure.objects.filter(is_active=True).order_by('position', 'code')
context = { context = {
'page_obj': page_obj, 'page_obj': page_obj,
'search_query': search_query, 'search_query': search_query,
'unit_filter': unit_filter,
'is_active_filter': is_active_filter, 'is_active_filter': is_active_filter,
'is_default_filter': is_default_filter, 'is_default_filter': is_default_filter,
'all_units': all_units,
'total_sales_units': sales_units.count(), 'total_sales_units': sales_units.count(),
} }