Удалены поля loyalty_tier, is_vip, get_loyalty_discount(), increment_total_spent(). Поле total_spent оставлено для будущего расчёта по заказам. Обновлены admin, forms, views и шаблоны. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
211 lines
11 KiB
Python
211 lines
11 KiB
Python
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, null=True, unique=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
|
||
|
||
total_spent = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
verbose_name="Общая сумма покупок"
|
||
)
|
||
|
||
# System customer flag
|
||
is_system_customer = models.BooleanField(
|
||
default=False,
|
||
db_index=True,
|
||
verbose_name="Системный клиент",
|
||
help_text="Автоматически созданный клиент для анонимных покупок и наличных продаж"
|
||
)
|
||
|
||
# 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']),
|
||
]
|
||
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
|
||
|
||
def validate_unique(self, exclude=None):
|
||
"""Переопределение для корректной проверки уникальности телефона при обновлениях"""
|
||
# Снова нормализуем номер телефона перед проверкой уникальности
|
||
if self.phone:
|
||
# Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении)
|
||
existing = Customer.objects.filter(phone=self.phone)
|
||
if self.pk:
|
||
existing = existing.exclude(pk=self.pk)
|
||
if existing.exists():
|
||
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
|
||
|
||
# Вызываем родительский validate_unique для обработки других проверок
|
||
super().validate_unique(exclude=exclude)
|
||
|
||
def clean_phone(self):
|
||
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
|
||
if self.phone:
|
||
try:
|
||
# Парсим номер телефона, чтобы проверить его валидность и нормализовать
|
||
raw_phone = str(self.phone)
|
||
|
||
# Если начинается с '8' и имеет 11 цифр, это может быть российский формат
|
||
if raw_phone.startswith('8') and len(raw_phone) == 11:
|
||
# Сначала пробуем BY для белорусских номеров
|
||
parsed = phonenumbers.parse(raw_phone, "BY")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
|
||
# Если BY не работает, пробуем RU как запасной вариант
|
||
parsed = phonenumbers.parse(raw_phone, "RU")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
|
||
# Пробуем парсить без кода страны (может быть уже в международном формате)
|
||
parsed = phonenumbers.parse(raw_phone, None)
|
||
if phonenumbers.is_valid_number(parsed):
|
||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
|
||
except phonenumbers.NumberParseException:
|
||
# Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
|
||
pass
|
||
|
||
return self.phone
|
||
|
||
def clean_fields(self, exclude=None):
|
||
# Нормализуем телефон перед запуском проверки полей
|
||
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:
|
||
# присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
|
||
self.phone = normalized
|
||
|
||
super().clean_fields(exclude=exclude)
|
||
|
||
def clean(self):
|
||
"""Дополнительная валидация при необходимости."""
|
||
super().clean()
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Защита системного клиента от изменений
|
||
if self.pk and self.is_system_customer:
|
||
# Получаем оригинальный объект из БД
|
||
try:
|
||
original = Customer.objects.get(pk=self.pk)
|
||
# Проверяем, не пытаются ли изменить критичные поля
|
||
if original.email != self.email:
|
||
raise ValidationError("Нельзя изменить email системного клиента")
|
||
if original.is_system_customer != self.is_system_customer:
|
||
raise ValidationError("Нельзя изменить флаг системного клиента")
|
||
except Customer.DoesNotExist:
|
||
pass
|
||
|
||
# Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
|
||
# На данный момент, если вызов прошел через валидацию формы, телефон уже должен быть нормализован
|
||
# Но если save вызывается непосредственно в модели, нам все равно нужно нормализовать
|
||
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
|
||
# Это, вероятно, номер в формате страны, который требует нормализации
|
||
try:
|
||
# Сначала пробуем BY для белорусских номеров
|
||
parsed = phonenumbers.parse(str(self.phone), "BY")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
else:
|
||
# Если BY не работает, пробуем RU как запасной вариант
|
||
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:
|
||
# Если парсинг не удался, не меняем и позволяем валидации поля обработать это
|
||
pass
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def delete(self, *args, **kwargs):
|
||
"""Защита системного клиента от удаления"""
|
||
if self.is_system_customer:
|
||
raise ValidationError("Нельзя удалить системного клиента. Он необходим для работы системы.")
|
||
super().delete(*args, **kwargs)
|
||
|
||
@classmethod
|
||
def get_or_create_system_customer(cls):
|
||
"""
|
||
Получить или создать системного клиента для анонимных покупок.
|
||
|
||
Системный клиент используется для:
|
||
- Анонимных покупок в POS системе
|
||
- Покупок от неизвестных клиентов (проходимость)
|
||
- Наличных продаж без указания покупателя
|
||
|
||
Возвращает:
|
||
tuple: (customer, created) - объект клиента и флаг создания
|
||
"""
|
||
customer, created = cls.objects.get_or_create(
|
||
email="system@pos.customer",
|
||
defaults={
|
||
"name": "АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS)",
|
||
"is_system_customer": True,
|
||
"notes": "SYSTEM_CUSTOMER - автоматически созданный клиент для анонимных покупок и наличных продаж",
|
||
}
|
||
)
|
||
return customer, created
|
||
|