diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index 32841ec..3914cbc 100644 --- a/myproject/accounts/migrations/0001_initial.py +++ b/myproject/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-22 13:03 +# Generated by Django 5.2.7 on 2025-10-23 20:27 import django.contrib.auth.validators import django.utils.timezone diff --git a/myproject/customers/__init__.py b/myproject/customers/__init__.py new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/myproject/customers/__init__.py @@ -0,0 +1 @@ + diff --git a/myproject/customers/admin.py b/myproject/customers/admin.py new file mode 100644 index 0000000..ad0e0c5 --- /dev/null +++ b/myproject/customers/admin.py @@ -0,0 +1,120 @@ +from django.contrib import admin +from django.db import models +from .models import Customer, Address + + +class IsVipFilter(admin.SimpleListFilter): + title = 'VIP статус' + parameter_name = 'is_vip' + + def lookups(self, request, model_admin): + return ( + ('yes', 'VIP'), + ('no', 'Не VIP'), + ) + + def queryset(self, request, queryset): + if self.value() == 'yes': + return queryset.filter(loyalty_tier__in=['gold', 'platinum']) + if self.value() == 'no': + return queryset.exclude(loyalty_tier__in=['gold', 'platinum']) + return queryset + + +class AddressInline(admin.TabularInline): + """Inline для управления адресами клиента в интерфейсе администратора""" + model = Address + extra = 1 + verbose_name = "Адрес доставки" + verbose_name_plural = "Адреса доставки" + + +@admin.register(Customer) +class CustomerAdmin(admin.ModelAdmin): + """Административный интерфейс для управления клиентами цветочного магазина""" + list_display = ( + 'full_name', + 'email', + 'phone', + 'loyalty_tier', + 'total_spent', + 'is_vip', + 'created_at' + ) + list_filter = ( + 'loyalty_tier', + IsVipFilter, + 'created_at' + ) + search_fields = ( + 'name', + 'email', + 'phone' + ) + date_hierarchy = 'created_at' + ordering = ('-created_at',) + readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_vip') + + + fieldsets = ( + ('Основная информация', { + 'fields': ('name', 'email', 'phone') + }), + ('Программа лояльности', { + 'fields': ('loyalty_tier', 'total_spent', 'is_vip'), + 'classes': ('collapse',) + }), + ('Даты', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + inlines = [AddressInline] + + +@admin.register(Address) +class AddressAdmin(admin.ModelAdmin): + """Административный интерфейс для управления адресами доставки""" + list_display = ( + 'recipient_name', + 'full_address', + 'customer', + 'district', + 'is_default' + ) + list_filter = ( + 'is_default', + 'district', + 'created_at' + ) + search_fields = ( + 'recipient_name', + 'street', + 'building_number', + 'customer__name', + 'customer__email' + ) + ordering = ('-is_default', '-created_at') + readonly_fields = ('created_at', 'updated_at') + + fieldsets = ( + ('Информация о получателе', { + 'fields': ('customer', 'recipient_name') + }), + ('Адрес доставки', { + 'fields': ('street', 'building_number', 'apartment_number', 'district') + }), + ('Дополнительная информация', { + 'fields': ('delivery_instructions',), + 'classes': ('collapse',) + }), + ('Статус', { + 'fields': ('is_default',), + 'classes': ('collapse',) + }), + ('Даты', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) diff --git a/myproject/customers/forms.py b/myproject/customers/forms.py new file mode 100644 index 0000000..53ec695 --- /dev/null +++ b/myproject/customers/forms.py @@ -0,0 +1,23 @@ +from django import forms +from .models import Customer + +class CustomerForm(forms.ModelForm): + class Meta: + model = Customer + fields = ['name', 'email', 'phone', 'loyalty_tier', 'notes'] + widgets = { + 'notes': forms.Textarea(attrs={'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field_name, field in self.fields.items(): + if field_name == 'notes': + # Textarea already has rows=3 from widget, just add class + field.widget.attrs.update({'class': 'form-control'}) + elif field_name == 'loyalty_tier': + # Select fields need form-select class + field.widget.attrs.update({'class': 'form-select'}) + else: + # Regular input fields get form-control class + field.widget.attrs.update({'class': 'form-control'}) \ No newline at end of file diff --git a/myproject/customers/migrations/0001_initial.py b/myproject/customers/migrations/0001_initial.py new file mode 100644 index 0000000..f6d232d --- /dev/null +++ b/myproject/customers/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# Generated by Django 5.2.7 on 2025-10-23 23:46 + +import django.db.models.deletion +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('products', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100, verbose_name='Имя')), + ('last_name', models.CharField(max_length=100, verbose_name='Фамилия')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')), + ('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Телефон в международном формате (например, +375291234567)', max_length=128, null=True, region=None, verbose_name='Телефон')), + ('preferred_colors', models.CharField(blank=True, help_text="Предпочтительные цветы цветов, например: 'красный, белый, желтый'", max_length=200, null=True, verbose_name='Предпочтительные цвета')), + ('loyalty_tier', models.CharField(choices=[('bronze', 'Бронза'), ('silver', 'Серебро'), ('gold', 'Золото'), ('platinum', 'Платина')], default='bronze', max_length=20, verbose_name='Уровень лояльности')), + ('total_spent', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Общая сумма покупок')), + ('birthday', models.DateField(blank=True, null=True, verbose_name='День рождения')), + ('anniversary', models.DateField(blank=True, null=True, verbose_name='Годовщина')), + ('notes', models.TextField(blank=True, help_text='Заметки о клиенте, особые предпочтения и т.д.', null=True, verbose_name='Заметки')), + ('is_active', models.BooleanField(default=True, verbose_name='Активный клиент')), + ('is_vip', models.BooleanField(default=False, verbose_name='VIP клиент')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('favorite_flower_types', models.ManyToManyField(blank=True, related_name='preferred_by_customers', to='products.product', verbose_name='Любимые виды цветов')), + ], + options={ + 'verbose_name': 'Клиент', + 'verbose_name_plural': 'Клиенты', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Address', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('recipient_name', models.CharField(help_text='Имя человека, которому будет доставлен заказ', max_length=200, verbose_name='Имя получателя')), + ('street', models.CharField(max_length=255, verbose_name='Улица')), + ('building_number', models.CharField(max_length=20, verbose_name='Номер здания')), + ('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')), + ('district', models.CharField(blank=True, help_text='Район в Минске для удобства доставки', max_length=100, null=True, verbose_name='Район')), + ('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера (домофон, подъезд и т.д.)', null=True, verbose_name='Инструкции для доставки')), + ('is_default', models.BooleanField(default=False, help_text='Использовать этот адрес для доставки по умолчанию', verbose_name='Адрес по умолчанию')), + ('is_active', models.BooleanField(default=True, verbose_name='Активный адрес')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='customers.customer', verbose_name='Клиент')), + ], + options={ + 'verbose_name': 'Адрес доставки', + 'verbose_name_plural': 'Адреса доставки', + 'ordering': ['-is_default', '-created_at'], + }, + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['email'], name='customers_c_email_4fdeb3_idx'), + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['phone'], name='customers_c_phone_8493fa_idx'), + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['created_at'], name='customers_c_created_1ed0f4_idx'), + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['is_active'], name='customers_c_is_acti_91d305_idx'), + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['loyalty_tier'], name='customers_c_loyalty_5162a0_idx'), + ), + migrations.AddIndex( + model_name='address', + index=models.Index(fields=['customer'], name='customers_a_custome_53b543_idx'), + ), + migrations.AddIndex( + model_name='address', + index=models.Index(fields=['is_default'], name='customers_a_is_defa_631851_idx'), + ), + migrations.AddIndex( + model_name='address', + index=models.Index(fields=['is_active'], name='customers_a_is_acti_433713_idx'), + ), + migrations.AddIndex( + model_name='address', + index=models.Index(fields=['district'], name='customers_a_distric_ac47d5_idx'), + ), + ] diff --git a/myproject/customers/migrations/0002_remove_customer_anniversary_remove_customer_birthday_and_more.py b/myproject/customers/migrations/0002_remove_customer_anniversary_remove_customer_birthday_and_more.py new file mode 100644 index 0000000..af77a7c --- /dev/null +++ b/myproject/customers/migrations/0002_remove_customer_anniversary_remove_customer_birthday_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.7 on 2025-10-24 07:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='customer', + name='anniversary', + ), + migrations.RemoveField( + model_name='customer', + name='birthday', + ), + migrations.RemoveField( + model_name='customer', + name='favorite_flower_types', + ), + migrations.RemoveField( + model_name='customer', + name='first_name', + ), + migrations.RemoveField( + model_name='customer', + name='last_name', + ), + migrations.RemoveField( + model_name='customer', + name='preferred_colors', + ), + migrations.AddField( + model_name='customer', + name='name', + field=models.CharField(blank=True, max_length=200, verbose_name='Имя'), + ), + migrations.AlterField( + model_name='customer', + name='email', + field=models.EmailField(blank=True, max_length=254, verbose_name='Email'), + ), + migrations.AlterField( + model_name='customer', + name='loyalty_tier', + field=models.CharField(choices=[('no_discount', 'Без скидки'), ('bronze', 'Бронза'), ('silver', 'Серебро'), ('gold', 'Золото'), ('platinum', 'Платина')], default='no_discount', max_length=20, verbose_name='Уровень лояльности'), + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['name'], name='customers_c_name_f018e2_idx'), + ), + ] diff --git a/myproject/customers/migrations/0003_alter_customer_phone.py b/myproject/customers/migrations/0003_alter_customer_phone.py new file mode 100644 index 0000000..c433652 --- /dev/null +++ b/myproject/customers/migrations/0003_alter_customer_phone.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-10-24 14:55 + +import phonenumber_field.modelfields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0002_remove_customer_anniversary_remove_customer_birthday_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='customer', + name='phone', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, verbose_name='Телефон'), + ), + ] diff --git a/myproject/customers/migrations/0004_remove_address_customers_a_is_acti_433713_idx_and_more.py b/myproject/customers/migrations/0004_remove_address_customers_a_is_acti_433713_idx_and_more.py new file mode 100644 index 0000000..f8e9dee --- /dev/null +++ b/myproject/customers/migrations/0004_remove_address_customers_a_is_acti_433713_idx_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.7 on 2025-10-24 16:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0003_alter_customer_phone'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='address', + name='customers_a_is_acti_433713_idx', + ), + migrations.RemoveIndex( + model_name='customer', + name='customers_c_is_acti_91d305_idx', + ), + migrations.RemoveField( + model_name='address', + name='is_active', + ), + migrations.RemoveField( + model_name='customer', + name='is_active', + ), + migrations.RemoveField( + model_name='customer', + name='is_vip', + ), + ] diff --git a/myproject/customers/migrations/0005_alter_customer_phone.py b/myproject/customers/migrations/0005_alter_customer_phone.py new file mode 100644 index 0000000..f4697c6 --- /dev/null +++ b/myproject/customers/migrations/0005_alter_customer_phone.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-10-24 17:01 + +import phonenumber_field.modelfields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0004_remove_address_customers_a_is_acti_433713_idx_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='customer', + name='phone', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, unique=True, verbose_name='Телефон'), + ), + ] diff --git a/myproject/customers/migrations/__init__.py b/myproject/customers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/customers/models.py b/myproject/customers/models.py new file mode 100644 index 0000000..01a93ce --- /dev/null +++ b/myproject/customers/models.py @@ -0,0 +1,288 @@ +import phonenumbers +from django.core.exceptions import ValidationError +from django.db import models +from phonenumber_field.modelfields import PhoneNumberField +from products.models import Product + + +class Customer(models.Model): + """ + Модель клиента для цветочного магазина в Минске, Беларусь. + """ + # Name field that is not required to be unique + name = models.CharField(max_length=200, blank=True, verbose_name="Имя") + + email = models.EmailField(blank=True, verbose_name="Email") + + # Phone with validation using django-phonenumber-field + phone = PhoneNumberField( + blank=True, + null=True, + unique=True, + verbose_name="Телефон", + help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат" + ) + + # Temporary field to store raw phone number during initialization + _raw_phone = None + + # Loyalty program + loyalty_tier = models.CharField( + max_length=20, + choices=[ + ('no_discount', 'Без скидки'), + ('bronze', 'Бронза'), + ('silver', 'Серебро'), + ('gold', 'Золото'), + ('platinum', 'Платина'), + ], + default='no_discount', + verbose_name="Уровень лояльности" + ) + + total_spent = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + verbose_name="Общая сумма покупок" + ) + + # Additional notes + notes = models.TextField( + blank=True, + null=True, + verbose_name="Заметки", + help_text="Заметки о клиенте, особые предпочтения и т.д." + ) + + + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") + + class Meta: + verbose_name = "Клиент" + verbose_name_plural = "Клиенты" + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['email']), + models.Index(fields=['phone']), + models.Index(fields=['created_at']), + models.Index(fields=['loyalty_tier']), + ] + ordering = ['-created_at'] + + def __str__(self): + if self.name: + return self.name + if self.phone: + return str(self.phone) + if self.email: + return self.email + return "Безымянный клиент" + + @property + def full_name(self): + """Полное имя клиента""" + return self.name + + @property + def is_vip(self): + """Проверяет, является ли клиент VIP на основе уровня лояльности""" + return self.loyalty_tier in ("gold", "platinum") + + def get_loyalty_discount(self): + """Возвращает скидку в зависимости от уровня лояльности""" + discounts = { + 'no_discount': 0, + 'bronze': 0, + 'silver': 5, # 5% + 'gold': 10, # 10% + 'platinum': 15 # 15% + } + return discounts.get(self.loyalty_tier, 0) + + def validate_unique(self, exclude=None): + """Override to handle unique phone validation properly during updates""" + # Run the phone number normalization again before unique validation + if self.phone: + # Check for existing customers with the same phone (excluding current instance if updating) + existing = Customer.objects.filter(phone=self.phone) + if self.pk: + existing = existing.exclude(pk=self.pk) + if existing.exists(): + raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'}) + + # Call parent validate_unique to handle other validation + super().validate_unique(exclude=exclude) + + def clean_phone(self): + """Custom cleaning for phone field to normalize before validation.""" + if self.phone: + try: + # Parse the phone number to check if it's valid and normalize it + raw_phone = str(self.phone) + + # If it starts with '8' and has 11 digits, it might be Russian domestic format + if raw_phone.startswith('8') and len(raw_phone) == 11: + # Try BY first for Belarusian numbers + parsed = phonenumbers.parse(raw_phone, "BY") + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + + # If BY doesn't work, try RU as fallback + parsed = phonenumbers.parse(raw_phone, "RU") + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + + # Try to parse without country code (might already be in international format) + parsed = phonenumbers.parse(raw_phone, None) + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + + except phonenumbers.NumberParseException: + # If parsing fails, return as is and let field validation handle it + pass + + return self.phone + + def clean_fields(self, exclude=None): + # Normalize phone before field validation runs + if not exclude: + exclude = [] + if 'phone' not in exclude and self.phone: + normalized = None + try: + normalized = self.clean_phone() + except Exception: + normalized = None + if normalized: + # assign normalized value (E.164) so PhoneNumberField sees корректный формат + self.phone = normalized + + super().clean_fields(exclude=exclude) + + def clean(self): + """Additional validation if needed.""" + super().clean() + + def save(self, *args, **kwargs): + # Ensure phone is normalized even if save is called directly (not through form) + # At this point, if it came through form validation, phone should already be normalized + # But if save is called directly on the model, we still need to normalize + if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11: + # This is likely a domestic format number that needs normalization + try: + # Try BY first for Belarusian numbers + parsed = phonenumbers.parse(str(self.phone), "BY") + if phonenumbers.is_valid_number(parsed): + self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + else: + # If BY doesn't work, try RU as fallback + parsed = phonenumbers.parse(str(self.phone), "RU") + if phonenumbers.is_valid_number(parsed): + self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + except phonenumbers.NumberParseException: + # If parsing fails, don't change it and let the field validation handle it + pass + + super().save(*args, **kwargs) + + def increment_total_spent(self, amount): + """Увеличивает общую сумму покупок""" + self.total_spent = self.total_spent + amount + self.save(update_fields=['total_spent']) + + +class Address(models.Model): + """ + Модель адреса доставки для клиентов цветочного магазина в Минске. + Клиент может иметь несколько адресов для разных получателей. + """ + customer = models.ForeignKey( + Customer, + on_delete=models.CASCADE, + related_name='addresses', + verbose_name="Клиент" + ) + + # Address information for delivery in Minsk + recipient_name = models.CharField( + max_length=200, + verbose_name="Имя получателя", + help_text="Имя человека, которому будет доставлен заказ" + ) + + street = models.CharField( + max_length=255, + verbose_name="Улица" + ) + + building_number = models.CharField( + max_length=20, + verbose_name="Номер здания" + ) + + apartment_number = models.CharField( + max_length=20, + blank=True, + null=True, + verbose_name="Номер квартиры/офиса" + ) + + district = models.CharField( + max_length=100, + blank=True, + null=True, + verbose_name="Район", + help_text="Район в Минске для удобства доставки" + ) + + # Additional information for delivery + delivery_instructions = models.TextField( + blank=True, + null=True, + verbose_name="Инструкции для доставки", + help_text="Дополнительные инструкции для курьера (домофон, подъезд и т.д.)" + ) + + is_default = models.BooleanField( + default=False, + verbose_name="Адрес по умолчанию", + help_text="Использовать этот адрес для доставки по умолчанию" + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") + + class Meta: + verbose_name = "Адрес доставки" + verbose_name_plural = "Адреса доставки" + indexes = [ + models.Index(fields=['customer']), + models.Index(fields=['is_default']), + models.Index(fields=['district']), + ] + ordering = ['-is_default', '-created_at'] + + def save(self, *args, **kwargs): + if self.is_default: + # If this address is being set as default, unset the default flag on other addresses for this customer + Address.objects.filter(customer=self.customer, is_default=True).update(is_default=False) + super().save(*args, **kwargs) + + def __str__(self): + address_line = f"{self.street}, {self.building_number}" + if self.apartment_number: + address_line += f", кв/офис {self.apartment_number}" + return f"{self.recipient_name} - {address_line}, {self.customer.full_name}" + + @property + def full_address(self): + """Полный адрес для доставки""" + address = f"{self.street}, {self.building_number}" + if self.apartment_number: + address += f", кв/офис {self.apartment_number}" + return address diff --git a/myproject/customers/templates/customers/customer_confirm_delete.html b/myproject/customers/templates/customers/customer_confirm_delete.html new file mode 100644 index 0000000..cca9cc3 --- /dev/null +++ b/myproject/customers/templates/customers/customer_confirm_delete.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Удалить клиента{% endblock %} + +{% block content %} +
+
+
+

