Очистка management commands: удалены неиспользуемые скрипты cleanup_photo_media, demo_variants, fix_category_slugs. Обновлены команды recalculate_product_costs и update_product_in_stock
This commit is contained in:
@@ -1,125 +0,0 @@
|
||||
"""
|
||||
Management команда для очистки старых файлов фотографий.
|
||||
Удаляет файлы с суффиксами (например, original_b374WLW.jpg) которые появляются при коллизии имен.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.files.storage import default_storage
|
||||
from products.models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Очистить старые файлы фотографий с суффиксами коллизии имен'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Показать что будет удалено без реального удаления',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--fix-db',
|
||||
action='store_true',
|
||||
help='Также проверить и исправить пути в БД если нужно',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
fix_db = options['fix_db']
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('=== Очистка старых файлов фотографий ===\n'))
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('⚠️ РЕЖИМ ТЕСТИРОВАНИЯ (--dry-run) - ничего не будет удалено\n'))
|
||||
|
||||
# Получаем все фотографии из БД
|
||||
photo_models = [ProductPhoto, ProductKitPhoto, ProductCategoryPhoto]
|
||||
total_photos = sum(model.objects.count() for model in photo_models)
|
||||
|
||||
self.stdout.write(f'Найдено {total_photos} фотографий в БД\n')
|
||||
|
||||
deleted_count = 0
|
||||
fixed_count = 0
|
||||
|
||||
# Проходим по всем фотографиям
|
||||
for model_class in photo_models:
|
||||
model_name = model_class.__name__
|
||||
self.stdout.write(f'\n--- {model_name} ---')
|
||||
|
||||
for photo in model_class.objects.all():
|
||||
if not photo.image or not photo.image.name:
|
||||
continue
|
||||
|
||||
# Получаем основной путь (без расширения)
|
||||
image_path = photo.image.name
|
||||
base_parts = image_path.rsplit('.', 1)
|
||||
base_path = base_parts[0]
|
||||
|
||||
# Ищем все файлы с этим base_path
|
||||
directory = os.path.dirname(image_path)
|
||||
|
||||
if not directory:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Список всех файлов в папке
|
||||
if hasattr(default_storage, '_storage'):
|
||||
# Для файловой системы
|
||||
full_dir = os.path.join(default_storage.location, directory)
|
||||
if os.path.exists(full_dir):
|
||||
files = os.listdir(full_dir)
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Анализируем файлы в папке
|
||||
expected_files = {
|
||||
'original.jpg', 'original.webp', 'original.png',
|
||||
'large.webp', 'medium.webp', 'thumb.webp',
|
||||
}
|
||||
|
||||
# Находим лишние файлы (с суффиксами)
|
||||
for filename in files:
|
||||
full_path = os.path.join(full_dir, filename)
|
||||
file_path = os.path.join(directory, filename)
|
||||
|
||||
# Проверяем есть ли суффикс коллизии (например, _b374WLW)
|
||||
if filename not in expected_files:
|
||||
# Это потенциальный старый файл с суффиксом
|
||||
self.stdout.write(f' Найден старый файл: {file_path}')
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
deleted_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f' ✓ Удален')
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f' ✗ Ошибка при удалении: {str(e)}')
|
||||
)
|
||||
else:
|
||||
deleted_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f' [dry-run] Будет удален')
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f' Ошибка при обработке {image_path}: {str(e)}')
|
||||
)
|
||||
|
||||
# Вывод итогов
|
||||
self.stdout.write(f'\n\n=== ИТОГИ ===')
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Обработано файлов с суффиксами: {deleted_count}'))
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n⚠️ Это был тестовый запуск. Запустите без --dry-run чтобы реально удалить файлы.'
|
||||
))
|
||||
@@ -1,243 +0,0 @@
|
||||
"""
|
||||
Management команда для демонстрации работы системы вариантов товаров.
|
||||
|
||||
Использование:
|
||||
python manage.py demo_variants
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from django.core.management.base import BaseCommand
|
||||
from products.models import (
|
||||
Product, ProductKit, KitItem, ProductCategory,
|
||||
ProductVariantGroup, KitItemPriority
|
||||
)
|
||||
from products.utils.stock_manager import StockManager
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Демонстрация работы системы вариантов товаров'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.SUCCESS("ДЕМОНСТРАЦИЯ СИСТЕМЫ ВАРИАНТОВ ТОВАРОВ"))
|
||||
self.stdout.write("="*60 + "\n")
|
||||
|
||||
# Создаём демо-данные
|
||||
group, rose_50, rose_60, rose_70 = self.create_variant_group()
|
||||
|
||||
# Создаём букеты
|
||||
premium_kit = self.create_premium_bouquet(group, rose_50, rose_60, rose_70)
|
||||
economy_kit = self.create_economy_bouquet(group, rose_50, rose_60, rose_70)
|
||||
|
||||
# Проверяем доступность
|
||||
self.check_availability(premium_kit, economy_kit)
|
||||
|
||||
# Получаем лучший товар
|
||||
self.show_best_product(premium_kit)
|
||||
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.SUCCESS("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА"))
|
||||
self.stdout.write("="*60 + "\n")
|
||||
|
||||
def create_variant_group(self):
|
||||
"""Создание группы вариантов"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 1: Создание группы вариантов"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
# Создаём категорию
|
||||
category, _ = ProductCategory.objects.get_or_create(
|
||||
name="Цветы",
|
||||
defaults={'slug': 'cvety'}
|
||||
)
|
||||
|
||||
# Создаём товары - розы разной длины
|
||||
rose_50, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 50см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('80.00'),
|
||||
'sale_price': Decimal('100.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
rose_60, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 60см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('120.00'),
|
||||
'sale_price': Decimal('150.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
rose_70, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 70см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('160.00'),
|
||||
'sale_price': Decimal('200.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
# Создаём группу вариантов
|
||||
group, created = ProductVariantGroup.objects.get_or_create(
|
||||
name="Роза красная Freedom",
|
||||
defaults={
|
||||
'description': 'Красная роза Freedom различной длины (50-70см)'
|
||||
}
|
||||
)
|
||||
|
||||
# Добавляем товары в группу
|
||||
rose_50.variant_groups.add(group)
|
||||
rose_60.variant_groups.add(group)
|
||||
rose_70.variant_groups.add(group)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Создана группа: {group.name}"))
|
||||
self.stdout.write(f" Товаров в группе: {group.get_products_count()}")
|
||||
self.stdout.write(" Товары:")
|
||||
for product in group.products.all():
|
||||
self.stdout.write(f" - {product.name} ({product.sale_price} руб.)")
|
||||
|
||||
return group, rose_50, rose_60, rose_70
|
||||
|
||||
def create_premium_bouquet(self, group, rose_50, rose_60, rose_70):
|
||||
"""Создание премиум букета"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 2: Создание премиум букета"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
# Создаём букет
|
||||
kit, _ = ProductKit.objects.get_or_create(
|
||||
name="Ранчо Виталия Премиум",
|
||||
defaults={
|
||||
'slug': 'rancho-vitaliya-premium',
|
||||
'pricing_method': 'from_sale_prices'
|
||||
}
|
||||
)
|
||||
|
||||
# Создаём позицию с группой вариантов
|
||||
kit_item, _ = KitItem.objects.get_or_create(
|
||||
kit=kit,
|
||||
variant_group=group,
|
||||
defaults={
|
||||
'quantity': Decimal('15.000')
|
||||
}
|
||||
)
|
||||
|
||||
# Настраиваем приоритеты (для премиум букета - сначала длинные)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_70,
|
||||
defaults={'priority': 0}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_60,
|
||||
defaults={'priority': 1}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_50,
|
||||
defaults={'priority': 2}
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Создан букет: {kit.name}"))
|
||||
self.stdout.write(f" Позиций: {kit.get_total_components_count()}")
|
||||
self.stdout.write(f" С вариантами: {kit.get_components_with_variants_count()}")
|
||||
self.stdout.write(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
|
||||
for priority in kit_item.priorities.all().order_by('priority'):
|
||||
self.stdout.write(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
|
||||
|
||||
return kit
|
||||
|
||||
def create_economy_bouquet(self, group, rose_50, rose_60, rose_70):
|
||||
"""Создание эконом букета"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 3: Создание эконом букета"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
kit, _ = ProductKit.objects.get_or_create(
|
||||
name="Ранчо Виталия Эконом",
|
||||
defaults={
|
||||
'slug': 'rancho-vitaliya-econom',
|
||||
'pricing_method': 'from_sale_prices'
|
||||
}
|
||||
)
|
||||
|
||||
kit_item, _ = KitItem.objects.get_or_create(
|
||||
kit=kit,
|
||||
variant_group=group,
|
||||
defaults={
|
||||
'quantity': Decimal('15.000')
|
||||
}
|
||||
)
|
||||
|
||||
# Для эконом букета - сначала короткие (дешевые)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_50,
|
||||
defaults={'priority': 0}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_60,
|
||||
defaults={'priority': 1}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_70,
|
||||
defaults={'priority': 2}
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Создан букет: {kit.name}"))
|
||||
self.stdout.write(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
|
||||
for priority in kit_item.priorities.all().order_by('priority'):
|
||||
self.stdout.write(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
|
||||
|
||||
return kit
|
||||
|
||||
def check_availability(self, premium_kit, economy_kit):
|
||||
"""Проверка доступности"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 4: Проверка доступности букетов"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
stock_manager = StockManager()
|
||||
|
||||
# Проверяем премиум букет
|
||||
self.stdout.write(f"\nПремиум букет: {premium_kit.name}")
|
||||
if premium_kit.check_availability(stock_manager):
|
||||
self.stdout.write(self.style.SUCCESS(" [OK] Доступен для сборки"))
|
||||
price = premium_kit.calculate_price_with_substitutions(stock_manager)
|
||||
self.stdout.write(f" Цена: {price} руб.")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(" [ERROR] Недоступен"))
|
||||
|
||||
# Проверяем эконом букет
|
||||
self.stdout.write(f"\nЭконом букет: {economy_kit.name}")
|
||||
if economy_kit.check_availability(stock_manager):
|
||||
self.stdout.write(self.style.SUCCESS(" [OK] Доступен для сборки"))
|
||||
price = economy_kit.calculate_price_with_substitutions(stock_manager)
|
||||
self.stdout.write(f" Цена: {price} руб.")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(" [ERROR] Недоступен"))
|
||||
|
||||
def show_best_product(self, kit):
|
||||
"""Показать лучший доступный товар"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 5: Выбор лучшего доступного товара"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
stock_manager = StockManager()
|
||||
|
||||
for kit_item in kit.kit_items.all():
|
||||
self.stdout.write(f"\nПозиция: {kit_item.get_display_name()}")
|
||||
self.stdout.write(f"Количество: {kit_item.quantity}")
|
||||
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
if best_product:
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Лучший доступный товар: {best_product.name}"))
|
||||
self.stdout.write(f" Цена: {best_product.sale_price} руб.")
|
||||
self.stdout.write(f" Стоимость позиции: {best_product.sale_price * kit_item.quantity} руб.")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR("[ERROR] Нет доступных товаров"))
|
||||
@@ -1,34 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.text import slugify
|
||||
from unidecode import unidecode
|
||||
from products.models import ProductCategory
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fixes category slugs by converting Cyrillic to Latin transliteration'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
categories = ProductCategory.objects.all()
|
||||
fixed_count = 0
|
||||
|
||||
for category in categories:
|
||||
old_slug = category.slug
|
||||
# Generate new slug with Latin transliteration
|
||||
transliterated_name = unidecode(category.name)
|
||||
new_slug = slugify(transliterated_name)
|
||||
|
||||
if old_slug != new_slug:
|
||||
category.slug = new_slug
|
||||
category.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Fixed: "{category.name}" | {old_slug} -> {new_slug}'
|
||||
)
|
||||
)
|
||||
fixed_count += 1
|
||||
else:
|
||||
self.stdout.write(f'OK: "{category.name}" | {old_slug}')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nTotal fixed: {fixed_count} categories')
|
||||
)
|
||||
@@ -1,15 +1,51 @@
|
||||
"""
|
||||
Команда управления для пересчёта себестоимости (cost_price) всех товаров.
|
||||
|
||||
Использование (для multi-tenant проекта):
|
||||
python manage.py recalculate_product_costs --schema=grach
|
||||
python manage.py recalculate_product_costs --schema=grach --verbose
|
||||
python manage.py recalculate_product_costs --schema=grach --dry-run
|
||||
НАЗНАЧЕНИЕ:
|
||||
Команда для разового массового пересчёта себестоимости товаров.
|
||||
В обычных условиях себестоимость обновляется автоматически через сигналы
|
||||
(inventory/signals.py: update_product_cost_on_batch_change).
|
||||
|
||||
Описание:
|
||||
Пересчитывает Product.cost_price на основе активных партий (StockBatch).
|
||||
Использует средневзвешенную стоимость по FIFO принципу.
|
||||
Товар без партий получает cost_price = 0.00.
|
||||
КОГДА ИСПОЛЬЗОВАТЬ:
|
||||
- После миграции данных или массового импорта товаров
|
||||
- После изменения партий (StockBatch) напрямую в БД (минуя ORM)
|
||||
- При обнаружении расхождений между кешированной и реальной стоимостью
|
||||
- После восстановления из резервной копии или исправления данных
|
||||
- Для проверки корректности расчётов (режим --dry-run)
|
||||
|
||||
АВТОМАТИЗАЦИЯ:
|
||||
✓ В продакшене работают сигналы - команда НЕ нужна регулярно
|
||||
✓ Сигналы обновляют cost_price при каждом изменении партий
|
||||
✓ См. inventory/signals.py (строки 1752-1812):
|
||||
- update_product_cost_on_batch_change (создание/изменение партии)
|
||||
- update_product_cost_on_batch_delete (удаление партии)
|
||||
✓ Калькулятор: products/services/cost_calculator.py
|
||||
|
||||
ИСПОЛЬЗОВАНИЕ (multi-tenant проект):
|
||||
# Обычный запуск (сохраняет изменения в БД)
|
||||
python switch_to_tenant.py anatol recalculate_product_costs
|
||||
python switch_to_tenant.py grach recalculate_product_costs
|
||||
|
||||
# Проверка без сохранения (безопасно - рекомендуется сначала)
|
||||
python switch_to_tenant.py anatol recalculate_product_costs --dry-run
|
||||
|
||||
# С подробным выводом всех товаров
|
||||
python switch_to_tenant.py anatol recalculate_product_costs --verbose
|
||||
|
||||
# Показать только изменённые товары
|
||||
python switch_to_tenant.py anatol recalculate_product_costs --only-changed
|
||||
|
||||
# Комбинация флагов
|
||||
python switch_to_tenant.py anatol recalculate_product_costs --dry-run --verbose --only-changed
|
||||
|
||||
# ❌ НЕПРАВИЛЬНО: python manage.py recalculate_product_costs --schema=anatol
|
||||
# ✓ ПРАВИЛЬНО: python switch_to_tenant.py anatol recalculate_product_costs
|
||||
|
||||
ЛОГИКА:
|
||||
Рассчитывает средневзвешенную себестоимость на основе активных партий:
|
||||
cost_price = SUM(quantity * cost_price) / SUM(quantity)
|
||||
|
||||
Если у товара нет активных партий с остатками - cost_price = 0.00
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
"""
|
||||
Команда управления для пересчёта статуса in_stock всех товаров.
|
||||
|
||||
Использование:
|
||||
python manage.py update_product_in_stock
|
||||
НАЗНАЧЕНИЕ:
|
||||
Команда для разового массового исправления флага наличия товаров.
|
||||
В обычных условиях статус обновляется автоматически через сигналы
|
||||
(inventory/signals.py: update_product_in_stock_on_stock_change).
|
||||
|
||||
Описание:
|
||||
Пересчитывает Product.in_stock на основе текущих остатков в Stock.
|
||||
Товар считается в наличии если есть хотя бы один Stock с quantity_available > 0.
|
||||
КОГДА ИСПОЛЬЗОВАТЬ:
|
||||
- После миграции данных или импорта товаров
|
||||
- После ручного изменения Stock в БД (минуя ORM)
|
||||
- При обнаружении рассинхронизации данных
|
||||
- После восстановления из резервной копии
|
||||
|
||||
АВТОМАТИЗАЦИЯ:
|
||||
✓ В продакшене работают сигналы - команда НЕ нужна регулярно
|
||||
✓ Сигналы обновляют in_stock при каждом изменении Stock
|
||||
✓ См. inventory/signals.py (строки 1696-1744):
|
||||
- update_product_in_stock_on_stock_change
|
||||
- update_product_in_stock_on_stock_delete
|
||||
|
||||
ИСПОЛЬЗОВАНИЕ:
|
||||
# ДЛЯ SINGLE-TENANT (без мультитенантности):
|
||||
python manage.py update_product_in_stock
|
||||
python manage.py update_product_in_stock --verbose
|
||||
|
||||
# ДЛЯ MULTI-TENANT (обязательно через switch_to_tenant.py):
|
||||
python switch_to_tenant.py anatol update_product_in_stock
|
||||
python switch_to_tenant.py anatol update_product_in_stock --verbose
|
||||
python switch_to_tenant.py grach update_product_in_stock
|
||||
|
||||
# ❌ НЕПРАВИЛЬНО: python manage.py update_product_in_stock --schema=anatol
|
||||
# ✓ ПРАВИЛЬНО: python switch_to_tenant.py anatol update_product_in_stock
|
||||
|
||||
ЛОГИКА:
|
||||
Товар считается в наличии (in_stock=True), если существует хотя бы
|
||||
один Stock с quantity_available > 0 на любом складе.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
Reference in New Issue
Block a user