Добавлен расчёт и отображение доступного количества комплектов

- Добавлен метод calculate_available_quantity() в модель ProductKit для точного расчёта максимального количества комплектов на основе свободных остатков компонентов
- Обновлён метод check_availability() для использования нового расчёта (обратная совместимость)
- Удалён устаревший сервис kit_availability.py

Исправлено отображение остатков комплектов:
- products_list.html: вместо прочерка показывается количество комплектов
- catalog.html: добавлено отображение доступного количества комплектов с цветовой индикацией
- POS terminal.js: в карточке товара показывается конкретное количество вместо общего 'В наличии'

Обновлены представления:
- ProductsListView: аннотирует комплекты атрибутом total_free
- CatalogView: рассчитывает доступное количество для каждого комплекта
- POS get_products(): убран хардкод, используется реальный расчёт по складу
This commit is contained in:
2026-01-06 01:02:28 +03:00
parent 2aba3d2404
commit d44ae0b598
12 changed files with 130 additions and 176 deletions

View File

@@ -547,7 +547,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
) )
except Exception as e: except Exception as e:
logger.error( logger.error(
f"❌ Ошибка финализации ShowcaseItem #{showcase_item.id}: {e}" f"❌ Ошибка финализации ShowcaseItem #{showcase_item.id}: {e}",
exc_info=True
) )
if finalized_count > 0: if finalized_count > 0:

View File

@@ -12,6 +12,7 @@ from .models import Order, OrderItem, Address, OrderStatus, Transaction, Payment
from .forms import OrderForm, OrderItemFormSet, OrderItemForm, OrderStatusForm, TransactionForm from .forms import OrderForm, OrderItemFormSet, OrderItemForm, OrderStatusForm, TransactionForm
from .filters import OrderFilter from .filters import OrderFilter
from .services.address_service import AddressService from .services.address_service import AddressService
from inventory.models import Reservation
import json import json
@@ -1067,6 +1068,7 @@ def create_order_from_pos(request):
showcase_item_ids = item_data.get('showcase_item_ids', []) showcase_item_ids = item_data.get('showcase_item_ids', [])
if not showcase_item_ids: if not showcase_item_ids:
logger.warning(f"⚠️ Пустой список showcase_item_ids для комплекта {kit.name}")
continue continue
# Создаём OrderItem с флагом is_from_showcase # Создаём OrderItem с флагом is_from_showcase
@@ -1086,8 +1088,29 @@ def create_order_from_pos(request):
locked_by_user=request.user locked_by_user=request.user
) )
reserved_count = 0
component_count = 0
for showcase_item in showcase_items: for showcase_item in showcase_items:
showcase_item.reserve_for_order(order_item) showcase_item.reserve_for_order(order_item)
reserved_count += 1
# КРИТИЧНО: Привязываем существующие резервы компонентов к OrderItem
# Эти резервы были созданы при добавлении букета на витрину
component_reservations = Reservation.objects.filter(
showcase_item=showcase_item,
status='reserved'
)
for reservation in component_reservations:
reservation.order_item = order_item
reservation.save(update_fields=['order_item'])
component_count += 1
logger.info(
f"✓ Витринный комплект '{kit.name}': зарезервировано {reserved_count} экз., "
f"привязано {component_count} резервов компонентов к OrderItem #{order_item.id}"
)
# 5. Пересчитываем стоимость заказа # 5. Пересчитываем стоимость заказа
order.calculate_total() order.calculate_total()

View File

@@ -902,10 +902,22 @@ function renderProducts() {
stock.style.color = '#28a745'; // Зелёный (достаточно) stock.style.color = '#28a745'; // Зелёный (достаточно)
} }
} else { } else {
// Fallback для старых данных или комплектов // Комплекты: показываем доступное количество
stock.textContent = item.in_stock ? 'В наличии' : 'Под заказ'; if (item.type === 'kit' && item.free_qty !== undefined) {
if (!item.in_stock) { const availableKits = parseFloat(item.free_qty) || 0;
stock.style.color = '#dc3545'; if (availableKits > 0) {
stock.textContent = `В наличии: ${Math.floor(availableKits)} компл.`;
stock.style.color = '#28a745'; // Зелёный
} else {
stock.textContent = 'Под заказ';
stock.style.color = '#dc3545'; // Красный
}
} else {
// Fallback для старых данных
stock.textContent = item.in_stock ? 'В наличии' : 'Под заказ';
if (!item.in_stock) {
stock.style.color = '#dc3545';
}
} }
} }