Удалить клиента

+ +
+
+

Вы уверены, что хотите удалить следующего клиента?

+ +
+
{{ customer.full_name }}
+

+ Email: {{ customer.email }}
+ Телефон: {{ customer.phone|default:"Не указан" }}
+ Уровень лояльности: {{ customer.get_loyalty_tier_display }}
+ VIP: {% if customer.is_vip %}Да{% else %}Нет{% endif %} +

+
+ +

Внимание: Это действие нельзя отменить. Все данные клиента будут удалены.

+ +
+ {% csrf_token %} +
+ + Отмена +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/customers/templates/customers/customer_detail.html b/myproject/customers/templates/customers/customer_detail.html new file mode 100644 index 0000000..29362f0 --- /dev/null +++ b/myproject/customers/templates/customers/customer_detail.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} + +{% block title %}{{ customer.full_name }}{% endblock %} + +{% block content %} +
+
+
+
+

Клиент: {{ customer.full_name }}

+
+ Редактировать + Удалить + Назад к списку +
+
+
+
+ +
+ +
+
+
+
Информация о клиенте
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Имя:{{ customer.full_name }}
Email:{{ customer.email }}
Телефон:{{ customer.phone }}
Уровень лояльности: + + {{ customer.get_loyalty_tier_display }} + + ({{ customer.get_loyalty_discount }}% скидка) +
Сумма покупок:{{ customer.total_spent|floatformat:2 }} руб.
VIP: + {% if customer.is_vip %} + Да + {% else %} + Нет + {% endif %} +
День рождения:{{ customer.birthday|date:"d.m.Y"|default:"Не указан" }}
Годовщина:{{ customer.anniversary|date:"d.m.Y"|default:"Не указана" }}
Предпочтительные цвета:{{ customer.preferred_colors|default:"Не указаны" }}
Заметки:{{ customer.notes|default:"Нет" }}
Дата создания:{{ customer.created_at|date:"d.m.Y H:i" }}
Дата обновления:{{ customer.updated_at|date:"d.m.Y H:i" }}
+
+
+
+ + +
+
+
+
Адреса доставки
+ Добавить адрес +
+
+ {% if addresses %} + {% for address in addresses %} +
+ {% if address.is_default %} + Адрес по умолчанию + {% endif %} +
{{ address.recipient_name }}
+

