- Исправлено: адрес теперь сохраняется для черновиков заказов - Исправлено: получатель корректно предзаполняется при редактировании заказа - Исправлено: адрес при редактировании отображается в режиме 'новый' для возможности редактирования - Исправлено: дата доставки корректно предзаполняется при редактировании заказа - Исправлено: при редактировании получателя обновляется существующий объект вместо создания нового - Улучшена логика обработки Delivery для черновиков (создание с опциональными полями) - Улучшена логика обновления получателя через загрузку заказа из БД с select_related
777 lines
35 KiB
Python
777 lines
35 KiB
Python
# -*- coding: utf-8 -*-
|
||
from django import forms
|
||
from django.forms import inlineformset_factory
|
||
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 = 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='Уточнить адрес у получателя'
|
||
)
|
||
|
||
# Поля для доставки
|
||
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',
|
||
'special_instructions',
|
||
]
|
||
widgets = {
|
||
'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['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 = self.instance.recipient.phone 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)
|
||
)
|
||
|
||
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'})
|
||
)
|
||
|
||
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,
|
||
)
|