View File

@@ -854,17 +854,20 @@ def get_items_api(request):
if not image_url: if not image_url:
image_url = None image_url = None
# Рассчитываем доступное количество комплектов на текущем складе
available_kits = k.calculate_available_quantity(warehouse=current_warehouse)
kits.append({ kits.append({
'id': k.id, 'id': k.id,
'name': k.name, 'name': k.name,
'price': str(k.actual_price), 'price': str(k.actual_price),
'category_ids': [c.id for c in k.categories.all()], 'category_ids': [c.id for c in k.categories.all()],
'in_stock': False, # Комплекты всегда "Под заказ" 'in_stock': available_kits > 0, # Доступен если можно собрать хоть один комплект
'sku': k.sku or '', 'sku': k.sku or '',
'image': image_url, 'image': image_url,
'type': 'kit', 'type': 'kit',
'free_qty': '0', # Строка для консистентности с товарами 'free_qty': str(available_kits), # Количество комплектов которые можно собрать
'free_qty_sort': 0 # Комплекты всегда внизу при сортировке 'free_qty_sort': float(available_kits) # Для сортировки
}) })
# Объединяем и сортируем по free_qty_sort DESC # Объединяем и сортируем по free_qty_sort DESC

View File

@@ -13,7 +13,6 @@ from .categories import ProductCategory, ProductTag
from .variants import ProductVariantGroup from .variants import ProductVariantGroup
from .products import Product from .products import Product
from ..utils.sku_generator import generate_kit_sku from ..utils.sku_generator import generate_kit_sku
from ..services.kit_availability import KitAvailabilityChecker
class ProductKit(BaseProductEntity): class ProductKit(BaseProductEntity):
@@ -225,10 +224,69 @@ class ProductKit(BaseProductEntity):
def check_availability(self, stock_manager=None): def check_availability(self, stock_manager=None):
""" """
Проверяет доступность всего комплекта. Проверяет доступность всего комплекта (возвращает True/False).
Делегирует проверку в сервис. Для обратной совместимости. Использует calculate_available_quantity().
""" """
return KitAvailabilityChecker.check_availability(self, stock_manager) return self.calculate_available_quantity() > 0
def calculate_available_quantity(self, warehouse=None):
"""
Рассчитывает максимальное количество комплектов, которое можно собрать
на основе свободных остатков компонентов на складе.
Args:
warehouse: Склад для проверки остатков. Если None, суммируются остатки по всем складам.
Returns:
Decimal: Максимальное количество комплектов (0 если хоть один компонент недоступен)
"""
from inventory.models import Stock
if not self.kit_items.exists():
return Decimal('0')
min_available = None
for kit_item in self.kit_items.select_related('product', 'variant_group'):
# Определяем товар для проверки
product = None
if kit_item.product:
product = kit_item.product
elif kit_item.variant_group:
# Берём первый активный товар из группы вариантов
available_products = kit_item.get_available_products()
product = available_products[0] if available_products else None
if not product:
# Если товар не найден - комплект недоступен
return Decimal('0')
# Получаем остатки на складе
stock_filter = {'product': product}
if warehouse:
stock_filter['warehouse'] = warehouse
stocks = Stock.objects.filter(**stock_filter)
# Суммируем свободное количество (available - reserved)
total_free = Decimal('0')
for stock in stocks:
free_qty = stock.quantity_available - stock.quantity_reserved
total_free += free_qty
# Вычисляем сколько комплектов можно собрать из этого компонента
component_quantity = kit_item.quantity or Decimal('1')
if component_quantity <= 0:
return Decimal('0')
kits_from_this_component = total_free / component_quantity
# Ищем минимум (узкое место)
if min_available is None or kits_from_this_component < min_available:
min_available = kits_from_this_component
# Возвращаем целую часть (нельзя собрать половину комплекта)
return Decimal(int(min_available)) if min_available is not None else Decimal('0')
def make_permanent(self): def make_permanent(self):
""" """

