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() {
// Определяем, системный ли это клиент
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
const displayName = isSystemCustomer ? 'Выбрать клиента' : selectedCustomer.name;
// Обновляем текст кнопки в корзине
const btnText = document.getElementById('customerSelectBtnText');
if (btnText) {
btnText.textContent = selectedCustomer.name;
btnText.textContent = displayName;
}
// Обновляем текст кнопки в модалке продажи
const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText');
if (checkoutBtnText) {
checkoutBtnText.textContent = selectedCustomer.name;
checkoutBtnText.textContent = displayName;
}
// Обновляем видимость кнопок сброса (в корзине и в модалке продажи)
// Приводим к числу для надёжного сравнения (JSON может вернуть разные типы)
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
[document.getElementById('resetCustomerBtn'),
document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => {
@@ -464,10 +466,10 @@ function renderUnitSelectionList() {
if (availableQty > 10) {
stockBadgeClass = 'stock-badge-good';
stockText = `${availableQty} ${unit.unit_short_name} доступно`;
stockText = `${availableQty} шт доступно`;
} else if (availableQty > 0) {
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="flex-grow-1">
<div class="unit-name">${unit.name}${defaultBadge}</div>
<div class="unit-code text-muted">${unit.unit_code} (${unit.unit_short_name})</div>
</div>
<div class="unit-price">${formatMoney(unit.actual_price)} руб</div>
</div>
@@ -509,7 +510,7 @@ function selectUnit(unit) {
// Обновляем отображение выбранной единицы
document.getElementById('selectedUnitDisplay').textContent =
`${unit.name} (${unit.unit_short_name})`;
unit.name;
// Устанавливаем минимальное количество и шаг
const qtyInput = document.getElementById('unitModalQuantity');
@@ -626,7 +627,6 @@ function addToCartFromModal() {
type: 'product',
sales_unit_id: selectedSalesUnit.id,
unit_name: selectedSalesUnit.name,
unit_short_name: selectedSalesUnit.unit_short_name,
quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества
price_overridden: priceOverridden
});
@@ -645,6 +645,54 @@ function addToCartFromModal() {
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() {
const grid = document.getElementById('categoryGrid');
grid.innerHTML = '';
@@ -699,7 +747,7 @@ function renderCategories() {
// Категории
CATEGORIES.forEach(cat => {
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');
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
@@ -752,7 +800,7 @@ function renderProducts() {
filtered.forEach(item => {
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');
card.className = 'card product-card';
@@ -852,54 +900,102 @@ function renderProducts() {
stock.style.color = '#856404';
stock.style.fontWeight = 'bold';
} 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 reserved = parseFloat(item.reserved_qty) || 0;
// Вычитаем количество из корзины для визуализации
const cartKey = `product-${item.id}`;
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
// Используем единицу продажи если есть
if (item.default_sales_unit) {
const unit = item.default_sales_unit;
const conversionFactor = parseFloat(unit.conversion_factor) || 1;
const free = available - reserved - inCart;
const freeRounded = roundQuantity(free, 3); // Округляем для отображения
// Вычисляем количество в корзине в единицах продажи
const cartKey = `product-${item.id}-${unit.id}`;
const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
// Создаём элементы для стилизации разных размеров
const freeSpan = document.createElement('span');
freeSpan.textContent = freeRounded; // Используем округлённое значение
freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold';
freeSpan.style.fontStyle = 'normal';
// Свободное количество в единицах продажи
const availableInUnit = parseFloat(item.available_qty_in_unit) || 0;
const reservedInUnit = reserved * conversionFactor;
const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty;
const freeRounded = roundQuantity(freeInUnit, 1); // Округляем для отображения
// Отображаем резерв и корзину если они есть
const suffixParts = [];
if (reserved > 0) {
suffixParts.push(`${roundQuantity(reserved, 3)}`);
}
if (inCart > 0) {
suffixParts.push(`${roundQuantity(inCart, 3)}🛒`);
}
// Создаём элементы для стилизации
const freeSpan = document.createElement('span');
freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold';
if (suffixParts.length > 0) {
const suffixSpan = document.createElement('span');
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
suffixSpan.style.fontSize = '0.85em';
suffixSpan.style.marginLeft = '3px';
suffixSpan.style.fontStyle = 'normal';
const qtyText = document.createElement('span');
qtyText.textContent = freeRounded;
freeSpan.appendChild(qtyText);
stock.appendChild(freeSpan);
stock.appendChild(suffixSpan);
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 {
stock.appendChild(freeSpan);
}
// Отображение в базовых единицах (старая логика)
const cartKey = `product-${item.id}`;
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
// Цветовая индикация: красный если свободных остатков нет или отрицательные
if (free <= 0) {
stock.style.color = '#dc3545'; // Красный
} else if (free < 5) {
stock.style.color = '#ffc107'; // Жёлтый (мало остатков)
} else {
stock.style.color = '#28a745'; // Зелёный (достаточно)
const free = available - reserved - inCart;
const freeRounded = roundQuantity(free, 3);
const freeSpan = document.createElement('span');
freeSpan.textContent = freeRounded;
freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold';
const suffixParts = [];
if (reserved > 0) {
suffixParts.push(`${roundQuantity(reserved, 3)}`);
}
if (inCart > 0) {
suffixParts.push(`${roundQuantity(inCart, 3)}🛒`);
}
if (suffixParts.length > 0) {
const suffixSpan = document.createElement('span');
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
suffixSpan.style.fontSize = '0.85em';
suffixSpan.style.marginLeft = '3px';
stock.appendChild(freeSpan);
stock.appendChild(suffixSpan);
} else {
stock.appendChild(freeSpan);
}
if (free <= 0) {
stock.style.color = '#dc3545';
} else if (free < 5) {
stock.style.color = '#ffc107';
} else {
stock.style.color = '#28a745';
}
}
} else {
// Комплекты: показываем доступное количество
@@ -929,7 +1025,9 @@ function renderProducts() {
const priceSpan = document.createElement('span');
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(priceSpan);
@@ -1022,9 +1120,18 @@ function setupInfiniteScroll() {
}
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);
return;
}
@@ -1187,8 +1294,10 @@ function renderCart() {
}
namePrice.innerHTML = `
<div class="fw-semibold small">${typeIcon}${item.name}${unitInfo}</div>
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / ${item.unit_short_name || 'шт'}</div>
<div class="fw-semibold small">${typeIcon}${item.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="row g-3" style="height: 100%;">
<!-- 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 -->
<div class="mb-3">
<div class="input-group">
@@ -39,7 +39,7 @@
</div>
<!-- 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="card mb-2">
@@ -67,11 +67,8 @@
<h6 class="mb-0">Корзина</h6>
<div class="d-flex gap-1 align-items-center">
<button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="customerSelectBtn">
<i class="bi bi-person me-1"></i>
<div class="d-flex flex-column align-items-start lh-1">
<small class="text-muted" style="font-size: 0.65rem;">Клиент</small>
<span id="customerSelectBtnText" class="fw-semibold">Выбрать</span>
</div>
<i class="bi bi-person"></i>
<span id="customerSelectBtnText">Выбрать клиента</span>
</button>
<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>
@@ -633,11 +630,8 @@
<!-- Цена -->
<div class="mb-3">
<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">
</div>
<div id="priceOverrideIndicator" class="text-warning small mt-1" style="display: none;">
<i class="bi bi-exclamation-triangle"></i> Цена изменена
</div>

View File

@@ -766,7 +766,8 @@ def get_items_api(request):
)
).prefetch_related(
'categories',
first_product_photo
first_product_photo,
'sales_units' # Загружаем единицы продажи для POS
)
# Фильтруем по категории, если указана
@@ -795,10 +796,33 @@ def get_items_api(request):
reserved = p.reserved_qty
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
# Находим единицу продажи по умолчанию
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({
'id': p.id,
'name': p.name,
@@ -811,9 +835,12 @@ def get_items_api(request):
'available_qty': str(available),
'reserved_qty': str(reserved),
'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,
'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 для первого фото комплектов

View File

@@ -408,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
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',),
@@ -834,10 +834,9 @@ class ProductSalesUnitInline(admin.TabularInline):
model = ProductSalesUnit
extra = 0
fields = (
'unit', 'name', 'conversion_factor', 'price', 'sale_price',
'name', 'conversion_factor', 'price', 'sale_price',
'min_quantity', 'quantity_step', 'is_default', 'is_active'
)
autocomplete_fields = ['unit']
verbose_name = "Единица продажи"
verbose_name_plural = "Единицы продажи"
@@ -984,18 +983,18 @@ class UnitOfMeasureAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для единиц продажи товаров"""
list_display = (
'product', 'name', 'unit', 'conversion_factor',
'product', 'name', 'conversion_factor',
'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')
autocomplete_fields = ['product', 'unit']
autocomplete_fields = ['product']
list_editable = ('is_default', 'is_active')
ordering = ('product', 'position')
fieldsets = (
('Товар и единица', {
'fields': ('product', 'unit', 'name')
('Товар и название', {
'fields': ('product', 'name')
}),
('Конверсия', {
'fields': ('conversion_factor',),

View File

@@ -156,8 +156,8 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
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 = 'Базовая единица для учета товара на складе. На основе этой единицы можно создать единицы продажи.'
self.fields['base_unit'].required = True
self.fields['base_unit'].help_text = 'Базовая единица хранения и закупки. На её основе создаются единицы продажи.'
# Маркетинговые флаги (switch-стиль)
for flag_field in ['is_new', 'is_popular', 'is_special']:
@@ -1085,13 +1085,12 @@ class ProductSalesUnitForm(forms.ModelForm):
class Meta:
model = ProductSalesUnit
fields = [
'product', 'unit', 'name', 'conversion_factor',
'product', 'name', 'conversion_factor',
'price', 'sale_price', 'min_quantity', 'quantity_step',
'is_default', 'is_active', 'position'
]
labels = {
'product': 'Товар',
'unit': 'Единица измерения',
'name': 'Название',
'conversion_factor': 'Коэффициент конверсии',
'price': 'Цена продажи',
@@ -1104,7 +1103,6 @@ class ProductSalesUnitForm(forms.ModelForm):
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'unit': forms.Select(attrs={'class': 'form-control'}),
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Например: Ветка большая, Стебель средний'
@@ -1155,11 +1153,6 @@ class ProductSalesUnitForm(forms.ModelForm):
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'
@@ -1167,3 +1160,150 @@ class ProductSalesUnitForm(forms.ModelForm):
# Сделать sale_price необязательным
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.
"""
UNIT_CHOICES = [
('шт', 'Штука'),
('м', 'Метр'),
('г', 'Грамм'),
('л', 'Литр'),
('кг', 'Килограмм'),
]
# Специфичные поля Product
variant_suffix = models.CharField(
max_length=20,
@@ -53,23 +45,13 @@ class Product(BaseProductEntity):
verbose_name="Группы вариантов"
)
unit = models.CharField(
max_length=10,
choices=UNIT_CHOICES,
default='шт',
verbose_name="Единица измерения (deprecated)"
)
# Новое поле: ссылка на справочник единиц измерения
# Базовая единица измерения (единица закупки/хранения)
base_unit = models.ForeignKey(
'UnitOfMeasure',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='products',
verbose_name="Базовая единица",
help_text="Единица хранения и закупки (банч, кг, шт). "
"Если указана, используется вместо поля 'unit'."
help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах."
)
# ЦЕНООБРАЗОВАНИЕ - переименованные поля
@@ -183,11 +165,9 @@ class Product(BaseProductEntity):
def unit_display(self):
"""
Отображаемое название единицы измерения.
Приоритет: base_unit.code > unit
Возвращает код базовой единицы.
"""
if self.base_unit:
return self.base_unit.code
return self.unit
return self.base_unit.code if self.base_unit else 'шт'
@property
def has_sales_units(self):

View File

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

View File

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

View File

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

View File

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

View File

@@ -581,6 +581,157 @@
<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: Информация о наличии (только при редактировании) -->
{% if object %}
<div class="mb-4 p-3 bg-info-light rounded border border-info">
@@ -686,6 +837,58 @@ document.addEventListener('DOMContentLoaded', function() {
});
}, 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>
{% endblock %}

View File

@@ -22,9 +22,6 @@
<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>

View File

@@ -20,32 +20,18 @@
</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 class="mb-3">
<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>
<!-- Название -->

View File

@@ -17,19 +17,9 @@
<!-- Поиск и фильтры -->
<form method="get" class="mb-3">
<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="Поиск по товару, артикулу, названию...">
</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>
@@ -63,15 +53,14 @@
<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: 28%;">Товар</th>
<th style="width: 18%;">Название единицы</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: 8%;" class="text-center">По умолч.</th>
<th style="width: 8%;" class="text-center">Статус</th>
<th style="width: 10%;" class="text-end">Действия</th>
</tr>
</thead>
@@ -87,9 +76,6 @@
{% 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 %}
@@ -142,7 +128,7 @@
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&laquo;</a>
<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>
{% endif %}
<li class="page-item active">
@@ -150,7 +136,7 @@
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&raquo;</a>
<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>
{% endif %}
</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">
<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> Добавить единицу
</a>
</div>
@@ -53,9 +53,12 @@
<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 %}">
<span class="badge {% if unit.usage_count > 0 %}bg-success{% else %}bg-secondary{% endif %}" title="Единицы продажи">
{{ unit.usage_count }}
</span>
<span class="badge {% if unit.products_count > 0 %}bg-primary{% else %}bg-secondary{% endif %}" title="Товаров">
{{ unit.products_count }}
</span>
</td>
<td class="text-center">
{% if unit.is_active %}
@@ -66,9 +69,12 @@
</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="Изменить">
<a href="{% url 'products:unit-update' unit.pk %}" class="btn btn-outline-secondary" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'products:unit-delete' unit.pk %}" class="btn btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</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>/set-default/', views.set_option_as_default, name='configurableproduct-set-default-option'),
# Управление единицами измерения
# Управление единицами измерения (справочник)
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/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'),

View File

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

View File

@@ -114,7 +114,7 @@ def search_products_and_variants(request):
numeric_id = int(item_id)
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 = []
@@ -122,8 +122,6 @@ def search_products_and_variants(request):
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),
@@ -216,7 +214,7 @@ def search_products_and_variants(request):
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 = products_qs.order_by('-created_at')[:page_size]
@@ -235,8 +233,6 @@ def search_products_and_variants(request):
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),
@@ -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)
# Добавляем prefetch для единиц продажи
products_query = products_query.prefetch_related('sales_units__unit')
products_query = products_query.prefetch_related('sales_units')
total_products = products_query.count()
start = (page - 1) * page_size
@@ -387,8 +383,6 @@ def search_products_and_variants(request):
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),
@@ -1422,7 +1416,7 @@ def get_product_sales_units_api(request, product_id):
warehouse_id = request.GET.get('warehouse', '').strip()
# Получаем товар с prefetch единиц продажи
product = Product.objects.prefetch_related('sales_units__unit').get(
product = Product.objects.prefetch_related('sales_units').get(
id=product_id, status='active'
)
@@ -1448,8 +1442,6 @@ def get_product_sales_units_api(request, product_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),

View File

@@ -10,7 +10,7 @@ from django.db.models.functions import Coalesce
from itertools import chain
from ..models import Product, ProductCategory, ProductTag, ProductKit
from ..forms import ProductForm
from ..forms import ProductForm, ProductSalesUnitFormSet
from .utils import handle_photos
from ..models import ProductPhoto
from user_roles.mixins import ManagerOwnerRequiredMixin
@@ -110,11 +110,36 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
def get_success_url(self):
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):
from django.db import IntegrityError
context = self.get_context_data()
sales_unit_formset = context['sales_unit_formset']
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')
@@ -127,7 +152,7 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
return response
return super().form_valid(form)
except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints
@@ -161,7 +186,7 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
'photos',
'categories',
'tags',
'sales_units__unit'
'sales_units'
).annotate(
total_available=total_available,
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['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
def form_valid(self, form):
from django.db import IntegrityError
context = self.get_context_data()
sales_unit_formset = context['sales_unit_formset']
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')
@@ -218,7 +270,7 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
return response
return super().form_valid(form)
except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints

View File

@@ -9,7 +9,7 @@ from django.core.paginator import Paginator
from django.urls import reverse
from products.models import UnitOfMeasure, ProductSalesUnit
from products.forms import ProductSalesUnitForm
from products.forms import ProductSalesUnitForm, UnitOfMeasureForm
@login_required
@@ -26,7 +26,8 @@ def unit_of_measure_list(request):
# Аннотируем количество использований
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)
@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
def product_sales_unit_list(request):
"""
@@ -65,14 +149,11 @@ 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()
sales_units = ProductSalesUnit.objects.select_related('product').all()
# Применяем фильтры
if search_query:
@@ -82,9 +163,6 @@ def product_sales_unit_list(request):
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'))
@@ -99,16 +177,11 @@ def product_sales_unit_list(request):
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(),
}