fix: Улучшения системы ценообразования комплектов

Исправлены 4 проблемы:
1. Расчёт цены первого товара - улучшена валидация в getProductPrice и calculateFinalPrice
2. Отображение actual_price в Select2 вместо обычной цены
3. Количество по умолчанию = 1 для новых форм компонентов
4. Auto-select текста при клике на поле количества для удобства редактирования

Изменённые файлы:
- products/forms.py: добавлен __init__ в KitItemForm для quantity.initial = 1
- products/templates/includes/select2-product-init.html: обновлена formatSelectResult
- products/templates/productkit_create.html: добавлен focus handler для auto-select
- products/templates/productkit_edit.html: добавлен focus handler для auto-select

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 19:04:03 +03:00
parent c84a372f98
commit 6c8af5ab2c
120 changed files with 9035 additions and 3036 deletions

View File

@@ -0,0 +1,4 @@
"""
Сервисы для бизнес-логики products приложения.
Следует принципу "Skinny Models, Fat Services".
"""

View File

@@ -0,0 +1,185 @@
"""
Сервис для расчета себестоимости товаров на основе партий (FIFO).
Извлекает сложную бизнес-логику из модели.
"""
from decimal import Decimal, InvalidOperation
import logging
logger = logging.getLogger(__name__)
class ProductCostCalculator:
"""
Калькулятор себестоимости для Product.
Рассчитывает средневзвешенную стоимость на основе активных партий товара.
"""
@staticmethod
def calculate_weighted_average_cost(product):
"""
Рассчитать средневзвешенную себестоимость из активных партий товара.
Логика:
- Если нет активных партий с quantity > 0: возвращает 0.00
- Если есть партии: (сумма(quantity * cost_price) / сумма(quantity))
Args:
product: Объект Product для расчета себестоимости
Returns:
Decimal: Средневзвешенная себестоимость, округленная до 2 знаков
"""
from inventory.models import StockBatch
try:
# Получаем все активные партии товара с остатками
batches = StockBatch.objects.filter(
product=product,
is_active=True,
quantity__gt=0
).values('quantity', 'cost_price')
if not batches:
logger.debug(f"Товар {product.sku} не имеет активных партий. Себестоимость = 0")
return Decimal('0.00')
# Рассчитываем средневзвешенную стоимость
total_value = Decimal('0.00')
total_quantity = Decimal('0.00')
for batch in batches:
quantity = Decimal(str(batch['quantity']))
cost_price = Decimal(str(batch['cost_price']))
total_value += quantity * cost_price
total_quantity += quantity
if total_quantity == 0:
logger.debug(f"Товар {product.sku} имеет партии, но общее количество = 0. Себестоимость = 0")
return Decimal('0.00')
# Рассчитываем средневзвешенную стоимость
weighted_cost = total_value / total_quantity
# Округляем до 2 знаков после запятой
result = weighted_cost.quantize(Decimal('0.01'))
logger.debug(
f"Товар {product.sku}: средневзвешенная себестоимость = {result} "
f"(партий: {len(batches)}, количество: {total_quantity})"
)
return result
except (InvalidOperation, ZeroDivisionError) as e:
logger.error(
f"Ошибка при расчете себестоимости для товара {product.sku}: {e}",
exc_info=True
)
return Decimal('0.00')
@staticmethod
def update_product_cost(product, save=True):
"""
Обновить кешированную себестоимость товара.
Рассчитывает новую себестоимость и обновляет поле cost_price,
если значение изменилось.
Args:
product: Объект Product для обновления
save: Если True, сохраняет изменения в БД (default: True)
Returns:
tuple: (old_cost, new_cost, was_updated)
"""
old_cost = product.cost_price
new_cost = ProductCostCalculator.calculate_weighted_average_cost(product)
was_updated = False
if old_cost != new_cost:
product.cost_price = new_cost
if save:
product.save(update_fields=['cost_price'])
logger.info(
f"Обновлена себестоимость товара {product.sku}: "
f"{old_cost} -> {new_cost}"
)
was_updated = True
else:
logger.debug(
f"Себестоимость товара {product.sku} не изменилась: {old_cost}"
)
return (old_cost, new_cost, was_updated)
@staticmethod
def get_cost_details(product):
"""
Получить детальную информацию о расчете себестоимости товара.
Возвращает детали по каждой партии для отображения в UI.
Args:
product: Объект Product
Returns:
dict: {
'cached_cost': Decimal, # Кешированная себестоимость
'calculated_cost': Decimal, # Рассчитанная себестоимость
'is_synced': bool, # Совпадают ли значения
'total_quantity': Decimal, # Общее количество в партиях
'batches': [ # Список партий
{
'warehouse_name': str,
'warehouse_id': int,
'quantity': Decimal,
'cost_price': Decimal,
'total_value': Decimal,
'created_at': datetime,
},
...
]
}
"""
from inventory.models import StockBatch
cached_cost = product.cost_price
calculated_cost = ProductCostCalculator.calculate_weighted_average_cost(product)
# Получаем все активные партии товара с остатками
batches_qs = StockBatch.objects.filter(
product=product,
is_active=True,
quantity__gt=0
).select_related('warehouse').order_by('created_at')
batches_list = []
total_quantity = Decimal('0.00')
for batch in batches_qs:
quantity = batch.quantity
cost_price = batch.cost_price
total_value = quantity * cost_price
batches_list.append({
'warehouse_name': batch.warehouse.name,
'warehouse_id': batch.warehouse.id,
'quantity': quantity,
'cost_price': cost_price,
'total_value': total_value,
'created_at': batch.created_at,
})
total_quantity += quantity
return {
'cached_cost': cached_cost,
'calculated_cost': calculated_cost,
'is_synced': cached_cost == calculated_cost,
'total_quantity': total_quantity,
'batches': batches_list,
}