+ Адрес: {{ address.full_address }}, {{ address.district }} +

+ {% if address.delivery_instructions %} +

+ Инструкции: {{ address.delivery_instructions }} +

+ {% endif %} + +
+ {% endfor %} + {% else %} +

У клиента нет сохраненных адресов доставки.

+ {% endif %} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/customers/templates/customers/customer_form.html b/myproject/customers/templates/customers/customer_form.html new file mode 100644 index 0000000..9f7bb63 --- /dev/null +++ b/myproject/customers/templates/customers/customer_form.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} + +{% block title %} + {% if is_creating %}Добавить нового клиента{% else %}Редактировать клиента{% endif %} +{% endblock %} + +{% block content %} +
+
+
+

+ {% if is_creating %} + Добавить нового клиента + {% else %} + Редактировать клиента + {% endif %} +

+ +
+ {% csrf_token %} + +
+ +
+
+
+
Личная информация
+
+
+
+ {{ form.name.label_tag }} + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ +
+ {{ form.phone.label_tag }} + {{ form.phone }} +
Введите телефон в любом формате, например: +375291234567, 80291234567
+ {% if form.phone.errors %} +
{{ form.phone.errors }}
+ {% endif %} +
+ +
+ {{ form.email.label_tag }} + {{ form.email }} + {% if form.email.errors %} +
{{ form.email.errors }}
+ {% endif %} +
+
+
+
+ + +
+
+
+
Предпочтения и статус
+
+
+
+ {{ form.loyalty_tier.label_tag }} + {{ form.loyalty_tier }} + {% if form.loyalty_tier.errors %} +
{{ form.loyalty_tier.errors }}
+ {% endif %} +
+ + + + +
+
+
+
+ + +
+
+
Дополнительная информация
+
+
+
+ {{ form.notes.label_tag }} + {{ form.notes }} + {% if form.notes.errors %} +
{{ form.notes.errors }}
+ {% endif %} +
+
+
+ + +
+ + Отмена +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/customers/templates/customers/customer_list.html b/myproject/customers/templates/customers/customer_list.html new file mode 100644 index 0000000..a96d758 --- /dev/null +++ b/myproject/customers/templates/customers/customer_list.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} + +{% block title %}Клиенты{% endblock %} + +{% block content %} +
+
+
+ +
+

