Files
octopus/myproject/orders/services/draft_service.py
Andrey Smakotin 3b4785e2ad Fix: Implement proper deletion of order items with confirmation dialog
Fixes deletion functionality for order items across frontend and backend:
- Remove restriction preventing deletion of last item
- Add confirmation dialog before deletion
- Properly track and send deleted item IDs to backend via autosave
- Update backend to handle item deletion by ID instead of index
- Fix visual feedback: deleted items are hidden immediately
- Auto-recalculate total sum after deletion

Technical changes:
- order_form.html: Add confirmation dialog, trigger autosave on delete
- autosave.js: Collect deleted item IDs, send to backend
- draft_service.py: Process deleted_item_ids, update items by ID

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 06:27:27 +03:00

501 lines
21 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)
# Обрабатываем удаление позиций заказа
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
)
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