Рефакторинг моделей заказов и добавление методов оплаты

This commit is contained in:
2025-11-26 13:38:02 +03:00
parent 08e8409a66
commit 0653ec0545
20 changed files with 1720 additions and 873 deletions

View File

@@ -0,0 +1,35 @@
"""
Модели приложения Orders.
Структура:
- OrderStatus: Статусы заказов
- Address: Адреса доставки
- Order: Главная модель заказа
- OrderItem: Позиции в заказе
- PaymentMethod: Способы оплаты (справочник)
- Payment: Платежи по заказам (поддержка смешанной оплаты)
"""
# Порядок импортов по зависимостям:
# 1. Независимые модели (справочники)
from .status import OrderStatus
from .payment import PaymentMethod
# 2. Модели с зависимостями от справочников
from .address import Address
# 3. Главная модель Order (зависит от Status, Address)
from .order import Order
# 4. Зависимые модели
from .order_item import OrderItem
from .payment import Payment
__all__ = [
'OrderStatus',
'Address',
'Order',
'OrderItem',
'PaymentMethod',
'Payment',
]

View File

@@ -0,0 +1,142 @@
from django.db import models
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

View File

@@ -0,0 +1,388 @@
from django.db import models
from django.core.exceptions import ValidationError
from accounts.models import CustomUser
from customers.models import Customer
from inventory.models import Warehouse
from simple_history.models import HistoricalRecords
from .status import OrderStatus
from .address import Address
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="Уникальный номер заказа"
)
# Тип доставки
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_warehouse = models.ForeignKey(
Warehouse,
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 и 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="Общая сумма заказа включая доставку"
)
# Скидки
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):
# Генерируем уникальный номер заказа при создании (начиная с 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
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_warehouse:
raise ValidationError({
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
})
# Проверка: время окончания должно быть позже времени начала
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_warehouse:
return f"Самовывоз со склада: {self.pickup_warehouse.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 "Время не указано"

View File

@@ -0,0 +1,154 @@
from django.db import models
from django.core.exceptions import ValidationError
from products.models import Product, ProductKit
from simple_history.models import HistoricalRecords
from .order import Order
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 если цена была изменена вручную при создании заказа"
)
# Витринные продажи
is_from_showcase = models.BooleanField(
default=False,
verbose_name="С витрины",
help_text="True если товар продан с витрины"
)
showcase = models.ForeignKey(
'inventory.Showcase',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='order_items',
verbose_name="Витрина",
help_text="Витрина, с которой был продан товар"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата добавления"
)
# История изменений
history = HistoricalRecords()
class Meta:
verbose_name = "Позиция заказа"
verbose_name_plural = "Позиции заказа"
indexes = [
models.Index(fields=['order']),
models.Index(fields=['product']),
models.Index(fields=['product_kit']),
models.Index(fields=['is_from_showcase']),
models.Index(fields=['showcase']),
]
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

View File

@@ -0,0 +1,144 @@
from django.db import models
from accounts.models import CustomUser
class PaymentMethod(models.Model):
"""
Способ оплаты заказа.
Справочник для управления доступными методами оплаты.
"""
# Код для программного доступа
code = models.SlugField(
unique=True,
max_length=50,
verbose_name="Код способа оплаты",
help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')"
)
# Отображаемое название
name = models.CharField(
max_length=100,
verbose_name="Название способа оплаты"
)
# Описание
description = models.TextField(
blank=True,
verbose_name="Описание",
help_text="Дополнительная информация о способе оплаты"
)
# Активность
is_active = models.BooleanField(
default=True,
verbose_name="Активен",
help_text="Отключенные способы оплаты не отображаются при создании заказа"
)
# Порядок отображения
order = models.PositiveIntegerField(
default=0,
verbose_name="Порядок отображения"
)
# Системный флаг
is_system = models.BooleanField(
default=False,
verbose_name="Системный",
help_text="Системные способы оплаты нельзя удалить через интерфейс"
)
# Аудит
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_payment_methods',
verbose_name="Создано"
)
class Meta:
verbose_name = "Способ оплаты"
verbose_name_plural = "Способы оплаты"
ordering = ['order', 'name']
indexes = [
models.Index(fields=['code']),
models.Index(fields=['is_active']),
models.Index(fields=['order']),
]
def __str__(self):
return self.name
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.ForeignKey(
'PaymentMethod',
on_delete=models.PROTECT,
related_name='payments',
verbose_name="Способ оплаты",
help_text="Способ оплаты данного платежа"
)
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()

View File

@@ -0,0 +1,100 @@
from django.db import models
from accounts.models import CustomUser
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
@property
def orders_count(self):
"""Количество заказов в этом статусе"""
return self.orders.count()