Клиенты

+ Добавить клиента +
+ + +
+
+
+
+ +
+
+ + {% if query %} + Очистить + {% endif %} +
+
+
+
+ + +
+
+ + {% if page_obj %} +
+ + + + + + + + + + + + + + {% for customer in page_obj %} + + + + + + + + + + + + + + {% endfor %} + +
ИмяEmailТелефонУровень лояльностиСумма покупокVIPДействия
{{ customer.full_name }}{{ customer.email|default:'—' }}{{ customer.phone|default:'—' }} + + {{ customer.get_loyalty_tier_display }} + + {{ customer.total_spent|default:0|floatformat:2 }} ₽ + {% if customer.is_vip %} + Да + {% else %} + Нет + {% endif %} + + 👁 + +
+
+ + + {% if page_obj.has_other_pages %} + + {% endif %} + + {% else %} +

Клиенты не найдены.

+ {% endif %} + +
+
+ +
+
+
+{% endblock %} diff --git a/myproject/customers/urls.py b/myproject/customers/urls.py new file mode 100644 index 0000000..acb3521 --- /dev/null +++ b/myproject/customers/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + +app_name = 'customers' + +urlpatterns = [ + path('', views.customer_list, name='customer-list'), + path('create/', views.customer_create, name='customer-create'), + path('/', views.customer_detail, name='customer-detail'), + path('/edit/', views.customer_update, name='customer-update'), + path('/delete/', views.customer_delete, name='customer-delete'), +] \ No newline at end of file diff --git a/myproject/customers/views.py b/myproject/customers/views.py new file mode 100644 index 0000000..c54b59d --- /dev/null +++ b/myproject/customers/views.py @@ -0,0 +1,105 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib import messages +from django.core.paginator import Paginator +from django.core.exceptions import ValidationError +from django.db.models import Q +import phonenumbers +from .models import Customer, Address +from .forms import CustomerForm + + +def normalize_query_phone(q): + """Normalize phone number for search""" + try: + parsed = phonenumbers.parse(q, "BY") + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + return q + except: + return q + + +def customer_list(request): + """Список всех клиентов""" + query = request.GET.get('q') + customers = Customer.objects.all() + + if query: + # Try to normalize the phone number for searching + phone_normalized = normalize_query_phone(query) + customers = customers.filter( + Q(name__icontains=query) | + Q(email__icontains=query) | + Q(phone__icontains=phone_normalized) + ) + + customers = customers.order_by('-created_at') + + # Пагинация + paginator = Paginator(customers, 25) # 25 клиентов на страницу + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'query': query, + } + return render(request, 'customers/customer_list.html', context) + + +def customer_detail(request, pk): + """Детали клиента""" + customer = get_object_or_404(Customer, pk=pk) + addresses = customer.addresses.all() + + context = { + 'customer': customer, + 'addresses': addresses, + } + return render(request, 'customers/customer_detail.html', context) + + +def customer_create(request): + """Создание нового клиента""" + if request.method == 'POST': + form = CustomerForm(request.POST) + if form.is_valid(): + customer = form.save() + messages.success(request, f'Клиент {customer.full_name} успешно создан.') + return redirect('customers:customer-detail', pk=customer.pk) + else: + form = CustomerForm() + + return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': True}) + + +def customer_update(request, pk): + """Редактирование клиента""" + customer = get_object_or_404(Customer, pk=pk) + + if request.method == 'POST': + form = CustomerForm(request.POST, instance=customer) + if form.is_valid(): + form.save() + messages.success(request, f'Клиент {customer.full_name} успешно обновлён.') + return redirect('customers:customer-detail', pk=customer.pk) + else: + form = CustomerForm(instance=customer) + + return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': False}) + + +def customer_delete(request, pk): + """Удаление клиента""" + customer = get_object_or_404(Customer, pk=pk) + + if request.method == 'POST': + customer_name = customer.full_name + customer.delete() + messages.success(request, f'Клиент {customer_name} успешно удален.') + return redirect('customers:customer-list') + + context = { + 'customer': customer + } + return render(request, 'customers/customer_confirm_delete.html', context) \ No newline at end of file diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py index 03d722f..53b638d 100644 --- a/myproject/inventory/migrations/0001_initial.py +++ b/myproject/inventory/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-22 13:03 +# Generated by Django 5.2.7 on 2025-10-23 20:27 import django.db.models.deletion from django.db import migrations, models diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 1471060..a01f7b2 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = [ 'products', 'inventory', 'orders', + 'customers', ] MIDDLEWARE = [ @@ -185,5 +186,20 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'noreply@example.com' +# Настройки телефонных номеров +PHONENUMBER_DEFAULT_REGION = 'BY' # Регион по умолчанию для номеров без кода страны + # Указываем нашу кастомную модель пользователя AUTH_USER_MODEL = 'accounts.CustomUser' + +# ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL +# Регистрируем кастомную функцию LOWER для поддержки кириллицы в SQLite +if 'sqlite' in DATABASES['default']['ENGINE']: + from django.db.backends.signals import connection_created + from django.dispatch import receiver + + @receiver(connection_created) + def setup_sqlite_unicode_support(sender, connection, **kwargs): + """Добавляет поддержку Unicode для LOWER() в SQLite""" + if connection.vendor == 'sqlite': + connection.connection.create_function('LOWER', 1, lambda s: s.lower() if s else s) diff --git a/myproject/myproject/urls.py b/myproject/myproject/urls.py index de51aa8..a8e3857 100644 --- a/myproject/myproject/urls.py +++ b/myproject/myproject/urls.py @@ -26,6 +26,7 @@ urlpatterns = [ path('', views.index, name='index'), # Main page path('accounts/', include('accounts.urls')), path('products/', include('products.urls')), + path('customers/', include('customers.urls')), ] # Serve media files during development diff --git a/myproject/myproject/urls.py.backup b/myproject/myproject/urls.py.backup new file mode 100644 index 0000000..de51aa8 --- /dev/null +++ b/myproject/myproject/urls.py.backup @@ -0,0 +1,33 @@ +""" +URL configuration for myproject project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from . import views + +urlpatterns = [ + path('_nested_admin/', include('nested_admin.urls')), # Для nested admin + path('admin/', admin.site.urls), + path('', views.index, name='index'), # Main page + path('accounts/', include('accounts.urls')), + path('products/', include('products.urls')), +] + +# Serve media files during development +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/myproject/myproject/views.py.backup b/myproject/myproject/views.py.backup new file mode 100644 index 0000000..2d042e6 --- /dev/null +++ b/myproject/myproject/views.py.backup @@ -0,0 +1,12 @@ +from django.shortcuts import render + + +def index(request): + # Главная страница - отображается для всех пользователей + # Если пользователь авторизован, можно показать персонализированное содержимое + if request.user.is_authenticated: + # Здесь можно отобразить персонализированное содержимое для авторизованных пользователей + return render(request, 'dashboard.html') # или другую страницу + else: + # Для неавторизованных пользователей показываем приветственную страницу + return render(request, 'home.html') \ No newline at end of file diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py index 05ad968..a73d2e5 100644 --- a/myproject/orders/migrations/0001_initial.py +++ b/myproject/orders/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-22 13:03 +# Generated by Django 5.2.7 on 2025-10-23 20:27 import django.db.models.deletion from django.conf import settings diff --git a/myproject/products/migrations/0002_kititem_products_ki_kit_id_d28dc9_idx_and_more.py b/myproject/products/migrations/0002_kititem_products_ki_kit_id_d28dc9_idx_and_more.py new file mode 100644 index 0000000..1a8841d --- /dev/null +++ b/myproject/products/migrations/0002_kititem_products_ki_kit_id_d28dc9_idx_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.7 on 2025-10-24 07:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + ] + + operations = [ + migrations.AddIndex( + model_name='kititem', + index=models.Index(fields=['kit'], name='products_ki_kit_id_d28dc9_idx'), + ), + migrations.AddIndex( + model_name='kititem', + index=models.Index(fields=['product'], name='products_ki_product_d2ad00_idx'), + ), + migrations.AddIndex( + model_name='kititem', + index=models.Index(fields=['variant_group'], name='products_ki_variant_e42628_idx'), + ), + migrations.AddIndex( + model_name='kititem', + index=models.Index(fields=['kit', 'product'], name='products_ki_kit_id_14738f_idx'), + ), + migrations.AddIndex( + model_name='kititem', + index=models.Index(fields=['kit', 'variant_group'], name='products_ki_kit_id_8199a8_idx'), + ), + ] diff --git a/myproject/products/migrations/0002_product_deleted_at_product_deleted_by_and_more.py b/myproject/products/migrations/0002_product_deleted_at_product_deleted_by_and_more.py deleted file mode 100644 index d49ec89..0000000 --- a/myproject/products/migrations/0002_product_deleted_at_product_deleted_by_and_more.py +++ /dev/null @@ -1,133 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-23 12:13 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), - ), - migrations.AddField( - model_name='product', - name='deleted_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_products', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'), - ), - migrations.AddField( - model_name='product', - name='is_deleted', - field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'), - ), - migrations.AddField( - model_name='product', - name='slug', - field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор'), - ), - migrations.AddField( - model_name='productcategory', - name='created_at', - field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'), - ), - migrations.AddField( - model_name='productcategory', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), - ), - migrations.AddField( - model_name='productcategory', - name='deleted_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_categories', to=settings.AUTH_USER_MODEL, verbose_name='Удалена пользователем'), - ), - migrations.AddField( - model_name='productcategory', - name='is_deleted', - field=models.BooleanField(db_index=True, default=False, verbose_name='Удалена'), - ), - migrations.AddField( - model_name='productcategory', - name='updated_at', - field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'), - ), - migrations.AddField( - model_name='productkit', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), - ), - migrations.AddField( - model_name='productkit', - name='deleted_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_kits', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'), - ), - migrations.AddField( - model_name='productkit', - name='is_deleted', - field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'), - ), - migrations.AddField( - model_name='producttag', - name='created_at', - field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'), - ), - migrations.AddField( - model_name='producttag', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), - ), - migrations.AddField( - model_name='producttag', - name='deleted_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_tags', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'), - ), - migrations.AddField( - model_name='producttag', - name='is_deleted', - field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'), - ), - migrations.AddField( - model_name='producttag', - name='updated_at', - field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'), - ), - migrations.AddIndex( - model_name='product', - index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_3bba04_idx'), - ), - migrations.AddIndex( - model_name='product', - index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_f30efb_idx'), - ), - migrations.AddIndex( - model_name='productcategory', - index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_2a96d1_idx'), - ), - migrations.AddIndex( - model_name='productcategory', - index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_b8cdf3_idx'), - ), - migrations.AddIndex( - model_name='productkit', - index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_e83a83_idx'), - ), - migrations.AddIndex( - model_name='productkit', - index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_1e5bec_idx'), - ), - migrations.AddIndex( - model_name='producttag', - index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_ea9be0_idx'), - ), - migrations.AddIndex( - model_name='producttag', - index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_bc2d9c_idx'), - ), - ] diff --git a/myproject/products/models.py b/myproject/products/models.py index 9d3c01b..c2ccd3b 100644 --- a/myproject/products/models.py +++ b/myproject/products/models.py @@ -523,6 +523,43 @@ class ProductKit(models.Model): def __str__(self): return self.name + def clean(self): + """Валидация комплекта перед сохранением""" + # Проверка соответствия метода ценообразования полям + if self.pricing_method == 'fixed' and not self.fixed_price: + raise ValidationError({ + 'fixed_price': 'Для метода ценообразования "fixed" необходимо указать фиксированную цену.' + }) + + if self.pricing_method == 'from_cost_plus_percent' and ( + self.markup_percent is None or self.markup_percent < 0 + ): + raise ValidationError({ + 'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.' + }) + + if self.pricing_method == 'from_cost_plus_amount' and ( + self.markup_amount is None or self.markup_amount < 0 + ): + raise ValidationError({ + 'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.' + }) + + # Проверка уникальности SKU (если задан) + if self.sku: + # Проверяем, что SKU не используется другим комплектом (если объект уже существует) + if self.pk: + if ProductKit.objects.filter(sku=self.sku).exclude(pk=self.pk).exists(): + raise ValidationError({ + 'sku': f'Артикул "{self.sku}" уже используется другим комплектом.' + }) + else: + # Для новых объектов просто проверяем, что SKU не используется + if ProductKit.objects.filter(sku=self.sku).exists(): + raise ValidationError({ + 'sku': f'Артикул "{self.sku}" уже используется другим комплектом.' + }) + def save(self, *args, **kwargs): if not self.slug: from unidecode import unidecode @@ -541,15 +578,30 @@ class ProductKit(models.Model): super().save(*args, **kwargs) def get_total_components_count(self): - """Возвращает количество позиций в букете""" + """ + Возвращает количество компонентов (строк) в комплекте. + + Returns: + int: Количество компонентов в комплекте + """ return self.kit_items.count() def get_components_with_variants_count(self): - """Возвращает количество позиций с группами вариантов""" + """ + Возвращает количество компонентов, которые используют группы вариантов. + + Returns: + int: Количество компонентов с группами вариантов + """ return self.kit_items.filter(variant_group__isnull=False).count() def get_sale_price(self): - """Возвращает рассчитанную цену продажи комплекта""" + """ + Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования. + + Returns: + Decimal: Цена продажи комплекта + """ try: return self.calculate_price_with_substitutions() except Exception: @@ -560,8 +612,16 @@ class ProductKit(models.Model): def check_availability(self, stock_manager=None): """ - Проверяет доступность всего букета. - Букет доступен, если для каждой позиции есть хотя бы один доступный вариант. + Проверяет доступность всего комплекта. + + Комплект доступен, если для каждой позиции в комплекте + есть хотя бы один доступный вариант товара. + + Args: + stock_manager: Объект управления складом (если не указан, используется стандартный) + + Returns: + bool: True, если комплект полностью доступен, иначе False """ from .utils.stock_manager import StockManager @@ -577,10 +637,18 @@ class ProductKit(models.Model): def calculate_price_with_substitutions(self, stock_manager=None): """ - Расчёт цены букета с учётом доступных замен. - Использует цены фактически доступных товаров. + Расчёт цены комплекта с учётом доступных замен компонентов. + + Метод определяет цену комплекта, учитывая доступные товары-заменители + и применяет выбранный метод ценообразования. + + Args: + stock_manager: Объект управления складом (если не указан, используется стандартный) + + Returns: + Decimal: Расчетная цена комплекта, или 0 в случае ошибки """ - from decimal import Decimal + from decimal import Decimal, InvalidOperation from .utils.stock_manager import StockManager if stock_manager is None: @@ -594,28 +662,75 @@ class ProductKit(models.Model): total_sale = Decimal('0.00') for kit_item in self.kit_items.select_related('product', 'variant_group'): - best_product = kit_item.get_best_available_product(stock_manager) + 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 not best_product: + # Если товар недоступен, используем цену первого в списке + available_products = kit_item.get_available_products() + best_product = available_products[0] if available_products else None - if best_product: - total_cost += best_product.cost_price * kit_item.quantity - total_sale += best_product.sale_price * kit_item.quantity + if best_product: + item_cost = best_product.cost_price + item_sale = best_product.sale_price + item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано + + # Проверяем корректность значений перед умножением + if item_cost and item_quantity: + total_cost += item_cost * item_quantity + if item_sale and item_quantity: + total_sale += item_sale * item_quantity + except (AttributeError, TypeError, InvalidOperation) as e: + # Логируем ошибку, но продолжаем вычисления + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Ошибка при расчёте цены для комплекта {self.name} (item: {kit_item}): {e}") + continue # Пропускаем ошибочный элемент и продолжаем с остальными # Применяем метод ценообразования - if self.pricing_method == 'from_sale_prices': - return total_sale - elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent: - return total_cost * (Decimal('1') + self.markup_percent / Decimal('100')) - elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount: - return total_cost + self.markup_amount - elif self.pricing_method == 'fixed' and self.fixed_price: - return self.fixed_price + try: + if self.pricing_method == 'from_sale_prices': + return total_sale + elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent is not None: + return total_cost * (Decimal('1') + self.markup_percent / Decimal('100')) + elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None: + return total_cost + self.markup_amount + elif self.pricing_method == 'fixed' and self.fixed_price: + return self.fixed_price - return total_sale + return total_sale + except (TypeError, InvalidOperation) as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}") + # Возвращаем фиксированную цену если есть, иначе 0 + if self.pricing_method == 'fixed' and self.fixed_price: + return self.fixed_price + return Decimal('0.00') + + def calculate_cost(self): + """ + Расчёт себестоимости комплекта на основе себестоимости компонентов. + + Returns: + Decimal: Себестоимость комплекта + """ + from decimal import Decimal + total_cost = Decimal('0.00') + + for kit_item in self.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: + item_cost = product.cost_price + item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано + total_cost += item_cost * item_quantity + + return total_cost def delete(self, *args, **kwargs): """Soft delete вместо hard delete - марк как удаленный""" @@ -663,6 +778,13 @@ class KitItem(models.Model): class Meta: verbose_name = "Компонент комплекта" verbose_name_plural = "Компоненты комплектов" + indexes = [ + models.Index(fields=['kit']), + models.Index(fields=['product']), + models.Index(fields=['variant_group']), + models.Index(fields=['kit', 'product']), + models.Index(fields=['kit', 'variant_group']), + ] def __str__(self): return f"{self.kit.name} - {self.get_display_name()}" @@ -679,17 +801,36 @@ class KitItem(models.Model): ) def get_display_name(self): - """Возвращает название для отображения (товар или группа)""" + """ + Возвращает строку для отображения названия компонента. + + Returns: + str: Название компонента (либо группа вариантов, либо конкретный товар) + """ if self.variant_group: return f"[Варианты] {self.variant_group.name}" return self.product.name if self.product else "Не указан" def has_priorities_set(self): - """Проверяет, настроены ли приоритеты""" + """ + Проверяет, настроены ли приоритеты замены для данного компонента. + + Returns: + bool: True, если приоритеты установлены, иначе False + """ return self.priorities.exists() def get_available_products(self): - """Возвращает список доступных товаров с учётом приоритетов""" + """ + Возвращает список доступных товаров для этого компонента. + + Если указан конкретный товар - возвращает его. + Если указаны приоритеты - возвращает товары в порядке приоритета. + Если не указаны приоритеты - возвращает все активные товары из группы вариантов. + + Returns: + list: Список доступных товаров + """ if self.product: # Если указан конкретный товар, возвращаем только его return [self.product] @@ -707,7 +848,15 @@ class KitItem(models.Model): return [] def get_best_available_product(self, stock_manager=None): - """Возвращает первый доступный товар по приоритету""" + """ + Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству. + + Args: + stock_manager: Объект управления складом (если не указан, используется стандартный) + + Returns: + Product or None: Первый доступный товар или None, если ничего не доступно + """ from .utils.stock_manager import StockManager if stock_manager is None: diff --git a/myproject/products/templates/products/includes/kititem_formset.html b/myproject/products/templates/products/includes/kititem_formset.html new file mode 100644 index 0000000..b63ae3a --- /dev/null +++ b/myproject/products/templates/products/includes/kititem_formset.html @@ -0,0 +1,71 @@ + +
+
+
Состав комплекта
+ + {{ kititem_formset.management_form }} + +
+ {% for kititem_form in kititem_formset %} +
+ {{ kititem_form.id }} +
+ {% if kititem_form.non_field_errors %} +
+ {% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %} +
+ {% endif %} + +
+
+ + {{ kititem_form.product }} + {% if kititem_form.product.errors %} +
{{ kititem_form.product.errors }}
+ {% endif %} +
+
+ + {{ kititem_form.variant_group }} + {% if kititem_form.variant_group.errors %} +
{{ kititem_form.variant_group.errors }}
+ {% endif %} +
+
+ + {{ kititem_form.quantity }} + {% if kititem_form.quantity.errors %} +
{{ kititem_form.quantity.errors }}
+ {% endif %} +
+
+ {% if kititem_form.DELETE %} + + {{ kititem_form.DELETE }} + {% endif %} +
+
+ + {% if kititem_form.notes %} +
+
+ + {{ kititem_form.notes }} +
+
+ {% endif %} +
+
+ {% endfor %} +
+ + +
+ +
+
+
diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html index e5d7b24..bf23913 100644 --- a/myproject/products/templates/products/productkit_create.html +++ b/myproject/products/templates/products/productkit_create.html @@ -46,76 +46,7 @@ -
-
-
Состав комплекта
- - {{ kititem_formset.management_form }} - -
- {% for kititem_form in kititem_formset %} -
- {{ kititem_form.id }} -
- {% if kititem_form.non_field_errors %} -
- {% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %} -
- {% endif %} - -
-
- - {{ kititem_form.product }} - {% if kititem_form.product.errors %} -
{{ kititem_form.product.errors }}
- {% endif %} -
-
- - {{ kititem_form.variant_group }} - {% if kititem_form.variant_group.errors %} -
{{ kititem_form.variant_group.errors }}
- {% endif %} -
-
- - {{ kititem_form.quantity }} - {% if kititem_form.quantity.errors %} -
{{ kititem_form.quantity.errors }}
- {% endif %} -
-
- {% if kititem_form.DELETE %} - - {{ kititem_form.DELETE }} - {% endif %} -
-
- - {% if kititem_form.notes %} -
-
- - {{ kititem_form.notes }} -
-
- {% endif %} -
-
- {% endfor %} -
- - -
- -
-
-
+ {% include 'products/includes/kititem_formset.html' %}
diff --git a/myproject/products/templates/products/productkit_detail.html b/myproject/products/templates/products/productkit_detail.html index 28f174b..58a215b 100644 --- a/myproject/products/templates/products/productkit_detail.html +++ b/myproject/products/templates/products/productkit_detail.html @@ -52,6 +52,11 @@ {{ kit.get_sale_price|floatformat:2 }} ₽ +
Себестоимость:
+
+ {{ kit.calculate_cost|floatformat:2 }} ₽ +
+
Ценообразование:
{{ kit.get_pricing_method_display }} diff --git a/myproject/products/templates/products/productkit_edit.html b/myproject/products/templates/products/productkit_edit.html index 517c085..e03ef02 100644 --- a/myproject/products/templates/products/productkit_edit.html +++ b/myproject/products/templates/products/productkit_edit.html @@ -3,1083 +3,777 @@ {% block title %}Редактировать комплект: {{ object.name }}{% endblock %} {% block content %} -
- -