View File

@@ -0,0 +1,36 @@
"""
Сервис для проверки доступности комплектов.
"""
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

@@ -0,0 +1,231 @@
"""
Сервисы для расчета цен комплектов (ProductKit).
Извлекает сложную бизнес-логику из модели.
"""
from decimal import Decimal, InvalidOperation
import logging
logger = logging.getLogger(__name__)
class KitPriceCalculator:
"""
Калькулятор цен для ProductKit.
Реализует различные методы ценообразования комплектов.
"""
@staticmethod
def calculate_price_with_substitutions(kit, stock_manager=None):
"""
Расчёт цены комплекта с учётом доступных замен компонентов.
Метод определяет цену комплекта, учитывая доступные товары-заменители
и применяет выбранный метод ценообразования.
Args:
kit (ProductKit): Комплект для расчета
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
Decimal: Расчетная цена комплекта, или 0 в случае ошибки
"""
from ..utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
# Если указана ручная цена, используем её
if kit.pricing_method == 'manual' and kit.price:
return kit.price
total_cost = Decimal('0.00')
total_sale = Decimal('0.00')
for kit_item in kit.kit_items.select_related('product', 'variant_group'):
try:
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
# Если товар недоступен, используем цену первого в списке
available_products = kit_item.get_available_products()
best_product = available_products[0] if available_products else None
if best_product:
item_cost = best_product.cost_price
item_price = best_product.price
item_quantity = kit_item.quantity or Decimal('1.00')
# Проверяем корректность значений перед умножением
if item_cost and item_quantity:
total_cost += item_cost * item_quantity
if item_price and item_quantity:
total_sale += item_price * item_quantity
except (AttributeError, TypeError, InvalidOperation) as e:
# Логируем ошибку, но продолжаем вычисления
logger.warning(
f"Ошибка при расчёте цены для комплекта {kit.name} (item: {kit_item}): {e}"
)
continue # Пропускаем ошибочный элемент и продолжаем с остальными
# Применяем метод ценообразования
try:
if kit.pricing_method == 'from_sale_prices':
return total_sale
elif kit.pricing_method == 'from_cost_plus_percent' and kit.markup_percent is not None:
return total_cost * (Decimal('1') + kit.markup_percent / Decimal('100'))
elif kit.pricing_method == 'from_cost_plus_amount' and kit.markup_amount is not None:
return total_cost + kit.markup_amount
elif kit.pricing_method == 'manual' and kit.price:
return kit.price
return total_sale
except (TypeError, InvalidOperation) as e:
logger.error(
f"Ошибка при применении метода ценообразования для комплекта {kit.name}: {e}"
)
# Возвращаем ручную цену если есть, иначе 0
if kit.pricing_method == 'manual' and kit.price:
return kit.price
return Decimal('0.00')
class KitCostCalculator:
"""
Калькулятор себестоимости для ProductKit.
Включает расчет и валидацию себестоимости комплекта.
"""
@staticmethod
def calculate_cost(kit):
"""
Расчёт себестоимости комплекта на основе себестоимости компонентов.
Args:
kit (ProductKit): Комплект для расчета
Returns:
Decimal: Себестоимость комплекта (может быть 0 если есть проблемы)
"""
total_cost = Decimal('0.00')
for kit_item in kit.kit_items.select_related('product', 'variant_group'):
# Получаем продукт - либо конкретный, либо первый из группы вариантов
product = kit_item.product
if not product and kit_item.variant_group:
# Берем первый продукт из группы вариантов
product = kit_item.variant_group.products.filter(is_active=True).first()
if product and product.cost_price:
item_cost = product.cost_price
item_quantity = kit_item.quantity or Decimal('1.00')
total_cost += item_cost * item_quantity
return total_cost
@staticmethod
def validate_and_calculate_cost(kit):
"""
Расчёт себестоимости с полной валидацией.
Проверяет, что все компоненты имеют себестоимость > 0.
Args:
kit (ProductKit): Комплект для валидации и расчета
Returns:
dict: {
'total_cost': Decimal or None,
'is_valid': bool,
'problems': list of dicts {
'component_name': str,
'reason': str,
'kit_item_id': int
}
}
"""
total_cost = Decimal('0.00')
problems = []
if not kit.kit_items.exists():
# Комплект без компонентов не может иметь корректную себестоимость
return {
'total_cost': None,
'is_valid': False,
'problems': [{
'component_name': 'Комплект',
'reason': 'Комплект не содержит компонентов'
}]
}
for kit_item in kit.kit_items.select_related('product', 'variant_group'):
# Получаем продукт
product = kit_item.product
product_name = ''
if not product and kit_item.variant_group:
# Берем первый активный продукт из группы вариантов
product = kit_item.variant_group.products.filter(is_active=True).first()
if kit_item.variant_group:
product_name = f"[Варианты] {kit_item.variant_group.name}"
if not product:
# Товар не найден или группа вариантов пуста
if kit_item.variant_group:
problems.append({
'component_name': f"[Варианты] {kit_item.variant_group.name}",
'reason': 'Группа не содержит активных товаров',
'kit_item_id': kit_item.id
})
else:
problems.append({
'component_name': 'Неизвестный компонент',
'reason': 'Товар не выбран и нет группы вариантов',
'kit_item_id': kit_item.id
})
continue
# Используем имя товара, если не установили выше
if not product_name:
product_name = product.name
# Проверяем наличие себестоимости
if product.cost_price is None:
problems.append({
'component_name': product_name,
'reason': 'Себестоимость не определена',
'kit_item_id': kit_item.id
})
continue
# Проверяем, что себестоимость > 0
if product.cost_price == Decimal('0.00') or product.cost_price <= 0:
problems.append({
'component_name': product_name,
'reason': 'Себестоимость равна 0',
'kit_item_id': kit_item.id
})
continue
# Если всё OK - добавляем в сумму
try:
item_quantity = kit_item.quantity or Decimal('1.00')
if item_quantity > 0:
total_cost += product.cost_price * item_quantity
except (TypeError, InvalidOperation) as e:
logger.warning(
f"Ошибка при расчете себестоимости компонента {product_name} "
f"комплекта {kit.name}: {e}"
)
problems.append({
'component_name': product_name,
'reason': 'Ошибка при расчете',
'kit_item_id': kit_item.id
})
# Если есть проблемы, себестоимость не валидна
is_valid = len(problems) == 0
return {
'total_cost': total_cost if is_valid else None,
'is_valid': is_valid,
'problems': problems
}

