Files
octopus/myproject/orders/services/draft_service.py
Andrey Smakotin 80260c8a34 Fix: Restore delivery address and order items data on page reload
Исправлены две критические проблемы с автосохранением и восстановлением
данных при перезагрузке страницы редактирования заказа:

1. ПРОБЛЕМА: Адрес доставки не восстанавливался после перезагрузки
   РЕШЕНИЕ (forms.py):
   - Добавлена инициализация полей адреса в OrderForm.__init__()
   - Поля заполняются из order.delivery_address при редактировании
   - Инициализируются все поля: улица, дом, квартира, подъезд, этаж, домофон, инструкции

2. ПРОБЛЕМА: Цены и количество товаров не сохранялись через автосохранение
   РЕШЕНИЕ (draft_service.py):
   - Добавлена обработка items в DraftOrderService.update_draft()
   - Автосохранение теперь обновляет/создаёт/удаляет позиции заказа
   - Сохраняются: product/product_kit, quantity, price, is_custom_price
   - Корректно определяется is_custom_price через сравнение с оригинальной ценой

Логика обработки items:
- Существующие позиции обновляются (product, quantity, price)
- Новые позиции создаются
- Лишние позиции удаляются
- Поддержка как товаров (product_id), так и комплектов (product_kit_id)

Теперь при перезагрузке страницы:
 Адрес доставки полностью восстанавливается во всех полях
 Товары сохраняются с правильными ценами и количествами
 Изменённые цены корректно отмечаются бейджем "Изменена"
 Все данные синхронизируются между автосохранением и базой данных

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 19:57:32 +03:00

479 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сервис для работы с черновиками заказов.
Содержит бизнес-логику создания, обновления и завершения черновиков.
"""
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')),
payment_method=data.get('payment_method', 'cash_to_courier'),
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', 'payment_method', '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)
# Обрабатываем позиции заказа (items)
if 'items' in data:
# Импортируем модели
from products.models import Product, ProductKit
from ..models import OrderItem
items_data = data['items']
# Получаем существующие позиции
existing_items = list(order.items.all())
# Удаляем все существующие позиции, которых нет в новых данных
items_to_keep_count = len(items_data)
for i, existing_item in enumerate(existing_items):
if i >= items_to_keep_count:
# Удаляем лишние позиции
existing_item.delete()
# Обновляем или создаём позиции
for index, item_data in enumerate(items_data):
product_id = item_data.get('product_id')
product_kit_id = item_data.get('product_kit_id')
quantity = item_data.get('quantity', 1)
price = item_data.get('price', 0)
# Конвертируем в Decimal
try:
quantity = Decimal(str(quantity))
price = Decimal(str(price))
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
is_custom_price = abs(price - original_price) > Decimal('0.01')
# Обновляем существующую позицию или создаём новую
if index < len(existing_items):
# Обновляем существующую
item = existing_items[index]
item.product = product
item.product_kit = product_kit
item.quantity = quantity
item.price = price
item.is_custom_price = is_custom_price
item.save()
else:
# Создаём новую
OrderItem.objects.create(
order=order,
product=product,
product_kit=product_kit,
quantity=quantity,
price=price,
is_custom_price=is_custom_price
)
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