Проблема: Платежи не сохранялись при создании/редактировании заказа. Причины: 1. JavaScript функция addNewPayment() использовала неправильный метод замены __prefix__. При clone().innerHTML.replace() атрибуты name оставались с буквальным "__prefix__" вместо номера формы. 2. PaymentForm не переопределял has_changed(), из-за чего Django formset считал заполненные формы "пустыми" и не сохранял их. Исправления: - order_form.html: Переписана addNewPayment() - теперь клонирует template.content, конвертирует в HTML строку, делает replace, и только потом парсит обратно в DOM элемент - forms.py: Добавлен метод PaymentForm.has_changed() который правильно определяет что форма заполнена если указан payment_method ИЛИ amount - views.py: Добавлена отладочная информация для диагностики проблем с formset (TODO: удалить после тестирования) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
576 lines
25 KiB
Python
576 lines
25 KiB
Python
# -*- 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)
|
||
|
||
ВАЖНО: 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 = "Код системного статуса нельзя менять"
|
||
|
||
|
||
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||
|
||
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 has_changed(self):
|
||
"""
|
||
Переопределяем has_changed() чтобы formset не считал форму пустой.
|
||
Форма считается заполненной если указан payment_method ИЛИ amount.
|
||
"""
|
||
# Если есть ID - значит форма существует в БД, проверяем изменения стандартно
|
||
if self.instance and self.instance.pk:
|
||
return super().has_changed()
|
||
|
||
# Для новых форм: считаем заполненной если есть payment_method или amount
|
||
payment_method = self.cleaned_data.get('payment_method') if hasattr(self, 'cleaned_data') else self.data.get(self.add_prefix('payment_method'))
|
||
amount = self.cleaned_data.get('amount') if hasattr(self, 'cleaned_data') else self.data.get(self.add_prefix('amount'))
|
||
|
||
# Форма изменена если заполнено хотя бы одно из ключевых полей
|
||
return bool(payment_method or amount)
|
||
|
||
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 для множественных платежей
|
||
PaymentFormSet = inlineformset_factory(
|
||
Order,
|
||
Payment,
|
||
form=PaymentForm,
|
||
extra=0, # Без пустых форм (добавляем через JavaScript)
|
||
can_delete=True,
|
||
min_num=0, # Платежи не обязательны при создании черновика
|
||
validate_min=False,
|
||
)
|