Files
octopus/myproject/orders/models.py
Andrey Smakotin 885ac839e2 Реализована система управления стоимостью доставки и исправлен баг выбора клиента
Изменения:

1. **Стоимость доставки (автоматическая/ручная)**:
   - Добавлено поле `is_custom_delivery_cost` в модель Order для отслеживания ручной стоимости
   - Создан сервис DeliveryCostCalculator для автоматического расчета стоимости доставки
   - Реализована логика: бесплатная доставка от 100 руб., иначе 15 руб.
   - В форме поле "Ручная стоимость доставки" - если заполнено, используется ручная стоимость, если пустое - автоматический расчет
   - Добавлены методы Order.get_delivery_cost(), set_delivery_cost(), reset_delivery_cost(), recalculate_delivery_cost()

2. **Исправление бага выбора клиента в Select2**:
   - Удален избыточный обработчик события select2:opening, который блокировал клики
   - Добавлены проверки на наличие e.params.data в обработчиках select2:selecting и select2:select
   - Теперь выбор клиента работает корректно, создается черновик заказа и происходит редирект

3. **Опциональные поля адреса**:
   - Поля recipient_name, street, building_number в модели Address сделаны необязательными (blank=True, null=True)
   - Обновлены методы __str__ и full_address для безопасной работы с None значениями

4. **UI улучшения**:
   - Удалена звездочка обязательности с полей адреса
   - Добавлена подсказка под полем ручной стоимости доставки о правилах автоматического расчета

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 15:48:50 +03:00

726 lines
25 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 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 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_CHOICES = [
('draft', 'Черновик'),
('new', 'Новый'),
('confirmed', 'Подтвержден'),
('in_assembly', 'В сборке'),
('in_delivery', 'В доставке'),
('delivered', 'Доставлен'),
('cancelled', 'Отменен'),
]
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='new',
verbose_name="Статус заказа"
)
# Автосохранение (для черновиков)
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 == '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()