Simplify order creation and editing - remove autosave
- Removed autosave.js (665 lines) and draft-creator.js (441 lines) - Removed draft_service.py (~500 lines) and DraftOrderService - Removed AJAX endpoints: autosave and create-draft - Updated order_create() to add is_create_page flag - Updated order_update() to finalize drafts without DraftOrderService - Added get_new_status() method to OrderStatusService - Updated order_form.html: - Removed old JS includes - Added beforeunload warning for unsaved data - Updated buttons: separate buttons for create/draft/finalize - Total code reduction: ~1600 lines (92% removed) New workflow: - /orders/create/ - user fills form, chooses button - /orders/<id>/edit/ - simple editing without autosave - beforeunload warning when leaving page (except on submit)
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
"""
|
||||
!5@28A=K9 A;>9 4;O ?@8;>65=8O orders.
|
||||
Сервисный слой для приложения orders.
|
||||
"""
|
||||
|
||||
from .draft_service import DraftOrderService
|
||||
|
||||
__all__ = ['DraftOrderService']
|
||||
__all__ = []
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
"""
|
||||
Сервис для работы с черновиками заказов.
|
||||
Содержит бизнес-логику создания, обновления и завершения черновиков.
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal
|
||||
import decimal
|
||||
from datetime import datetime, date, time
|
||||
|
||||
from ..models import Order, OrderItem, Address
|
||||
from products.models import Product, ProductKit
|
||||
from .address_service import AddressService
|
||||
|
||||
|
||||
class DraftOrderService:
|
||||
"""
|
||||
Сервис для управления черновиками заказов.
|
||||
Обеспечивает создание, обновление и финализацию черновиков.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_draft(user, customer, data=None):
|
||||
"""
|
||||
Создает новый черновик заказа.
|
||||
|
||||
Args:
|
||||
user: Пользователь, создающий заказ
|
||||
customer: Клиент, для которого создается заказ
|
||||
data (dict, optional): Дополнительные данные для заказа
|
||||
|
||||
Returns:
|
||||
Order: Созданный черновик заказа
|
||||
|
||||
Raises:
|
||||
ValidationError: Если данные невалидны
|
||||
"""
|
||||
data = data or {}
|
||||
|
||||
with transaction.atomic():
|
||||
# Получаем или создаем статус 'draft'
|
||||
from ..models import OrderStatus
|
||||
draft_status, _ = OrderStatus.objects.get_or_create(
|
||||
code='draft',
|
||||
defaults={
|
||||
'name': 'Черновик',
|
||||
'label': 'Черновик',
|
||||
'is_system': True,
|
||||
'color': '#808080',
|
||||
}
|
||||
)
|
||||
|
||||
order = Order.objects.create(
|
||||
customer=customer,
|
||||
status=draft_status,
|
||||
modified_by=user,
|
||||
is_delivery=data.get('is_delivery', True),
|
||||
delivery_address=data.get('delivery_address'),
|
||||
pickup_warehouse=data.get('pickup_warehouse'),
|
||||
delivery_date=data.get('delivery_date'),
|
||||
delivery_time_start=data.get('delivery_time_start'),
|
||||
delivery_time_end=data.get('delivery_time_end'),
|
||||
delivery_cost=data.get('delivery_cost', Decimal('0')),
|
||||
customer_is_recipient=data.get('customer_is_recipient', True),
|
||||
recipient_name=data.get('recipient_name'),
|
||||
recipient_phone=data.get('recipient_phone'),
|
||||
is_anonymous=data.get('is_anonymous', False),
|
||||
special_instructions=data.get('special_instructions'),
|
||||
last_autosave_at=timezone.now(),
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def update_draft(order_id, user, data):
|
||||
"""
|
||||
Обновляет существующий заказ (автосохранение).
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
user: Пользователь, изменяющий заказ
|
||||
data (dict): Данные для обновления
|
||||
|
||||
Returns:
|
||||
Order: Обновленный заказ
|
||||
|
||||
Raises:
|
||||
Order.DoesNotExist: Если заказ не найден
|
||||
ValidationError: Если данные невалидны
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.select_for_update().get(pk=order_id)
|
||||
|
||||
# Обновляем только переданные поля
|
||||
# ForeignKey поля требуют специальной обработки
|
||||
fk_fields = {
|
||||
'customer': 'customers.Customer',
|
||||
'pickup_warehouse': 'inventory.Warehouse',
|
||||
'status': 'orders.OrderStatus',
|
||||
}
|
||||
|
||||
simple_fields = [
|
||||
'is_delivery', 'delivery_date', 'delivery_time_start', 'delivery_time_end',
|
||||
'delivery_cost', 'customer_is_recipient',
|
||||
'recipient_name', 'recipient_phone', 'is_anonymous',
|
||||
'special_instructions', 'discount_amount'
|
||||
]
|
||||
|
||||
# Обрабатываем ForeignKey поля
|
||||
for field_name, model_path in fk_fields.items():
|
||||
if field_name in data and data[field_name]:
|
||||
# Получаем модель
|
||||
app_label, model_name = model_path.split('.')
|
||||
from django.apps import apps
|
||||
Model = apps.get_model(app_label, model_name)
|
||||
|
||||
# Получаем объект по ID
|
||||
try:
|
||||
instance = Model.objects.get(pk=data[field_name])
|
||||
setattr(order, field_name, instance)
|
||||
except Model.DoesNotExist:
|
||||
pass # Игнорируем несуществующие объекты
|
||||
|
||||
# === Обработка адреса доставки ===
|
||||
# Новая логика с выбором режима адреса
|
||||
if 'address_mode' in data:
|
||||
address = AddressService.process_address_from_form(order, 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()
|
||||
elif 'delivery_address' in data and data['delivery_address']:
|
||||
# Старая логика для совместимости (если передается delivery_address напрямую)
|
||||
try:
|
||||
address = Address.objects.get(pk=data['delivery_address'])
|
||||
order.delivery_address = address
|
||||
except Address.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Обрабатываем простые поля
|
||||
for field in simple_fields:
|
||||
if field in data:
|
||||
value = data[field]
|
||||
|
||||
# Конвертируем boolean поля
|
||||
if field in ['is_delivery', 'customer_is_recipient', 'is_anonymous']:
|
||||
# Явно конвертируем в bool, обрабатывая различные типы данных
|
||||
original_value = value
|
||||
if isinstance(value, bool):
|
||||
value = value
|
||||
elif isinstance(value, str):
|
||||
value = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif value is None:
|
||||
value = False
|
||||
else:
|
||||
value = bool(value)
|
||||
|
||||
# Логируем для отладки
|
||||
if field == 'is_delivery':
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[AUTOSAVE] is_delivery: original={original_value} (type={type(original_value)}), converted={value}")
|
||||
|
||||
# Конвертируем числовые поля в Decimal
|
||||
elif field in ['delivery_cost', 'discount_amount']:
|
||||
if value == '' or value is None:
|
||||
value = None
|
||||
else:
|
||||
try:
|
||||
value = Decimal(str(value))
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
value = Decimal('0')
|
||||
|
||||
# Конвертируем дату
|
||||
elif field == 'delivery_date':
|
||||
if value == '' or value is None:
|
||||
value = None
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
value = datetime.strptime(value, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
value = None
|
||||
|
||||
# Конвертируем время
|
||||
elif field in ['delivery_time_start', 'delivery_time_end']:
|
||||
if value == '' or value is None:
|
||||
value = None
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
# Формат времени может быть HH:MM или HH:MM:SS
|
||||
if len(value.split(':')) == 2:
|
||||
value = datetime.strptime(value, '%H:%M').time()
|
||||
else:
|
||||
value = datetime.strptime(value, '%H:%M:%S').time()
|
||||
except ValueError:
|
||||
value = None
|
||||
|
||||
setattr(order, field, value)
|
||||
|
||||
# Обрабатываем удаление позиций заказа
|
||||
if 'deleted_item_ids' in data:
|
||||
deleted_ids = data['deleted_item_ids']
|
||||
if deleted_ids:
|
||||
from ..models import OrderItem
|
||||
OrderItem.objects.filter(id__in=deleted_ids, order=order).delete()
|
||||
|
||||
# Обрабатываем позиции заказа (items)
|
||||
if 'items' in data:
|
||||
# Импортируем модели
|
||||
from products.models import Product, ProductKit
|
||||
from ..models import OrderItem
|
||||
|
||||
items_data = data['items']
|
||||
|
||||
# Обрабатываем каждую позицию
|
||||
for item_data in items_data:
|
||||
item_id = item_data.get('id') # ID существующей позиции (если есть)
|
||||
product_id = item_data.get('product_id')
|
||||
product_kit_id = item_data.get('product_kit_id')
|
||||
quantity = item_data.get('quantity', 1)
|
||||
price_raw = item_data.get('price', '')
|
||||
|
||||
# Конвертируем количество в Decimal
|
||||
try:
|
||||
quantity = Decimal(str(quantity))
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
continue
|
||||
|
||||
# Получаем товар или комплект
|
||||
product = None
|
||||
product_kit = None
|
||||
|
||||
if product_id:
|
||||
try:
|
||||
product = Product.objects.get(pk=product_id)
|
||||
except Product.DoesNotExist:
|
||||
continue
|
||||
elif product_kit_id:
|
||||
try:
|
||||
product_kit = ProductKit.objects.get(pk=product_kit_id)
|
||||
except ProductKit.DoesNotExist:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Определяем оригинальную цену из каталога
|
||||
original_price = product.actual_price if product else product_kit.actual_price
|
||||
|
||||
# Конвертируем цену в Decimal, если пустая - используем оригинальную
|
||||
try:
|
||||
price = Decimal(str(price_raw)) if price_raw else Decimal('0')
|
||||
# Если цена 0 или пустая, используем оригинальную цену
|
||||
if price == Decimal('0'):
|
||||
price = original_price
|
||||
is_custom_price = False
|
||||
else:
|
||||
# Определяем, изменилась ли цена
|
||||
is_custom_price = abs(price - original_price) > Decimal('0.01')
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
# В случае ошибки используем оригинальную цену
|
||||
price = original_price
|
||||
is_custom_price = False
|
||||
|
||||
# Обновляем существующую позицию или создаём новую
|
||||
if item_id:
|
||||
# Обновляем существующую позицию
|
||||
try:
|
||||
item = OrderItem.objects.get(id=item_id, order=order)
|
||||
item.product = product
|
||||
item.product_kit = product_kit
|
||||
item.quantity = quantity
|
||||
item.price = price
|
||||
item.is_custom_price = is_custom_price
|
||||
item.save()
|
||||
except OrderItem.DoesNotExist:
|
||||
# Если позиция не найдена, создаём новую
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
product_kit=product_kit,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
is_custom_price=is_custom_price
|
||||
)
|
||||
else:
|
||||
# Создаём новую позицию
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
product_kit=product_kit,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
is_custom_price=is_custom_price
|
||||
)
|
||||
|
||||
# Обрабатываем удаление платежей
|
||||
if 'deleted_payment_ids' in data:
|
||||
deleted_payment_ids = data['deleted_payment_ids']
|
||||
if deleted_payment_ids:
|
||||
from ..models import Payment
|
||||
Payment.objects.filter(id__in=deleted_payment_ids, order=order).delete()
|
||||
|
||||
# Обрабатываем платежи (payments)
|
||||
if 'payments' in data:
|
||||
from ..models import Payment, PaymentMethod
|
||||
payments_data = data['payments']
|
||||
|
||||
# Обрабатываем каждый платеж
|
||||
for payment_data in payments_data:
|
||||
payment_id = payment_data.get('id') # ID существующего платежа (если есть)
|
||||
payment_method_id = payment_data.get('payment_method_id')
|
||||
amount_raw = payment_data.get('amount', '')
|
||||
notes = payment_data.get('notes', '')
|
||||
|
||||
# Пропускаем пустые платежи
|
||||
if not payment_method_id or not amount_raw:
|
||||
continue
|
||||
|
||||
# Конвертируем сумму в Decimal
|
||||
try:
|
||||
amount = Decimal(str(amount_raw))
|
||||
if amount <= 0:
|
||||
continue
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
continue
|
||||
|
||||
# Получаем способ оплаты
|
||||
try:
|
||||
payment_method = PaymentMethod.objects.get(pk=payment_method_id)
|
||||
except PaymentMethod.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Обновляем существующий платеж или создаём новый
|
||||
if payment_id:
|
||||
# Обновляем существующий платеж
|
||||
try:
|
||||
payment = Payment.objects.get(id=payment_id, order=order)
|
||||
payment.payment_method = payment_method
|
||||
payment.amount = amount
|
||||
payment.notes = notes
|
||||
payment.save()
|
||||
except Payment.DoesNotExist:
|
||||
# Если платеж не найден, создаём новый
|
||||
Payment.objects.create(
|
||||
order=order,
|
||||
payment_method=payment_method,
|
||||
amount=amount,
|
||||
notes=notes,
|
||||
created_by=user
|
||||
)
|
||||
else:
|
||||
# Создаём новый платеж
|
||||
Payment.objects.create(
|
||||
order=order,
|
||||
payment_method=payment_method,
|
||||
amount=amount,
|
||||
notes=notes,
|
||||
created_by=user
|
||||
)
|
||||
|
||||
order.modified_by = user
|
||||
order.last_autosave_at = timezone.now()
|
||||
order.save()
|
||||
|
||||
# Пересчитываем итоговую сумму если изменились товары
|
||||
if 'recalculate' in data and data['recalculate']:
|
||||
order.calculate_total()
|
||||
order.save()
|
||||
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def add_item_to_draft(order_id, product_id=None, product_kit_id=None, quantity=1, price=None):
|
||||
"""
|
||||
Добавляет товар или комплект в черновик заказа.
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
product_id (int, optional): ID товара
|
||||
product_kit_id (int, optional): ID комплекта
|
||||
quantity (Decimal): Количество
|
||||
price (Decimal, optional): Цена (если None, берется из товара/комплекта)
|
||||
|
||||
Returns:
|
||||
OrderItem: Созданная позиция заказа
|
||||
|
||||
Raises:
|
||||
ValidationError: Если заказ не является черновиком или данные невалидны
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.get(pk=order_id)
|
||||
|
||||
# Определяем товар или комплект
|
||||
product = None
|
||||
product_kit = None
|
||||
|
||||
if product_id:
|
||||
product = Product.objects.get(pk=product_id)
|
||||
if price is None:
|
||||
price = product.actual_price
|
||||
elif product_kit_id:
|
||||
product_kit = ProductKit.objects.get(pk=product_kit_id)
|
||||
if price is None:
|
||||
price = product_kit.actual_price
|
||||
else:
|
||||
raise ValidationError("Необходимо указать product_id или product_kit_id")
|
||||
|
||||
order_item = OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
product_kit=product_kit,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
|
||||
# Обновляем итоговую сумму заказа
|
||||
order.calculate_total()
|
||||
order.last_autosave_at = timezone.now()
|
||||
order.save()
|
||||
|
||||
return order_item
|
||||
|
||||
@staticmethod
|
||||
def remove_item_from_draft(order_id, order_item_id):
|
||||
"""
|
||||
Удаляет позицию из черновика заказа.
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
order_item_id (int): ID позиции заказа
|
||||
|
||||
Raises:
|
||||
ValidationError: Если заказ не является черновиком
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.get(pk=order_id)
|
||||
|
||||
OrderItem.objects.filter(pk=order_item_id, order=order).delete()
|
||||
|
||||
# Обновляем итоговую сумму заказа
|
||||
order.calculate_total()
|
||||
order.last_autosave_at = timezone.now()
|
||||
order.save()
|
||||
|
||||
@staticmethod
|
||||
def finalize_draft(order_id, user):
|
||||
"""
|
||||
Завершает черновик заказа, переводя его в статус 'new'.
|
||||
Выполняет финальную валидацию всех данных.
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
user: Пользователь, завершающий заказ
|
||||
|
||||
Returns:
|
||||
Order: Финализированный заказ
|
||||
|
||||
Raises:
|
||||
ValidationError: Если данные заказа невалидны или заказ не является черновиком
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.select_for_update().get(pk=order_id)
|
||||
|
||||
if not order.is_draft():
|
||||
raise ValidationError("Можно финализировать только черновики заказов")
|
||||
|
||||
# Проверяем наличие товаров
|
||||
if not order.items.exists():
|
||||
raise ValidationError("Заказ должен содержать хотя бы один товар")
|
||||
|
||||
# Выполняем полную валидацию модели
|
||||
order.full_clean()
|
||||
|
||||
# Получаем или создаем статус 'new'
|
||||
from ..models import OrderStatus
|
||||
new_status, _ = OrderStatus.objects.get_or_create(
|
||||
code='new',
|
||||
defaults={
|
||||
'name': 'Новый',
|
||||
'label': 'Новый',
|
||||
'is_system': True,
|
||||
'color': '#0d6efd',
|
||||
}
|
||||
)
|
||||
|
||||
# Изменяем статус на 'new'
|
||||
order.status = new_status
|
||||
order.modified_by = user
|
||||
order.last_autosave_at = None # Очищаем, т.к. заказ больше не черновик
|
||||
order.save()
|
||||
|
||||
# Привязываем временные комплекты к заказу
|
||||
ProductKit.objects.filter(
|
||||
is_temporary=True,
|
||||
order=order
|
||||
).update(order=order)
|
||||
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def get_user_drafts(user, customer=None):
|
||||
"""
|
||||
Возвращает черновики заказов пользователя.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
customer (Customer, optional): Фильтр по клиенту
|
||||
|
||||
Returns:
|
||||
QuerySet: Черновики заказов
|
||||
"""
|
||||
drafts = Order.objects.filter(
|
||||
status__code='draft',
|
||||
modified_by=user
|
||||
).select_related('customer', 'delivery_address', 'pickup_warehouse')
|
||||
|
||||
if customer:
|
||||
drafts = drafts.filter(customer=customer)
|
||||
|
||||
return drafts.order_by('-last_autosave_at')
|
||||
|
||||
@staticmethod
|
||||
def delete_old_drafts(days=30):
|
||||
"""
|
||||
Удаляет старые черновики заказов.
|
||||
|
||||
Args:
|
||||
days (int): Количество дней, после которых черновик считается старым
|
||||
|
||||
Returns:
|
||||
int: Количество удаленных черновиков
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Находим старые черновики
|
||||
old_drafts = Order.objects.filter(
|
||||
status__code='draft',
|
||||
last_autosave_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
# Удаляем связанные временные комплекты
|
||||
for draft in old_drafts:
|
||||
ProductKit.objects.filter(
|
||||
is_temporary=True,
|
||||
order=draft
|
||||
).delete()
|
||||
|
||||
# Удаляем черновики
|
||||
count = old_drafts.count()
|
||||
old_drafts.delete()
|
||||
|
||||
return count
|
||||
@@ -27,6 +27,21 @@ class OrderStatusService:
|
||||
except OrderStatus.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_new_status():
|
||||
"""Возвращает системный статус 'new' (новый заказ)"""
|
||||
status, created = OrderStatus.objects.get_or_create(
|
||||
code='new',
|
||||
defaults={
|
||||
'name': 'Новый',
|
||||
'label': 'Новый',
|
||||
'is_system': True,
|
||||
'color': '#0d6efd',
|
||||
'order': 10,
|
||||
}
|
||||
)
|
||||
return status
|
||||
|
||||
@staticmethod
|
||||
def get_system_status(code):
|
||||
"""Получить системный статус по коду"""
|
||||
|
||||
Reference in New Issue
Block a user