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 %}
+
+
+
+
Внимание: Это действие нельзя отменить. Все данные клиента будут удалены.
+
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
+{% 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 page_obj %}
+
+
+
+
+ | Имя |
+ Email |
+ Телефон |
+ Уровень лояльности |
+ Сумма покупок |
+ VIP |
+ Действия |
+
+
+
+ {% for customer in page_obj %}
+
+ | {{ 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 %}
+ |
+
+
+ 👁
+ ✎
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% 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 }}
+
+
+
+
+
+
+
+
+
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 }}
-
-
-
-
-
-
-
-
-
+ {% 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 %}
-