Files
octopus/myproject/orders/forms.py
Andrey Smakotin c62cdb0298 feat: Add customer prefill from URL parameter in order creation
- Modified order_create view to read customer from GET parameter
- Pass preselected_customer to template context
- Template renders select with preselected option for Select2
- Fixed draft creation timing with callback after Select2 initialization
- Auto-create draft when customer is preselected from URL
- Graceful handling if customer not found or invalid ID
2025-11-27 00:17:02 +03:00

534 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from django import forms
from django.forms import inlineformset_factory
from .models import Order, OrderItem, Payment, Address, OrderStatus
from customers.models import Customer
from inventory.models import Warehouse
from products.models import Product, ProductKit
from decimal import Decimal
class OrderForm(forms.ModelForm):
"""Форма для создания и редактирования заказа"""
# Поля для ввода адреса
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_name',
'recipient_phone',
'status',
'discount_amount',
'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)
# Добавляем 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_name'].required = False
self.fields['recipient_phone'].required = False
# Поле ручной стоимости доставки опционально
self.fields['delivery_cost'].required = False
self.fields['delivery_cost'].label = 'Ручная стоимость доставки'
self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета'
# Инициализируем 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(
order__in=customer_orders
).distinct().order_by('-created_at')
# Инициализируем поля адреса из существующего 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)
"""
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 → автоматический расчет
instance.reset_delivery_cost()
if commit:
instance.save()
return instance
class OrderItemForm(forms.ModelForm):
"""Форма для позиции заказа"""
class Meta:
model = OrderItem
fields = ['product', 'product_kit', 'quantity', 'price', 'is_custom_price']
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(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 = "Код системного статуса нельзя менять"
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
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 PaymentForm(forms.ModelForm):
"""
Форма для создания платежа по заказу.
Поддерживает смешанную оплату (несколько платежей на один заказ).
"""
class Meta:
model = Payment
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.')
if not order:
raise forms.ValidationError('Заказ не найден для платежа.')
# Проверка для оплаты из кошелька
if 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 для множественных платежей
PaymentFormSet = inlineformset_factory(
Order,
Payment,
form=PaymentForm,
extra=0, # Без пустых форм (добавляем через JavaScript)
can_delete=True,
min_num=0, # Платежи не обязательны при создании черновика
validate_min=False,
)