ПРОБЛЕМА: Использование PaymentFormSet для платежей было НЕПРАВИЛЬНЫМ подходом: 1. Платежи = финансовые транзакции (не должны редактироваться inline) 2. Формы валидировали существующие платежи как новые 3. Сложная логика с formset management forms 4. Конфликты валидации кошелька РЕШЕНИЕ (Django Best Practices): Разделили управление платежами на отдельные операции: АРХИТЕКТУРА: ` POST /orders/111/payments/add/ # Добавить платеж POST /orders/111/payments/123/delete/ # Удалить платеж ` ПРЕИМУЩЕСТВА: ✅ Чистая архитектура (separation of concerns) ✅ Платежи = неизменяемые транзакции ✅ Простая валидация (только для новых) ✅ Легко тестировать ✅ API-ready структура ИЗМЕНЕНИЯ: 1. orders/views.py: - Убран PaymentFormSet из order_create и order_update - Добавлен payment_add(request, order_number) - Добавлен payment_delete(request, order_number, payment_id) - Используется простой PaymentForm вместо formset - Payment.save() автоматически обрабатывает: * Списание из кошелька * Обработку переплаты * Обновление amount_paid 2. orders/urls.py: - Добавлены URL patterns для payment-add и payment-delete - Структура: /orders/<number>/payments/add|<id>/delete/ 3. orders/templates/orders/order_form.html: - Убран PaymentFormSet и все его скрипты (~265 строк) - Простая HTML форма для добавления платежа - Существующие платежи: read-only список с кнопками удаления - Каждое удаление = отдельный POST запрос - Для создания: показываем предупреждение вместо формы 4. orders/templatetags/orders_tags.py (NEW): - Template tag get_payment_methods - Загружает активные способы оплаты - Использование: {% get_payment_methods as payment_methods %} РЕЗУЛЬТАТ: - Код: -191 строка - Логика: простая и понятная - Архитектура: правильная (как в учебнике) - Платежи: только add/delete (без edit) - Валидация: работает корректно - UX: чище и понятнее
561 lines
21 KiB
Python
561 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
||
from django.shortcuts import render, redirect, get_object_or_404
|
||
from django.contrib import messages
|
||
from django.core.paginator import Paginator
|
||
from django.http import JsonResponse
|
||
from django.views.decorators.http import require_http_methods
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
from decimal import Decimal
|
||
from .models import Order, OrderItem, Address, OrderStatus, Payment, PaymentMethod
|
||
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentForm
|
||
from .filters import OrderFilter
|
||
from .services.address_service import AddressService
|
||
import json
|
||
|
||
|
||
def order_list(request):
|
||
"""
|
||
Список всех заказов с фильтрацией и поиском
|
||
Использует django-filter для фильтрации данных
|
||
"""
|
||
# Базовый queryset с оптимизацией запросов
|
||
orders = Order.objects.select_related(
|
||
'customer', 'delivery_address', 'pickup_warehouse'
|
||
).all()
|
||
|
||
# Применяем фильтры через django-filter
|
||
order_filter = OrderFilter(request.GET, queryset=orders)
|
||
|
||
# Сортировка
|
||
filtered_orders = order_filter.qs.order_by('-created_at')
|
||
|
||
# Пагинация
|
||
paginator = Paginator(filtered_orders, 25)
|
||
page_number = request.GET.get('page')
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
context = {
|
||
'filter': order_filter,
|
||
'page_obj': page_obj,
|
||
'status_choices': OrderStatus.objects.all().order_by('order'),
|
||
}
|
||
|
||
return render(request, 'orders/order_list.html', context)
|
||
|
||
|
||
def order_detail(request, order_number):
|
||
"""Детальная информация о заказе"""
|
||
order = get_object_or_404(
|
||
Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by')
|
||
.prefetch_related('items__product', 'items__product_kit', 'payments__created_by'),
|
||
order_number=order_number
|
||
)
|
||
|
||
context = {
|
||
'order': order,
|
||
}
|
||
|
||
return render(request, 'orders/order_detail.html', context)
|
||
|
||
|
||
def order_create(request):
|
||
"""Создание нового заказа"""
|
||
if request.method == 'POST':
|
||
form = OrderForm(request.POST)
|
||
formset = OrderItemFormSet(request.POST)
|
||
|
||
if form.is_valid() and formset.is_valid():
|
||
# Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items
|
||
order = form.save(commit=False)
|
||
|
||
# Обрабатываем адрес доставки
|
||
if order.is_delivery:
|
||
address = AddressService.process_address_from_form(order, form.cleaned_data)
|
||
if address:
|
||
# Если адрес не существует в БД, сохраняем его
|
||
if not address.pk:
|
||
address.save()
|
||
order.delivery_address = address
|
||
|
||
# Статус берём из формы (в том числе может быть "Черновик")
|
||
order.modified_by = request.user
|
||
|
||
# Сохраняем заказ в БД (теперь у него есть pk)
|
||
order.save()
|
||
|
||
# Сохраняем позиции заказа
|
||
formset.instance = order
|
||
formset.save()
|
||
|
||
# Пересчитываем стоимость доставки если она не установлена вручную
|
||
delivery_cost = form.cleaned_data.get('delivery_cost')
|
||
if not delivery_cost or delivery_cost <= 0:
|
||
order.reset_delivery_cost()
|
||
|
||
# Пересчитываем итоговую стоимость
|
||
order.calculate_total()
|
||
order.update_payment_status()
|
||
|
||
messages.success(request, f'Заказ #{order.order_number} успешно создан!')
|
||
return redirect('orders:order-detail', order_number=order.order_number)
|
||
else:
|
||
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
|
||
else:
|
||
# Предзаполнение клиента из GET параметра
|
||
initial_data = {}
|
||
preselected_customer = None
|
||
customer_id = request.GET.get('customer')
|
||
if customer_id:
|
||
try:
|
||
from customers.models import Customer
|
||
preselected_customer = Customer.objects.get(pk=customer_id)
|
||
initial_data['customer'] = preselected_customer.pk
|
||
except (Customer.DoesNotExist, ValueError):
|
||
pass
|
||
|
||
form = OrderForm(initial=initial_data)
|
||
formset = OrderItemFormSet()
|
||
|
||
context = {
|
||
'form': form,
|
||
'formset': formset,
|
||
'preselected_customer': preselected_customer,
|
||
'title': 'Создание заказа',
|
||
'button_text': 'Создать заказ',
|
||
'is_create_page': True,
|
||
}
|
||
|
||
return render(request, 'orders/order_form.html', context)
|
||
|
||
|
||
def order_update(request, order_number):
|
||
"""Редактирование заказа"""
|
||
order = get_object_or_404(Order, order_number=order_number)
|
||
|
||
if request.method == 'POST':
|
||
form = OrderForm(request.POST, instance=order)
|
||
formset = OrderItemFormSet(request.POST, instance=order)
|
||
|
||
if form.is_valid() and formset.is_valid():
|
||
order = form.save(commit=False)
|
||
|
||
# Обрабатываем адрес доставки
|
||
if order.is_delivery:
|
||
address = AddressService.process_address_from_form(order, form.cleaned_data)
|
||
if address:
|
||
# Если адрес не существует в БД, сохраняем его
|
||
if not address.pk:
|
||
address.save()
|
||
order.delivery_address = address
|
||
else:
|
||
# Если режим "без адреса", удаляем существующий адрес
|
||
if order.delivery_address:
|
||
old_address = order.delivery_address
|
||
order.delivery_address = None
|
||
# Удаляем старый адрес, если он больше не используется
|
||
if old_address and not old_address.order:
|
||
old_address.delete()
|
||
else:
|
||
# Если не доставка, удаляем адрес если он был
|
||
if order.delivery_address:
|
||
old_address = order.delivery_address
|
||
order.delivery_address = None
|
||
# Удаляем старый адрес
|
||
if old_address and not old_address.order:
|
||
old_address.delete()
|
||
|
||
order.modified_by = request.user
|
||
order.save()
|
||
formset.save()
|
||
|
||
# Пересчитываем итоговую стоимость
|
||
order.calculate_total()
|
||
order.update_payment_status()
|
||
|
||
messages.success(request, f'Заказ #{order.order_number} успешно обновлен!')
|
||
return redirect('orders:order-detail', order_number=order.order_number)
|
||
else:
|
||
# Логируем ошибки для отладки
|
||
print("\n=== ОШИБКИ ВАЛИДАЦИИ ФОРМЫ ===")
|
||
if not form.is_valid():
|
||
print(f"OrderForm errors: {form.errors}")
|
||
if not formset.is_valid():
|
||
print(f"OrderItemFormSet errors: {formset.errors}")
|
||
print(f"OrderItemFormSet non_form_errors: {formset.non_form_errors()}")
|
||
for i, item_form in enumerate(formset):
|
||
if item_form.errors:
|
||
print(f" Item form {i} errors: {item_form.errors}")
|
||
print("=== КОНЕЦ ОШИБОК ===\n")
|
||
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
|
||
else:
|
||
form = OrderForm(instance=order)
|
||
formset = OrderItemFormSet(instance=order)
|
||
|
||
context = {
|
||
'form': form,
|
||
'formset': formset,
|
||
'order': order,
|
||
'title': f'Редактирование заказа #{order.order_number}',
|
||
'button_text': 'Сохранить изменения',
|
||
}
|
||
|
||
return render(request, 'orders/order_form.html', context)
|
||
|
||
|
||
def order_delete(request, order_number):
|
||
"""Удаление заказа с подтверждением"""
|
||
order = get_object_or_404(Order, order_number=order_number)
|
||
|
||
if request.method == 'POST':
|
||
order_number = order.order_number
|
||
order.delete()
|
||
messages.success(request, f'Заказ #{order_number} успешно удален.')
|
||
return redirect('orders:order-list')
|
||
|
||
context = {
|
||
'order': order,
|
||
}
|
||
|
||
return render(request, 'orders/order_confirm_delete.html', context)
|
||
|
||
|
||
# === УПРАВЛЕНИЕ ПЛАТЕЖАМИ ===
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def payment_add(request, order_number):
|
||
"""
|
||
Добавление нового платежа к заказу.
|
||
Отдельный endpoint для чистоты архитектуры.
|
||
"""
|
||
order = get_object_or_404(Order, order_number=order_number)
|
||
|
||
form = PaymentForm(request.POST)
|
||
|
||
if form.is_valid():
|
||
payment = form.save(commit=False)
|
||
payment.order = order
|
||
payment.created_by = request.user
|
||
|
||
try:
|
||
# save() вызовет Payment.save() который обработает:
|
||
# - Списание из кошелька (если account_balance)
|
||
# - Обработку переплаты
|
||
# - Обновление amount_paid и payment_status
|
||
payment.save()
|
||
|
||
messages.success(
|
||
request,
|
||
f'Платеж на сумму {payment.amount} руб. '
|
||
f'({payment.payment_method.name}) успешно добавлен.'
|
||
)
|
||
except ValidationError as e:
|
||
messages.error(request, f'Ошибка при добавлении платежа: {e}')
|
||
else:
|
||
# Показываем ошибки валидации
|
||
for field, errors in form.errors.items():
|
||
for error in errors:
|
||
messages.error(request, f'{field}: {error}')
|
||
|
||
# Возвращаемся на страницу редактирования
|
||
return redirect('orders:order-update', order_number=order.order_number)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def payment_delete(request, order_number, payment_id):
|
||
"""
|
||
Удаление платежа.
|
||
Возвращает средства в кошелек, если платеж был из кошелька.
|
||
"""
|
||
order = get_object_or_404(Order, order_number=order_number)
|
||
payment = get_object_or_404(Payment, pk=payment_id, order=order)
|
||
|
||
# Сохраняем данные для сообщения
|
||
payment_info = f'{payment.payment_method.name} на сумму {payment.amount} руб.'
|
||
|
||
# Если это платеж из кошелька - возвращаем средства
|
||
if payment.payment_method.code == 'account_balance':
|
||
from customers.services.wallet_service import WalletService
|
||
WalletService.refund_wallet_payment(order, payment.amount, request.user)
|
||
|
||
payment.delete()
|
||
|
||
# Пересчитываем сумму оплаты
|
||
order.amount_paid = sum(p.amount for p in order.payments.all())
|
||
order.update_payment_status()
|
||
|
||
messages.success(request, f'Платеж {payment_info} успешно удален.')
|
||
return redirect('orders:order-update', order_number=order.order_number)
|
||
|
||
|
||
# === AJAX ENDPOINTS ===
|
||
|
||
@require_http_methods(["POST"])
|
||
@require_http_methods(["GET"])
|
||
@login_required
|
||
def get_customer_address_history(request):
|
||
"""
|
||
AJAX endpoint для получения истории адресов клиента.
|
||
|
||
GET параметры:
|
||
- customer_id: ID клиента
|
||
|
||
Возвращает JSON со списком адресов из истории заказов клиента.
|
||
"""
|
||
try:
|
||
customer_id = request.GET.get('customer_id')
|
||
|
||
if not customer_id:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'customer_id не указан'
|
||
}, status=400)
|
||
|
||
from customers.models import Customer
|
||
|
||
try:
|
||
customer = Customer.objects.get(pk=customer_id)
|
||
except Customer.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Клиент не найден'
|
||
}, status=404)
|
||
|
||
# Получаем адреса из истории заказов
|
||
addresses = AddressService.get_customer_address_history(customer)
|
||
|
||
# Форматируем для отправки клиенту
|
||
addresses_data = [
|
||
{
|
||
'id': addr.id,
|
||
'display': AddressService.format_address_for_display(addr),
|
||
'street': addr.street,
|
||
'building_number': addr.building_number,
|
||
'apartment_number': addr.apartment_number,
|
||
'entrance': addr.entrance,
|
||
'floor': addr.floor,
|
||
'intercom_code': addr.intercom_code,
|
||
'recipient_name': addr.recipient_name,
|
||
'recipient_phone': addr.recipient_phone,
|
||
}
|
||
for addr in addresses
|
||
]
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'addresses': addresses_data,
|
||
'count': len(addresses_data)
|
||
})
|
||
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка сервера: {str(e)}'
|
||
}, status=500)
|
||
|
||
|
||
# === УПРАВЛЕНИЕ СТАТУСАМИ ЗАКАЗОВ ===
|
||
|
||
@login_required
|
||
def order_status_list(request):
|
||
"""Список всех статусов заказов"""
|
||
statuses = OrderStatus.objects.all().order_by('order', 'name')
|
||
|
||
# orders_count уже доступен как property в модели OrderStatus
|
||
# Не нужно вручную добавлять атрибут
|
||
|
||
context = {
|
||
'statuses': statuses,
|
||
'title': 'Статусы заказов'
|
||
}
|
||
|
||
return render(request, 'orders/status_list.html', context)
|
||
|
||
|
||
@login_required
|
||
def order_status_create(request):
|
||
"""Создание нового статуса"""
|
||
if request.method == 'POST':
|
||
form = OrderStatusForm(request.POST)
|
||
|
||
if form.is_valid():
|
||
status = form.save(commit=False)
|
||
status.created_by = request.user
|
||
status.updated_by = request.user
|
||
|
||
# Если не указан порядок - делаем его последним
|
||
if not status.order:
|
||
max_order = OrderStatus.objects.aggregate(models.Max('order'))['order__max'] or 0
|
||
status.order = max_order + 10
|
||
|
||
status.save()
|
||
messages.success(request, f'Статус "{status.name}" успешно создан')
|
||
return redirect('orders:status_list')
|
||
else:
|
||
form = OrderStatusForm()
|
||
|
||
context = {
|
||
'form': form,
|
||
'title': 'Создать новый статус',
|
||
'button_text': 'Создать'
|
||
}
|
||
|
||
return render(request, 'orders/status_form.html', context)
|
||
|
||
|
||
@login_required
|
||
def order_status_update(request, pk):
|
||
"""Редактирование статуса"""
|
||
status = get_object_or_404(OrderStatus, pk=pk)
|
||
|
||
if request.method == 'POST':
|
||
form = OrderStatusForm(request.POST, instance=status)
|
||
|
||
if form.is_valid():
|
||
status = form.save(commit=False)
|
||
status.updated_by = request.user
|
||
status.save()
|
||
messages.success(request, f'Статус "{status.name}" успешно обновлен')
|
||
return redirect('orders:status_list')
|
||
else:
|
||
form = OrderStatusForm(instance=status)
|
||
|
||
context = {
|
||
'form': form,
|
||
'status': status,
|
||
'title': f'Редактировать статус: {status.name}',
|
||
'button_text': 'Сохранить',
|
||
'is_system': status.is_system
|
||
}
|
||
|
||
return render(request, 'orders/status_form.html', context)
|
||
|
||
|
||
@login_required
|
||
def order_status_delete(request, pk):
|
||
"""Удаление статуса"""
|
||
status = get_object_or_404(OrderStatus, pk=pk)
|
||
|
||
if status.is_system:
|
||
messages.error(request, f'Нельзя удалить системный статус "{status.name}"')
|
||
return redirect('orders:status_list')
|
||
|
||
if request.method == 'POST':
|
||
# Проверяем, что статус не используется в заказах
|
||
orders_count = Order.objects.filter(status=status).count()
|
||
|
||
if orders_count > 0:
|
||
messages.error(
|
||
request,
|
||
f'Невозможно удалить статус. Есть {orders_count} заказов с этим статусом.'
|
||
)
|
||
return redirect('orders:status_list')
|
||
|
||
status_name = status.name
|
||
status.delete()
|
||
messages.success(request, f'Статус "{status_name}" успешно удален')
|
||
return redirect('orders:status_list')
|
||
|
||
# Информация для подтверждения удаления
|
||
orders_count = Order.objects.filter(status=status).count()
|
||
|
||
context = {
|
||
'status': status,
|
||
'orders_count': orders_count,
|
||
'title': 'Удалить статус'
|
||
}
|
||
|
||
return render(request, 'orders/status_confirm_delete.html', context)
|
||
|
||
|
||
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||
# УДАЛЕНО: Логика создания временных комплектов перенесена в products.services.kit_service
|
||
# Используйте API endpoint: products:api-temporary-kit-create
|
||
|
||
|
||
# === КОШЕЛЁК КЛИЕНТА ===
|
||
|
||
@login_required
|
||
def apply_wallet_payment(request, order_number):
|
||
"""
|
||
Применение оплаты из кошелька клиента к заказу.
|
||
Вызывается через POST-запрос с суммой для списания.
|
||
"""
|
||
if request.method != 'POST':
|
||
return redirect('orders:order-detail', order_number=order_number)
|
||
|
||
order = get_object_or_404(Order, order_number=order_number)
|
||
|
||
# Получаем запрашиваемую сумму из формы
|
||
try:
|
||
raw_amount = request.POST.get('wallet_amount', '0')
|
||
amount = Decimal(str(raw_amount).replace(',', '.'))
|
||
except (ValueError, TypeError, ArithmeticError):
|
||
messages.error(request, 'Некорректная сумма для списания из кошелька.')
|
||
return redirect('orders:order-detail', order_number=order.order_number)
|
||
|
||
# Вызываем сервис для оплаты из кошелька
|
||
try:
|
||
from customers.services.wallet_service import WalletService
|
||
paid_amount = WalletService.pay_with_wallet(order, amount, request.user)
|
||
|
||
if paid_amount and paid_amount > 0:
|
||
messages.success(
|
||
request,
|
||
f'Из кошелька клиента списано {paid_amount} руб. для оплаты заказа #{order.order_number}.'
|
||
)
|
||
else:
|
||
messages.warning(
|
||
request,
|
||
'Не удалось списать средства из кошелька. Проверьте баланс и сумму заказа.'
|
||
)
|
||
except ValueError as e:
|
||
messages.error(request, str(e))
|
||
except Exception as e:
|
||
messages.error(request, f'Ошибка при оплате из кошелька: {str(e)}')
|
||
|
||
return redirect('orders:order-detail', order_number=order.order_number)
|
||
|
||
|
||
@require_http_methods(["POST"])
|
||
@login_required
|
||
def set_order_status(request, order_number):
|
||
"""
|
||
Update order status via AJAX.
|
||
Accepts POST with 'status_id' (can be empty to clear).
|
||
Returns JSON with the resulting status info.
|
||
"""
|
||
try:
|
||
order = get_object_or_404(Order, order_number=order_number)
|
||
status_id = request.POST.get('status_id', '').strip()
|
||
|
||
# Allow clearing status if empty
|
||
if status_id == '':
|
||
order.status = None
|
||
order.modified_by = request.user
|
||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||
return JsonResponse({'success': True, 'status': None})
|
||
|
||
try:
|
||
status = OrderStatus.objects.get(pk=status_id)
|
||
except OrderStatus.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': 'Status not found'}, status=404)
|
||
|
||
order.status = status
|
||
order.modified_by = request.user
|
||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'status': {
|
||
'id': status.pk,
|
||
'name': status.label or status.name,
|
||
'color': status.color
|
||
}
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'success': False, 'error': f'Server error: {str(e)}'}, status=500)
|