View File

@@ -0,0 +1,68 @@
"""
Сервисы для бизнес-логики Product модели.
Извлекает сложную логику из save() метода.
"""
class ProductSaveService:
"""
Сервис для обработки сохранения Product.
Извлекает variant_suffix, генерирует SKU и поисковые ключевые слова.
"""
@staticmethod
def prepare_product_for_save(product):
"""
Подготавливает продукт к сохранению:
- Извлекает variant_suffix из названия
- Генерирует SKU если не задан
- Создает базовые поисковые ключевые слова
Args:
product (Product): Экземпляр продукта
Returns:
Product: Обновленный экземпляр продукта
"""
from ..utils.sku_generator import parse_variant_suffix, generate_product_sku
# Автоматическое извлечение variant_suffix из названия
if not product.variant_suffix and product.name:
parsed_suffix = parse_variant_suffix(product.name)
if parsed_suffix:
product.variant_suffix = parsed_suffix
# Генерация артикула для новых товаров
if not product.sku:
product.sku = generate_product_sku(product)
# Автоматическая генерация ключевых слов для поиска
keywords_parts = [
product.name or '',
product.sku or '',
product.description or '',
]
if not product.search_keywords:
product.search_keywords = ' '.join(filter(None, keywords_parts))
return product
@staticmethod
def update_search_keywords_with_categories(product):
"""
Обновляет поисковые ключевые слова с названиями категорий.
Должен вызываться после сохранения, т.к. ManyToMany требует существующего объекта.
Args:
product (Product): Сохраненный экземпляр продукта
"""
# Добавляем названия категорий в search_keywords после сохранения
# (ManyToMany требует, чтобы объект уже существовал в БД)
if product.pk and product.categories.exists():
category_names = ' '.join([cat.name for cat in product.categories.all()])
if category_names and category_names not in product.search_keywords:
product.search_keywords = f"{product.search_keywords} {category_names}".strip()
# Используем update чтобы избежать рекурсии
from ..models.products import Product
Product.objects.filter(pk=product.pk).update(search_keywords=product.search_keywords)

