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:
4
myproject/products/services/__init__.py
Normal file
4
myproject/products/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Сервисы для бизнес-логики products приложения.
|
||||
Следует принципу "Skinny Models, Fat Services".
|
||||
"""
|
||||
185
myproject/products/services/cost_calculator.py
Normal file
185
myproject/products/services/cost_calculator.py
Normal 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,
|
||||
}
|
||||
36
myproject/products/services/kit_availability.py
Normal file
36
myproject/products/services/kit_availability.py
Normal 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
|
||||
231
myproject/products/services/kit_pricing.py
Normal file
231
myproject/products/services/kit_pricing.py
Normal 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
|
||||
}
|
||||
68
myproject/products/services/product_service.py
Normal file
68
myproject/products/services/product_service.py
Normal 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)
|
||||
72
myproject/products/services/slug_service.py
Normal file
72
myproject/products/services/slug_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user