diff --git a/myproject/products/management/commands/cleanup_photo_media.py b/myproject/products/management/commands/cleanup_photo_media.py deleted file mode 100644 index 9cb80d0..0000000 --- a/myproject/products/management/commands/cleanup_photo_media.py +++ /dev/null @@ -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 чтобы реально удалить файлы.' - )) diff --git a/myproject/products/management/commands/demo_variants.py b/myproject/products/management/commands/demo_variants.py deleted file mode 100644 index 2b844b4..0000000 --- a/myproject/products/management/commands/demo_variants.py +++ /dev/null @@ -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] Нет доступных товаров")) diff --git a/myproject/products/management/commands/fix_category_slugs.py b/myproject/products/management/commands/fix_category_slugs.py deleted file mode 100644 index d18abbb..0000000 --- a/myproject/products/management/commands/fix_category_slugs.py +++ /dev/null @@ -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') - ) diff --git a/myproject/products/management/commands/recalculate_product_costs.py b/myproject/products/management/commands/recalculate_product_costs.py index 2ae25b7..5f929ec 100644 --- a/myproject/products/management/commands/recalculate_product_costs.py +++ b/myproject/products/management/commands/recalculate_product_costs.py @@ -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). + +КОГДА ИСПОЛЬЗОВАТЬ: + - После миграции данных или массового импорта товаров + - После изменения партий (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 -Описание: - Пересчитывает Product.cost_price на основе активных партий (StockBatch). - Использует средневзвешенную стоимость по FIFO принципу. - Товар без партий получает cost_price = 0.00. +ИСПОЛЬЗОВАНИЕ (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 diff --git a/myproject/products/management/commands/update_product_in_stock.py b/myproject/products/management/commands/update_product_in_stock.py index 9d13546..c376fb5 100644 --- a/myproject/products/management/commands/update_product_in_stock.py +++ b/myproject/products/management/commands/update_product_in_stock.py @@ -1,12 +1,40 @@ """ Команда управления для пересчёта статуса in_stock всех товаров. -Использование: - python manage.py update_product_in_stock +НАЗНАЧЕНИЕ: + Команда для разового массового исправления флага наличия товаров. + В обычных условиях статус обновляется автоматически через сигналы + (inventory/signals.py: update_product_in_stock_on_stock_change). + +КОГДА ИСПОЛЬЗОВАТЬ: + - После миграции данных или импорта товаров + - После ручного изменения Stock в БД (минуя ORM) + - При обнаружении рассинхронизации данных + - После восстановления из резервной копии + +АВТОМАТИЗАЦИЯ: + ✓ В продакшене работают сигналы - команда НЕ нужна регулярно + ✓ Сигналы обновляют in_stock при каждом изменении Stock + ✓ См. inventory/signals.py (строки 1696-1744): + - update_product_in_stock_on_stock_change + - update_product_in_stock_on_stock_delete -Описание: - Пересчитывает Product.in_stock на основе текущих остатков в Stock. - Товар считается в наличии если есть хотя бы один Stock с quantity_available > 0. +ИСПОЛЬЗОВАНИЕ: + # ДЛЯ 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