feat: упростить создание заказов и рефакторинг единиц измерения
- Добавить inline-редактирование цен в списке товаров - Оптимизировать карточки товаров в POS-терминале - Рефакторинг моделей единиц измерения - Миграция unit -> base_unit в SalesUnit - Улучшить UI форм создания/редактирования товаров Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
|
||||
// Знак умножения
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 для первого фото комплектов
|
||||
|
||||
@@ -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',),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
101
myproject/products/migrations/0002_migrate_unit_to_base_unit.py
Normal file
101
myproject/products/migrations/0002_migrate_unit_to_base_unit.py
Normal 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='Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- Название -->
|
||||
|
||||
@@ -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 %}">«</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 %}">«</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 %}">»</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 %}">»</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
67
myproject/products/templates/products/uom/unit_delete.html
Normal file
67
myproject/products/templates/products/uom/unit_delete.html
Normal 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 %}
|
||||
110
myproject/products/templates/products/uom/unit_form.html
Normal file
110
myproject/products/templates/products/uom/unit_form.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user