Features: - Created OrderStatus model for managing statuses per tenant - Added system-level statuses: draft, new, confirmed, in_assembly, in_delivery, completed, return, cancelled - Implemented CRUD views for managing order statuses - Created OrderStatusService with status transitions and business logic hooks - Updated Order model to use ForeignKey to OrderStatus - Added is_returned flag for tracking returned orders - Updated filters to work with new OrderStatus model - Created management command for status initialization - Added HTML templates for status list, form, and confirmation - Fixed views.py to use OrderStatus instead of removed STATUS_CHOICES 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
818 lines
28 KiB
Python
818 lines
28 KiB
Python
from django.db import models
|
||
from django.core.exceptions import ValidationError
|
||
from accounts.models import CustomUser
|
||
from customers.models import Customer
|
||
from products.models import Product, ProductKit
|
||
from shops.models import Shop
|
||
from simple_history.models import HistoricalRecords
|
||
|
||
|
||
class OrderStatus(models.Model):
|
||
"""
|
||
Статус заказа, управляется отдельно для каждого тенанта.
|
||
Благодаря django-tenants в TENANT_APPS, данные изолированы по схемам.
|
||
"""
|
||
name = models.CharField(
|
||
max_length=100,
|
||
verbose_name="Название статуса"
|
||
)
|
||
|
||
code = models.SlugField(
|
||
unique=True,
|
||
verbose_name="Код статуса",
|
||
help_text="Уникальный идентификатор (например: 'completed', 'cancelled')"
|
||
)
|
||
|
||
label = models.CharField(
|
||
max_length=100,
|
||
verbose_name="Метка для отображения",
|
||
blank=True
|
||
)
|
||
|
||
is_system = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Системный статус",
|
||
help_text="True для встроенных статусов (draft, completed, cancelled)"
|
||
)
|
||
|
||
is_positive_end = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Положительный конец",
|
||
help_text="True если это финальный успешный статус (Выполнен)"
|
||
)
|
||
|
||
is_negative_end = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Отрицательный конец",
|
||
help_text="True если это финальный отрицательный статус (Отменен)"
|
||
)
|
||
|
||
order = models.PositiveIntegerField(
|
||
default=0,
|
||
verbose_name="Порядок отображения"
|
||
)
|
||
|
||
color = models.CharField(
|
||
max_length=7,
|
||
blank=True,
|
||
default='#808080',
|
||
verbose_name="Цвет (hex)",
|
||
help_text="Например: #FF5733"
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name="Описание"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
created_by = models.ForeignKey(
|
||
CustomUser,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='created_order_statuses',
|
||
verbose_name="Создано"
|
||
)
|
||
|
||
updated_by = models.ForeignKey(
|
||
CustomUser,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='updated_order_statuses',
|
||
verbose_name="Последнее изменение"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Статус заказа"
|
||
verbose_name_plural = "Статусы заказов"
|
||
ordering = ['order', 'name']
|
||
indexes = [
|
||
models.Index(fields=['code']),
|
||
models.Index(fields=['is_system']),
|
||
models.Index(fields=['order']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Address(models.Model):
|
||
"""
|
||
Модель адреса доставки для заказа цветочного магазина в Минске.
|
||
Адрес принадлежит конкретному заказу доставки.
|
||
"""
|
||
# Информация о получателе
|
||
recipient_name = models.CharField(
|
||
max_length=200,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Имя получателя",
|
||
help_text="Имя человека, которому будет доставлен заказ"
|
||
)
|
||
|
||
recipient_phone = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Телефон получателя",
|
||
help_text="Контактный телефон получателя для уточнения адреса"
|
||
)
|
||
|
||
street = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Улица"
|
||
)
|
||
|
||
building_number = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Номер здания"
|
||
)
|
||
|
||
apartment_number = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Номер квартиры/офиса"
|
||
)
|
||
|
||
entrance = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Подъезд",
|
||
help_text="Номер подъезда/входа"
|
||
)
|
||
|
||
floor = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Этаж"
|
||
)
|
||
|
||
intercom_code = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Код домофона",
|
||
help_text="Код домофона для входа в здание"
|
||
)
|
||
|
||
# Дополнительная информация для доставки
|
||
delivery_instructions = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Инструкции для доставки",
|
||
help_text="Дополнительные инструкции для курьера"
|
||
)
|
||
|
||
confirm_address_with_recipient = 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="Дата обновления")
|
||
|
||
class Meta:
|
||
verbose_name = "Адрес доставки"
|
||
verbose_name_plural = "Адреса доставки"
|
||
indexes = [
|
||
models.Index(fields=['created_at']),
|
||
]
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
# Собираем компоненты адреса
|
||
address_parts = []
|
||
if self.street:
|
||
address_parts.append(self.street)
|
||
if self.building_number:
|
||
address_parts.append(self.building_number)
|
||
if self.apartment_number:
|
||
address_parts.append(f"кв/офис {self.apartment_number}")
|
||
|
||
address_line = ", ".join(address_parts) if address_parts else "Адрес не указан"
|
||
|
||
# Формируем строку с именем получателя
|
||
if self.recipient_name:
|
||
return f"{self.recipient_name} - {address_line}"
|
||
return address_line
|
||
|
||
@property
|
||
def full_address(self):
|
||
"""Полный адрес для доставки"""
|
||
# Собираем основные компоненты адреса
|
||
address_parts = []
|
||
if self.street:
|
||
address_parts.append(self.street)
|
||
if self.building_number:
|
||
address_parts.append(self.building_number)
|
||
|
||
# Если нет основных данных, возвращаем сообщение
|
||
if not address_parts:
|
||
return "Адрес не указан"
|
||
|
||
address = ", ".join(address_parts)
|
||
|
||
# Добавляем квартиру/офис
|
||
if self.apartment_number:
|
||
address += f", кв/офис {self.apartment_number}"
|
||
|
||
# Собираем дополнительные детали
|
||
details = []
|
||
if self.entrance:
|
||
details.append(f"подъезд {self.entrance}")
|
||
if self.floor:
|
||
details.append(f"этаж {self.floor}")
|
||
if details:
|
||
address += f" ({', '.join(details)})"
|
||
|
||
return address
|
||
|
||
|
||
class Order(models.Model):
|
||
"""
|
||
Заказ клиента для доставки цветов.
|
||
"""
|
||
# Основная информация
|
||
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="Уникальный номер заказа"
|
||
)
|
||
|
||
# Тип доставки
|
||
is_delivery = models.BooleanField(
|
||
default=True,
|
||
verbose_name="С доставкой",
|
||
help_text="True - доставка курьером, False - самовывоз"
|
||
)
|
||
|
||
# Адрес доставки (для курьерской доставки)
|
||
delivery_address = models.OneToOneField(
|
||
Address,
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order',
|
||
verbose_name="Адрес доставки",
|
||
help_text="Обязательно для курьерской доставки"
|
||
)
|
||
|
||
# Пункт самовывоза (для самовывоза)
|
||
pickup_shop = models.ForeignKey(
|
||
Shop,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
related_name='pickup_orders',
|
||
verbose_name="Точка самовывоза",
|
||
help_text="Обязательно для самовывоза"
|
||
)
|
||
|
||
# Дата и время доставки/самовывоза
|
||
delivery_date = models.DateField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Дата доставки/самовывоза",
|
||
help_text="Может быть заполнено позже"
|
||
)
|
||
|
||
delivery_time_start = models.TimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Время от",
|
||
help_text="Начало временного интервала"
|
||
)
|
||
|
||
delivery_time_end = models.TimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Время до",
|
||
help_text="Конец временного интервала"
|
||
)
|
||
|
||
delivery_cost = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
verbose_name="Стоимость доставки",
|
||
help_text="0 для самовывоза"
|
||
)
|
||
|
||
is_custom_delivery_cost = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Стоимость доставки установлена вручную",
|
||
help_text="True если стоимость доставки была изменена вручную"
|
||
)
|
||
|
||
# Статус заказа
|
||
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 = [
|
||
('cash_to_courier', 'Наличные курьеру'),
|
||
('card_to_courier', 'Карта курьеру'),
|
||
('online', 'Онлайн оплата'),
|
||
('bank_transfer', 'Банковский перевод'),
|
||
]
|
||
|
||
payment_method = models.CharField(
|
||
max_length=20,
|
||
choices=PAYMENT_METHOD_CHOICES,
|
||
default='cash_to_courier',
|
||
verbose_name="Способ оплаты"
|
||
)
|
||
|
||
is_paid = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Оплачен"
|
||
)
|
||
|
||
total_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
verbose_name="Итоговая сумма заказа",
|
||
help_text="Общая сумма заказа включая доставку"
|
||
)
|
||
|
||
# Скидки
|
||
discount_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="Обновляется автоматически при добавлении платежей"
|
||
)
|
||
|
||
# Дополнительная информация
|
||
customer_is_recipient = models.BooleanField(
|
||
default=True,
|
||
verbose_name="Покупатель является получателем",
|
||
help_text="Если отмечено, данные получателя не требуются отдельно"
|
||
)
|
||
|
||
# Данные получателя (если покупатель != получатель)
|
||
recipient_name = models.CharField(
|
||
max_length=200,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Имя получателя",
|
||
help_text="Заполняется, если покупатель не является получателем"
|
||
)
|
||
|
||
recipient_phone = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
null=True,
|
||
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="Комментарии и пожелания к заказу"
|
||
)
|
||
|
||
# Временные метки
|
||
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="Последний пользователь, изменивший заказ"
|
||
)
|
||
|
||
# История изменений
|
||
history = HistoricalRecords()
|
||
|
||
class Meta:
|
||
verbose_name = "Заказ"
|
||
verbose_name_plural = "Заказы"
|
||
indexes = [
|
||
models.Index(fields=['customer']),
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['delivery_date']),
|
||
models.Index(fields=['is_delivery']),
|
||
models.Index(fields=['payment_status']),
|
||
models.Index(fields=['created_at']),
|
||
models.Index(fields=['order_number']),
|
||
models.Index(fields=['is_custom_delivery_cost']),
|
||
]
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f"Заказ #{self.order_number} - {self.customer}"
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Генерируем уникальный номер заказа при создании
|
||
if not self.order_number:
|
||
last_order = Order.objects.order_by('-order_number').first()
|
||
self.order_number = (last_order.order_number if last_order else 0) + 1
|
||
super().save(*args, **kwargs)
|
||
|
||
def clean(self):
|
||
"""Валидация модели"""
|
||
super().clean()
|
||
|
||
# Проверка: для доставки обязателен адрес
|
||
if self.is_delivery and not self.delivery_address:
|
||
raise ValidationError({
|
||
'delivery_address': 'Для доставки необходимо указать адрес доставки'
|
||
})
|
||
|
||
# Проверка: для самовывоза обязателен пункт самовывоза
|
||
if not self.is_delivery and not self.pickup_shop:
|
||
raise ValidationError({
|
||
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза'
|
||
})
|
||
|
||
# Проверка: время окончания должно быть позже времени начала
|
||
if self.delivery_time_start and self.delivery_time_end:
|
||
if self.delivery_time_end <= self.delivery_time_start:
|
||
raise ValidationError({
|
||
'delivery_time_end': 'Время окончания должно быть позже времени начала'
|
||
})
|
||
|
||
def get_delivery_cost(self):
|
||
"""
|
||
Возвращает стоимость доставки:
|
||
- Если установлена вручную - использует ручное значение
|
||
- Если автоматическая - вычисляет на основе правил
|
||
|
||
Returns:
|
||
Decimal: Стоимость доставки
|
||
"""
|
||
if self.is_custom_delivery_cost:
|
||
return self.delivery_cost
|
||
else:
|
||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||
return DeliveryCostCalculator.calculate(self)
|
||
|
||
def set_delivery_cost(self, cost, is_custom=True):
|
||
"""
|
||
Устанавливает стоимость доставки.
|
||
|
||
Args:
|
||
cost: Новая стоимость доставки (Decimal)
|
||
is_custom: True если устанавливается вручную, False если автоматически
|
||
"""
|
||
self.delivery_cost = cost
|
||
self.is_custom_delivery_cost = is_custom
|
||
|
||
def reset_delivery_cost(self):
|
||
"""
|
||
Сбрасывает стоимость доставки на автоматический расчет.
|
||
"""
|
||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||
self.is_custom_delivery_cost = False
|
||
|
||
def recalculate_delivery_cost(self):
|
||
"""
|
||
Пересчитывает стоимость доставки, если она не установлена вручную.
|
||
Используется при изменении параметров заказа (товаров, адреса и т.д.)
|
||
"""
|
||
if not self.is_custom_delivery_cost:
|
||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||
|
||
def calculate_total(self):
|
||
"""Рассчитывает итоговую сумму заказа"""
|
||
items_total = sum(item.get_total_price() for item in self.items.all())
|
||
|
||
# Пересчитываем стоимость доставки если она автоматическая
|
||
self.recalculate_delivery_cost()
|
||
|
||
subtotal = items_total + self.delivery_cost
|
||
self.total_amount = subtotal - self.discount_amount
|
||
return self.total_amount
|
||
|
||
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()
|
||
|
||
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 delivery_cost_display(self):
|
||
"""
|
||
Возвращает строку для отображения стоимости доставки с пометкой.
|
||
Полезно в админке и шаблонах.
|
||
"""
|
||
cost = self.get_delivery_cost()
|
||
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
|
||
return f"{cost} руб.{suffix}"
|
||
|
||
@property
|
||
def delivery_info(self):
|
||
"""Информация о доставке для отображения"""
|
||
if self.is_delivery:
|
||
if self.delivery_address:
|
||
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||
return "Доставка (адрес не указан)"
|
||
else:
|
||
if self.pickup_shop:
|
||
return f"Самовывоз из: {self.pickup_shop.name}"
|
||
return "Самовывоз (точка не указана)"
|
||
|
||
@property
|
||
def delivery_time_window(self):
|
||
"""Временное окно доставки"""
|
||
if self.delivery_time_start and self.delivery_time_end:
|
||
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||
return "Время не указано"
|
||
|
||
|
||
class OrderItem(models.Model):
|
||
"""
|
||
Позиция (товар) в заказе.
|
||
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
||
"""
|
||
order = models.ForeignKey(
|
||
Order,
|
||
on_delete=models.CASCADE,
|
||
related_name='items',
|
||
verbose_name="Заказ"
|
||
)
|
||
|
||
# Товар или комплект (один из двух должен быть заполнен)
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order_items',
|
||
verbose_name="Товар"
|
||
)
|
||
|
||
product_kit = models.ForeignKey(
|
||
ProductKit,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order_items',
|
||
verbose_name="Комплект товаров"
|
||
)
|
||
|
||
quantity = models.PositiveIntegerField(
|
||
default=1,
|
||
verbose_name="Количество"
|
||
)
|
||
|
||
price = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name="Цена за единицу",
|
||
help_text="Цена на момент создания заказа (фиксируется)"
|
||
)
|
||
|
||
is_custom_price = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Цена изменена вручную",
|
||
help_text="True если цена была изменена вручную при создании заказа"
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата добавления"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Позиция заказа"
|
||
verbose_name_plural = "Позиции заказа"
|
||
indexes = [
|
||
models.Index(fields=['order']),
|
||
models.Index(fields=['product']),
|
||
models.Index(fields=['product_kit']),
|
||
]
|
||
|
||
def __str__(self):
|
||
item_name = ""
|
||
if self.product:
|
||
item_name = self.product.name
|
||
elif self.product_kit:
|
||
item_name = self.product_kit.name
|
||
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
||
|
||
def clean(self):
|
||
"""Валидация модели"""
|
||
super().clean()
|
||
|
||
# Проверка: должен быть заполнен либо product, либо product_kit
|
||
if not self.product and not self.product_kit:
|
||
raise ValidationError(
|
||
'Необходимо указать либо товар, либо комплект товаров'
|
||
)
|
||
|
||
# Проверка: не должны быть заполнены оба поля одновременно
|
||
if self.product and self.product_kit:
|
||
raise ValidationError(
|
||
'Нельзя указать одновременно и товар, и комплект'
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Автоматически фиксируем цену при создании, если она не указана
|
||
if not self.price:
|
||
if self.product:
|
||
self.price = self.product.price
|
||
elif self.product_kit:
|
||
self.price = self.product_kit.price
|
||
super().save(*args, **kwargs)
|
||
|
||
def get_total_price(self):
|
||
"""Возвращает общую стоимость позиции"""
|
||
return self.price * self.quantity
|
||
|
||
@property
|
||
def item_name(self):
|
||
"""Название товара/комплекта"""
|
||
if self.product:
|
||
return self.product.name
|
||
elif self.product_kit:
|
||
return self.product_kit.name
|
||
return "Не указано"
|
||
|
||
@property
|
||
def original_price(self):
|
||
"""Оригинальная цена товара/комплекта из каталога"""
|
||
if self.product:
|
||
return self.product.actual_price
|
||
elif self.product_kit:
|
||
return self.product_kit.actual_price
|
||
return None
|
||
|
||
@property
|
||
def price_difference(self):
|
||
"""Разница между установленной ценой и оригинальной"""
|
||
if self.is_custom_price and self.original_price:
|
||
return self.price - self.original_price
|
||
return None
|
||
|
||
|
||
class Payment(models.Model):
|
||
"""
|
||
Платеж по заказу.
|
||
Хранит историю всех платежей, включая частичные оплаты.
|
||
"""
|
||
order = models.ForeignKey(
|
||
Order,
|
||
on_delete=models.CASCADE,
|
||
related_name='payments',
|
||
verbose_name="Заказ"
|
||
)
|
||
|
||
amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name="Сумма платежа"
|
||
)
|
||
|
||
payment_method = models.CharField(
|
||
max_length=20,
|
||
choices=Order.PAYMENT_METHOD_CHOICES,
|
||
verbose_name="Способ оплаты"
|
||
)
|
||
|
||
payment_date = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата и время платежа"
|
||
)
|
||
|
||
created_by = models.ForeignKey(
|
||
CustomUser,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='payments_created',
|
||
verbose_name="Принял платеж"
|
||
)
|
||
|
||
notes = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Примечания",
|
||
help_text="Дополнительная информация о платеже"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Платеж"
|
||
verbose_name_plural = "Платежи"
|
||
ordering = ['-payment_date']
|
||
indexes = [
|
||
models.Index(fields=['order']),
|
||
models.Index(fields=['payment_date']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}"
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""При сохранении платежа обновляем сумму оплаты в заказе"""
|
||
super().save(*args, **kwargs)
|
||
# Пересчитываем общую сумму оплаты в заказе
|
||
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
||
self.order.update_payment_status()
|