Добавлена поддержка черновиков заказов (Этап 1/3)

Реализован базовый функционал для работы с черновиками заказов.
Это первый этап из трех для полной реализации автосохранения.

Изменения:

1. Модель Order (orders/models.py):
   - Добавлен статус 'draft' в STATUS_CHOICES
   - Добавлено поле last_autosave_at для отслеживания автосохранения
   - Добавлен метод is_draft() для проверки статуса черновика

2. Миграция (0005_add_draft_support.py):
   - Добавление поля last_autosave_at в Order
   - Добавление статуса 'draft' в choices

3. Service Layer (orders/services/draft_service.py):
   - Класс DraftOrderService с методами:
     * create_draft() - создание нового черновика
     * update_draft() - обновление существующего черновика
     * add_item_to_draft() - добавление товара в черновик
     * remove_item_from_draft() - удаление товара из черновика
     * finalize_draft() - завершение черновика (draft → new)
     * get_user_drafts() - получение черновиков пользователя
     * delete_old_drafts() - очистка старых черновиков
   - Вся бизнес-логика инкапсулирована в сервисный слой

Архитектурные решения:
- Использование service layer pattern для изоляции бизнес-логики
- Транзакционность всех операций с черновиками
- Автоматический пересчет итоговой суммы при изменении товаров
- Связь временных комплектов с черновиками через FK

Следующие этапы (в следующих коммитах):
- Этап 2: AJAX endpoints и модификация views
- Этап 3: JavaScript автосохранение и UI

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 18:26:24 +03:00
parent 848b6b1574
commit dc8604732f
4 changed files with 337 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0.10 on 2025-11-08 15:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0004_orderitem_is_custom_price'),
]
operations = [
migrations.AddField(
model_name='historicalorder',
name='last_autosave_at',
field=models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение'),
),
migrations.AddField(
model_name='order',
name='last_autosave_at',
field=models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение'),
),
migrations.AlterField(
model_name='historicalorder',
name='status',
field=models.CharField(choices=[('draft', 'Черновик'), ('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, verbose_name='Статус заказа'),
),
migrations.AlterField(
model_name='order',
name='status',
field=models.CharField(choices=[('draft', 'Черновик'), ('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, verbose_name='Статус заказа'),
),
]

View File

@@ -89,6 +89,7 @@ class Order(models.Model):
# Статус заказа # Статус заказа
STATUS_CHOICES = [ STATUS_CHOICES = [
('draft', 'Черновик'),
('new', 'Новый'), ('new', 'Новый'),
('confirmed', 'Подтвержден'), ('confirmed', 'Подтвержден'),
('in_assembly', 'В сборке'), ('in_assembly', 'В сборке'),
@@ -104,6 +105,14 @@ class Order(models.Model):
verbose_name="Статус заказа" verbose_name="Статус заказа"
) )
# Автосохранение (для черновиков)
last_autosave_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Последнее автосохранение",
help_text="Время последнего автоматического сохранения черновика"
)
# Оплата # Оплата
PAYMENT_METHOD_CHOICES = [ PAYMENT_METHOD_CHOICES = [
('cash_to_courier', 'Наличные курьеру'), ('cash_to_courier', 'Наличные курьеру'),
@@ -299,6 +308,10 @@ class Order(models.Model):
self.is_paid = False self.is_paid = False
self.save() self.save()
def is_draft(self):
"""Проверяет, является ли заказ черновиком"""
return self.status == 'draft'
@property @property
def amount_due(self): def amount_due(self):
"""Остаток к оплате""" """Остаток к оплате"""

View File

@@ -0,0 +1,7 @@
"""
!5@28A=K9 A;>9 4;O ?@8;>65=8O orders.
"""
from .draft_service import DraftOrderService
__all__ = ['DraftOrderService']

View File

@@ -0,0 +1,284 @@
"""
Сервис для работы с черновиками заказов.
Содержит бизнес-логику создания, обновления и завершения черновиков.
"""
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import ValidationError
from decimal import Decimal
from ..models import Order, OrderItem
from products.models import Product, ProductKit
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():
order = Order.objects.create(
customer=customer,
status='draft',
modified_by=user,
is_delivery=data.get('is_delivery', True),
delivery_address=data.get('delivery_address'),
pickup_shop=data.get('pickup_shop'),
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)
if not order.is_draft():
raise ValidationError("Можно обновлять только черновики заказов")
# Обновляем только переданные поля
updatable_fields = [
'customer', 'is_delivery', 'delivery_address', 'pickup_shop',
'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'
]
for field in updatable_fields:
if field in data:
setattr(order, field, data[field])
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)
if not order.is_draft():
raise ValidationError("Можно добавлять товары только в черновики заказов")
# Определяем товар или комплект
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)
if not order.is_draft():
raise ValidationError("Можно удалять товары только из черновиков заказов")
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'
order.status = 'new'
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='draft',
modified_by=user
).select_related('customer', 'delivery_address', 'pickup_shop')
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='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