From e54d7d04d78b56113bef8f2a349918e5db791f6a Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Fri, 12 Dec 2025 04:58:26 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B?= =?UTF-8?q?=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=BF=D0=BE=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=D1=83=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлено: - Команда clear_tenant_data для полной очистки данных тенанта без удаления схемы * Очищает все таблицы через TRUNCATE CASCADE * Сбрасывает ID-последовательности * Сохраняет схему БД и запись Client * Поддержка флага --noinput для автоматизации - Команда init_tenant_data для инициализации системных данных тенанта * Создаёт системного клиента (АНОНИМНЫЙ ПОКУПАТЕЛЬ для POS) * Создаёт 8 системных статусов заказов * Создаёт 5 системных способов оплаты * Поддержка флага --reset для пересоздания данных Исправлено: - Заменены устаревшие фильтры is_active на status='active' для Product и ProductKit * products/views/category_views.py: исправлены фильтры в build_category_tree и get_context_data * products/services/kit_pricing.py: исправлены фильтры при получении товаров из variant_group * products/models/kits.py: исправлен фильтр в get_available_products * Устранена ошибка FieldError при работе со списком категорий Улучшено: - Команда clear_tenant_data теперь предлагает пользователю инициализировать системные данные после очистки - Добавлена детальная информация о процессе очистки и инициализации данных --- .../templates/customers/customer_detail.html | 8 +- .../orders/templates/orders/order_list.html | 6 +- myproject/products/models/kits.py | 2 +- myproject/products/services/kit_pricing.py | 4 +- myproject/products/views/category_views.py | 8 +- .../management/commands/clear_tenant_data.py | 200 ++++++++++++++++++ .../management/commands/init_tenant_data.py | 185 ++++++++++++++++ 7 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 myproject/tenants/management/commands/clear_tenant_data.py create mode 100644 myproject/tenants/management/commands/init_tenant_data.py diff --git a/myproject/customers/templates/customers/customer_detail.html b/myproject/customers/templates/customers/customer_detail.html index f8c4207..005304b 100644 --- a/myproject/customers/templates/customers/customer_detail.html +++ b/myproject/customers/templates/customers/customer_detail.html @@ -345,8 +345,8 @@ Оплачено {% elif order.status and order.status.is_negative_end and order.amount_paid > 0 %} - - Возврат + + Возврат ({{ order.amount_paid|floatformat:2 }} руб.) {% elif order.amount_paid > 0 %} @@ -377,8 +377,8 @@ {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %} - - {{ order.amount_paid|floatformat:2 }} руб. + + {{ order.amount_paid|floatformat:2 }} руб. {% else %} diff --git a/myproject/orders/templates/orders/order_list.html b/myproject/orders/templates/orders/order_list.html index 2d32d46..efedb18 100644 --- a/myproject/orders/templates/orders/order_list.html +++ b/myproject/orders/templates/orders/order_list.html @@ -105,7 +105,7 @@ {% for order in page_obj %} - + 0 %}class="table-warning"{% endif %}> {{ order.order_number }} @@ -157,8 +157,8 @@ Оплачен {% elif order.status and order.status.is_negative_end and order.amount_paid > 0 %} - - Возврат + + Возврат ({{ order.amount_paid|floatformat:2 }} руб.) {% elif order.amount_paid > 0 %} diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index e57bb24..8070f3e 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -338,7 +338,7 @@ class KitItem(models.Model): for priority in self.priorities.select_related('product').order_by('priority', 'id') ] # Иначе возвращаем все товары из группы - return list(self.variant_group.products.filter(is_active=True)) + return list(self.variant_group.products.filter(status='active')) return [] diff --git a/myproject/products/services/kit_pricing.py b/myproject/products/services/kit_pricing.py index 47585d4..0ec98d0 100644 --- a/myproject/products/services/kit_pricing.py +++ b/myproject/products/services/kit_pricing.py @@ -113,7 +113,7 @@ class KitCostCalculator: product = kit_item.product if not product and kit_item.variant_group: # Берем первый продукт из группы вариантов - product = kit_item.variant_group.products.filter(is_active=True).first() + product = kit_item.variant_group.products.filter(status='active').first() if product and product.cost_price: item_cost = product.cost_price @@ -163,7 +163,7 @@ class KitCostCalculator: if not product and kit_item.variant_group: # Берем первый активный продукт из группы вариантов - product = kit_item.variant_group.products.filter(is_active=True).first() + product = kit_item.variant_group.products.filter(status='active').first() if kit_item.variant_group: product_name = f"[Варианты] {kit_item.variant_group.name}" diff --git a/myproject/products/views/category_views.py b/myproject/products/views/category_views.py index 257458a..c2b9cfe 100644 --- a/myproject/products/views/category_views.py +++ b/myproject/products/views/category_views.py @@ -112,13 +112,13 @@ class ProductCategoryListView(LoginRequiredMixin, ListView): result.append(tree_item) # 2. Добавляем активные товары этой категории (отсортированные по имени) - products = category.products.filter(is_active=True).order_by('name') + products = category.products.filter(status='active').order_by('name') for product in products: product_item = TreeItem(product, 'product', depth + 1, category.pk) result.append(product_item) # 3. Добавляем активные наборы этой категории (отсортированные по имени) - kits = category.kits.filter(is_active=True).order_by('name') + kits = category.kits.filter(status='active').order_by('name') for kit in kits: kit_item = TreeItem(kit, 'kit', depth + 1, category.pk) result.append(kit_item) @@ -234,8 +234,8 @@ class ProductCategoryDetailView(LoginRequiredMixin, DetailView): # Получаем дочерние категории context['children_categories'] = self.object.children.filter(is_active=True) # Получаем товары в категории - context['products'] = self.object.products.filter(is_active=True)[:20] - context['products_count'] = self.object.products.filter(is_active=True).count() + context['products'] = self.object.products.filter(status='active')[:20] + context['products_count'] = self.object.products.filter(status='active').count() return context diff --git a/myproject/tenants/management/commands/clear_tenant_data.py b/myproject/tenants/management/commands/clear_tenant_data.py new file mode 100644 index 0000000..8eccf0b --- /dev/null +++ b/myproject/tenants/management/commands/clear_tenant_data.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +""" +Management команда для полной очистки данных тенанта без удаления схемы и самого тенанта. + +ВАЖНО: Это необратимая операция! Все данные тенанта будут удалены, но схема и запись в Client останутся. + +Использование: + # Очистка всех данных тенанта + python manage.py clear_tenant_data --schema=anatol --noinput + + # С подтверждением + python manage.py clear_tenant_data --schema=anatol +""" +from django.core.management.base import BaseCommand +from django.db import connection +from django.apps import apps +from django_tenants.utils import schema_context +from tenants.models import Client + + +class Command(BaseCommand): + help = 'Полная очистка всех данных в схеме тенанта без удаления схемы и Client' + + def add_arguments(self, parser): + parser.add_argument( + '--schema', + type=str, + required=True, + help='Имя схемы БД тенанта для очистки (пример: anatol)' + ) + parser.add_argument( + '--noinput', + action='store_true', + help='Не запрашивать подтверждение (для автоматизации)' + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('\n=== Очистка данных тенанта ===\n')) + + # Получаем параметры + schema_name = options.get('schema') + noinput = options.get('noinput', False) + + # Проверяем что тенант существует + try: + tenant = Client.objects.get(schema_name=schema_name) + except Client.DoesNotExist: + self.stdout.write(self.style.ERROR(f'\nОШИБКА: Тенант со схемой "{schema_name}" не найден\n')) + return + + # Получаем список всех таблиц в схеме (кроме служебных django) + tables = self._get_tenant_tables(schema_name) + + # Показываем информацию о том что будет удалено + self.stdout.write('='*70) + self.stdout.write(self.style.WARNING('ВНИМАНИЕ! Следующие данные будут удалены:')) + self.stdout.write('='*70) + self.stdout.write(f'\n[Тенант]') + self.stdout.write(f' Название: {tenant.name}') + self.stdout.write(f' Схема: {tenant.schema_name}') + self.stdout.write(f' Владелец: {tenant.owner_email}') + + self.stdout.write(f'\n[База данных]') + self.stdout.write(f' Схема: "{schema_name}" (НЕ будет удалена)') + self.stdout.write(f' Будет очищено таблиц: {len(tables)}') + + if tables: + self.stdout.write(f'\n[Таблицы для очистки]') + for table in tables[:20]: # Показываем первые 20 + self.stdout.write(f' - {table}') + if len(tables) > 20: + self.stdout.write(f' ... и ещё {len(tables) - 20} таблиц') + + self.stdout.write(f'\n[Что НЕ будет удалено]') + self.stdout.write(f' ✓ Запись Client (тенант)') + self.stdout.write(f' ✓ Схема БД "{schema_name}"') + self.stdout.write(f' ✓ Домены тенанта') + + self.stdout.write('\n' + '='*70) + + # Запрашиваем подтверждение + if not noinput: + confirm = input(self.style.WARNING('\nВы уверены? Введите "yes" для подтверждения: ')) + if confirm.lower() not in ['yes', 'да', 'y']: + self.stdout.write(self.style.ERROR('\nОтменено\n')) + return + + # Начинаем очистку + self.stdout.write('\n' + '='*70) + self.stdout.write(self.style.SUCCESS('Начинаю очистку данных...')) + self.stdout.write('='*70 + '\n') + + try: + deleted_count = self._clear_tenant_data(schema_name, tables) + + # Итоговое сообщение + self.stdout.write('\n' + '='*70) + self.stdout.write(self.style.SUCCESS('УСПЕХ: Данные тенанта очищены!')) + self.stdout.write('='*70) + self.stdout.write(self.style.WARNING('\nИтоги очистки:')) + self.stdout.write(f' - Очищено таблиц: {deleted_count}') + self.stdout.write(f' - Схема "{schema_name}" сохранена') + self.stdout.write(f' - Тенант "{tenant.name}" сохранён') + + # Предлагаем инициализировать системные данные + self._suggest_init_data(schema_name) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'\nОШИБКА при очистке данных: {e}')) + self.stdout.write(self.style.ERROR(' Данные могут быть частично удалены. Проверьте состояние БД.\n')) + raise + + def _get_tenant_tables(self, schema_name): + """ + Получает список всех таблиц в схеме тенанта (кроме служебных). + """ + with connection.cursor() as cursor: + cursor.execute(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = %s + AND table_type = 'BASE TABLE' + AND table_name NOT IN ( + 'django_migrations', + 'django_content_type', + 'django_admin_log', + 'django_session' + ) + ORDER BY table_name + """, [schema_name]) + + return [row[0] for row in cursor.fetchall()] + + def _clear_tenant_data(self, schema_name, tables): + """ + Очищает все данные в таблицах тенанта. + Использует TRUNCATE CASCADE для удаления связанных данных. + """ + deleted_count = 0 + + with connection.cursor() as cursor: + # Устанавливаем search_path на схему тенанта + cursor.execute(f'SET search_path TO {schema_name}') + + # Отключаем триггеры и ограничения для быстрой очистки + self.stdout.write('[1] Отключение триггеров и ограничений...') + cursor.execute('SET session_replication_role = replica;') + + # Очищаем каждую таблицу + self.stdout.write(f'[2] Очистка {len(tables)} таблиц...') + for i, table in enumerate(tables, 1): + try: + # Используем TRUNCATE CASCADE для удаления связанных данных + cursor.execute(f'TRUNCATE TABLE {table} CASCADE;') + deleted_count += 1 + + if i % 10 == 0: + self.stdout.write(f' Очищено таблиц: {i}/{len(tables)}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f' Предупреждение: не удалось очистить таблицу {table}: {e}') + ) + + # Включаем триггеры и ограничения обратно + self.stdout.write('[3] Включение триггеров и ограничений...') + cursor.execute('SET session_replication_role = DEFAULT;') + + # Сбрасываем последовательности (sequences) для ID + self.stdout.write('[4] Сброс последовательностей (ID sequences)...') + cursor.execute(""" + SELECT 'SELECT SETVAL(' || + quote_literal(quote_ident(sequence_schema) || '.' || quote_ident(sequence_name)) || + ', 1, false);' + FROM information_schema.sequences + WHERE sequence_schema = %s; + """, [schema_name]) + + for row in cursor.fetchall(): + try: + cursor.execute(row[0]) + except Exception as e: + # Игнорируем ошибки при сбросе последовательностей + pass + + self.stdout.write(self.style.SUCCESS(f' OK: Все данные очищены')) + + return deleted_count + + def _suggest_init_data(self, schema_name): + """ + Предлагает пользователю инициализировать системные данные. + """ + self.stdout.write('\n' + '-'*70) + self.stdout.write(self.style.WARNING('Рекомендация:')) + self.stdout.write('-'*70) + self.stdout.write('Для работы системы необходимы системные данные.') + self.stdout.write('Выполните команду для их создания:\n') + self.stdout.write(self.style.SUCCESS(f' python manage.py init_tenant_data --schema={schema_name}')) + self.stdout.write('') diff --git a/myproject/tenants/management/commands/init_tenant_data.py b/myproject/tenants/management/commands/init_tenant_data.py new file mode 100644 index 0000000..a743ff9 --- /dev/null +++ b/myproject/tenants/management/commands/init_tenant_data.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +""" +Management команда для инициализации всех системных данных тенанта. + +Создаёт: +- Системного клиента (анонимный покупатель для POS) +- Системные статусы заказов +- Системные способы оплаты + +Использование: + # Инициализация для конкретного тенанта + python manage.py init_tenant_data --schema=anatol + + # С флагом --reset для пересоздания данных + python manage.py init_tenant_data --schema=anatol --reset +""" +from django.core.management.base import BaseCommand +from django.db import connection +from django_tenants.utils import get_tenant_model, schema_context + + +class Command(BaseCommand): + help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты)' + + def add_arguments(self, parser): + parser.add_argument( + '--schema', + type=str, + required=True, + help='Имя схемы БД тенанта (пример: anatol)' + ) + parser.add_argument( + '--reset', + action='store_true', + help='Удалить и пересоздать все системные данные' + ) + + def handle(self, *args, **options): + schema_name = options.get('schema') + reset = options.get('reset', False) + + # Проверяем что тенант существует + Tenant = get_tenant_model() + try: + tenant = Tenant.objects.get(schema_name=schema_name) + except Tenant.DoesNotExist: + self.stdout.write(self.style.ERROR(f'\nОШИБКА: Тенант со схемой "{schema_name}" не найден\n')) + return + + self.stdout.write(self.style.SUCCESS('\n=== Инициализация системных данных тенанта ===\n')) + self.stdout.write(f'Тенант: {tenant.name} ({schema_name})\n') + + # Переключаемся на схему тенанта + with schema_context(schema_name): + # 1. Создаём системного клиента + self.stdout.write('\n' + '='*70) + self.stdout.write('[1] Создание системного клиента...') + self.stdout.write('='*70) + + from customers.models import Customer + + if reset: + # Удаляем существующего системного клиента + system_customers = Customer.objects.filter(email="system@pos.customer") + if system_customers.exists(): + count = system_customers.count() + system_customers.delete() + self.stdout.write(self.style.WARNING(f' Удалено системных клиентов: {count}')) + + try: + system_customer, created = Customer.get_or_create_system_customer() + if created: + self.stdout.write(self.style.SUCCESS(f' ✓ Системный клиент создан: {system_customer.name}')) + self.stdout.write(f' Email: {system_customer.email}') + self.stdout.write(f' ID: {system_customer.id}') + else: + self.stdout.write(self.style.WARNING(f' • Системный клиент уже существует: {system_customer.name}')) + self.stdout.write(f' ID: {system_customer.id}') + except Exception as e: + self.stdout.write(self.style.ERROR(f' ✗ Ошибка при создании системного клиента: {e}')) + + # 2. Создаём системные статусы заказов + self.stdout.write('\n' + '='*70) + self.stdout.write('[2] Создание системных статусов заказов...') + self.stdout.write('='*70) + + from orders.models import OrderStatus + from orders.services.order_status_service import OrderStatusService + + if reset: + count = OrderStatus.objects.filter(is_system=True).count() + if count > 0: + OrderStatus.objects.filter(is_system=True).delete() + self.stdout.write(self.style.WARNING(f' Удалено системных статусов: {count}')) + + try: + OrderStatusService.create_default_statuses() + statuses = OrderStatus.objects.filter(is_system=True).order_by('order') + self.stdout.write(self.style.SUCCESS(f' ✓ Создано системных статусов: {statuses.count()}')) + + for status in statuses: + end_type = '' + if status.is_positive_end: + end_type = ' [Успешный]' + elif status.is_negative_end: + end_type = ' [Отрицательный]' + self.stdout.write(f' - {status.name:<20} ({status.code:<15}){end_type}') + + except Exception as e: + self.stdout.write(self.style.ERROR(f' ✗ Ошибка при создании статусов: {e}')) + + # 3. Создаём системные способы оплаты + self.stdout.write('\n' + '='*70) + self.stdout.write('[3] Создание системных способов оплаты...') + self.stdout.write('='*70) + + from orders.models import PaymentMethod + + if reset: + count = PaymentMethod.objects.filter(is_system=True).count() + if count > 0: + PaymentMethod.objects.filter(is_system=True).delete() + self.stdout.write(self.style.WARNING(f' Удалено системных способов оплаты: {count}')) + + payment_methods = [ + { + 'code': 'account_balance', + 'name': 'С баланса счёта', + 'description': 'Оплата из кошелька клиента', + 'is_system': True, + 'order': 0 + }, + { + 'code': 'cash', + 'name': 'Наличными', + 'description': 'Оплата наличными деньгами', + 'is_system': True, + 'order': 1 + }, + { + 'code': 'card', + 'name': 'Картой', + 'description': 'Оплата банковской картой', + 'is_system': True, + 'order': 2 + }, + { + 'code': 'online', + 'name': 'Онлайн', + 'description': 'Онлайн оплата через платежную систему', + 'is_system': True, + 'order': 3 + }, + { + 'code': 'legal_entity', + 'name': 'Безнал от ЮРЛИЦ', + 'description': 'Безналичный расчёт от юридических лиц', + 'is_system': True, + 'order': 4 + }, + ] + + created_count = 0 + try: + for method_data in payment_methods: + method, created = PaymentMethod.objects.get_or_create( + code=method_data['code'], + defaults=method_data + ) + if created: + created_count += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ Создан способ оплаты: {method.name}')) + else: + self.stdout.write(self.style.WARNING(f' • Уже существует: {method.name}')) + + self.stdout.write(self.style.SUCCESS(f'\n Готово! Создано {created_count} новых способов оплаты.')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f' ✗ Ошибка при создании способов оплаты: {e}')) + + # Итоговое сообщение + self.stdout.write('\n' + '='*70) + self.stdout.write(self.style.SUCCESS('УСПЕХ: Инициализация системных данных завершена!')) + self.stdout.write('='*70) + self.stdout.write('\nТенант готов к работе.\n')