Files
octopus/myproject/orders/models/order.py

479 lines
22 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 models
from accounts.models import CustomUser
from customers.models import Customer
from .status import OrderStatus
from .recipient import Recipient
class Order(models.Model):
"""
Заказ клиента для доставки цветов.
ВАЖНО: Поле payment_method УДАЛЕНО для поддержки смешанной оплаты.
Используйте модель Payment (один Order → много Payment) для платежей.
"""
# Основная информация
customer = models.ForeignKey(
Customer,
on_delete=models.PROTECT,
related_name='orders',
verbose_name="Клиент"
)
order_number = models.PositiveIntegerField(
unique=True,
editable=False,
verbose_name="Номер заказа",
help_text="Уникальный номер заказа"
)
# Статус заказа
status = models.ForeignKey(
'OrderStatus',
on_delete=models.PROTECT,
related_name='orders',
null=True,
blank=True,
verbose_name="Статус заказа"
)
# Флаг для отслеживания возвратов
is_returned = models.BooleanField(
default=False,
verbose_name="Возвращен",
help_text="True если заказ был выполнен, но потом отменен или возвращен клиентом"
)
# Автосохранение (для черновиков)
last_autosave_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Последнее автосохранение",
help_text="Время последнего автоматического сохранения черновика"
)
# Оплата
# УДАЛЕНО: PAYMENT_METHOD_CHOICES и payment_method поле
# Вместо этого используйте модель Payment для смешанной оплаты
is_paid = models.BooleanField(
default=False,
verbose_name="Оплачен"
)
total_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Итоговая сумма заказа",
help_text="Общая сумма заказа"
)
# Частичная оплата
amount_paid = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Оплачено",
help_text="Сумма, внесенная клиентом"
)
PAYMENT_STATUS_CHOICES = [
('unpaid', 'Не оплачен'),
('partial', 'Частично оплачен'),
('paid', 'Оплачен полностью'),
]
payment_status = models.CharField(
max_length=20,
choices=PAYMENT_STATUS_CHOICES,
default='unpaid',
verbose_name="Статус оплаты",
help_text="Обновляется автоматически при добавлении платежей"
)
# Информация о получателе
# Получатель (если None - получатель = покупатель, иначе - отдельный получатель)
recipient = models.ForeignKey(
Recipient,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='orders',
verbose_name="Получатель",
help_text="Заполняется, если покупатель не является получателем"
)
is_anonymous = models.BooleanField(
default=False,
verbose_name="Анонимная доставка",
help_text="Не сообщать получателю имя отправителя"
)
special_instructions = models.TextField(
blank=True,
null=True,
verbose_name="Особые пожелания",
help_text="Комментарии и пожелания к заказу"
)
# Фото
needs_product_photo = models.BooleanField(
default=False,
verbose_name="Необходимо фото товара",
help_text="Требуется фотография товара перед отправкой"
)
needs_delivery_photo = models.BooleanField(
default=False,
verbose_name="Необходимо фото вручения",
help_text="Требуется фотография процесса вручения заказа"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления"
)
modified_by = models.ForeignKey(
CustomUser,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='modified_orders',
verbose_name="Изменен пользователем",
help_text="Последний пользователь, изменивший заказ"
)
# TODO: История изменений (simple_history) - вернуть позже
# history = HistoricalRecords()
class Meta:
verbose_name = "Заказ"
verbose_name_plural = "Заказы"
indexes = [
models.Index(fields=['customer']),
models.Index(fields=['status']),
models.Index(fields=['payment_status']),
models.Index(fields=['created_at']),
models.Index(fields=['order_number']),
]
ordering = ['-created_at']
def __str__(self):
return f"Заказ #{self.order_number} - {self.customer}"
def get_absolute_url(self):
"""Возвращает канонический URL для заказа"""
from django.urls import reverse
return reverse('orders:order-detail', kwargs={'order_number': self.order_number})
def save(self, *args, **kwargs):
from django.db import transaction
from django.core.exceptions import ValidationError
import logging
logger = logging.getLogger(__name__)
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
if not self.order_number:
last_order = Order.objects.order_by('-order_number').first()
if last_order:
# Если ранее нумерация была ниже 100, начинаем с 100; иначе инкремент
self.order_number = max(last_order.order_number + 1, 100)
else:
self.order_number = 100
# === АВТОМАТИЧЕСКИЙ ПРОМЕЖУТОЧНЫЙ ПЕРЕХОД: cancelled → draft → completed ===
# При прямом переходе из отрицательного (cancelled) в положительный (completed) статус,
# делаем промежуточный переход через нейтральный статус 'draft'.
# Это гарантирует корректную работу всех сигналов:
# 1. cancelled → draft: витринные букеты available → reserved
# 2. draft → completed: резервы и букеты корректно финализируются в sold
if self.pk: # Только при редактировании
try:
old_instance = Order.objects.get(pk=self.pk)
old_status = old_instance.status
new_status = self.status
# Проверяем: переход от отрицательного к положительному?
if (old_status and old_status.is_negative_end and
new_status and new_status.is_positive_end):
logger.info(
f"🔄 Заказ #{self.order_number}: Обнаружен прямой переход "
f"{old_status.name} (отрицательный) → {new_status.name} (положительный). "
f"Выполняем автоматический промежуточный переход через 'draft'..."
)
# Получаем статус 'draft'
try:
draft_status = OrderStatus.objects.get(code='draft', is_system=True)
except OrderStatus.DoesNotExist:
raise ValidationError(
f"Невозможно выполнить переход из '{old_status.name}' в '{new_status.name}': "
f"системный статус 'draft' не найден. Обратитесь к администратору."
)
# Сохраняем целевой статус для второго шага
target_status = new_status
# ШАГ 1: cancelled → draft
logger.info(f" 📍 Шаг 1/2: {old_status.name} → draft")
self.status = draft_status
with transaction.atomic():
super().save(*args, **kwargs)
# Обновляем old_instance для следующего шага
old_instance.refresh_from_db()
# ШАГ 2: draft → completed
logger.info(f" 📍 Шаг 2/2: draft → {target_status.name}")
self.status = target_status
with transaction.atomic():
super().save(*args, **kwargs)
logger.info(
f"✅ Заказ #{self.order_number}: Промежуточный переход завершён успешно. "
f"Итоговый статус: {target_status.name}"
)
return # Выходим, т.к. save() уже вызван дважды
except Order.DoesNotExist:
# Заказ ещё не создан в БД (не должно произойти, но на всякий случай)
pass
# === ВАЛИДАЦИЯ: Проверяем доступность витринных комплектов ===
# При переходе ИЗ cancelled к любому не-отменённому статусу
if self.pk: # Только при редактировании
try:
# Получаем старый статус из БД
old_instance = Order.objects.get(pk=self.pk)
old_status = old_instance.status
new_status = self.status
# Проверяем: переход от cancelled к не-cancelled?
if (old_status and old_status.is_negative_end and
new_status and not new_status.is_negative_end):
# Находим все витринные комплекты в этом заказе
from orders.models import OrderItem
showcase_items = OrderItem.objects.filter(
order=self,
product_kit__is_temporary=True,
product_kit__showcase__isnull=False
).select_related('product_kit')
if showcase_items.exists():
# Проверяем доступность резервов для каждого комплекта
from inventory.models import Reservation
unavailable_kits = []
for item in showcase_items:
kit = item.product_kit
# КРИТИЧНО: Ищем ВСЕ витринные резервы этого комплекта
# Проверяем не привязаны ли они к ДРУГОМУ заказу (в ЛЮБОМ статусе)
occupied_reservations = Reservation.objects.filter(
product_kit=kit,
showcase__isnull=False,
order_item__isnull=False # Привязаны к какому-то заказу
).exclude(
order_item__order=self # Исключаем текущий заказ
).select_related('order_item__order')
if occupied_reservations.exists():
# Резервы заняты другим заказом - блокируем переход
occupied_res = occupied_reservations.first()
other_order_number = occupied_res.order_item.order.order_number
other_order_status = occupied_res.order_item.order.status.name if occupied_res.order_item.order.status else 'неизвестен'
unavailable_kits.append(
f"Витринный комплект '{kit.name}' занят заказом #{other_order_number} (статус: {other_order_status})"
)
else:
# Проверяем что вообще есть резервы для этого комплекта
any_reservations = Reservation.objects.filter(
product_kit=kit,
showcase__isnull=False
).exists()
if not any_reservations:
# Комплект демонтирован или удалён
unavailable_kits.append(
f"Витринный комплект '{kit.name}' больше не существует на витрине"
)
# Если есть недоступные комплекты - блокируем переход
if unavailable_kits:
error_message = (
f"Невозможно восстановить заказ #{self.order_number}. "
f"Витринные комплекты уже проданы:\n\n" +
"\n".join(f"\u2022 {msg}" for msg in unavailable_kits) +
f"\n\nОтмените сначала соответствующие заказы или удалите эти позиции из заказа."
)
raise ValidationError(error_message)
except Order.DoesNotExist:
# Заказ ещё не создан в БД (не должно произойти, но на всякий случай)
pass
# Оборачиваем в транзакцию чтобы ValidationError в сигналах откатывал save()
with transaction.atomic():
super().save(*args, **kwargs)
def recalculate_amount_paid(self):
"""
Пересчитывает оплаченную сумму на основе транзакций.
amount_paid = сумма_платежей - суммаозвратов
"""
from django.db.models import Sum, Q
from decimal import Decimal
payments_sum = self.transactions.filter(
transaction_type='payment'
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
refunds_sum = self.transactions.filter(
transaction_type='refund'
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
self.amount_paid = payments_sum - refunds_sum
self.update_payment_status()
def update_payment_status(self):
"""Автоматически обновляет статус оплаты на основе amount_paid"""
if self.amount_paid >= self.total_amount:
self.payment_status = 'paid'
self.is_paid = True
elif self.amount_paid > 0:
self.payment_status = 'partial'
self.is_paid = False
else:
self.payment_status = 'unpaid'
self.is_paid = False
# Сохраняем только изменённые поля для оптимизации
self.save(update_fields=['payment_status', 'is_paid', 'amount_paid'])
def is_draft(self):
"""Проверяет, является ли заказ черновиком"""
return self.status and self.status.code == 'draft'
@property
def amount_due(self):
"""Остаток к оплате"""
return max(self.total_amount - self.amount_paid, 0)
@property
def overpayment(self):
"""Переплата (если amount_paid > total_amount)"""
return max(self.amount_paid - self.total_amount, 0)
@property
def subtotal(self):
"""Сумма только товаров (без доставки)"""
return sum(item.get_total_price() for item in self.items.all())
def calculate_total(self):
"""
Пересчитывает итоговую сумму заказа.
total_amount = subtotal + delivery_cost
"""
from decimal import Decimal
subtotal = self.subtotal
delivery_cost = Decimal('0')
# Получаем стоимость доставки из связанной модели Delivery
if hasattr(self, 'delivery'):
delivery_cost = self.delivery.cost
self.total_amount = subtotal + delivery_cost
self.save(update_fields=['total_amount'])
def reset_delivery_cost(self):
"""
Сбрасывает стоимость доставки.
Если есть Delivery, устанавливает cost = 0.
"""
if hasattr(self, 'delivery'):
self.delivery.cost = 0
self.delivery.save(update_fields=['cost'])
# === Свойства обратной совместимости для доступа к полям доставки ===
# Эти свойства обеспечивают доступ к полям Delivery через Order для обратной совместимости
# после рефакторинга, когда поля доставки были перенесены в отдельную модель Delivery
@property
def delivery_date(self):
"""Дата доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.delivery_date
return None
@property
def delivery_time_start(self):
"""Время начала доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.time_from
return None
@property
def delivery_time_end(self):
"""Время окончания доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.time_to
return None
@property
def delivery_time_window(self):
"""Форматированное окно времени доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
if self.delivery.time_from and self.delivery.time_to:
return f"{self.delivery.time_from.strftime('%H:%M')} - {self.delivery.time_to.strftime('%H:%M')}"
return None
@property
def delivery_time(self):
"""Время доставки (обратная совместимость, использует delivery_time_window)"""
return self.delivery_time_window
@property
def is_delivery(self):
"""Является ли заказ доставкой (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
from .delivery import Delivery
return self.delivery.delivery_type == Delivery.DELIVERY_TYPE_COURIER
return False
@property
def delivery_address(self):
"""Адрес доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.address
return None
@property
def delivery_cost(self):
"""Стоимость доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.cost
return 0
@property
def pickup_warehouse(self):
"""Склад самовывоза (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.pickup_warehouse
return None
@property
def is_customer_recipient(self):
"""Является ли покупатель получателем (обратная совместимость)"""
return self.recipient is None