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() {
|
function updateCustomerDisplay() {
|
||||||
|
// Определяем, системный ли это клиент
|
||||||
|
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
|
||||||
|
const displayName = isSystemCustomer ? 'Выбрать клиента' : selectedCustomer.name;
|
||||||
|
|
||||||
// Обновляем текст кнопки в корзине
|
// Обновляем текст кнопки в корзине
|
||||||
const btnText = document.getElementById('customerSelectBtnText');
|
const btnText = document.getElementById('customerSelectBtnText');
|
||||||
if (btnText) {
|
if (btnText) {
|
||||||
btnText.textContent = selectedCustomer.name;
|
btnText.textContent = displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем текст кнопки в модалке продажи
|
// Обновляем текст кнопки в модалке продажи
|
||||||
const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText');
|
const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText');
|
||||||
if (checkoutBtnText) {
|
if (checkoutBtnText) {
|
||||||
checkoutBtnText.textContent = selectedCustomer.name;
|
checkoutBtnText.textContent = displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем видимость кнопок сброса (в корзине и в модалке продажи)
|
// Обновляем видимость кнопок сброса (в корзине и в модалке продажи)
|
||||||
// Приводим к числу для надёжного сравнения (JSON может вернуть разные типы)
|
|
||||||
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
|
|
||||||
|
|
||||||
[document.getElementById('resetCustomerBtn'),
|
[document.getElementById('resetCustomerBtn'),
|
||||||
document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => {
|
document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => {
|
||||||
@@ -464,10 +466,10 @@ function renderUnitSelectionList() {
|
|||||||
|
|
||||||
if (availableQty > 10) {
|
if (availableQty > 10) {
|
||||||
stockBadgeClass = 'stock-badge-good';
|
stockBadgeClass = 'stock-badge-good';
|
||||||
stockText = `${availableQty} ${unit.unit_short_name} доступно`;
|
stockText = `${availableQty} шт доступно`;
|
||||||
} else if (availableQty > 0) {
|
} else if (availableQty > 0) {
|
||||||
stockBadgeClass = 'stock-badge-low';
|
stockBadgeClass = 'stock-badge-low';
|
||||||
stockText = `${availableQty} ${unit.unit_short_name} доступно`;
|
stockText = `${availableQty} шт доступно`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Бейдж "По умолчанию"
|
// Бейдж "По умолчанию"
|
||||||
@@ -478,7 +480,6 @@ function renderUnitSelectionList() {
|
|||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="unit-name">${unit.name}${defaultBadge}</div>
|
<div class="unit-name">${unit.name}${defaultBadge}</div>
|
||||||
<div class="unit-code text-muted">${unit.unit_code} (${unit.unit_short_name})</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="unit-price">${formatMoney(unit.actual_price)} руб</div>
|
<div class="unit-price">${formatMoney(unit.actual_price)} руб</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,7 +510,7 @@ function selectUnit(unit) {
|
|||||||
|
|
||||||
// Обновляем отображение выбранной единицы
|
// Обновляем отображение выбранной единицы
|
||||||
document.getElementById('selectedUnitDisplay').textContent =
|
document.getElementById('selectedUnitDisplay').textContent =
|
||||||
`${unit.name} (${unit.unit_short_name})`;
|
unit.name;
|
||||||
|
|
||||||
// Устанавливаем минимальное количество и шаг
|
// Устанавливаем минимальное количество и шаг
|
||||||
const qtyInput = document.getElementById('unitModalQuantity');
|
const qtyInput = document.getElementById('unitModalQuantity');
|
||||||
@@ -626,7 +627,6 @@ function addToCartFromModal() {
|
|||||||
type: 'product',
|
type: 'product',
|
||||||
sales_unit_id: selectedSalesUnit.id,
|
sales_unit_id: selectedSalesUnit.id,
|
||||||
unit_name: selectedSalesUnit.name,
|
unit_name: selectedSalesUnit.name,
|
||||||
unit_short_name: selectedSalesUnit.unit_short_name,
|
|
||||||
quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества
|
quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества
|
||||||
price_overridden: priceOverridden
|
price_overridden: priceOverridden
|
||||||
});
|
});
|
||||||
@@ -645,6 +645,54 @@ function addToCartFromModal() {
|
|||||||
unitModalInstance.hide();
|
unitModalInstance.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет товар с единицей продажи напрямую в корзину (без модального окна)
|
||||||
|
* Используется для быстрого добавления когда у товара только одна единица продажи
|
||||||
|
* @param {object} product - Товар из ITEMS
|
||||||
|
* @param {object} salesUnit - Единица продажи (default_sales_unit)
|
||||||
|
* @param {number} qty - Количество для добавления
|
||||||
|
*/
|
||||||
|
async function addProductWithUnitToCart(product, salesUnit, qty = 1) {
|
||||||
|
const cartKey = `product-${product.id}-${salesUnit.id}`;
|
||||||
|
|
||||||
|
if (cart.has(cartKey)) {
|
||||||
|
const existing = cart.get(cartKey);
|
||||||
|
existing.qty = roundQuantity(existing.qty + qty, 3);
|
||||||
|
} else {
|
||||||
|
cart.set(cartKey, {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
price: Number(salesUnit.price),
|
||||||
|
qty: qty,
|
||||||
|
type: 'product',
|
||||||
|
sales_unit_id: salesUnit.id,
|
||||||
|
unit_name: salesUnit.name,
|
||||||
|
quantity_step: parseFloat(salesUnit.quantity_step) || 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCart();
|
||||||
|
saveCartToRedis();
|
||||||
|
|
||||||
|
// Перерисовываем товары для обновления визуального остатка
|
||||||
|
if (!isShowcaseView) {
|
||||||
|
renderProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фокус на поле количества
|
||||||
|
setTimeout(() => {
|
||||||
|
const qtyInputs = document.querySelectorAll('.qty-input');
|
||||||
|
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
|
||||||
|
|
||||||
|
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
|
||||||
|
if (!isMobileDevice()) {
|
||||||
|
qtyInputs[itemIndex].focus();
|
||||||
|
qtyInputs[itemIndex].select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
function renderCategories() {
|
function renderCategories() {
|
||||||
const grid = document.getElementById('categoryGrid');
|
const grid = document.getElementById('categoryGrid');
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
@@ -699,7 +747,7 @@ function renderCategories() {
|
|||||||
// Категории
|
// Категории
|
||||||
CATEGORIES.forEach(cat => {
|
CATEGORIES.forEach(cat => {
|
||||||
const col = document.createElement('div');
|
const col = document.createElement('div');
|
||||||
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5';
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
|
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
|
||||||
@@ -752,7 +800,7 @@ function renderProducts() {
|
|||||||
|
|
||||||
filtered.forEach(item => {
|
filtered.forEach(item => {
|
||||||
const col = document.createElement('div');
|
const col = document.createElement('div');
|
||||||
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5';
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card product-card';
|
card.className = 'card product-card';
|
||||||
@@ -852,26 +900,75 @@ function renderProducts() {
|
|||||||
stock.style.color = '#856404';
|
stock.style.color = '#856404';
|
||||||
stock.style.fontWeight = 'bold';
|
stock.style.fontWeight = 'bold';
|
||||||
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
|
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
|
||||||
// Для обычных товаров показываем остатки: FREE(-RESERVED-IN_CART)
|
// Для обычных товаров показываем остатки
|
||||||
// FREE = доступно для продажи (available - reserved - в корзине)
|
// Если у товара есть единицы продажи - отображаем в единицах продажи
|
||||||
const available = parseFloat(item.available_qty) || 0;
|
const available = parseFloat(item.available_qty) || 0;
|
||||||
const reserved = parseFloat(item.reserved_qty) || 0;
|
const reserved = parseFloat(item.reserved_qty) || 0;
|
||||||
|
|
||||||
// Вычитаем количество из корзины для визуализации
|
// Используем единицу продажи если есть
|
||||||
|
if (item.default_sales_unit) {
|
||||||
|
const unit = item.default_sales_unit;
|
||||||
|
const conversionFactor = parseFloat(unit.conversion_factor) || 1;
|
||||||
|
|
||||||
|
// Вычисляем количество в корзине в единицах продажи
|
||||||
|
const cartKey = `product-${item.id}-${unit.id}`;
|
||||||
|
const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||||
|
|
||||||
|
// Свободное количество в единицах продажи
|
||||||
|
const availableInUnit = parseFloat(item.available_qty_in_unit) || 0;
|
||||||
|
const reservedInUnit = reserved * conversionFactor;
|
||||||
|
const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty;
|
||||||
|
const freeRounded = roundQuantity(freeInUnit, 1); // Округляем для отображения
|
||||||
|
|
||||||
|
// Создаём элементы для стилизации
|
||||||
|
const freeSpan = document.createElement('span');
|
||||||
|
freeSpan.style.fontSize = '1.1em';
|
||||||
|
freeSpan.style.fontWeight = 'bold';
|
||||||
|
|
||||||
|
const qtyText = document.createElement('span');
|
||||||
|
qtyText.textContent = freeRounded;
|
||||||
|
freeSpan.appendChild(qtyText);
|
||||||
|
|
||||||
|
const unitBadge = document.createElement('span');
|
||||||
|
unitBadge.className = 'badge bg-secondary ms-1';
|
||||||
|
unitBadge.style.fontSize = '0.7rem';
|
||||||
|
unitBadge.textContent = unit.name;
|
||||||
|
freeSpan.appendChild(unitBadge);
|
||||||
|
|
||||||
|
// Отображаем корзину если есть
|
||||||
|
if (inCartBaseQty > 0) {
|
||||||
|
const suffixSpan = document.createElement('span');
|
||||||
|
suffixSpan.textContent = ` (−${roundQuantity(inCartBaseQty, 1)}🛒)`;
|
||||||
|
suffixSpan.style.fontSize = '0.85em';
|
||||||
|
suffixSpan.style.marginLeft = '3px';
|
||||||
|
|
||||||
|
stock.appendChild(freeSpan);
|
||||||
|
stock.appendChild(suffixSpan);
|
||||||
|
} else {
|
||||||
|
stock.appendChild(freeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цветовая индикация
|
||||||
|
if (freeInUnit <= 0) {
|
||||||
|
stock.style.color = '#dc3545'; // Красный
|
||||||
|
} else if (freeInUnit < 5) {
|
||||||
|
stock.style.color = '#ffc107'; // Жёлтый
|
||||||
|
} else {
|
||||||
|
stock.style.color = '#28a745'; // Зелёный
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Отображение в базовых единицах (старая логика)
|
||||||
const cartKey = `product-${item.id}`;
|
const cartKey = `product-${item.id}`;
|
||||||
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||||
|
|
||||||
const free = available - reserved - inCart;
|
const free = available - reserved - inCart;
|
||||||
const freeRounded = roundQuantity(free, 3); // Округляем для отображения
|
const freeRounded = roundQuantity(free, 3);
|
||||||
|
|
||||||
// Создаём элементы для стилизации разных размеров
|
|
||||||
const freeSpan = document.createElement('span');
|
const freeSpan = document.createElement('span');
|
||||||
freeSpan.textContent = freeRounded; // Используем округлённое значение
|
freeSpan.textContent = freeRounded;
|
||||||
freeSpan.style.fontSize = '1.1em';
|
freeSpan.style.fontSize = '1.1em';
|
||||||
freeSpan.style.fontWeight = 'bold';
|
freeSpan.style.fontWeight = 'bold';
|
||||||
freeSpan.style.fontStyle = 'normal';
|
|
||||||
|
|
||||||
// Отображаем резерв и корзину если они есть
|
|
||||||
const suffixParts = [];
|
const suffixParts = [];
|
||||||
if (reserved > 0) {
|
if (reserved > 0) {
|
||||||
suffixParts.push(`−${roundQuantity(reserved, 3)}`);
|
suffixParts.push(`−${roundQuantity(reserved, 3)}`);
|
||||||
@@ -885,7 +982,6 @@ function renderProducts() {
|
|||||||
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
|
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
|
||||||
suffixSpan.style.fontSize = '0.85em';
|
suffixSpan.style.fontSize = '0.85em';
|
||||||
suffixSpan.style.marginLeft = '3px';
|
suffixSpan.style.marginLeft = '3px';
|
||||||
suffixSpan.style.fontStyle = 'normal';
|
|
||||||
|
|
||||||
stock.appendChild(freeSpan);
|
stock.appendChild(freeSpan);
|
||||||
stock.appendChild(suffixSpan);
|
stock.appendChild(suffixSpan);
|
||||||
@@ -893,13 +989,13 @@ function renderProducts() {
|
|||||||
stock.appendChild(freeSpan);
|
stock.appendChild(freeSpan);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Цветовая индикация: красный если свободных остатков нет или отрицательные
|
|
||||||
if (free <= 0) {
|
if (free <= 0) {
|
||||||
stock.style.color = '#dc3545'; // Красный
|
stock.style.color = '#dc3545';
|
||||||
} else if (free < 5) {
|
} else if (free < 5) {
|
||||||
stock.style.color = '#ffc107'; // Жёлтый (мало остатков)
|
stock.style.color = '#ffc107';
|
||||||
} else {
|
} else {
|
||||||
stock.style.color = '#28a745'; // Зелёный (достаточно)
|
stock.style.color = '#28a745';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Комплекты: показываем доступное количество
|
// Комплекты: показываем доступное количество
|
||||||
@@ -929,7 +1025,9 @@ function renderProducts() {
|
|||||||
|
|
||||||
const priceSpan = document.createElement('span');
|
const priceSpan = document.createElement('span');
|
||||||
priceSpan.className = 'product-price';
|
priceSpan.className = 'product-price';
|
||||||
priceSpan.textContent = `${formatMoney(item.price)}`;
|
// Используем цену из единицы продажи если есть, иначе базовую цену
|
||||||
|
const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price;
|
||||||
|
priceSpan.textContent = `${formatMoney(itemPrice)}`;
|
||||||
|
|
||||||
sku.appendChild(skuText);
|
sku.appendChild(skuText);
|
||||||
sku.appendChild(priceSpan);
|
sku.appendChild(priceSpan);
|
||||||
@@ -1022,9 +1120,18 @@ function setupInfiniteScroll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addToCart(item) {
|
async function addToCart(item) {
|
||||||
// ПРОВЕРКА НА НАЛИЧИЕ НЕСКОЛЬКИХ ЕДИНИЦ ПРОДАЖИ
|
// ПРОВЕРКА НА НАЛИЧИЕ ЕДИНИЦ ПРОДАЖИ
|
||||||
if (item.type === 'product' && item.sales_units_count > 1) {
|
// Если у товара одна единица продажи - добавляем сразу
|
||||||
// Открываем модальное окно выбора единицы
|
// Если несколько - показываем модальное окно выбора
|
||||||
|
console.log('addToCart:', item.name, 'has_sales_units:', item.has_sales_units, 'sales_units_count:', item.sales_units_count);
|
||||||
|
|
||||||
|
if (item.type === 'product' && item.has_sales_units) {
|
||||||
|
// Если одна единица продажи - добавляем сразу
|
||||||
|
if (item.sales_units_count === 1 && item.default_sales_unit) {
|
||||||
|
await addProductWithUnitToCart(item, item.default_sales_unit, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Иначе открываем модальное окно выбора единицы
|
||||||
await openProductUnitModal(item);
|
await openProductUnitModal(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1187,8 +1294,10 @@ function renderCart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
namePrice.innerHTML = `
|
namePrice.innerHTML = `
|
||||||
<div class="fw-semibold small">${typeIcon}${item.name}${unitInfo}</div>
|
<div class="fw-semibold small">${typeIcon}${item.name}</div>
|
||||||
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / ${item.unit_short_name || 'шт'}</div>
|
<div class="price-unit-row">
|
||||||
|
<span class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)}</span>${unitInfo}
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Знак умножения
|
// Знак умножения
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="pos-container">
|
<div class="pos-container">
|
||||||
<div class="row g-3" style="height: 100%;">
|
<div class="row g-3" style="height: 100%;">
|
||||||
<!-- Products Grid (Left side - 8/12) -->
|
<!-- Products Grid (Left side - 8/12) -->
|
||||||
<div class="col-12 col-md-8" style="display: flex; flex-direction: column; height: 100%;">
|
<div class="col-12 col-md-7" style="display: flex; flex-direction: column; height: 100%;">
|
||||||
<!-- Search Box -->
|
<!-- Search Box -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Panel (4/12) - Fixed -->
|
<!-- Right Panel (4/12) - Fixed -->
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-5">
|
||||||
<div class="right-panel-fixed d-flex flex-column">
|
<div class="right-panel-fixed d-flex flex-column">
|
||||||
<!-- Информация о складе (всегда показываем блок для фиксированной позиции) -->
|
<!-- Информация о складе (всегда показываем блок для фиксированной позиции) -->
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
@@ -67,11 +67,8 @@
|
|||||||
<h6 class="mb-0">Корзина</h6>
|
<h6 class="mb-0">Корзина</h6>
|
||||||
<div class="d-flex gap-1 align-items-center">
|
<div class="d-flex gap-1 align-items-center">
|
||||||
<button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="customerSelectBtn">
|
<button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="customerSelectBtn">
|
||||||
<i class="bi bi-person me-1"></i>
|
<i class="bi bi-person"></i>
|
||||||
<div class="d-flex flex-column align-items-start lh-1">
|
<span id="customerSelectBtnText">Выбрать клиента</span>
|
||||||
<small class="text-muted" style="font-size: 0.65rem;">Клиент</small>
|
|
||||||
<span id="customerSelectBtnText" class="fw-semibold">Выбрать</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<a href="#" id="customerProfileLink" class="btn btn-sm btn-outline-secondary" title="Открыть анкету клиента" target="_blank" style="display: none;">
|
<a href="#" id="customerProfileLink" class="btn btn-sm btn-outline-secondary" title="Открыть анкету клиента" target="_blank" style="display: none;">
|
||||||
<i class="bi bi-box-arrow-up-right"></i>
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
@@ -633,11 +630,8 @@
|
|||||||
<!-- Цена -->
|
<!-- Цена -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="unitModalPrice" class="form-label">Цена за единицу</label>
|
<label for="unitModalPrice" class="form-label">Цена за единицу</label>
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">₽</span>
|
|
||||||
<input type="number" class="form-control" id="unitModalPrice"
|
<input type="number" class="form-control" id="unitModalPrice"
|
||||||
value="0" min="0" step="0.01">
|
value="0" min="0" step="0.01">
|
||||||
</div>
|
|
||||||
<div id="priceOverrideIndicator" class="text-warning small mt-1" style="display: none;">
|
<div id="priceOverrideIndicator" class="text-warning small mt-1" style="display: none;">
|
||||||
<i class="bi bi-exclamation-triangle"></i> Цена изменена
|
<i class="bi bi-exclamation-triangle"></i> Цена изменена
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -766,7 +766,8 @@ def get_items_api(request):
|
|||||||
)
|
)
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'categories',
|
'categories',
|
||||||
first_product_photo
|
first_product_photo,
|
||||||
|
'sales_units' # Загружаем единицы продажи для POS
|
||||||
)
|
)
|
||||||
|
|
||||||
# Фильтруем по категории, если указана
|
# Фильтруем по категории, если указана
|
||||||
@@ -795,10 +796,33 @@ def get_items_api(request):
|
|||||||
reserved = p.reserved_qty
|
reserved = p.reserved_qty
|
||||||
free_qty = available - reserved
|
free_qty = available - reserved
|
||||||
|
|
||||||
# Подсчитываем активные единицы продажи
|
# Получаем активные единицы продажи
|
||||||
sales_units_count = p.sales_units.filter(is_active=True).count()
|
active_sales_units = [su for su in p.sales_units.all() if su.is_active]
|
||||||
|
sales_units_count = len(active_sales_units)
|
||||||
has_sales_units = sales_units_count > 0
|
has_sales_units = sales_units_count > 0
|
||||||
|
|
||||||
|
# Находим единицу продажи по умолчанию
|
||||||
|
default_sales_unit = None
|
||||||
|
available_qty_in_unit = free_qty # Количество в единицах продажи
|
||||||
|
price_in_unit = str(p.actual_price) # Цена в единицах продажи
|
||||||
|
|
||||||
|
if has_sales_units:
|
||||||
|
# Ищем единицу с is_default=True или берем первую активную
|
||||||
|
default_unit = next((su for su in active_sales_units if su.is_default), active_sales_units[0])
|
||||||
|
if default_unit and default_unit.conversion_factor and default_unit.conversion_factor > 0:
|
||||||
|
# Конвертируем свободное количество в единицы продажи
|
||||||
|
available_qty_in_unit = free_qty * default_unit.conversion_factor
|
||||||
|
price_in_unit = str(default_unit.actual_price)
|
||||||
|
default_sales_unit = {
|
||||||
|
'id': default_unit.id,
|
||||||
|
'name': default_unit.name,
|
||||||
|
'price': str(default_unit.actual_price),
|
||||||
|
'conversion_factor': str(default_unit.conversion_factor),
|
||||||
|
'min_quantity': str(default_unit.min_quantity),
|
||||||
|
'quantity_step': str(default_unit.quantity_step),
|
||||||
|
'is_default': default_unit.is_default
|
||||||
|
}
|
||||||
|
|
||||||
products.append({
|
products.append({
|
||||||
'id': p.id,
|
'id': p.id,
|
||||||
'name': p.name,
|
'name': p.name,
|
||||||
@@ -811,9 +835,12 @@ def get_items_api(request):
|
|||||||
'available_qty': str(available),
|
'available_qty': str(available),
|
||||||
'reserved_qty': str(reserved),
|
'reserved_qty': str(reserved),
|
||||||
'free_qty': str(free_qty), # Передаём как строку для сохранения точности
|
'free_qty': str(free_qty), # Передаём как строку для сохранения точности
|
||||||
'free_qty_sort': float(free_qty), # Для сортировки отдельное поле
|
'free_qty_sort': float(available_qty_in_unit if has_sales_units and default_sales_unit else free_qty), # Для сортировки
|
||||||
'sales_units_count': sales_units_count,
|
'sales_units_count': sales_units_count,
|
||||||
'has_sales_units': has_sales_units
|
'has_sales_units': has_sales_units,
|
||||||
|
'default_sales_unit': default_sales_unit,
|
||||||
|
'available_qty_in_unit': str(available_qty_in_unit),
|
||||||
|
'price_in_unit': price_in_unit
|
||||||
})
|
})
|
||||||
|
|
||||||
# Prefetch для первого фото комплектов
|
# Prefetch для первого фото комплектов
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Основная информация', {
|
('Основная информация', {
|
||||||
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'external_category', 'base_unit', 'unit', 'price', 'sale_price')
|
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'external_category', 'base_unit', 'price', 'sale_price')
|
||||||
}),
|
}),
|
||||||
('Себестоимость', {
|
('Себестоимость', {
|
||||||
'fields': ('cost_price_details_display',),
|
'fields': ('cost_price_details_display',),
|
||||||
@@ -834,10 +834,9 @@ class ProductSalesUnitInline(admin.TabularInline):
|
|||||||
model = ProductSalesUnit
|
model = ProductSalesUnit
|
||||||
extra = 0
|
extra = 0
|
||||||
fields = (
|
fields = (
|
||||||
'unit', 'name', 'conversion_factor', 'price', 'sale_price',
|
'name', 'conversion_factor', 'price', 'sale_price',
|
||||||
'min_quantity', 'quantity_step', 'is_default', 'is_active'
|
'min_quantity', 'quantity_step', 'is_default', 'is_active'
|
||||||
)
|
)
|
||||||
autocomplete_fields = ['unit']
|
|
||||||
verbose_name = "Единица продажи"
|
verbose_name = "Единица продажи"
|
||||||
verbose_name_plural = "Единицы продажи"
|
verbose_name_plural = "Единицы продажи"
|
||||||
|
|
||||||
@@ -984,18 +983,18 @@ class UnitOfMeasureAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""Админка для единиц продажи товаров"""
|
"""Админка для единиц продажи товаров"""
|
||||||
list_display = (
|
list_display = (
|
||||||
'product', 'name', 'unit', 'conversion_factor',
|
'product', 'name', 'conversion_factor',
|
||||||
'get_price_display', 'min_quantity', 'is_default', 'is_active'
|
'get_price_display', 'min_quantity', 'is_default', 'is_active'
|
||||||
)
|
)
|
||||||
list_filter = ('is_active', 'is_default', 'unit')
|
list_filter = ('is_active', 'is_default')
|
||||||
search_fields = ('product__name', 'product__sku', 'name')
|
search_fields = ('product__name', 'product__sku', 'name')
|
||||||
autocomplete_fields = ['product', 'unit']
|
autocomplete_fields = ['product']
|
||||||
list_editable = ('is_default', 'is_active')
|
list_editable = ('is_default', 'is_active')
|
||||||
ordering = ('product', 'position')
|
ordering = ('product', 'position')
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Товар и единица', {
|
('Товар и название', {
|
||||||
'fields': ('product', 'unit', 'name')
|
'fields': ('product', 'name')
|
||||||
}),
|
}),
|
||||||
('Конверсия', {
|
('Конверсия', {
|
||||||
'fields': ('conversion_factor',),
|
'fields': ('conversion_factor',),
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
|
|||||||
self.fields['base_unit'].queryset = UnitOfMeasure.objects.filter(
|
self.fields['base_unit'].queryset = UnitOfMeasure.objects.filter(
|
||||||
is_active=True
|
is_active=True
|
||||||
).order_by('position', 'code')
|
).order_by('position', 'code')
|
||||||
self.fields['base_unit'].required = False
|
self.fields['base_unit'].required = True
|
||||||
self.fields['base_unit'].help_text = 'Базовая единица для учета товара на складе. На основе этой единицы можно создать единицы продажи.'
|
self.fields['base_unit'].help_text = 'Базовая единица хранения и закупки. На её основе создаются единицы продажи.'
|
||||||
|
|
||||||
# Маркетинговые флаги (switch-стиль)
|
# Маркетинговые флаги (switch-стиль)
|
||||||
for flag_field in ['is_new', 'is_popular', 'is_special']:
|
for flag_field in ['is_new', 'is_popular', 'is_special']:
|
||||||
@@ -1085,13 +1085,12 @@ class ProductSalesUnitForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ProductSalesUnit
|
model = ProductSalesUnit
|
||||||
fields = [
|
fields = [
|
||||||
'product', 'unit', 'name', 'conversion_factor',
|
'product', 'name', 'conversion_factor',
|
||||||
'price', 'sale_price', 'min_quantity', 'quantity_step',
|
'price', 'sale_price', 'min_quantity', 'quantity_step',
|
||||||
'is_default', 'is_active', 'position'
|
'is_default', 'is_active', 'position'
|
||||||
]
|
]
|
||||||
labels = {
|
labels = {
|
||||||
'product': 'Товар',
|
'product': 'Товар',
|
||||||
'unit': 'Единица измерения',
|
|
||||||
'name': 'Название',
|
'name': 'Название',
|
||||||
'conversion_factor': 'Коэффициент конверсии',
|
'conversion_factor': 'Коэффициент конверсии',
|
||||||
'price': 'Цена продажи',
|
'price': 'Цена продажи',
|
||||||
@@ -1104,7 +1103,6 @@ class ProductSalesUnitForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'product': forms.Select(attrs={'class': 'form-control'}),
|
'product': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'unit': forms.Select(attrs={'class': 'form-control'}),
|
|
||||||
'name': forms.TextInput(attrs={
|
'name': forms.TextInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': 'Например: Ветка большая, Стебель средний'
|
'placeholder': 'Например: Ветка большая, Стебель средний'
|
||||||
@@ -1155,11 +1153,6 @@ class ProductSalesUnitForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Фильтруем только активные единицы измерения
|
|
||||||
self.fields['unit'].queryset = UnitOfMeasure.objects.filter(
|
|
||||||
is_active=True
|
|
||||||
).order_by('position', 'code')
|
|
||||||
|
|
||||||
# Фильтруем только активные товары
|
# Фильтруем только активные товары
|
||||||
self.fields['product'].queryset = Product.objects.filter(
|
self.fields['product'].queryset = Product.objects.filter(
|
||||||
status='active'
|
status='active'
|
||||||
@@ -1167,3 +1160,150 @@ class ProductSalesUnitForm(forms.ModelForm):
|
|||||||
|
|
||||||
# Сделать sale_price необязательным
|
# Сделать sale_price необязательным
|
||||||
self.fields['sale_price'].required = False
|
self.fields['sale_price'].required = False
|
||||||
|
|
||||||
|
|
||||||
|
class UnitOfMeasureForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания и редактирования единицы измерения
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = UnitOfMeasure
|
||||||
|
fields = ['code', 'name', 'short_name', 'position', 'is_active']
|
||||||
|
labels = {
|
||||||
|
'code': 'Код',
|
||||||
|
'name': 'Название',
|
||||||
|
'short_name': 'Сокращение',
|
||||||
|
'position': 'Порядок сортировки',
|
||||||
|
'is_active': 'Активна',
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'code': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'шт, кг, банч'
|
||||||
|
}),
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Штука, Килограмм, Банч'
|
||||||
|
}),
|
||||||
|
'short_name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'шт., кг., бан.'
|
||||||
|
}),
|
||||||
|
'position': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'value': '0'
|
||||||
|
}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'code': 'Короткий уникальный код (используется в системе)',
|
||||||
|
'name': 'Полное название для отображения',
|
||||||
|
'short_name': 'Сокращённое название для таблиц',
|
||||||
|
'position': 'Порядок в списках (меньше = выше)',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# === INLINE FORMSET ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
|
||||||
|
|
||||||
|
class ProductSalesUnitInlineForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма единицы продажи для inline редактирования в форме товара
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = ProductSalesUnit
|
||||||
|
fields = [
|
||||||
|
'name', 'conversion_factor',
|
||||||
|
'price', 'sale_price', 'min_quantity', 'quantity_step',
|
||||||
|
'is_default', 'is_active', 'position'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'placeholder': 'Ветка большая'
|
||||||
|
}),
|
||||||
|
'conversion_factor': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'step': '0.000001',
|
||||||
|
'min': '0.000001',
|
||||||
|
'placeholder': '15.0'
|
||||||
|
}),
|
||||||
|
'price': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'step': '0.01',
|
||||||
|
'min': '0',
|
||||||
|
}),
|
||||||
|
'sale_price': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'step': '0.01',
|
||||||
|
'min': '0',
|
||||||
|
}),
|
||||||
|
'min_quantity': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'step': '0.001',
|
||||||
|
'min': '0.001',
|
||||||
|
'value': '1'
|
||||||
|
}),
|
||||||
|
'quantity_step': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'step': '0.001',
|
||||||
|
'min': '0.001',
|
||||||
|
'value': '1'
|
||||||
|
}),
|
||||||
|
'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'position': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'style': 'width: 60px;'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['sale_price'].required = False
|
||||||
|
|
||||||
|
def has_changed(self):
|
||||||
|
"""
|
||||||
|
Считаем форму неизмененной, если это новая форма без заполненных полей.
|
||||||
|
Это позволяет избежать ошибок валидации для пустых добавленных форм.
|
||||||
|
"""
|
||||||
|
# Если это существующая запись - используем стандартную логику
|
||||||
|
if self.instance.pk:
|
||||||
|
return super().has_changed()
|
||||||
|
|
||||||
|
# Для новых форм проверяем, есть ли заполненные данные
|
||||||
|
try:
|
||||||
|
# Проверяем ключевые поля
|
||||||
|
cleaned_data = getattr(self, 'cleaned_data', {})
|
||||||
|
if cleaned_data.get('name'):
|
||||||
|
return True
|
||||||
|
if cleaned_data.get('price'):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Если cleaned_data ещё нет, проверяем raw data
|
||||||
|
data = self.data if hasattr(self, 'data') else {}
|
||||||
|
prefix = self.prefix
|
||||||
|
name_field = f'{prefix}-name'
|
||||||
|
price_field = f'{prefix}-price'
|
||||||
|
|
||||||
|
if data.get(name_field):
|
||||||
|
return True
|
||||||
|
if data.get(price_field):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Форма пустая - считаем неизмененной
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
# При ошибке используем стандартную логику
|
||||||
|
return super().has_changed()
|
||||||
|
|
||||||
|
|
||||||
|
# Inline formset для единиц продажи
|
||||||
|
ProductSalesUnitFormSet = inlineformset_factory(
|
||||||
|
Product,
|
||||||
|
ProductSalesUnit,
|
||||||
|
form=ProductSalesUnitInlineForm,
|
||||||
|
extra=1,
|
||||||
|
can_delete=True,
|
||||||
|
min_num=0,
|
||||||
|
validate_min=False,
|
||||||
|
)
|
||||||
|
|||||||
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.
|
Наследует общие поля из BaseProductEntity.
|
||||||
"""
|
"""
|
||||||
UNIT_CHOICES = [
|
|
||||||
('шт', 'Штука'),
|
|
||||||
('м', 'Метр'),
|
|
||||||
('г', 'Грамм'),
|
|
||||||
('л', 'Литр'),
|
|
||||||
('кг', 'Килограмм'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Специфичные поля Product
|
# Специфичные поля Product
|
||||||
variant_suffix = models.CharField(
|
variant_suffix = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@@ -53,23 +45,13 @@ class Product(BaseProductEntity):
|
|||||||
verbose_name="Группы вариантов"
|
verbose_name="Группы вариантов"
|
||||||
)
|
)
|
||||||
|
|
||||||
unit = models.CharField(
|
# Базовая единица измерения (единица закупки/хранения)
|
||||||
max_length=10,
|
|
||||||
choices=UNIT_CHOICES,
|
|
||||||
default='шт',
|
|
||||||
verbose_name="Единица измерения (deprecated)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Новое поле: ссылка на справочник единиц измерения
|
|
||||||
base_unit = models.ForeignKey(
|
base_unit = models.ForeignKey(
|
||||||
'UnitOfMeasure',
|
'UnitOfMeasure',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='products',
|
related_name='products',
|
||||||
verbose_name="Базовая единица",
|
verbose_name="Базовая единица",
|
||||||
help_text="Единица хранения и закупки (банч, кг, шт). "
|
help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах."
|
||||||
"Если указана, используется вместо поля 'unit'."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ЦЕНООБРАЗОВАНИЕ - переименованные поля
|
# ЦЕНООБРАЗОВАНИЕ - переименованные поля
|
||||||
@@ -183,11 +165,9 @@ class Product(BaseProductEntity):
|
|||||||
def unit_display(self):
|
def unit_display(self):
|
||||||
"""
|
"""
|
||||||
Отображаемое название единицы измерения.
|
Отображаемое название единицы измерения.
|
||||||
Приоритет: base_unit.code > unit
|
Возвращает код базовой единицы.
|
||||||
"""
|
"""
|
||||||
if self.base_unit:
|
return self.base_unit.code if self.base_unit else 'шт'
|
||||||
return self.base_unit.code
|
|
||||||
return self.unit
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_sales_units(self):
|
def has_sales_units(self):
|
||||||
|
|||||||
@@ -66,11 +66,6 @@ class ProductSalesUnit(models.Model):
|
|||||||
related_name='sales_units',
|
related_name='sales_units',
|
||||||
verbose_name="Товар"
|
verbose_name="Товар"
|
||||||
)
|
)
|
||||||
unit = models.ForeignKey(
|
|
||||||
UnitOfMeasure,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
verbose_name="Единица измерения"
|
|
||||||
)
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
verbose_name="Название",
|
verbose_name="Название",
|
||||||
@@ -135,7 +130,7 @@ class ProductSalesUnit(models.Model):
|
|||||||
unique_together = [['product', 'name']]
|
unique_together = [['product', 'name']]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.product.name} - {self.name} ({self.unit.code})"
|
return f"{self.product.name} - {self.name}"
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|||||||
@@ -333,10 +333,20 @@ class ProductImporter:
|
|||||||
if not unit:
|
if not unit:
|
||||||
unit = 'шт'
|
unit = 'шт'
|
||||||
|
|
||||||
# Валидация единицы измерения
|
# Находим единицу измерения в справочнике
|
||||||
valid_units = [choice[0] for choice in Product.UNIT_CHOICES]
|
from products.models import UnitOfMeasure
|
||||||
if unit not in valid_units:
|
base_unit = UnitOfMeasure.objects.filter(code=unit, is_active=True).first()
|
||||||
unit = 'шт' # fallback
|
if not base_unit:
|
||||||
|
# Пробуем найти по названию или короткому имени
|
||||||
|
base_unit = UnitOfMeasure.objects.filter(
|
||||||
|
Q(name__iexact=unit) | Q(short_name__iexact=unit),
|
||||||
|
is_active=True
|
||||||
|
).first()
|
||||||
|
if not base_unit:
|
||||||
|
# Fallback на штуку
|
||||||
|
base_unit = UnitOfMeasure.objects.filter(code='шт').first()
|
||||||
|
if not base_unit:
|
||||||
|
base_unit = UnitOfMeasure.objects.first()
|
||||||
|
|
||||||
# Пытаемся найти существующего товара
|
# Пытаемся найти существующего товара
|
||||||
existing = None
|
existing = None
|
||||||
@@ -366,7 +376,7 @@ class ProductImporter:
|
|||||||
if short_description:
|
if short_description:
|
||||||
existing.short_description = short_description
|
existing.short_description = short_description
|
||||||
if unit:
|
if unit:
|
||||||
existing.unit = unit
|
existing.base_unit = base_unit
|
||||||
|
|
||||||
existing.price = price
|
existing.price = price
|
||||||
if cost_price is not None:
|
if cost_price is not None:
|
||||||
@@ -406,7 +416,7 @@ class ProductImporter:
|
|||||||
sku=sku or None,
|
sku=sku or None,
|
||||||
description=description or "",
|
description=description or "",
|
||||||
short_description=short_description or "",
|
short_description=short_description or "",
|
||||||
unit=unit,
|
base_unit=base_unit,
|
||||||
price=price,
|
price=price,
|
||||||
cost_price=cost_price or 0, # Устанавливаем 0 вместо None (для CostPriceHistory)
|
cost_price=cost_price or 0, # Устанавливаем 0 вместо None (для CostPriceHistory)
|
||||||
sale_price=sale_price,
|
sale_price=sale_price,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
data-min-qty="${unit.min_quantity}"
|
data-min-qty="${unit.min_quantity}"
|
||||||
data-step="${unit.quantity_step}"
|
data-step="${unit.quantity_step}"
|
||||||
data-available="${unit.available_quantity || ''}">
|
data-available="${unit.available_quantity || ''}">
|
||||||
${unit.name} (${unit.unit_short_name}) - ${price} руб.${isDefault}
|
${unit.name} - ${price} руб.${isDefault}
|
||||||
</option>`;
|
</option>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,6 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
<th>Единица</th>
|
|
||||||
<th class="text-end">Коэфф.</th>
|
<th class="text-end">Коэфф.</th>
|
||||||
<th class="text-end">Цена</th>
|
<th class="text-end">Цена</th>
|
||||||
<th class="text-center">Мин. кол-во</th>
|
<th class="text-center">Мин. кол-во</th>
|
||||||
@@ -306,9 +305,6 @@
|
|||||||
{{ su.name }}
|
{{ su.name }}
|
||||||
{% if su.is_default %}<span class="badge bg-success ms-1">По умолчанию</span>{% endif %}
|
{% if su.is_default %}<span class="badge bg-success ms-1">По умолчанию</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="badge bg-secondary">{{ su.unit.short_name }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">{{ su.conversion_factor }}</td>
|
<td class="text-end">{{ su.conversion_factor }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if su.sale_price %}
|
{% if su.sale_price %}
|
||||||
|
|||||||
@@ -581,6 +581,157 @@
|
|||||||
|
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Блок: Единицы продажи -->
|
||||||
|
{% if sales_unit_formset %}
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-gradient" style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%);">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0 text-white">
|
||||||
|
<i class="bi bi-box-seam"></i> Единицы продажи
|
||||||
|
</h5>
|
||||||
|
<a href="{% url 'products:unit-list' %}" class="btn btn-sm btn-light" target="_blank">
|
||||||
|
<i class="bi bi-rulers"></i> Справочник единиц
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Настройте, в каких единицах продается товар (ветка, кг, штука).
|
||||||
|
Коэффициент указывает, сколько единиц продажи получается из 1 базовой единицы.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{ sales_unit_formset.management_form }}
|
||||||
|
|
||||||
|
<!-- Шаблон для новых форм (скрыт) -->
|
||||||
|
<template id="empty-sales-unit-template">
|
||||||
|
{% with form=sales_unit_formset.empty_form %}
|
||||||
|
<div class="sales-unit-row border rounded p-3 mb-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Ед. измерения</label>
|
||||||
|
{{ form.unit }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Название</label>
|
||||||
|
{{ form.name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Коэфф.</label>
|
||||||
|
{{ form.conversion_factor }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Цена</label>
|
||||||
|
{{ form.price }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Скидка</label>
|
||||||
|
{{ form.sale_price }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Мин.кол</label>
|
||||||
|
{{ form.min_quantity }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Шаг</label>
|
||||||
|
{{ form.quantity_step }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Поз.</label>
|
||||||
|
{{ form.position }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 text-center">
|
||||||
|
<label class="form-label small d-block">По умолч.</label>
|
||||||
|
{{ form.is_default }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<div class="form-check" title="Активна">
|
||||||
|
{{ form.is_active }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div id="sales-units-container">
|
||||||
|
{% for form in sales_unit_formset %}
|
||||||
|
<div class="sales-unit-row border rounded p-3 mb-2 {% if form.instance.pk %}bg-light{% endif %}">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
<input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Ед. измерения</label>
|
||||||
|
{{ form.unit }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Название</label>
|
||||||
|
{{ form.name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Коэфф.</label>
|
||||||
|
{{ form.conversion_factor }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Цена</label>
|
||||||
|
{{ form.price }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Скидка</label>
|
||||||
|
{{ form.sale_price }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Мин.кол</label>
|
||||||
|
{{ form.min_quantity }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Шаг</label>
|
||||||
|
{{ form.quantity_step }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Поз.</label>
|
||||||
|
{{ form.position }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 text-center">
|
||||||
|
<label class="form-label small d-block">По умолч.</label>
|
||||||
|
{{ form.is_default }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<div class="form-check" title="Активна">
|
||||||
|
{{ form.is_active }}
|
||||||
|
</div>
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
<div class="form-check" title="Удалить">
|
||||||
|
{{ form.DELETE }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{% for field, errors in form.errors.items %}
|
||||||
|
{{ field }}: {{ errors|join:", " }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm mt-2" id="add-sales-unit">
|
||||||
|
<i class="bi bi-plus-circle"></i> Добавить единицу продажи
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
<!-- Блок 2.5: Информация о наличии (только при редактировании) -->
|
<!-- Блок 2.5: Информация о наличии (только при редактировании) -->
|
||||||
{% if object %}
|
{% if object %}
|
||||||
<div class="mb-4 p-3 bg-info-light rounded border border-info">
|
<div class="mb-4 p-3 bg-info-light rounded border border-info">
|
||||||
@@ -686,6 +837,58 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}, 250);
|
}, 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === Динамическое добавление единиц продажи ===
|
||||||
|
const addButton = document.getElementById('add-sales-unit');
|
||||||
|
const container = document.getElementById('sales-units-container');
|
||||||
|
const totalFormsInput = document.querySelector('[name="sales_units-TOTAL_FORMS"]');
|
||||||
|
|
||||||
|
if (addButton && container && totalFormsInput) {
|
||||||
|
addButton.addEventListener('click', function() {
|
||||||
|
const formCount = parseInt(totalFormsInput.value);
|
||||||
|
const template = document.getElementById('empty-sales-unit-template');
|
||||||
|
|
||||||
|
if (template) {
|
||||||
|
// Клонируем содержимое шаблона
|
||||||
|
const newRow = template.content.cloneNode(true);
|
||||||
|
const rowDiv = newRow.querySelector('.sales-unit-row');
|
||||||
|
|
||||||
|
// Обновляем имена и id полей
|
||||||
|
rowDiv.querySelectorAll('input, select').forEach(input => {
|
||||||
|
const name = input.getAttribute('name');
|
||||||
|
const id = input.getAttribute('id');
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
// Заменяем __prefix__ на текущий индекс
|
||||||
|
input.setAttribute('name', name.replace('__prefix__', formCount));
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
input.setAttribute('id', id.replace('__prefix__', formCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем значения по умолчанию
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
if (input.name.includes('is_active')) {
|
||||||
|
input.checked = true;
|
||||||
|
} else {
|
||||||
|
input.checked = false;
|
||||||
|
}
|
||||||
|
} else if (input.type !== 'hidden') {
|
||||||
|
if (input.name.includes('min_quantity') || input.name.includes('quantity_step')) {
|
||||||
|
input.value = '1';
|
||||||
|
} else if (input.name.includes('position')) {
|
||||||
|
input.value = formCount;
|
||||||
|
} else if (input.name.includes('conversion_factor')) {
|
||||||
|
input.value = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(rowDiv);
|
||||||
|
totalFormsInput.value = formCount + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -22,9 +22,6 @@
|
|||||||
<dt class="col-sm-4">Название:</dt>
|
<dt class="col-sm-4">Название:</dt>
|
||||||
<dd class="col-sm-8">{{ sales_unit.name }}</dd>
|
<dd class="col-sm-8">{{ sales_unit.name }}</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Единица:</dt>
|
|
||||||
<dd class="col-sm-8">{{ sales_unit.unit.name }} ({{ sales_unit.unit.short_name }})</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Коэффициент:</dt>
|
<dt class="col-sm-4">Коэффициент:</dt>
|
||||||
<dd class="col-sm-8">{{ sales_unit.conversion_factor }}</dd>
|
<dd class="col-sm-8">{{ sales_unit.conversion_factor }}</dd>
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Товар и Единица измерения -->
|
<!-- Товар -->
|
||||||
<div class="row mb-3">
|
<div class="mb-3">
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="{{ form.product.id_for_label }}" class="form-label">
|
<label for="{{ form.product.id_for_label }}" class="form-label">
|
||||||
{{ form.product.label }} <span class="text-danger">*</span>
|
{{ form.product.label }} <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -34,19 +33,6 @@
|
|||||||
<div class="text-danger">{{ form.product.errors }}</div>
|
<div class="text-danger">{{ form.product.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="{{ form.unit.id_for_label }}" class="form-label">
|
|
||||||
{{ form.unit.label }} <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
{{ form.unit }}
|
|
||||||
{% if form.unit.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.unit.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.unit.errors %}
|
|
||||||
<div class="text-danger">{{ form.unit.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Название -->
|
<!-- Название -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -17,19 +17,9 @@
|
|||||||
<!-- Поиск и фильтры -->
|
<!-- Поиск и фильтры -->
|
||||||
<form method="get" class="mb-3">
|
<form method="get" class="mb-3">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-6">
|
||||||
<input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по товару, артикулу, названию...">
|
<input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по товару, артикулу, названию...">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
|
||||||
<select class="form-select form-select-sm" name="unit" onchange="this.form.submit()">
|
|
||||||
<option value="">Все единицы</option>
|
|
||||||
{% for unit in all_units %}
|
|
||||||
<option value="{{ unit.id }}" {% if unit_filter == unit.id|stringformat:"s" %}selected{% endif %}>
|
|
||||||
{{ unit.short_name }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<select class="form-select form-select-sm" name="is_active" onchange="this.form.submit()">
|
<select class="form-select form-select-sm" name="is_active" onchange="this.form.submit()">
|
||||||
<option value="">Все</option>
|
<option value="">Все</option>
|
||||||
@@ -63,15 +53,14 @@
|
|||||||
<table class="table table-hover table-sm align-middle">
|
<table class="table table-hover table-sm align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 25%;">Товар</th>
|
<th style="width: 28%;">Товар</th>
|
||||||
<th style="width: 15%;">Название единицы</th>
|
<th style="width: 18%;">Название единицы</th>
|
||||||
<th style="width: 10%;" class="text-center">Единица</th>
|
|
||||||
<th style="width: 10%;" class="text-end">Коэфф.</th>
|
<th style="width: 10%;" class="text-end">Коэфф.</th>
|
||||||
<th style="width: 12%;" class="text-end">Цена</th>
|
<th style="width: 12%;" class="text-end">Цена</th>
|
||||||
<th style="width: 8%;" class="text-center">Мин.</th>
|
<th style="width: 8%;" class="text-center">Мин.</th>
|
||||||
<th style="width: 8%;" class="text-center">Шаг</th>
|
<th style="width: 8%;" class="text-center">Шаг</th>
|
||||||
<th style="width: 6%;" class="text-center">По умолч.</th>
|
<th style="width: 8%;" class="text-center">По умолч.</th>
|
||||||
<th style="width: 6%;" class="text-center">Статус</th>
|
<th style="width: 8%;" class="text-center">Статус</th>
|
||||||
<th style="width: 10%;" class="text-end">Действия</th>
|
<th style="width: 10%;" class="text-end">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -87,9 +76,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ su.name }}</td>
|
<td>{{ su.name }}</td>
|
||||||
<td class="text-center">
|
|
||||||
<span class="badge bg-secondary">{{ su.unit.short_name }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end"><code>{{ su.conversion_factor }}</code></td>
|
<td class="text-end"><code>{{ su.conversion_factor }}</code></td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if su.sale_price %}
|
{% if su.sale_price %}
|
||||||
@@ -142,7 +128,7 @@
|
|||||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||||
{% if page_obj.has_previous %}
|
{% if page_obj.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">«</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>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="page-item active">
|
<li class="page-item active">
|
||||||
@@ -150,7 +136,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if page_obj.has_next %}
|
{% if page_obj.has_next %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">»</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>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</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">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<h4 class="mb-0"><i class="bi bi-rulers text-primary"></i> Единицы измерения</h4>
|
<h4 class="mb-0"><i class="bi bi-rulers text-primary"></i> Единицы измерения</h4>
|
||||||
<a href="{% url 'admin:products_unitofmeasure_add' %}" class="btn btn-primary btn-sm">
|
<a href="{% url 'products:unit-create' %}" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus-circle"></i> Добавить единицу
|
<i class="bi bi-plus-circle"></i> Добавить единицу
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,9 +53,12 @@
|
|||||||
<td><span class="badge bg-secondary">{{ unit.short_name }}</span></td>
|
<td><span class="badge bg-secondary">{{ unit.short_name }}</span></td>
|
||||||
<td class="text-center">{{ unit.position }}</td>
|
<td class="text-center">{{ unit.position }}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<span class="badge {% if unit.usage_count > 0 %}bg-success{% else %}bg-secondary{% endif %}">
|
<span class="badge {% if unit.usage_count > 0 %}bg-success{% else %}bg-secondary{% endif %}" title="Единицы продажи">
|
||||||
{{ unit.usage_count }}
|
{{ unit.usage_count }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="badge {% if unit.products_count > 0 %}bg-primary{% else %}bg-secondary{% endif %}" title="Товаров">
|
||||||
|
{{ unit.products_count }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if unit.is_active %}
|
{% if unit.is_active %}
|
||||||
@@ -66,9 +69,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<a href="{% url 'admin:products_unitofmeasure_change' unit.pk %}" class="btn btn-outline-secondary" title="Изменить">
|
<a href="{% url 'products:unit-update' unit.pk %}" class="btn btn-outline-secondary" title="Изменить">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'products:unit-delete' unit.pk %}" class="btn btn-outline-danger" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -115,8 +115,13 @@ urlpatterns = [
|
|||||||
path('configurable/<int:pk>/options/<int:option_id>/remove/', views.remove_option_from_configurable, name='configurableproduct-remove-option'),
|
path('configurable/<int:pk>/options/<int:option_id>/remove/', views.remove_option_from_configurable, name='configurableproduct-remove-option'),
|
||||||
path('configurable/<int:pk>/options/<int:option_id>/set-default/', views.set_option_as_default, name='configurableproduct-set-default-option'),
|
path('configurable/<int:pk>/options/<int:option_id>/set-default/', views.set_option_as_default, name='configurableproduct-set-default-option'),
|
||||||
|
|
||||||
# Управление единицами измерения
|
# Управление единицами измерения (справочник)
|
||||||
path('units/', views.unit_of_measure_list, name='unit-list'),
|
path('units/', views.unit_of_measure_list, name='unit-list'),
|
||||||
|
path('units/create/', views.unit_of_measure_create, name='unit-create'),
|
||||||
|
path('units/<int:pk>/edit/', views.unit_of_measure_update, name='unit-update'),
|
||||||
|
path('units/<int:pk>/delete/', views.unit_of_measure_delete, name='unit-delete'),
|
||||||
|
|
||||||
|
# Единицы продажи (устаревшие отдельные страницы, для совместимости)
|
||||||
path('sales-units/', views.product_sales_unit_list, name='sales-unit-list'),
|
path('sales-units/', views.product_sales_unit_list, name='sales-unit-list'),
|
||||||
path('sales-units/create/', views.product_sales_unit_create, name='sales-unit-create'),
|
path('sales-units/create/', views.product_sales_unit_create, name='sales-unit-create'),
|
||||||
path('sales-units/<int:pk>/edit/', views.product_sales_unit_update, name='sales-unit-update'),
|
path('sales-units/<int:pk>/edit/', views.product_sales_unit_update, name='sales-unit-update'),
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ from .catalog_views import CatalogView
|
|||||||
# Управление единицами измерения
|
# Управление единицами измерения
|
||||||
from .uom_views import (
|
from .uom_views import (
|
||||||
unit_of_measure_list,
|
unit_of_measure_list,
|
||||||
|
unit_of_measure_create,
|
||||||
|
unit_of_measure_update,
|
||||||
|
unit_of_measure_delete,
|
||||||
product_sales_unit_list,
|
product_sales_unit_list,
|
||||||
product_sales_unit_create,
|
product_sales_unit_create,
|
||||||
product_sales_unit_update,
|
product_sales_unit_update,
|
||||||
@@ -228,6 +231,9 @@ __all__ = [
|
|||||||
|
|
||||||
# Управление единицами измерения
|
# Управление единицами измерения
|
||||||
'unit_of_measure_list',
|
'unit_of_measure_list',
|
||||||
|
'unit_of_measure_create',
|
||||||
|
'unit_of_measure_update',
|
||||||
|
'unit_of_measure_delete',
|
||||||
'product_sales_unit_list',
|
'product_sales_unit_list',
|
||||||
'product_sales_unit_create',
|
'product_sales_unit_create',
|
||||||
'product_sales_unit_update',
|
'product_sales_unit_update',
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ def search_products_and_variants(request):
|
|||||||
numeric_id = int(item_id)
|
numeric_id = int(item_id)
|
||||||
|
|
||||||
if item_type == 'product':
|
if item_type == 'product':
|
||||||
product = Product.objects.prefetch_related('sales_units__unit').get(id=numeric_id, status='active')
|
product = Product.objects.prefetch_related('sales_units').get(id=numeric_id, status='active')
|
||||||
|
|
||||||
# Сериализация единиц продажи
|
# Сериализация единиц продажи
|
||||||
sales_units_data = []
|
sales_units_data = []
|
||||||
@@ -122,8 +122,6 @@ def search_products_and_variants(request):
|
|||||||
sales_units_data.append({
|
sales_units_data.append({
|
||||||
'id': su.id,
|
'id': su.id,
|
||||||
'name': su.name,
|
'name': su.name,
|
||||||
'unit_code': su.unit.code,
|
|
||||||
'unit_short_name': su.unit.short_name,
|
|
||||||
'price': str(su.price),
|
'price': str(su.price),
|
||||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||||
'actual_price': str(su.actual_price),
|
'actual_price': str(su.actual_price),
|
||||||
@@ -216,7 +214,7 @@ def search_products_and_variants(request):
|
|||||||
|
|
||||||
if search_type in ['all', 'product']:
|
if search_type in ['all', 'product']:
|
||||||
# Показываем последние добавленные активные товары
|
# Показываем последние добавленные активные товары
|
||||||
products_qs = Product.objects.filter(status='active').prefetch_related('sales_units__unit')
|
products_qs = Product.objects.filter(status='active').prefetch_related('sales_units')
|
||||||
# Применяем фильтры
|
# Применяем фильтры
|
||||||
products_qs = _apply_product_filters(products_qs, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter)
|
products_qs = _apply_product_filters(products_qs, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter)
|
||||||
products = products_qs.order_by('-created_at')[:page_size]
|
products = products_qs.order_by('-created_at')[:page_size]
|
||||||
@@ -235,8 +233,6 @@ def search_products_and_variants(request):
|
|||||||
sales_units_data.append({
|
sales_units_data.append({
|
||||||
'id': su.id,
|
'id': su.id,
|
||||||
'name': su.name,
|
'name': su.name,
|
||||||
'unit_code': su.unit.code,
|
|
||||||
'unit_short_name': su.unit.short_name,
|
|
||||||
'price': str(su.price),
|
'price': str(su.price),
|
||||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||||
'actual_price': str(su.actual_price),
|
'actual_price': str(su.actual_price),
|
||||||
@@ -365,7 +361,7 @@ def search_products_and_variants(request):
|
|||||||
products_query = _apply_product_filters(products_query, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter)
|
products_query = _apply_product_filters(products_query, category_id, tag_id, stock_status, warehouse_id, skip_stock_filter)
|
||||||
|
|
||||||
# Добавляем prefetch для единиц продажи
|
# Добавляем prefetch для единиц продажи
|
||||||
products_query = products_query.prefetch_related('sales_units__unit')
|
products_query = products_query.prefetch_related('sales_units')
|
||||||
|
|
||||||
total_products = products_query.count()
|
total_products = products_query.count()
|
||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
@@ -387,8 +383,6 @@ def search_products_and_variants(request):
|
|||||||
sales_units_data.append({
|
sales_units_data.append({
|
||||||
'id': su.id,
|
'id': su.id,
|
||||||
'name': su.name,
|
'name': su.name,
|
||||||
'unit_code': su.unit.code,
|
|
||||||
'unit_short_name': su.unit.short_name,
|
|
||||||
'price': str(su.price),
|
'price': str(su.price),
|
||||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||||
'actual_price': str(su.actual_price),
|
'actual_price': str(su.actual_price),
|
||||||
@@ -1422,7 +1416,7 @@ def get_product_sales_units_api(request, product_id):
|
|||||||
warehouse_id = request.GET.get('warehouse', '').strip()
|
warehouse_id = request.GET.get('warehouse', '').strip()
|
||||||
|
|
||||||
# Получаем товар с prefetch единиц продажи
|
# Получаем товар с prefetch единиц продажи
|
||||||
product = Product.objects.prefetch_related('sales_units__unit').get(
|
product = Product.objects.prefetch_related('sales_units').get(
|
||||||
id=product_id, status='active'
|
id=product_id, status='active'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1448,8 +1442,6 @@ def get_product_sales_units_api(request, product_id):
|
|||||||
sales_units_data.append({
|
sales_units_data.append({
|
||||||
'id': su.id,
|
'id': su.id,
|
||||||
'name': su.name,
|
'name': su.name,
|
||||||
'unit_code': su.unit.code,
|
|
||||||
'unit_short_name': su.unit.short_name,
|
|
||||||
'price': str(su.price),
|
'price': str(su.price),
|
||||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||||
'actual_price': str(su.actual_price),
|
'actual_price': str(su.actual_price),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.db.models.functions import Coalesce
|
|||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
from ..models import Product, ProductCategory, ProductTag, ProductKit
|
from ..models import Product, ProductCategory, ProductTag, ProductKit
|
||||||
from ..forms import ProductForm
|
from ..forms import ProductForm, ProductSalesUnitFormSet
|
||||||
from .utils import handle_photos
|
from .utils import handle_photos
|
||||||
from ..models import ProductPhoto
|
from ..models import ProductPhoto
|
||||||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||||||
@@ -110,11 +110,36 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
|
|||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy('products:products-list')
|
return reverse_lazy('products:products-list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.request.POST:
|
||||||
|
context['sales_unit_formset'] = ProductSalesUnitFormSet(
|
||||||
|
self.request.POST,
|
||||||
|
prefix='sales_units'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context['sales_unit_formset'] = ProductSalesUnitFormSet(prefix='sales_units')
|
||||||
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
sales_unit_formset = context['sales_unit_formset']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = super().form_valid(form)
|
# Сначала сохраняем товар
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
|
# Затем сохраняем единицы продажи
|
||||||
|
if sales_unit_formset.is_valid():
|
||||||
|
sales_unit_formset.instance = self.object
|
||||||
|
sales_unit_formset.save()
|
||||||
|
else:
|
||||||
|
# Если formset невалиден, показываем ошибки
|
||||||
|
for error in sales_unit_formset.errors:
|
||||||
|
if error:
|
||||||
|
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||||
|
|
||||||
# Обработка загрузки фотографий
|
# Обработка загрузки фотографий
|
||||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||||
@@ -127,7 +152,7 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
|
|||||||
messages.error(self.request, error)
|
messages.error(self.request, error)
|
||||||
|
|
||||||
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
||||||
return response
|
return super().form_valid(form)
|
||||||
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
# Обработка ошибки дублирования slug'а или других unique constraints
|
# Обработка ошибки дублирования slug'а или других unique constraints
|
||||||
@@ -161,7 +186,7 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
|
|||||||
'photos',
|
'photos',
|
||||||
'categories',
|
'categories',
|
||||||
'tags',
|
'tags',
|
||||||
'sales_units__unit'
|
'sales_units'
|
||||||
).annotate(
|
).annotate(
|
||||||
total_available=total_available,
|
total_available=total_available,
|
||||||
total_reserved=total_reserved,
|
total_reserved=total_reserved,
|
||||||
@@ -199,13 +224,40 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
|
|||||||
# Добавляем фотографии товара в контекст
|
# Добавляем фотографии товара в контекст
|
||||||
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||||||
context['photos_count'] = self.object.photos.count()
|
context['photos_count'] = self.object.photos.count()
|
||||||
|
|
||||||
|
# Добавляем formset единиц продажи
|
||||||
|
if self.request.POST:
|
||||||
|
context['sales_unit_formset'] = ProductSalesUnitFormSet(
|
||||||
|
self.request.POST,
|
||||||
|
instance=self.object,
|
||||||
|
prefix='sales_units'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context['sales_unit_formset'] = ProductSalesUnitFormSet(
|
||||||
|
instance=self.object,
|
||||||
|
prefix='sales_units'
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
sales_unit_formset = context['sales_unit_formset']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = super().form_valid(form)
|
# Сначала сохраняем товар
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
|
# Затем сохраняем единицы продажи
|
||||||
|
if sales_unit_formset.is_valid():
|
||||||
|
sales_unit_formset.instance = self.object
|
||||||
|
sales_unit_formset.save()
|
||||||
|
else:
|
||||||
|
# Если formset невалиден, показываем ошибки
|
||||||
|
for error in sales_unit_formset.errors:
|
||||||
|
if error:
|
||||||
|
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||||
|
|
||||||
# Обработка загрузки фотографий
|
# Обработка загрузки фотографий
|
||||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||||
@@ -218,7 +270,7 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
|
|||||||
messages.error(self.request, error)
|
messages.error(self.request, error)
|
||||||
|
|
||||||
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
||||||
return response
|
return super().form_valid(form)
|
||||||
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
# Обработка ошибки дублирования slug'а или других unique constraints
|
# Обработка ошибки дублирования slug'а или других unique constraints
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from django.core.paginator import Paginator
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from products.models import UnitOfMeasure, ProductSalesUnit
|
from products.models import UnitOfMeasure, ProductSalesUnit
|
||||||
from products.forms import ProductSalesUnitForm
|
from products.forms import ProductSalesUnitForm, UnitOfMeasureForm
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -26,7 +26,8 @@ def unit_of_measure_list(request):
|
|||||||
|
|
||||||
# Аннотируем количество использований
|
# Аннотируем количество использований
|
||||||
units = units.annotate(
|
units = units.annotate(
|
||||||
usage_count=Count('productsalesunit')
|
usage_count=Count('productsalesunit'),
|
||||||
|
products_count=Count('products')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Применяем фильтры
|
# Применяем фильтры
|
||||||
@@ -58,6 +59,89 @@ def unit_of_measure_list(request):
|
|||||||
return render(request, 'products/uom/unit_list.html', context)
|
return render(request, 'products/uom/unit_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def unit_of_measure_create(request):
|
||||||
|
"""
|
||||||
|
Создание новой единицы измерения
|
||||||
|
"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = UnitOfMeasureForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
unit = form.save()
|
||||||
|
messages.success(request, f'Единица измерения "{unit.name}" успешно создана!')
|
||||||
|
return redirect('products:unit-list')
|
||||||
|
else:
|
||||||
|
form = UnitOfMeasureForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'title': 'Создание единицы измерения',
|
||||||
|
'submit_text': 'Создать'
|
||||||
|
}
|
||||||
|
return render(request, 'products/uom/unit_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def unit_of_measure_update(request, pk):
|
||||||
|
"""
|
||||||
|
Редактирование единицы измерения
|
||||||
|
"""
|
||||||
|
unit = get_object_or_404(UnitOfMeasure, pk=pk)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = UnitOfMeasureForm(request.POST, instance=unit)
|
||||||
|
if form.is_valid():
|
||||||
|
unit = form.save()
|
||||||
|
messages.success(request, f'Единица измерения "{unit.name}" успешно обновлена!')
|
||||||
|
return redirect('products:unit-list')
|
||||||
|
else:
|
||||||
|
form = UnitOfMeasureForm(instance=unit)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'unit': unit,
|
||||||
|
'title': f'Редактирование: {unit.name}',
|
||||||
|
'submit_text': 'Сохранить'
|
||||||
|
}
|
||||||
|
return render(request, 'products/uom/unit_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def unit_of_measure_delete(request, pk):
|
||||||
|
"""
|
||||||
|
Удаление единицы измерения
|
||||||
|
"""
|
||||||
|
unit = get_object_or_404(UnitOfMeasure, pk=pk)
|
||||||
|
|
||||||
|
# Проверяем использование
|
||||||
|
products_using = unit.products.count()
|
||||||
|
sales_units_using = unit.productsalesunit_set.count()
|
||||||
|
|
||||||
|
can_delete = products_using == 0 and sales_units_using == 0
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if can_delete:
|
||||||
|
name = unit.name
|
||||||
|
unit.delete()
|
||||||
|
messages.success(request, f'Единица измерения "{name}" успешно удалена!')
|
||||||
|
return redirect('products:unit-list')
|
||||||
|
else:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f'Невозможно удалить единицу измерения "{unit.name}". '
|
||||||
|
f'Она используется в {products_using} товарах и {sales_units_using} единицах продажи.'
|
||||||
|
)
|
||||||
|
return redirect('products:unit-list')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'unit': unit,
|
||||||
|
'can_delete': can_delete,
|
||||||
|
'products_using': products_using,
|
||||||
|
'sales_units_using': sales_units_using,
|
||||||
|
}
|
||||||
|
return render(request, 'products/uom/unit_delete.html', context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def product_sales_unit_list(request):
|
def product_sales_unit_list(request):
|
||||||
"""
|
"""
|
||||||
@@ -65,14 +149,11 @@ def product_sales_unit_list(request):
|
|||||||
"""
|
"""
|
||||||
# Получаем параметры фильтрации
|
# Получаем параметры фильтрации
|
||||||
search_query = request.GET.get('q', '').strip()
|
search_query = request.GET.get('q', '').strip()
|
||||||
unit_filter = request.GET.get('unit', '')
|
|
||||||
is_active_filter = request.GET.get('is_active', '')
|
is_active_filter = request.GET.get('is_active', '')
|
||||||
is_default_filter = request.GET.get('is_default', '')
|
is_default_filter = request.GET.get('is_default', '')
|
||||||
|
|
||||||
# Базовый queryset
|
# Базовый queryset
|
||||||
sales_units = ProductSalesUnit.objects.select_related(
|
sales_units = ProductSalesUnit.objects.select_related('product').all()
|
||||||
'product', 'unit'
|
|
||||||
).all()
|
|
||||||
|
|
||||||
# Применяем фильтры
|
# Применяем фильтры
|
||||||
if search_query:
|
if search_query:
|
||||||
@@ -82,9 +163,6 @@ def product_sales_unit_list(request):
|
|||||||
Q(name__icontains=search_query)
|
Q(name__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
if unit_filter:
|
|
||||||
sales_units = sales_units.filter(unit_id=unit_filter)
|
|
||||||
|
|
||||||
if is_active_filter:
|
if is_active_filter:
|
||||||
sales_units = sales_units.filter(is_active=(is_active_filter == 'true'))
|
sales_units = sales_units.filter(is_active=(is_active_filter == 'true'))
|
||||||
|
|
||||||
@@ -99,16 +177,11 @@ def product_sales_unit_list(request):
|
|||||||
page_number = request.GET.get('page')
|
page_number = request.GET.get('page')
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Для фильтра единиц
|
|
||||||
all_units = UnitOfMeasure.objects.filter(is_active=True).order_by('position', 'code')
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'search_query': search_query,
|
'search_query': search_query,
|
||||||
'unit_filter': unit_filter,
|
|
||||||
'is_active_filter': is_active_filter,
|
'is_active_filter': is_active_filter,
|
||||||
'is_default_filter': is_default_filter,
|
'is_default_filter': is_default_filter,
|
||||||
'all_units': all_units,
|
|
||||||
'total_sales_units': sales_units.count(),
|
'total_sales_units': sales_units.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user