feat: Добавлена возможность ручного изменения цены товаров/комплектов в заказе
- Добавлено поле is_custom_price в модель OrderItem для отслеживания ручных изменений - Добавлены свойства original_price и price_difference для отображения оригинальной цены и разницы - Поле цены теперь редактируемое (убран атрибут readonly) - Добавлены визуальные индикаторы: бейдж "Изменена" и информация об оригинальной цене - JavaScript автоматически отслеживает изменения цены и устанавливает флаг is_custom_price - В детальном просмотре заказа показывается информация о кастомных ценах с разницей - Цена товара в каталоге не изменяется - изменения только для конкретного заказа 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ from accounts.models import CustomUser
|
||||
from customers.models import Customer, Address
|
||||
from products.models import Product, ProductKit
|
||||
from shops.models import Shop
|
||||
from simple_history.models import HistoricalRecords
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -28,16 +29,10 @@ class Order(models.Model):
|
||||
)
|
||||
|
||||
# Тип доставки
|
||||
DELIVERY_TYPE_CHOICES = [
|
||||
('courier', 'Курьерская доставка'),
|
||||
('pickup', 'Самовывоз'),
|
||||
]
|
||||
|
||||
delivery_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=DELIVERY_TYPE_CHOICES,
|
||||
default='courier',
|
||||
verbose_name="Тип доставки"
|
||||
is_delivery = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="С доставкой",
|
||||
help_text="True - доставка курьером, False - самовывоз"
|
||||
)
|
||||
|
||||
# Адрес доставки (для курьерской доставки)
|
||||
@@ -64,15 +59,22 @@ class Order(models.Model):
|
||||
|
||||
# Дата и время доставки/самовывоза
|
||||
delivery_date = models.DateField(
|
||||
verbose_name="Дата доставки/самовывоза"
|
||||
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="Конец временного интервала"
|
||||
)
|
||||
@@ -130,7 +132,62 @@ class Order(models.Model):
|
||||
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="Анонимная доставка",
|
||||
@@ -155,6 +212,19 @@ class Order(models.Model):
|
||||
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 = "Заказы"
|
||||
@@ -162,7 +232,8 @@ class Order(models.Model):
|
||||
models.Index(fields=['customer']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['delivery_date']),
|
||||
models.Index(fields=['delivery_type']),
|
||||
models.Index(fields=['is_delivery']),
|
||||
models.Index(fields=['payment_status']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['order_number']),
|
||||
]
|
||||
@@ -189,14 +260,14 @@ class Order(models.Model):
|
||||
"""Валидация модели"""
|
||||
super().clean()
|
||||
|
||||
# Проверка: для курьерской доставки обязателен адрес
|
||||
if self.delivery_type == 'courier' and not self.delivery_address:
|
||||
# Проверка: для доставки обязателен адрес
|
||||
if self.is_delivery and not self.delivery_address:
|
||||
raise ValidationError({
|
||||
'delivery_address': 'Для курьерской доставки необходимо указать адрес доставки'
|
||||
'delivery_address': 'Для доставки необходимо указать адрес доставки'
|
||||
})
|
||||
|
||||
# Проверка: для самовывоза обязателен пункт самовывоза
|
||||
if self.delivery_type == 'pickup' and not self.pickup_shop:
|
||||
if not self.is_delivery and not self.pickup_shop:
|
||||
raise ValidationError({
|
||||
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза'
|
||||
})
|
||||
@@ -211,22 +282,46 @@ class Order(models.Model):
|
||||
def calculate_total(self):
|
||||
"""Рассчитывает итоговую сумму заказа"""
|
||||
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||
self.total_amount = items_total + self.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()
|
||||
|
||||
@property
|
||||
def amount_due(self):
|
||||
"""Остаток к оплате"""
|
||||
return max(self.total_amount - self.amount_paid, 0)
|
||||
|
||||
@property
|
||||
def delivery_info(self):
|
||||
"""Информация о доставке для отображения"""
|
||||
if self.delivery_type == 'courier':
|
||||
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||||
elif self.delivery_type == 'pickup':
|
||||
return f"Самовывоз из: {self.pickup_shop.name}"
|
||||
return "Не указано"
|
||||
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):
|
||||
"""Временное окно доставки"""
|
||||
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||
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):
|
||||
@@ -272,6 +367,12 @@ class OrderItem(models.Model):
|
||||
help_text="Цена на момент создания заказа (фиксируется)"
|
||||
)
|
||||
|
||||
is_custom_price = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Цена изменена вручную",
|
||||
help_text="True если цена была изменена вручную при создании заказа"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
@@ -332,3 +433,84 @@ class OrderItem(models.Model):
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user