View File

@@ -1,36 +0,0 @@
"""
Сервис для проверки доступности комплектов.
"""
class KitAvailabilityChecker:
"""
Проверяет доступность комплектов на основе остатков товаров.
"""
@staticmethod
def check_availability(kit, stock_manager=None):
"""
Проверяет доступность всего комплекта.
Комплект доступен, если для каждой позиции в комплекте
есть хотя бы один доступный вариант товара.
Args:
kit (ProductKit): Комплект для проверки
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
bool: True, если комплект полностью доступен, иначе False
"""
from ..utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
for kit_item in kit.kit_items.all():
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
return False
return True

View File

@@ -290,6 +290,14 @@
<span class="text-muted">/ {{ item.total_available|floatformat:"-3" }} всего</span> <span class="text-muted">/ {{ item.total_available|floatformat:"-3" }} всего</span>
</small> </small>
</div> </div>
{% elif item.item_type == 'kit' %}
{# Информация об остатках для комплектов #}
<div class="mt-1">
<small class="stock-info {% if item.total_free > 0 %}text-success{% else %}text-danger{% endif %}">
<i class="bi bi-box-seam"></i>
<strong>{{ item.total_free|floatformat:"0" }}</strong> компл. доступно
</small>
</div>
{% endif %} {% endif %}
<div class="d-flex justify-content-between align-items-center mt-1"> <div class="d-flex justify-content-between align-items-center mt-1">

View File

@@ -196,6 +196,8 @@
<strong class="{% if item.total_free > 0 %}text-success{% else %}text-danger{% endif %}">{{ item.total_free|floatformat:-2 }}</strong>{% if item.total_reserved > 0 %}<small class="text-muted"> / {{ item.total_available|floatformat:-2 }}</small> <strong class="{% if item.total_free > 0 %}text-success{% else %}text-danger{% endif %}">{{ item.total_free|floatformat:-2 }}</strong>{% if item.total_reserved > 0 %}<small class="text-muted"> / {{ item.total_available|floatformat:-2 }}</small>
<small class="text-warning d-block">{{ item.total_reserved|floatformat:-2 }} в резерве</small> <small class="text-warning d-block">{{ item.total_reserved|floatformat:-2 }} в резерве</small>
{% endif %} {% endif %}
{% elif item.item_type == 'kit' %}
<strong class="{% if item.total_free > 0 %}text-success{% else %}text-danger{% endif %}">{{ item.total_free|floatformat:0 }}</strong> <small class="text-muted">компл.</small>
{% else %} {% else %}
<span class="text-muted">-</span> <span class="text-muted">-</span>
{% endif %} {% endif %}

View File

@@ -78,6 +78,8 @@ class CatalogView(LoginRequiredMixin, TemplateView):
if k.id not in kits_dict: if k.id not in kits_dict:
k.item_type = 'kit' k.item_type = 'kit'
k.main_photo = k.photos.all()[0] if k.photos.all() else None k.main_photo = k.photos.all()[0] if k.photos.all() else None
# Рассчитываем доступное количество комплектов
k.total_free = k.calculate_available_quantity()
kits_dict[k.id] = k kits_dict[k.id] = k
# Теперь добавляем все товары, которых еще нет (товары без категорий или не загруженные) # Теперь добавляем все товары, которых еще нет (товары без категорий или не загруженные)
@@ -104,6 +106,8 @@ class CatalogView(LoginRequiredMixin, TemplateView):
if k.id not in kits_dict: if k.id not in kits_dict:
k.item_type = 'kit' k.item_type = 'kit'
k.main_photo = k.photos.all()[0] if k.photos.all() else None k.main_photo = k.photos.all()[0] if k.photos.all() else None
# Рассчитываем доступное количество комплектов
k.total_free = k.calculate_available_quantity()
kits_dict[k.id] = k kits_dict[k.id] = k
# Объединяем и сортируем # Объединяем и сортируем

View File

@@ -347,6 +347,8 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
kits_list = list(kits.order_by('-created_at')) kits_list = list(kits.order_by('-created_at'))
for k in kits_list: for k in kits_list:
k.item_type = 'kit' k.item_type = 'kit'
# Рассчитываем доступное количество комплектов (для отображения остатков)
k.total_free = k.calculate_available_quantity()
# Объединяем и сортируем по дате создания # Объединяем и сортируем по дате создания
combined = sorted( combined = sorted(

View File

@@ -61,131 +61,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Модальное окно с паролем -->
{% if generated_password %}
<div class="modal fade" id="passwordModal" tabindex="-1" aria-labelledby="passwordModalLabel"
aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="passwordModalLabel">
<i class="bi bi-check-circle-fill"></i> Пользователь успешно создан
</h5>
</div>
<div class="modal-body">
<div class="alert alert-warning" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<strong>Важно!</strong> Сохраните этот пароль. Он больше не будет показан.
</div>
<p><strong>Email:</strong> {{ created_user_email }}</p>
<div class="mb-3">
<label class="form-label"><strong>Пароль:</strong></label>
<div class="input-group">
<input type="text" class="form-control form-control-lg font-monospace"
id="generatedPassword" value="{{ generated_password }}" readonly>
<button class="btn btn-outline-primary" type="button"
onclick="copyPassword()">
<i class="bi bi-clipboard"></i> Копировать
</button>
</div>
</div>
<div class="mb-3">
<button class="btn btn-outline-success w-100" type="button"
onclick="copyCredentials()">
<i class="bi bi-clipboard-check"></i> Скопировать логин и пароль
</button>
<div class="form-text text-center mt-2">Скопирует в формате: <code>{{ created_user_email }} / пароль</code></div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="passwordSavedCheck"
onchange="toggleCloseButton()">
<label class="form-check-label" for="passwordSavedCheck">
Я скопировал(а) пароль
</label>
</div>
</div>
<div class="modal-footer">
<a href="{% url 'user_roles:list' %}"
class="btn btn-success disabled"
id="closeModalBtn"
disabled>
Вернуться к списку
</a>
</div>
</div>
</div>
</div>
<script>
function copyPassword() {
const passwordInput = document.getElementById('generatedPassword');
passwordInput.select();
passwordInput.setSelectionRange(0, 99999); // Для мобильных устройств
navigator.clipboard.writeText(passwordInput.value).then(function() {
// Меняем текст кнопки на короткое время
const btn = event.target.closest('button');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check-lg"></i> Скопировано!';
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-success');
setTimeout(function() {
btn.innerHTML = originalHTML;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-primary');
}, 2000);
});
}
function copyCredentials() {
const email = '{{ created_user_email }}';
const password = '{{ generated_password }}';
const credentials = `${email} / ${password}`;
navigator.clipboard.writeText(credentials).then(function() {
const btn = event.target.closest('button');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check-lg"></i> Скопировано!';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-success');
setTimeout(function() {
btn.innerHTML = originalHTML;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-success');
}, 2000);
});
}
function toggleCloseButton() {
const checkbox = document.getElementById('passwordSavedCheck');
const closeBtn = document.getElementById('closeModalBtn');
if (checkbox.checked) {
closeBtn.classList.remove('disabled');
closeBtn.removeAttribute('disabled');
} else {
closeBtn.classList.add('disabled');
closeBtn.setAttribute('disabled', 'disabled');
}
}
// Автоматически показываем модальное окно при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
const modalElement = document.getElementById('passwordModal');
if (modalElement) {
const passwordModal = new bootstrap.Modal(modalElement);
passwordModal.show();
console.log('Password modal shown');
} else {
console.error('Password modal element not found');
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -43,7 +43,11 @@ def user_role_create(request):
email = request.POST.get('email') email = request.POST.get('email')
name = request.POST.get('name') name = request.POST.get('name')
role_code = request.POST.get('role') role_code = request.POST.get('role')
password = request.POST.get('password', User.objects.make_random_password(12)) password = request.POST.get('password', '').strip()
# Если пароль не указан, генерируем случайный
if not password:
password = User.objects.make_random_password(12)
try: try:
# Проверяем, не существует ли уже пользователь с таким email # Проверяем, не существует ли уже пользователь с таким email