Добавлена возможность выбора режима "Без адреса (заполнить позже)" при создании заказа, что позволяет пользователям пропустить шаг указания адреса доставки на этапе оформления
826 lines
38 KiB
Python
826 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
||
from django import forms
|
||
from django.forms import inlineformset_factory
|
||
from phonenumber_field.formfields import PhoneNumberField
|
||
from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient, Delivery
|
||
from customers.models import Customer
|
||
from products.models import Product, ProductKit
|
||
from decimal import Decimal
|
||
|
||
|
||
class OrderForm(forms.ModelForm):
|
||
"""Форма для создания и редактирования заказа"""
|
||
|
||
# Поля для работы с получателем
|
||
other_recipient = forms.BooleanField(
|
||
required=False,
|
||
initial=False,
|
||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
label='Другой получатель',
|
||
help_text='Если отмечено, получатель отличается от покупателя'
|
||
)
|
||
|
||
# Режим выбора другого получателя (история или новый)
|
||
recipient_source = forms.ChoiceField(
|
||
choices=[
|
||
('history', 'Выбрать из истории'),
|
||
('new', 'Новый получатель'),
|
||
],
|
||
initial='new',
|
||
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 = PhoneNumberField(
|
||
region=None,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Телефон получателя'}),
|
||
label='Телефон получателя',
|
||
help_text='Введите телефон в любом формате, будет автоматически преобразован'
|
||
)
|
||
|
||
recipient_notes = forms.CharField(
|
||
max_length=200,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Мессенджер, соцсеть и т.д.'}),
|
||
label='Дополнительная информация',
|
||
help_text='Мессенджер, соцсеть или другая информация о получателе (необязательно)'
|
||
)
|
||
|
||
# Поля для работы с адресом
|
||
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='Уточнить адрес у получателя'
|
||
)
|
||
|
||
# Поля для доставки
|
||
delivery_type = forms.ChoiceField(
|
||
choices=Delivery.DELIVERY_TYPE_CHOICES,
|
||
required=True,
|
||
widget=forms.RadioSelect(attrs={'class': 'form-check-input'}),
|
||
label='Способ доставки',
|
||
initial=Delivery.DELIVERY_TYPE_COURIER
|
||
)
|
||
|
||
delivery_date = forms.DateField(
|
||
required=True,
|
||
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||
label='Дата доставки'
|
||
)
|
||
|
||
time_from = forms.TimeField(
|
||
required=False,
|
||
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
|
||
label='Время доставки от'
|
||
)
|
||
|
||
time_to = forms.TimeField(
|
||
required=False,
|
||
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
|
||
label='Время доставки до'
|
||
)
|
||
|
||
pickup_warehouse = forms.ModelChoiceField(
|
||
queryset=None, # Будет установлен в __init__
|
||
required=False,
|
||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||
label='Склад самовывоза',
|
||
empty_label='Выберите склад'
|
||
)
|
||
|
||
delivery_cost = forms.DecimalField(
|
||
required=False,
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
|
||
label='Стоимость доставки',
|
||
initial=0
|
||
)
|
||
|
||
class Meta:
|
||
model = Order
|
||
fields = [
|
||
'customer',
|
||
'recipient',
|
||
'status',
|
||
'is_anonymous',
|
||
'needs_product_photo',
|
||
'needs_delivery_photo',
|
||
'special_instructions',
|
||
'summary',
|
||
]
|
||
widgets = {
|
||
'special_instructions': forms.Textarea(attrs={'rows': 3}),
|
||
'summary': forms.Textarea(attrs={
|
||
'rows': 5,
|
||
'placeholder': 'Кратко опишите заказ на естественном языке...',
|
||
'class': 'form-control',
|
||
'style': 'resize: vertical; min-height: 100px;'
|
||
}),
|
||
}
|
||
|
||
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['recipient'].required = False
|
||
|
||
# Инициализируем чекбокс other_recipient на основе наличия recipient
|
||
if self.instance.pk:
|
||
# При редактировании заказа: если есть recipient, чекбокс включен
|
||
if self.instance.recipient:
|
||
self.fields['other_recipient'].initial = True
|
||
# Определяем источник получателя
|
||
# Проверяем, есть ли этот recipient в истории клиента
|
||
if self.instance.customer:
|
||
customer_orders = Order.objects.filter(
|
||
customer=self.instance.customer,
|
||
recipient__isnull=False
|
||
).exclude(pk=self.instance.pk).order_by('-created_at')
|
||
history_recipients = Recipient.objects.filter(
|
||
orders__in=customer_orders
|
||
).distinct()
|
||
if self.instance.recipient in history_recipients:
|
||
self.fields['recipient_source'].initial = 'history'
|
||
self.fields['recipient_from_history'].initial = self.instance.recipient.pk
|
||
else:
|
||
self.fields['recipient_source'].initial = 'new'
|
||
self.fields['recipient_name'].initial = self.instance.recipient.name or ''
|
||
self.fields['recipient_phone'].initial = str(self.instance.recipient.phone) if self.instance.recipient.phone else ''
|
||
self.fields['recipient_notes'].initial = self.instance.recipient.notes or ''
|
||
else:
|
||
self.fields['other_recipient'].initial = False
|
||
|
||
# Инициализируем queryset для recipient_from_history
|
||
if self.instance.pk and self.instance.customer:
|
||
# При редактировании заказа загружаем историю получателей этого клиента
|
||
customer_orders = Order.objects.filter(
|
||
customer=self.instance.customer,
|
||
recipient__isnull=False
|
||
).exclude(pk=self.instance.pk).order_by('-created_at')
|
||
self.fields['recipient_from_history'].queryset = Recipient.objects.filter(
|
||
orders__in=customer_orders
|
||
).distinct().order_by('-created_at')
|
||
elif not self.instance.pk:
|
||
# При создании нового заказа queryset пустой (будет заполнен через JS при выборе клиента)
|
||
self.fields['recipient_from_history'].queryset = Recipient.objects.none()
|
||
|
||
# Инициализируем queryset для pickup_warehouse
|
||
# Фильтруем только активные склады, доступные для самовывоза
|
||
# Сортируем: сначала склад по умолчанию, потом по названию
|
||
from inventory.models import Warehouse
|
||
self.fields['pickup_warehouse'].queryset = Warehouse.objects.filter(
|
||
is_active=True,
|
||
is_pickup_point=True
|
||
).order_by('-is_default', 'name')
|
||
|
||
# Если это новый заказ и еще не выбран склад, выбираем склад по умолчанию
|
||
if not self.instance.pk:
|
||
default_warehouse = Warehouse.objects.filter(
|
||
is_active=True,
|
||
is_pickup_point=True,
|
||
is_default=True
|
||
).first()
|
||
if default_warehouse:
|
||
self.fields['pickup_warehouse'].initial = default_warehouse
|
||
|
||
# Инициализируем поля доставки из существующей Delivery
|
||
if self.instance.pk and hasattr(self.instance, 'delivery'):
|
||
delivery = self.instance.delivery
|
||
|
||
# Устанавливаем значения через fields.initial для правильной работы виджетов
|
||
self.fields['delivery_type'].initial = delivery.delivery_type
|
||
self.fields['delivery_date'].initial = delivery.delivery_date
|
||
self.fields['time_from'].initial = delivery.time_from
|
||
self.fields['time_to'].initial = delivery.time_to
|
||
self.fields['pickup_warehouse'].initial = delivery.pickup_warehouse
|
||
self.fields['delivery_cost'].initial = delivery.cost
|
||
|
||
# Также устанавливаем через self.initial для совместимости
|
||
self.initial['delivery_type'] = delivery.delivery_type
|
||
self.initial['delivery_date'] = delivery.delivery_date
|
||
self.initial['time_from'] = delivery.time_from
|
||
self.initial['time_to'] = delivery.time_to
|
||
self.initial['pickup_warehouse'] = delivery.pickup_warehouse
|
||
self.initial['delivery_cost'] = delivery.cost
|
||
|
||
# Для DateInput с type='date' нужно установить значение в формате YYYY-MM-DD
|
||
if delivery.delivery_date:
|
||
# Устанавливаем значение напрямую в виджете
|
||
self.fields['delivery_date'].widget.attrs['value'] = delivery.delivery_date.strftime('%Y-%m-%d')
|
||
|
||
# Если выбран самовывоз, но склад не указан - выбираем склад по умолчанию
|
||
if delivery.delivery_type == Delivery.DELIVERY_TYPE_PICKUP and not delivery.pickup_warehouse:
|
||
default_warehouse = Warehouse.objects.filter(
|
||
is_active=True,
|
||
is_pickup_point=True,
|
||
is_default=True
|
||
).first()
|
||
if default_warehouse:
|
||
self.fields['pickup_warehouse'].initial = default_warehouse
|
||
|
||
# Инициализируем поля адреса, если есть адрес доставки
|
||
if delivery.address:
|
||
# При редактировании всегда используем режим "новый", чтобы можно было редактировать адрес
|
||
# Инициализируем queryset для истории адресов (для выбора из истории, если нужно)
|
||
if self.instance.customer:
|
||
customer_addresses = Address.objects.filter(
|
||
deliveries__order__customer=self.instance.customer
|
||
).exclude(
|
||
deliveries__order=self.instance # Исключаем адрес текущего заказа
|
||
).distinct()
|
||
self.fields['address_from_history'].queryset = customer_addresses
|
||
|
||
# Всегда используем режим "новый" для редактирования, чтобы можно было редактировать
|
||
self.initial['address_mode'] = 'new'
|
||
self.initial['address_street'] = delivery.address.street
|
||
self.initial['address_building_number'] = delivery.address.building_number
|
||
self.initial['address_apartment_number'] = delivery.address.apartment_number
|
||
self.initial['address_entrance'] = delivery.address.entrance
|
||
self.initial['address_floor'] = delivery.address.floor
|
||
self.initial['address_intercom_code'] = delivery.address.intercom_code
|
||
self.initial['address_delivery_instructions'] = delivery.address.delivery_instructions
|
||
self.initial['address_confirm_with_recipient'] = delivery.address.confirm_address_with_recipient
|
||
|
||
def clean(self):
|
||
"""Валидация формы заказа, включая обязательные поля доставки"""
|
||
cleaned_data = super().clean()
|
||
|
||
# Проверяем, является ли заказ черновиком
|
||
status = cleaned_data.get('status')
|
||
is_draft = status and hasattr(status, 'code') and status.code == 'draft'
|
||
|
||
# Для черновиков Delivery не обязательна
|
||
if is_draft:
|
||
return cleaned_data
|
||
|
||
# Для не-черновиков Delivery обязательна
|
||
delivery_type = cleaned_data.get('delivery_type')
|
||
delivery_date = cleaned_data.get('delivery_date')
|
||
time_from = cleaned_data.get('time_from')
|
||
time_to = cleaned_data.get('time_to')
|
||
pickup_warehouse = cleaned_data.get('pickup_warehouse')
|
||
|
||
# Проверяем обязательные поля доставки
|
||
if not delivery_type:
|
||
raise forms.ValidationError({'delivery_type': 'Необходимо выбрать способ доставки'})
|
||
|
||
if not delivery_date:
|
||
raise forms.ValidationError({'delivery_date': 'Необходимо указать дату доставки'})
|
||
|
||
# Время необязательно, но если указано одно, должно быть указано и другое
|
||
if (time_from and not time_to) or (time_to and not time_from):
|
||
raise forms.ValidationError({
|
||
'time_to': 'Если указано время начала, необходимо указать и время окончания, и наоборот'
|
||
})
|
||
|
||
# Проверяем, что время "до" позже времени "от" (если оба указаны)
|
||
if time_from and time_to and time_from >= time_to:
|
||
raise forms.ValidationError({
|
||
'time_to': 'Время окончания доставки должно быть позже времени начала'
|
||
})
|
||
|
||
# Проверяем специфичные требования для каждого типа доставки
|
||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
||
# Для курьерской доставки нужен адрес
|
||
address_mode = cleaned_data.get('address_mode')
|
||
address_from_history = cleaned_data.get('address_from_history')
|
||
address_street = cleaned_data.get('address_street', '').strip()
|
||
|
||
has_address = (
|
||
(address_mode == 'history' and address_from_history) or
|
||
(address_mode == 'new' and address_street) or
|
||
address_mode == 'empty' # Разрешаем "Без адреса (заполнить позже)"
|
||
)
|
||
|
||
if not has_address:
|
||
raise forms.ValidationError({
|
||
'address_mode': 'Для курьерской доставки необходимо указать адрес'
|
||
})
|
||
|
||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
||
# Для самовывоза нужен склад
|
||
if not pickup_warehouse:
|
||
raise forms.ValidationError({
|
||
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
||
})
|
||
|
||
return cleaned_data
|
||
|
||
def save(self, commit=True):
|
||
"""Сохраняет форму заказа."""
|
||
instance = super().save(commit=False)
|
||
|
||
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'})
|
||
)
|
||
|
||
# Поле DELETE, которое автоматически добавляется в inline формсете
|
||
DELETE = forms.BooleanField(required=False, widget=forms.HiddenInput())
|
||
|
||
class Meta:
|
||
model = OrderItem
|
||
fields = ['id', 'product', 'product_kit', 'sales_unit', 'quantity', 'price', 'is_custom_price', 'is_from_showcase']
|
||
# ВАЖНО: Теперь включаем 'id' в fields для правильной работы inline формсета
|
||
widgets = {
|
||
'id': forms.HiddenInput(), # Скрываем поле id, но оставляем его для формсета
|
||
'quantity': forms.NumberInput(attrs={'min': 1}),
|
||
# Скрываем поля product и product_kit - они будут заполняться через JS
|
||
'product': forms.HiddenInput(),
|
||
'product_kit': forms.HiddenInput(),
|
||
'sales_unit': forms.HiddenInput(), # Управляется через JS
|
||
'is_custom_price': forms.HiddenInput(),
|
||
'is_from_showcase': forms.HiddenInput(), # Устанавливается через JS для showcase_kit
|
||
}
|
||
|
||
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):
|
||
css_class = 'form-control'
|
||
# Добавляем is-invalid если есть ошибки в поле
|
||
if self.errors.get(field_name):
|
||
css_class += ' is-invalid'
|
||
field.widget.attrs.update({'class': css_class})
|
||
|
||
# Поля product и product_kit опциональны
|
||
self.fields['product'].required = False
|
||
self.fields['product_kit'].required = False
|
||
|
||
# Поле sales_unit опционально (управляется через JS)
|
||
self.fields['sales_unit'].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
|
||
|
||
# Поле is_from_showcase устанавливается через JS для showcase_kit
|
||
self.fields['is_from_showcase'].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')
|
||
sales_unit = cleaned_data.get('sales_unit')
|
||
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')
|
||
|
||
# Валидация единицы продажи
|
||
if sales_unit:
|
||
if product and sales_unit.product_id != product.id:
|
||
raise forms.ValidationError('Единица продажи не принадлежит товару')
|
||
|
||
if quantity:
|
||
try:
|
||
sales_unit.validate_quantity(quantity)
|
||
except ValidationError as e:
|
||
raise forms.ValidationError(str(e))
|
||
|
||
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,
|
||
)
|