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:
2025-11-28 23:29:19 +03:00
parent f911a57640
commit 9a44c98e6e
8 changed files with 110 additions and 1946 deletions

View File

@@ -1,7 +1,5 @@
"""
!5@28A=K9 A;>9 4;O ?@8;>65=8O orders.
Сервисный слой для приложения orders.
"""
from .draft_service import DraftOrderService
__all__ = ['DraftOrderService']
__all__ = []

View File

@@ -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

View File

@@ -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):
"""Получить системный статус по коду"""