View File

@@ -0,0 +1,72 @@
"""
Сервис для генерации уникальных slug для моделей.
Централизует логику транслитерации и обеспечения уникальности.
"""
from django.utils.text import slugify
from unidecode import unidecode
class SlugService:
"""
Статический сервис для генерации уникальных slug.
Используется моделями Product, ProductKit, ProductCategory, ProductTag.
"""
@staticmethod
def generate_unique_slug(name, model_class, instance_pk=None):
"""
Генерирует уникальный slug из названия с транслитерацией кириллицы.
Args:
name (str): Исходное название для генерации slug
model_class (Model): Класс модели для проверки уникальности
instance_pk (int, optional): ID текущего экземпляра (для исключения при обновлении)
Returns:
str: Уникальный slug
Example:
>>> SlugService.generate_unique_slug("Роза красная", Product, None)
'roza-krasnaya'
>>> SlugService.generate_unique_slug("Роза красная", Product, None) # если уже существует
'roza-krasnaya-1'
"""
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(name)
base_slug = slugify(transliterated_name)
# Обеспечиваем уникальность
slug = base_slug
counter = 1
while True:
# Проверяем существование slug, исключая текущий экземпляр если это обновление
query = model_class.objects.filter(slug=slug)
if instance_pk:
query = query.exclude(pk=instance_pk)
if not query.exists():
break
# Если slug занят, добавляем счетчик
slug = f"{base_slug}-{counter}"
counter += 1
return slug
@staticmethod
def transliterate(text):
"""
Транслитерирует текст (кириллицу в латиницу).
Args:
text (str): Текст для транслитерации
Returns:
str: Транслитерированный текст
Example:
>>> SlugService.transliterate("Привет мир")
'Privet mir'
"""
return unidecode(text)