# -*- coding: utf-8 -*- from django import forms from django.forms import inlineformset_factory from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient from customers.models import Customer from inventory.models import Warehouse from products.models import Product, ProductKit from decimal import Decimal class OrderForm(forms.ModelForm): """Форма для создания и редактирования заказа""" # Поля для работы с получателем recipient_mode = forms.ChoiceField( choices=[ ('customer', 'Покупатель является получателем'), ('history', 'Выбрать из истории'), ('new', 'Другой получатель'), ], initial='customer', widget=forms.RadioSelect(attrs={'class': 'form-check-input'}), required=False, label='Получатель' ) # Выбор получателя из истории recipient_from_history = forms.ModelChoiceField( queryset=Recipient.objects.none(), required=False, widget=forms.Select(attrs={'class': 'form-select'}), label='Получатель из истории' ) # Поля для нового получателя recipient_name = forms.CharField( max_length=200, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Имя получателя'}), label='Имя получателя' ) recipient_phone = forms.CharField( max_length=20, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Телефон получателя'}), label='Телефон получателя' ) # Поля для работы с адресом address_mode = forms.ChoiceField( choices=[ ('history', 'Выбрать из истории'), ('new', 'Ввести новый адрес'), ('empty', 'Без адреса (заполнить позже)'), ], initial='empty', widget=forms.RadioSelect(attrs={'class': 'form-check-input'}), required=False, label='Способ указания адреса' ) # Выбор адреса из истории address_from_history = forms.ModelChoiceField( queryset=Address.objects.none(), required=False, widget=forms.Select(attrs={'class': 'form-select'}), label='Адрес из истории' ) # Поля для ввода нового адреса address_street = forms.CharField( max_length=255, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Улица'}), label='Улица' ) address_building_number = forms.CharField( max_length=20, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Номер дома'}), label='Номер дома' ) address_apartment_number = forms.CharField( max_length=20, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Квартира/офис'}), label='Квартира/офис' ) address_entrance = forms.CharField( max_length=20, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Подъезд'}), label='Подъезд' ) address_floor = forms.CharField( max_length=20, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Этаж'}), label='Этаж' ) address_intercom_code = forms.CharField( max_length=100, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Код домофона'}), label='Код домофона' ) address_delivery_instructions = forms.CharField( required=False, widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Инструкции для курьера'}), label='Инструкции для доставки' ) address_confirm_with_recipient = forms.BooleanField( required=False, widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), label='Уточнить адрес у получателя' ) class Meta: model = Order fields = [ 'customer', 'is_delivery', 'delivery_address', 'pickup_warehouse', 'delivery_date', 'delivery_time_start', 'delivery_time_end', 'delivery_cost', 'customer_is_recipient', 'recipient', 'status', 'is_anonymous', 'special_instructions', ] widgets = { 'delivery_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), 'delivery_time_start': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'), 'delivery_time_end': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'), 'special_instructions': forms.Textarea(attrs={'rows': 3}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Ограничиваем выбор статусов при создании заказа только промежуточными # (исключаем финальные положительные и отрицательные статусы) if not self.instance.pk: from .models import OrderStatus # Только промежуточные статусы (не финальные) intermediate_statuses = OrderStatus.objects.filter( is_positive_end=False, is_negative_end=False ).order_by('order', 'name') self.fields['status'].queryset = intermediate_statuses # Устанавливаем статус "Черновик" по умолчанию try: draft_status = OrderStatus.objects.get(code='draft', is_system=True) self.fields['status'].initial = draft_status.pk except OrderStatus.DoesNotExist: pass else: # При редактировании заказа доступны все статусы from .models import OrderStatus self.fields['status'].queryset = OrderStatus.objects.all().order_by('order', 'name') # Делаем поле status обязательным и убираем пустой выбор "-------" self.fields['status'].required = True self.fields['status'].empty_label = None # Добавляем Bootstrap классы ко всем полям for field_name, field in self.fields.items(): if isinstance(field.widget, forms.CheckboxInput): field.widget.attrs.update({'class': 'form-check-input'}) elif isinstance(field.widget, forms.Textarea): field.widget.attrs.update({'class': 'form-control', 'rows': 3}) elif isinstance(field.widget, forms.RadioSelect): # RadioSelect не нуждается в доп классах (уже есть form-check-input) pass elif isinstance(field.widget, forms.Select): # Select поля получают form-select field.widget.attrs.update({'class': 'form-select'}) else: # Остальные поля (TextInput, NumberInput, etc) field.widget.attrs.update({'class': 'form-control'}) # Select2 для поля customer с AJAX поиском (инициализируется отдельно в JS) # Django автоматически генерирует ID как id_customer self.fields['customer'].widget.attrs.update({ 'class': 'form-select', 'data-placeholder': 'Начните вводить имя, телефон или email' }) self.fields['delivery_address'].widget.attrs.update({ 'class': 'form-select select2', 'data-placeholder': 'Выберите адрес доставки' }) # Адрес доставки не обязателен при редактировании (создаётся из отдельных полей) self.fields['delivery_address'].required = False self.fields['pickup_warehouse'].widget.attrs.update({ 'class': 'form-select select2', 'data-placeholder': 'Выберите склад для самовывоза' }) self.fields['pickup_warehouse'].required = False # Опциональные поля даты/времени self.fields['delivery_date'].required = False self.fields['delivery_time_start'].required = False self.fields['delivery_time_end'].required = False # Подсказки self.fields['is_delivery'].label = 'С доставкой' self.fields['customer_is_recipient'].label = 'Покупатель = получатель' # Поле получателя опционально self.fields['recipient'].required = False # Поле ручной стоимости доставки опционально self.fields['delivery_cost'].required = False self.fields['delivery_cost'].label = 'Ручная стоимость доставки' self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета' # Инициализируем queryset для recipient_from_history if self.instance.pk and self.instance.customer: # При редактировании заказа загружаем историю получателей этого клиента customer_orders = Order.objects.filter( customer=self.instance.customer, recipient__isnull=False ).order_by('-created_at') self.fields['recipient_from_history'].queryset = Recipient.objects.filter( orders__in=customer_orders ).distinct().order_by('-created_at') # Инициализируем queryset для address_from_history # Это будет переопределено в представлении после выбора клиента if self.instance.pk and self.instance.customer: # При редактировании заказа загружаем историю адресов этого клиента customer_orders = Order.objects.filter( customer=self.instance.customer, delivery_address__isnull=False ).order_by('-created_at') self.fields['address_from_history'].queryset = Address.objects.filter( orders__in=customer_orders ).distinct().order_by('-created_at') # Инициализируем поля получателя из существующего recipient if self.instance.pk and self.instance.recipient: recipient = self.instance.recipient self.fields['recipient_name'].initial = recipient.name or '' self.fields['recipient_phone'].initial = recipient.phone or '' # Инициализируем поля адреса из существующего delivery_address if self.instance.pk and self.instance.delivery_address: address = self.instance.delivery_address self.fields['address_street'].initial = address.street or '' self.fields['address_building_number'].initial = address.building_number or '' self.fields['address_apartment_number'].initial = address.apartment_number or '' self.fields['address_entrance'].initial = address.entrance or '' self.fields['address_floor'].initial = address.floor or '' self.fields['address_intercom_code'].initial = address.intercom_code or '' self.fields['address_delivery_instructions'].initial = address.delivery_instructions or '' self.fields['address_confirm_with_recipient'].initial = address.confirm_address_with_recipient def save(self, commit=True): """ Сохраняет форму с учетом автоматического/ручного расчета стоимости доставки. Логика: - Если delivery_cost заполнено → используется ручное значение (is_custom_delivery_cost = True) - Если delivery_cost пустое → автоматический расчет (is_custom_delivery_cost = False) ВАЖНО: reset_delivery_cost() вызывается только при commit=True, т.к. требует наличия сохраненных items в БД. """ instance = super().save(commit=False) # Получаем значение ручной стоимости доставки delivery_cost = self.cleaned_data.get('delivery_cost') if delivery_cost is not None and delivery_cost > 0: # Ручное значение указано instance.set_delivery_cost(delivery_cost, is_custom=True) else: # Пустое поле или 0 → помечаем что нужен автоматический расчет # НО не вызываем reset_delivery_cost() если commit=False! instance.is_custom_delivery_cost = False if commit: # Автоматический расчет только при commit=True instance.reset_delivery_cost() if commit: instance.save() return instance class OrderItemForm(forms.ModelForm): """Форма для позиции заказа""" # Элегантно переопределяем поле формы, чтобы парсить '277,00' как Decimal price = forms.CharField( required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}) ) class Meta: model = OrderItem fields = ['product', 'product_kit', 'quantity', 'price', 'is_custom_price'] # ВАЖНО: НЕ включаем 'id' в fields - это предотвращает ошибку валидации widgets = { 'quantity': forms.NumberInput(attrs={'min': 1, 'value': 1}), # Скрываем поля product и product_kit - они будут заполняться через JS 'product': forms.HiddenInput(), 'product_kit': forms.HiddenInput(), 'is_custom_price': forms.HiddenInput(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Bootstrap классы for field_name, field in self.fields.items(): if not isinstance(field.widget, forms.HiddenInput): field.widget.attrs.update({'class': 'form-control'}) # Поля product и product_kit опциональны self.fields['product'].required = False self.fields['product_kit'].required = False # Поле цены заполняется автоматически, но можно редактировать вручную self.fields['price'].widget.attrs.update({ 'placeholder': 'Цена', 'step': '0.01' }) self.fields['price'].required = False # Поле is_custom_price устанавливается через JS self.fields['is_custom_price'].required = False def clean_price(self): """Парсим цену с запятой или точкой и округляем до 2 знаков""" value = self.cleaned_data.get('price') if value in (None, ''): return None value_str = str(value).strip().replace(',', '.') try: price = Decimal(value_str) # Округляем до 2 знаков после запятой return price.quantize(Decimal('0.01')) except Exception: raise forms.ValidationError('Введите число.') def clean(self): """Валидация: должен быть выбран либо товар, либо комплект (не оба, не ни один)""" cleaned_data = super().clean() product = cleaned_data.get('product') product_kit = cleaned_data.get('product_kit') quantity = cleaned_data.get('quantity') # Пустая форма - это нормально (будет удалена) if not product and not product_kit: # Обнуляем количество для пустых форм cleaned_data['quantity'] = None return cleaned_data # Проверка: нельзя выбрать оба одновременно if product and product_kit: raise forms.ValidationError( 'Нельзя указывать одновременно товар и комплект. Выберите что-то одно.' ) # Проверка: если выбрано что-то, количество обязательно if (product or product_kit): if not quantity or quantity <= 0: raise forms.ValidationError('Необходимо указать количество больше 0') return cleaned_data # Formset для inline добавления товаров в заказ OrderItemFormSet = inlineformset_factory( Order, OrderItem, form=OrderItemForm, extra=0, # Без пустых форм (будем добавлять через JavaScript) can_delete=True, min_num=0, # Минимум 0 товаров (валидация на уровне бизнес-логики) validate_min=False, ) # === СТАТУСЫ ЗАКАЗОВ === class OrderStatusForm(forms.ModelForm): """Форма для создания и редактирования статусов заказов""" class Meta: model = OrderStatus fields = [ 'name', 'code', 'label', 'color', 'description', 'is_positive_end', 'is_negative_end', ] widgets = { 'name': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Например: Выполнен, В процессе' }), 'code': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Например: completed, in_progress' }), 'label': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Метка для отображения (опционально)' }), 'color': forms.TextInput(attrs={ 'class': 'form-control', 'type': 'color' }), 'description': forms.Textarea(attrs={ 'class': 'form-control', 'rows': 3, 'placeholder': 'Описание статуса (опционально)' }), 'is_positive_end': forms.CheckboxInput(attrs={ 'class': 'form-check-input' }), 'is_negative_end': forms.CheckboxInput(attrs={ 'class': 'form-check-input' }), } def clean(self): cleaned_data = super().clean() # Нельзя быть одновременно положительным и отрицательным концом if cleaned_data.get('is_positive_end') and cleaned_data.get('is_negative_end'): raise forms.ValidationError( "Статус не может быть одновременно положительным и отрицательным концом" ) # Системные статусы нельзя редактировать код if self.instance.pk and self.instance.is_system: original_code = OrderStatus.objects.get(pk=self.instance.pk).code new_code = cleaned_data.get('code') if original_code != new_code: raise forms.ValidationError( "Нельзя менять код системного статуса" ) return cleaned_data def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Если редактируем системный статус - делаем код readonly if self.instance.pk and self.instance.is_system: self.fields['code'].widget.attrs['readonly'] = True self.fields['code'].help_text = "Код системного статуса нельзя менять" # КРИТИЧНО: Блокируем изменение флагов is_positive_end и is_negative_end # Эти флаги используются в сигналах для управления резервами и списаниями # Изменение может привести к: # - Неправильному освобождению резервов # - Двойному резервированию товара # - Блокировке товара навсегда self.fields['is_positive_end'].disabled = True self.fields['is_negative_end'].disabled = True self.fields['is_positive_end'].help_text = "Нельзя изменять для системных статусов (влияет на резервирование)" self.fields['is_negative_end'].help_text = "Нельзя изменять для системных статусов (влияет на резервирование)" # === ВРЕМЕННЫЕ КОМПЛЕКТЫ === class TemporaryKitForm(forms.ModelForm): """ Упрощенная форма для создания временного комплекта. Используется при оформлении заказа для создания букета "на лету". """ class Meta: model = ProductKit fields = ['name', 'description'] widgets = { 'description': forms.Textarea(attrs={'rows': 2, 'placeholder': 'Краткое описание (опционально)'}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Bootstrap классы for field in self.fields.values(): if isinstance(field.widget, forms.Textarea): field.widget.attrs.update({'class': 'form-control'}) else: field.widget.attrs.update({'class': 'form-control'}) # Название обязательно self.fields['name'].required = True self.fields['name'].widget.attrs.update({ 'placeholder': 'Название временного букета (например: "Букет для Анны")' }) # Описание опционально self.fields['description'].required = False class TemporaryKitItemForm(forms.Form): """ Форма для компонента временного комплекта. Используется в формсете для добавления товаров в букет. """ product = forms.IntegerField(required=False, widget=forms.HiddenInput()) quantity = forms.DecimalField( required=False, min_value=0.001, max_digits=10, decimal_places=3, widget=forms.NumberInput(attrs={'class': 'form-control', 'min': '0.001', 'step': '1'}) ) def clean(self): cleaned_data = super().clean() product_id = cleaned_data.get('product') quantity = cleaned_data.get('quantity') # Пустая форма - это нормально (будет удалена) if not product_id: return cleaned_data # Если выбран товар, количество обязательно if product_id and (not quantity or quantity <= 0): raise forms.ValidationError('Необходимо указать количество больше 0') return cleaned_data # Formset для компонентов временного комплекта from django.forms import formset_factory TemporaryKitItemFormSet = formset_factory( TemporaryKitItemForm, extra=1, # Одна пустая форма для добавления can_delete=True, min_num=1, # Минимум 1 компонент в комплекте validate_min=True, ) # === ТРАНЗАКЦИИ (СМЕШАННАЯ ОПЛАТА И ВОЗВРАТЫ) === class TransactionForm(forms.ModelForm): """ Форма для создания транзакций по заказу. Поддерживает смешанную оплату и возвраты. """ class Meta: model = Transaction fields = ['payment_method', 'amount', 'notes'] widgets = { 'payment_method': forms.Select(attrs={'class': 'form-select'}), 'amount': forms.NumberInput(attrs={ 'class': 'form-control', 'step': '0.01', 'min': '0', 'placeholder': 'Сумма платежа' }), 'notes': forms.Textarea(attrs={ 'class': 'form-control', 'rows': 2, 'placeholder': 'Примечания к платежу (опционально)' }), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Фильтруем только активные способы оплаты from .models import PaymentMethod self.fields['payment_method'].queryset = PaymentMethod.objects.filter( is_active=True ).order_by('order', 'name') # Делаем notes опциональным self.fields['notes'].required = False def clean(self): """Валидация платежа, особенно для оплаты из кошелька""" cleaned = super().clean() method = cleaned.get('payment_method') amount = cleaned.get('amount') order = getattr(self.instance, 'order', None) # Пустые формы допустимы при удалении if not method and not amount: return cleaned # Базовые проверки if amount is None or amount <= 0: self.add_error('amount', 'Введите сумму больше 0.') # ВАЖНО: order может быть None при создании нового заказа! # Проверки кошелька делаем только если order уже существует if order and method and getattr(method, 'code', None) == 'account_balance': wallet_balance = order.customer.wallet_balance if order.customer else Decimal('0') amount_due = max(order.total_amount - order.amount_paid, Decimal('0')) if wallet_balance <= 0: self.add_error('payment_method', 'Недостаточно средств в кошельке клиента (баланс 0).') if amount and amount > wallet_balance: self.add_error('amount', f'Недостаточно средств в кошельке. Доступно {wallet_balance} руб.') if amount and amount > amount_due: self.add_error('amount', f'Сумма превышает остаток к оплате ({amount_due} руб.)') if self.errors: # Общее сообщение для блока формы raise forms.ValidationError('Проверьте поля оплаты из кошелька.') return cleaned # Formset для множественных транзакций TransactionFormSet = inlineformset_factory( Order, Transaction, form=TransactionForm, extra=0, # Без пустых форм (добавляем через JavaScript) can_delete=False, # Используйте refund вместо удаления min_num=0, # Транзакции не обязательны при создании черновика validate_min=False, )