Рефакторинг: отделение Delivery от Order, обязательные поля доставки, исправление доменов

- Отделена модель Delivery от Order (OneToOne связь)
- Добавлены обязательные поля delivery_date, time_from, time_to в Delivery
- Delivery обязательна при создании заказа (кроме черновиков)
- Добавлены методы calculate_total() и reset_delivery_cost() в Order
- Добавлена валидация полей доставки в OrderForm
- Исправлено создание доменов - убран порт из домена в БД
- Исправлен редирект после установки пароля (правильный формат URL)
- Исправлена ошибка NoReverseMatch в navbar для public схемы
- Удалены все старые миграции (база создается с нуля)
- Обновлены views для работы с новой моделью Delivery
This commit is contained in:
2025-12-23 23:52:59 +03:00
parent d29c736252
commit 94fe363cb1
61 changed files with 1342 additions and 2189 deletions

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from django.contrib import admin
from django.utils.html import format_html
from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient
from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient, Delivery
class TransactionInline(admin.TabularInline):
@@ -31,6 +31,18 @@ class OrderItemInline(admin.TabularInline):
return []
class DeliveryInline(admin.StackedInline):
"""
Inline для управления доставкой заказа.
"""
model = Delivery
extra = 0
max_num = 1
fields = ['delivery_type', 'address', 'pickup_warehouse', 'cost']
verbose_name = 'Доставка'
verbose_name_plural = 'Доставка'
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
"""
@@ -39,8 +51,6 @@ class OrderAdmin(admin.ModelAdmin):
list_display = [
'order_number',
'customer',
'is_delivery',
'delivery_date',
'status',
'total_amount',
'payment_status',
@@ -50,9 +60,7 @@ class OrderAdmin(admin.ModelAdmin):
list_filter = [
'status',
'is_delivery',
'payment_status',
'delivery_date',
'created_at',
]
@@ -62,15 +70,12 @@ class OrderAdmin(admin.ModelAdmin):
'customer__phone',
'customer__email',
'recipient__name',
'delivery_address__street',
]
readonly_fields = [
'order_number',
'created_at',
'updated_at',
'delivery_info',
'delivery_time_window',
'amount_due',
'payment_status',
]
@@ -79,18 +84,10 @@ class OrderAdmin(admin.ModelAdmin):
('Основная информация', {
'fields': ('order_number', 'customer', 'status')
}),
('Доставка', {
('Получатель', {
'fields': (
'is_delivery',
'customer_is_recipient',
'delivery_address',
'pickup_warehouse',
'delivery_date',
'delivery_time_start',
'delivery_time_end',
'delivery_cost',
'delivery_info',
'delivery_time_window',
'recipient',
)
}),
('Оплата', {
@@ -111,7 +108,7 @@ class OrderAdmin(admin.ModelAdmin):
}),
)
inlines = [OrderItemInline, TransactionInline]
inlines = [OrderItemInline, DeliveryInline, TransactionInline]
actions = [
'mark_as_confirmed',

View File

@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
from django import forms
from django.forms import inlineformset_factory
from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient
from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient, Delivery
from customers.models import Customer
from inventory.models import Warehouse
from products.models import Product, ProductKit
from decimal import Decimal
@@ -123,17 +122,54 @@ class OrderForm(forms.ModelForm):
label='Уточнить адрес у получателя'
)
# Поля для доставки
delivery_type = forms.ChoiceField(
choices=Delivery.DELIVERY_TYPE_CHOICES,
required=True,
widget=forms.RadioSelect(attrs={'class': 'form-check-input'}),
label='Способ доставки',
initial=Delivery.DELIVERY_TYPE_COURIER
)
delivery_date = forms.DateField(
required=True,
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
label='Дата доставки'
)
time_from = forms.TimeField(
required=True,
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
label='Время доставки от'
)
time_to = forms.TimeField(
required=True,
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
label='Время доставки до'
)
pickup_warehouse = forms.ModelChoiceField(
queryset=None, # Будет установлен в __init__
required=False,
widget=forms.Select(attrs={'class': 'form-select'}),
label='Склад самовывоза',
empty_label='Выберите склад'
)
delivery_cost = forms.DecimalField(
required=False,
max_digits=10,
decimal_places=2,
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
label='Стоимость доставки',
initial=0
)
class Meta:
model = Order
fields = [
'customer',
'is_delivery',
'delivery_address',
'pickup_warehouse',
'delivery_date',
'delivery_time_start',
'delivery_time_end',
'delivery_cost',
'customer_is_recipient',
'recipient',
'status',
@@ -141,9 +177,6 @@ class OrderForm(forms.ModelForm):
'special_instructions',
]
widgets = {
'delivery_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'delivery_time_start': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'),
'delivery_time_end': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'),
'special_instructions': forms.Textarea(attrs={'rows': 3}),
}
@@ -199,36 +232,12 @@ class OrderForm(forms.ModelForm):
'data-placeholder': 'Начните вводить имя, телефон или email'
})
self.fields['delivery_address'].widget.attrs.update({
'class': 'form-select select2',
'data-placeholder': 'Выберите адрес доставки'
})
# Адрес доставки не обязателен при редактировании (создаётся из отдельных полей)
self.fields['delivery_address'].required = False
self.fields['pickup_warehouse'].widget.attrs.update({
'class': 'form-select select2',
'data-placeholder': 'Выберите склад для самовывоза'
})
self.fields['pickup_warehouse'].required = False
# Опциональные поля даты/времени
self.fields['delivery_date'].required = False
self.fields['delivery_time_start'].required = False
self.fields['delivery_time_end'].required = False
# Подсказки
self.fields['is_delivery'].label = 'С доставкой'
self.fields['customer_is_recipient'].label = 'Покупатель = получатель'
# Поле получателя опционально
self.fields['recipient'].required = False
# Поле ручной стоимости доставки опционально
self.fields['delivery_cost'].required = False
self.fields['delivery_cost'].label = 'Ручная стоимость доставки'
self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета'
# Инициализируем queryset для recipient_from_history
if self.instance.pk and self.instance.customer:
# При редактировании заказа загружаем историю получателей этого клиента
@@ -240,62 +249,94 @@ class OrderForm(forms.ModelForm):
orders__in=customer_orders
).distinct().order_by('-created_at')
# Инициализируем queryset для address_from_history
# Это будет переопределено в представлении после выбора клиента
if self.instance.pk and self.instance.customer:
# При редактировании заказа загружаем историю адресов этого клиента
customer_orders = Order.objects.filter(
customer=self.instance.customer,
delivery_address__isnull=False
).order_by('-created_at')
self.fields['address_from_history'].queryset = Address.objects.filter(
orders__in=customer_orders
).distinct().order_by('-created_at')
# Инициализируем поля получателя из существующего recipient
if self.instance.pk and self.instance.recipient:
recipient = self.instance.recipient
self.fields['recipient_name'].initial = recipient.name or ''
self.fields['recipient_phone'].initial = recipient.phone or ''
# Инициализируем поля адреса из существующего delivery_address
if self.instance.pk and self.instance.delivery_address:
address = self.instance.delivery_address
self.fields['address_street'].initial = address.street or ''
self.fields['address_building_number'].initial = address.building_number or ''
self.fields['address_apartment_number'].initial = address.apartment_number or ''
self.fields['address_entrance'].initial = address.entrance or ''
self.fields['address_floor'].initial = address.floor or ''
self.fields['address_intercom_code'].initial = address.intercom_code or ''
self.fields['address_delivery_instructions'].initial = address.delivery_instructions or ''
self.fields['address_confirm_with_recipient'].initial = address.confirm_address_with_recipient
# Инициализируем queryset для pickup_warehouse
from inventory.models import Warehouse
self.fields['pickup_warehouse'].queryset = Warehouse.objects.filter(is_active=True).order_by('name')
# Инициализируем поля доставки из существующей Delivery
if self.instance.pk and hasattr(self.instance, 'delivery'):
delivery = self.instance.delivery
self.fields['delivery_type'].initial = delivery.delivery_type
self.fields['delivery_date'].initial = delivery.delivery_date
self.fields['time_from'].initial = delivery.time_from
self.fields['time_to'].initial = delivery.time_to
self.fields['pickup_warehouse'].initial = delivery.pickup_warehouse
self.fields['delivery_cost'].initial = delivery.cost
def clean(self):
"""Валидация формы заказа, включая обязательные поля доставки"""
cleaned_data = super().clean()
# Проверяем, является ли заказ черновиком
status = cleaned_data.get('status')
is_draft = status and hasattr(status, 'code') and status.code == 'draft'
# Для черновиков Delivery не обязательна
if is_draft:
return cleaned_data
# Для не-черновиков Delivery обязательна
delivery_type = cleaned_data.get('delivery_type')
delivery_date = cleaned_data.get('delivery_date')
time_from = cleaned_data.get('time_from')
time_to = cleaned_data.get('time_to')
pickup_warehouse = cleaned_data.get('pickup_warehouse')
# Проверяем обязательные поля доставки
if not delivery_type:
raise forms.ValidationError({'delivery_type': 'Необходимо выбрать способ доставки'})
if not delivery_date:
raise forms.ValidationError({'delivery_date': 'Необходимо указать дату доставки'})
if not time_from:
raise forms.ValidationError({'time_from': 'Необходимо указать время начала доставки'})
if not time_to:
raise forms.ValidationError({'time_to': 'Необходимо указать время окончания доставки'})
# Проверяем, что время "до" позже времени "от"
if time_from and time_to and time_from >= time_to:
raise forms.ValidationError({
'time_to': 'Время окончания доставки должно быть позже времени начала'
})
# Проверяем специфичные требования для каждого типа доставки
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
# Для курьерской доставки нужен адрес
address_mode = cleaned_data.get('address_mode')
address_from_history = cleaned_data.get('address_from_history')
address_street = cleaned_data.get('address_street', '').strip()
has_address = (
(address_mode == 'history' and address_from_history) or
(address_mode == 'new' and address_street)
)
if not has_address:
raise forms.ValidationError({
'address_mode': 'Для курьерской доставки необходимо указать адрес'
})
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
# Для самовывоза нужен склад
if not pickup_warehouse:
raise forms.ValidationError({
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
})
return cleaned_data
def save(self, commit=True):
"""
Сохраняет форму с учетом автоматического/ручного расчета стоимости доставки.
Логика:
- Если delivery_cost заполнено → используется ручное значение (is_custom_delivery_cost = True)
- Если delivery_cost пустое → автоматический расчет (is_custom_delivery_cost = False)
ВАЖНО: reset_delivery_cost() вызывается только при commit=True,
т.к. требует наличия сохраненных items в БД.
"""
"""Сохраняет форму заказа."""
instance = super().save(commit=False)
# Получаем значение ручной стоимости доставки
delivery_cost = self.cleaned_data.get('delivery_cost')
if delivery_cost is not None and delivery_cost > 0:
# Ручное значение указано
instance.set_delivery_cost(delivery_cost, is_custom=True)
else:
# Пустое поле или 0 → помечаем что нужен автоматический расчет
# НО не вызываем reset_delivery_cost() если commit=False!
instance.is_custom_delivery_cost = False
if commit:
# Автоматический расчет только при commit=True
instance.reset_delivery_cost()
if commit:
instance.save()

View File

@@ -1,7 +1,8 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57
# Generated by Django 5.0.10 on 2025-12-23 20:38
import django.db.models.deletion
import simple_history.models
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
@@ -17,28 +18,54 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='KitItemSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_name', models.CharField(blank=True, max_length=200, verbose_name='Название товара')),
('product_sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул товара')),
('product_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Цена товара')),
('variant_group_name', models.CharField(blank=True, max_length=200, verbose_name='Группа вариантов')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
],
options={
'verbose_name': 'Снимок компонента',
'verbose_name_plural': 'Снимки компонентов',
},
),
migrations.CreateModel(
name='KitSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('base_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Базовая цена')),
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Итоговая цена')),
('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена со скидкой')),
('price_adjustment_type', models.CharField(default='none', max_length=20, verbose_name='Тип корректировки')),
('price_adjustment_value', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Значение корректировки')),
('is_temporary', models.BooleanField(default=False, verbose_name='Временный комплект')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
],
options={
'verbose_name': 'Снимок комплекта',
'verbose_name_plural': 'Снимки комплектов',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_number', models.PositiveIntegerField(editable=False, help_text='Уникальный номер заказа', unique=True, verbose_name='Номер заказа')),
('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')),
('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')),
('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')),
('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')),
('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')),
('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')),
('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')),
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')),
('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')),
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')),
('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')),
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')),
('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')),
('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')),
('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')),
('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')),
('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')),
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
@@ -54,9 +81,12 @@ class Migration(migrations.Migration):
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
],
options={
@@ -87,26 +117,59 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='Payment',
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма платежа')),
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], max_length=20, verbose_name='Способ оплаты')),
('payment_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время платежа')),
('notes', models.TextField(blank=True, help_text='Дополнительная информация о платеже', null=True, verbose_name='Примечания')),
('code', models.SlugField(help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')", unique=True, verbose_name='Код способа оплаты')),
('name', models.CharField(max_length=100, verbose_name='Название способа оплаты')),
('description', models.TextField(blank=True, help_text='Дополнительная информация о способе оплаты', verbose_name='Описание')),
('is_active', models.BooleanField(default=True, help_text='Отключенные способы оплаты не отображаются при создании заказа', verbose_name='Активен')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
('is_system', models.BooleanField(default=False, help_text='Системные способы оплаты нельзя удалить через интерфейс', verbose_name='Системный')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Платеж',
'verbose_name_plural': 'Платежи',
'ordering': ['-payment_date'],
'verbose_name': 'Способ оплаты',
'verbose_name_plural': 'Способы оплаты',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='Recipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='ФИО или название организации получателя', max_length=200, verbose_name='Имя получателя')),
('phone', models.CharField(help_text='Контактный телефон для связи с получателем', max_length=20, verbose_name='Телефон получателя')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Получатель',
'verbose_name_plural': 'Получатели',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(choices=[('payment', 'Платёж'), ('refund', 'Возврат')], default='payment', max_length=20, verbose_name='Тип транзакции')),
('amount', models.DecimalField(decimal_places=2, help_text="Всегда положительная. Для возврата используется transaction_type='refund'", max_digits=10, verbose_name='Сумма')),
('transaction_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время транзакции')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('reason', models.CharField(blank=True, help_text='Причина возврата или особенности платежа', max_length=255, null=True, verbose_name='Причина')),
],
options={
'verbose_name': 'Транзакция',
'verbose_name_plural': 'Транзакции',
'ordering': ['-transaction_date'],
},
),
migrations.CreateModel(
name='Address',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recipient_name', models.CharField(blank=True, help_text='Имя человека, которому будет доставлен заказ', max_length=200, null=True, verbose_name='Имя получателя')),
('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=20, null=True, verbose_name='Телефон получателя')),
('street', models.CharField(blank=True, max_length=255, null=True, verbose_name='Улица')),
('building_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер здания')),
('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')),
@@ -125,28 +188,38 @@ class Migration(migrations.Migration):
'indexes': [models.Index(fields=['created_at'], name='orders_addr_created_98ad97_idx')],
},
),
migrations.CreateModel(
name='Delivery',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('delivery_type', models.CharField(choices=[('courier', 'Доставка курьером'), ('pickup', 'Самовывоз')], db_index=True, default='courier', max_length=20, verbose_name='Способ доставки')),
('delivery_date', models.DateField(help_text='Дата, когда должна быть выполнена доставка', verbose_name='Дата доставки')),
('time_from', models.TimeField(help_text='Начальное время временного интервала доставки', verbose_name='Время доставки от')),
('time_to', models.TimeField(help_text='Конечное время временного интервала доставки', verbose_name='Время доставки до')),
('cost', models.DecimalField(decimal_places=2, default=0, help_text='Стоимость доставки в рублях. 0 для бесплатной доставки/самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('address', models.ForeignKey(blank=True, help_text='Адрес для курьерской доставки. На один адрес может быть много доставок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deliveries', to='orders.address', verbose_name='Адрес доставки')),
('pickup_warehouse', models.ForeignKey(blank=True, help_text='Склад для самовывоза заказа', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='deliveries', to='inventory.warehouse', verbose_name='Склад самовывоза')),
],
options={
'verbose_name': 'Доставка',
'verbose_name_plural': 'Доставки',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='HistoricalOrder',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('order_number', models.PositiveIntegerField(db_index=True, editable=False, help_text='Уникальный номер заказа', verbose_name='Номер заказа')),
('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')),
('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')),
('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')),
('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')),
('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')),
('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')),
('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')),
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')),
('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')),
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')),
('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')),
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')),
('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')),
('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')),
('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')),
('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')),
('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')),
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата создания')),
@@ -156,10 +229,8 @@ class Migration(migrations.Migration):
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('customer', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='customers.customer', verbose_name='Клиент')),
('delivery_address', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.address', verbose_name='Адрес доставки')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('modified_by', models.ForeignKey(blank=True, db_constraint=False, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем')),
('pickup_warehouse', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.warehouse', verbose_name='Склад для самовывоза')),
],
options={
'verbose_name': 'historical Заказ',
@@ -173,9 +244,12 @@ class Migration(migrations.Migration):
name='HistoricalOrderItem',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')),
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата добавления')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57
# Generated by Django 5.0.10 on 2025-12-23 20:38
import django.db.models.deletion
from django.conf import settings
@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('customers', '0001_initial'),
('customers', '0002_initial'),
('inventory', '0002_initial'),
('orders', '0001_initial'),
('products', '0001_initial'),
@@ -29,30 +29,55 @@ class Migration(migrations.Migration):
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.productkit', verbose_name='Комплект товаров'),
),
migrations.AddField(
model_name='order',
name='customer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент'),
model_name='historicalorderitem',
name='showcase',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddField(
model_name='kititemsnapshot',
name='original_product',
field=models.ForeignKey(blank=True, help_text='Ссылка на товар для резервирования на складе', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.product', verbose_name='Оригинальный товар'),
),
migrations.AddField(
model_name='kitsnapshot',
name='original_kit',
field=models.ForeignKey(blank=True, help_text='Ссылка на комплект, с которого создан снимок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='snapshots', to='products.productkit', verbose_name='Оригинальный комплект'),
),
migrations.AddField(
model_name='kititemsnapshot',
name='kit_snapshot',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
),
migrations.AddField(
model_name='historicalorderitem',
name='kit_snapshot',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
),
migrations.AddField(
model_name='order',
name='delivery_address',
field=models.OneToOneField(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='orders.address', verbose_name='Адрес доставки'),
name='customer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент'),
),
migrations.AddField(
model_name='order',
name='modified_by',
field=models.ForeignKey(blank=True, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_orders', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем'),
),
migrations.AddField(
model_name='order',
name='pickup_warehouse',
field=models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='inventory.warehouse', verbose_name='Склад для самовывоза'),
),
migrations.AddField(
model_name='historicalorderitem',
name='order',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.order', verbose_name='Заказ'),
),
migrations.AddField(
model_name='delivery',
name='order',
field=models.OneToOneField(help_text='Заказ, к которому относится доставка', on_delete=django.db.models.deletion.CASCADE, related_name='delivery', to='orders.order', verbose_name='Заказ'),
),
migrations.AddField(
model_name='orderitem',
name='kit_snapshot',
field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
),
migrations.AddField(
model_name='orderitem',
name='order',
@@ -68,6 +93,11 @@ class Migration(migrations.Migration):
name='product_kit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.productkit', verbose_name='Комплект товаров'),
),
migrations.AddField(
model_name='orderitem',
name='showcase',
field=models.ForeignKey(blank=True, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddField(
model_name='orderstatus',
name='created_by',
@@ -89,14 +119,83 @@ class Migration(migrations.Migration):
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.orderstatus', verbose_name='Статус заказа'),
),
migrations.AddField(
model_name='payment',
model_name='paymentmethod',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments_created', to=settings.AUTH_USER_MODEL, verbose_name='Принял платеж'),
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_payment_methods', to=settings.AUTH_USER_MODEL, verbose_name='Создано'),
),
migrations.AddIndex(
model_name='recipient',
index=models.Index(fields=['phone'], name='orders_reci_phone_735356_idx'),
),
migrations.AddIndex(
model_name='recipient',
index=models.Index(fields=['name'], name='orders_reci_name_e52d5b_idx'),
),
migrations.AddIndex(
model_name='recipient',
index=models.Index(fields=['created_at'], name='orders_reci_created_34a391_idx'),
),
migrations.AddField(
model_name='payment',
model_name='order',
name='recipient',
field=models.ForeignKey(blank=True, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.recipient', verbose_name='Получатель'),
),
migrations.AddField(
model_name='historicalorder',
name='recipient',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.recipient', verbose_name='Получатель'),
),
migrations.AddField(
model_name='transaction',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
),
migrations.AddField(
model_name='transaction',
name='order',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='orders.order', verbose_name='Заказ'),
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='orders.order', verbose_name='Заказ'),
),
migrations.AddField(
model_name='transaction',
name='payment_method',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='orders.paymentmethod', verbose_name='Способ оплаты/возврата'),
),
migrations.AddField(
model_name='transaction',
name='related_payment',
field=models.ForeignKey(blank=True, help_text='Для возвратов - на какой платёж ссылается этот возврат', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refunds', to='orders.transaction', verbose_name='Связанный платёж'),
),
migrations.AddIndex(
model_name='kitsnapshot',
index=models.Index(fields=['original_kit'], name='orders_kits_origina_f8d311_idx'),
),
migrations.AddIndex(
model_name='kitsnapshot',
index=models.Index(fields=['created_at'], name='orders_kits_created_70de88_idx'),
),
migrations.AddIndex(
model_name='kititemsnapshot',
index=models.Index(fields=['kit_snapshot'], name='orders_kiti_kit_sna_bf307e_idx'),
),
migrations.AddIndex(
model_name='delivery',
index=models.Index(fields=['delivery_type'], name='orders_deli_deliver_ac3dc8_idx'),
),
migrations.AddIndex(
model_name='delivery',
index=models.Index(fields=['created_at'], name='orders_deli_created_1a3ff3_idx'),
),
migrations.AddIndex(
model_name='delivery',
index=models.Index(fields=['delivery_date'], name='orders_deli_deliver_e898e4_idx'),
),
migrations.AddIndex(
model_name='delivery',
index=models.Index(fields=['time_from'], name='orders_deli_time_fr_916f57_idx'),
),
migrations.AddIndex(
model_name='delivery',
index=models.Index(fields=['time_to'], name='orders_deli_time_to_7f2573_idx'),
),
migrations.AddIndex(
model_name='orderitem',
@@ -110,6 +209,14 @@ class Migration(migrations.Migration):
model_name='orderitem',
index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['is_from_showcase'], name='orders_orde_is_from_32d8f7_idx'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['showcase'], name='orders_orde_showcas_aa97bd_idx'),
),
migrations.AddIndex(
model_name='orderstatus',
index=models.Index(fields=['code'], name='orders_orde_code_5e1ef7_idx'),
@@ -122,6 +229,18 @@ class Migration(migrations.Migration):
model_name='orderstatus',
index=models.Index(fields=['order'], name='orders_orde_order_2e2930_idx'),
),
migrations.AddIndex(
model_name='paymentmethod',
index=models.Index(fields=['code'], name='orders_paym_code_f40d7e_idx'),
),
migrations.AddIndex(
model_name='paymentmethod',
index=models.Index(fields=['is_active'], name='orders_paym_is_acti_e2be69_idx'),
),
migrations.AddIndex(
model_name='paymentmethod',
index=models.Index(fields=['order'], name='orders_paym_order_94e282_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'),
@@ -130,14 +249,6 @@ class Migration(migrations.Migration):
model_name='order',
index=models.Index(fields=['status'], name='orders_orde_status__eb4f00_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['delivery_date'], name='orders_orde_deliver_e4274f_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['is_delivery'], name='orders_orde_is_deli_07c9c0_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['payment_status'], name='orders_orde_payment_bc131d_idx'),
@@ -151,15 +262,19 @@ class Migration(migrations.Migration):
index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'),
model_name='transaction',
index=models.Index(fields=['order', '-transaction_date'], name='orders_tran_order_i_dc90ee_idx'),
),
migrations.AddIndex(
model_name='payment',
index=models.Index(fields=['order'], name='orders_paym_order_i_8c8d98_idx'),
model_name='transaction',
index=models.Index(fields=['transaction_type'], name='orders_tran_transac_3d971d_idx'),
),
migrations.AddIndex(
model_name='payment',
index=models.Index(fields=['payment_date'], name='orders_paym_payment_9e5ac0_idx'),
model_name='transaction',
index=models.Index(fields=['payment_method'], name='orders_tran_payment_7e354c_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['transaction_date'], name='orders_tran_transac_1bae48_idx'),
),
]

View File

@@ -1,44 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-16 18:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0003_showcase_reservation_showcase_and_more'),
('orders', '0002_initial'),
('products', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='historicalorderitem',
name='is_from_showcase',
field=models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины'),
),
migrations.AddField(
model_name='historicalorderitem',
name='showcase',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddField(
model_name='orderitem',
name='is_from_showcase',
field=models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины'),
),
migrations.AddField(
model_name='orderitem',
name='showcase',
field=models.ForeignKey(blank=True, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['is_from_showcase'], name='orders_orde_is_from_32d8f7_idx'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['showcase'], name='orders_orde_showcas_aa97bd_idx'),
),
]

View File

@@ -1,61 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-26 08:06
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='historicalorder',
name='payment_method',
),
migrations.RemoveField(
model_name='order',
name='payment_method',
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')", unique=True, verbose_name='Код способа оплаты')),
('name', models.CharField(max_length=100, verbose_name='Название способа оплаты')),
('description', models.TextField(blank=True, help_text='Дополнительная информация о способе оплаты', verbose_name='Описание')),
('is_active', models.BooleanField(default=True, help_text='Отключенные способы оплаты не отображаются при создании заказа', verbose_name='Активен')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
('is_system', models.BooleanField(default=False, help_text='Системные способы оплаты нельзя удалить через интерфейс', verbose_name='Системный')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_payment_methods', to=settings.AUTH_USER_MODEL, verbose_name='Создано')),
],
options={
'verbose_name': 'Способ оплаты',
'verbose_name_plural': 'Способы оплаты',
'ordering': ['order', 'name'],
},
),
migrations.AlterField(
model_name='payment',
name='payment_method',
field=models.ForeignKey(help_text='Способ оплаты данного платежа', on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.paymentmethod', verbose_name='Способ оплаты'),
),
migrations.AddIndex(
model_name='paymentmethod',
index=models.Index(fields=['code'], name='orders_paym_code_f40d7e_idx'),
),
migrations.AddIndex(
model_name='paymentmethod',
index=models.Index(fields=['is_active'], name='orders_paym_is_acti_e2be69_idx'),
),
migrations.AddIndex(
model_name='paymentmethod',
index=models.Index(fields=['order'], name='orders_paym_order_94e282_idx'),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-28 23:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0004_refactor_models_and_add_payment_method'),
]
operations = [
migrations.RemoveField(
model_name='historicalorder',
name='discount_amount',
),
migrations.RemoveField(
model_name='order',
name='discount_amount',
),
]

View File

@@ -1,55 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-29 09:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0005_remove_historicalorder_discount_amount_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(choices=[('payment', 'Платёж'), ('refund', 'Возврат')], default='payment', max_length=20, verbose_name='Тип транзакции')),
('amount', models.DecimalField(decimal_places=2, help_text="Всегда положительная. Для возврата используется transaction_type='refund'", max_digits=10, verbose_name='Сумма')),
('transaction_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время транзакции')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('reason', models.CharField(blank=True, help_text='Причина возврата или особенности платежа', max_length=255, null=True, verbose_name='Причина')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='orders.order', verbose_name='Заказ')),
('payment_method', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='orders.paymentmethod', verbose_name='Способ оплаты/возврата')),
('related_payment', models.ForeignKey(blank=True, help_text='Для возвратов - на какой платёж ссылается этот возврат', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refunds', to='orders.transaction', verbose_name='Связанный платёж')),
],
options={
'verbose_name': 'Транзакция',
'verbose_name_plural': 'Транзакции',
'ordering': ['-transaction_date'],
},
),
migrations.DeleteModel(
name='Payment',
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['order', '-transaction_date'], name='orders_tran_order_i_dc90ee_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['transaction_type'], name='orders_tran_transac_3d971d_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['payment_method'], name='orders_tran_payment_7e354c_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['transaction_date'], name='orders_tran_transac_1bae48_idx'),
),
]

View File

@@ -1,76 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-17 07:50
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0006_transaction_delete_payment_and_more'),
('products', '0010_alter_product_cost_price'),
]
operations = [
migrations.CreateModel(
name='KitSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('base_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Базовая цена')),
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Итоговая цена')),
('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена со скидкой')),
('price_adjustment_type', models.CharField(default='none', max_length=20, verbose_name='Тип корректировки')),
('price_adjustment_value', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Значение корректировки')),
('is_temporary', models.BooleanField(default=False, verbose_name='Временный комплект')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('original_kit', models.ForeignKey(blank=True, help_text='Ссылка на комплект, с которого создан снимок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='snapshots', to='products.productkit', verbose_name='Оригинальный комплект')),
],
options={
'verbose_name': 'Снимок комплекта',
'verbose_name_plural': 'Снимки комплектов',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='KitItemSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_name', models.CharField(blank=True, max_length=200, verbose_name='Название товара')),
('product_sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул товара')),
('product_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Цена товара')),
('variant_group_name', models.CharField(blank=True, max_length=200, verbose_name='Группа вариантов')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('kit_snapshot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.kitsnapshot', verbose_name='Снимок комплекта')),
],
options={
'verbose_name': 'Снимок компонента',
'verbose_name_plural': 'Снимки компонентов',
},
),
migrations.AddField(
model_name='historicalorderitem',
name='kit_snapshot',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
),
migrations.AddField(
model_name='orderitem',
name='kit_snapshot',
field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
),
migrations.AddIndex(
model_name='kitsnapshot',
index=models.Index(fields=['original_kit'], name='orders_kits_origina_f8d311_idx'),
),
migrations.AddIndex(
model_name='kitsnapshot',
index=models.Index(fields=['created_at'], name='orders_kits_created_70de88_idx'),
),
migrations.AddIndex(
model_name='kititemsnapshot',
index=models.Index(fields=['kit_snapshot'], name='orders_kiti_kit_sna_bf307e_idx'),
),
]

View File

@@ -1,39 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-17 11:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0007_kit_snapshots'),
]
operations = [
migrations.AddField(
model_name='historicalorderitem',
name='item_name_snapshot',
field=models.CharField(default='', max_length=200, verbose_name='Название на момент заказа'),
),
migrations.AddField(
model_name='historicalorderitem',
name='item_sku_snapshot',
field=models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа'),
),
migrations.AddField(
model_name='orderitem',
name='item_name_snapshot',
field=models.CharField(default='', max_length=200, verbose_name='Название на момент заказа'),
),
migrations.AddField(
model_name='orderitem',
name='item_sku_snapshot',
field=models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа'),
),
migrations.AlterField(
model_name='orderitem',
name='kit_snapshot',
field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-17 18:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0008_add_item_snapshots'),
('products', '0010_alter_product_cost_price'),
]
operations = [
migrations.AddField(
model_name='kititemsnapshot',
name='original_product',
field=models.ForeignKey(blank=True, help_text='Ссылка на товар для резервирования на складе', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.product', verbose_name='Оригинальный товар'),
),
]

View File

@@ -1,69 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-22 19:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0009_add_original_product_to_kit_item_snapshot'),
]
operations = [
migrations.RemoveField(
model_name='address',
name='recipient_name',
),
migrations.RemoveField(
model_name='address',
name='recipient_phone',
),
migrations.RemoveField(
model_name='historicalorder',
name='recipient_name',
),
migrations.RemoveField(
model_name='historicalorder',
name='recipient_phone',
),
migrations.RemoveField(
model_name='order',
name='recipient_name',
),
migrations.RemoveField(
model_name='order',
name='recipient_phone',
),
migrations.AlterField(
model_name='order',
name='delivery_address',
field=models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.address', verbose_name='Адрес доставки'),
),
migrations.CreateModel(
name='Recipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='ФИО или название организации получателя', max_length=200, verbose_name='Имя получателя')),
('phone', models.CharField(help_text='Контактный телефон для связи с получателем', max_length=20, verbose_name='Телефон получателя')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Получатель',
'verbose_name_plural': 'Получатели',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['phone'], name='orders_reci_phone_735356_idx'), models.Index(fields=['name'], name='orders_reci_name_e52d5b_idx'), models.Index(fields=['created_at'], name='orders_reci_created_34a391_idx')],
},
),
migrations.AddField(
model_name='historicalorder',
name='recipient',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.recipient', verbose_name='Получатель'),
),
migrations.AddField(
model_name='order',
name='recipient',
field=models.ForeignKey(blank=True, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.recipient', verbose_name='Получатель'),
),
]

View File

@@ -1,77 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-22 19:32
from django.db import migrations
def migrate_recipient_data_forward(apps, schema_editor):
"""
Перенос данных получателей из старых полей Order в новую модель Recipient.
Так как поля recipient_name и recipient_phone уже удалены,
мы используем HistoricalOrder для восстановления данных.
"""
# Получаем модели
HistoricalOrder = apps.get_model('orders', 'HistoricalOrder')
Recipient = apps.get_model('orders', 'Recipient')
Order = apps.get_model('orders', 'Order')
# Словарь для кэширования recipient'ов
recipients_cache = {}
# Обрабатываем каждый заказ
for order in Order.objects.all():
# Находим последнюю историческую запись для этого заказа
hist = HistoricalOrder.objects.filter(
order_number=order.order_number
).order_by('-history_date').first()
if not hist:
continue
# Проверяем, есть ли данные получателя
recipient_name = getattr(hist, 'recipient_name', None)
recipient_phone = getattr(hist, 'recipient_phone', None)
# Если получатель не указан или customer_is_recipient=True, пропускаем
if not recipient_name or not recipient_phone or order.customer_is_recipient:
continue
# Создаем ключ для кэша
cache_key = f"{recipient_name}|{recipient_phone}"
# Проверяем, есть ли уже такой получатель в кэше
if cache_key in recipients_cache:
recipient = recipients_cache[cache_key]
else:
# Создаем нового получателя
recipient, created = Recipient.objects.get_or_create(
name=recipient_name,
phone=recipient_phone
)
recipients_cache[cache_key] = recipient
# Привязываем получателя к заказу
order.recipient = recipient
order.save(update_fields=['recipient'])
def migrate_recipient_data_backward(apps, schema_editor):
"""
Обратная миграция - просто очищаем recipient поле в Order.
Данные вернутся из HistoricalOrder при повторном apply.
"""
Order = apps.get_model('orders', 'Order')
Order.objects.all().update(recipient=None)
class Migration(migrations.Migration):
dependencies = [
('orders', '0010_remove_address_recipient_name_and_more'),
]
operations = [
migrations.RunPython(
migrate_recipient_data_forward,
migrate_recipient_data_backward
),
]

View File

@@ -27,6 +27,7 @@ from .order import Order
from .kit_snapshot import KitSnapshot, KitItemSnapshot
from .order_item import OrderItem
from .transaction import Transaction
from .delivery import Delivery
__all__ = [
'OrderStatus',
@@ -38,4 +39,5 @@ __all__ = [
'Transaction',
'KitSnapshot',
'KitItemSnapshot',
'Delivery',
]

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
from django.db import models
from django.core.exceptions import ValidationError
class Delivery(models.Model):
"""
Модель доставки заказа.
Один заказ имеет одну доставку.
"""
# Константы для типов доставки
DELIVERY_TYPE_COURIER = 'courier'
DELIVERY_TYPE_PICKUP = 'pickup'
DELIVERY_TYPE_CHOICES = [
(DELIVERY_TYPE_COURIER, 'Доставка курьером'),
(DELIVERY_TYPE_PICKUP, 'Самовывоз'),
]
# === Связи ===
order = models.OneToOneField(
'orders.Order',
on_delete=models.CASCADE,
related_name='delivery',
verbose_name='Заказ',
help_text='Заказ, к которому относится доставка'
)
# Адрес доставки (только для курьерской доставки)
address = models.ForeignKey(
'orders.Address',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deliveries',
verbose_name='Адрес доставки',
help_text='Адрес для курьерской доставки. На один адрес может быть много доставок'
)
# Склад для самовывоза (только для самовывоза)
pickup_warehouse = models.ForeignKey(
'inventory.Warehouse',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='deliveries',
verbose_name='Склад самовывоза',
help_text='Склад для самовывоза заказа'
)
# === Основные поля ===
delivery_type = models.CharField(
max_length=20,
choices=DELIVERY_TYPE_CHOICES,
default=DELIVERY_TYPE_COURIER,
verbose_name='Способ доставки',
db_index=True
)
# Дата и время доставки
delivery_date = models.DateField(
verbose_name='Дата доставки',
help_text='Дата, когда должна быть выполнена доставка'
)
time_from = models.TimeField(
verbose_name='Время доставки от',
help_text='Начальное время временного интервала доставки'
)
time_to = models.TimeField(
verbose_name='Время доставки до',
help_text='Конечное время временного интервала доставки'
)
cost = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name='Стоимость доставки',
help_text='Стоимость доставки в рублях. 0 для бесплатной доставки/самовывоза'
)
# === Метаданные ===
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 = 'Доставки'
ordering = ['-created_at']
indexes = [
models.Index(fields=['delivery_type']),
models.Index(fields=['created_at']),
models.Index(fields=['delivery_date']),
models.Index(fields=['time_from']),
models.Index(fields=['time_to']),
]
def __str__(self):
"""Строковое представление доставки"""
type_display = self.get_delivery_type_display()
return f"{type_display} для заказа #{self.order.order_number}"
def clean(self):
"""Валидация модели"""
super().clean()
# Проверка: для курьерской доставки должен быть адрес
if self.delivery_type == self.DELIVERY_TYPE_COURIER:
if not self.address:
raise ValidationError({
'address': 'Для курьерской доставки необходимо указать адрес'
})
if self.pickup_warehouse:
raise ValidationError({
'pickup_warehouse': 'Для курьерской доставки склад не указывается'
})
# Проверка: для самовывоза должен быть склад
if self.delivery_type == self.DELIVERY_TYPE_PICKUP:
if not self.pickup_warehouse:
raise ValidationError({
'pickup_warehouse': 'Для самовывоза необходимо указать склад'
})
if self.address:
raise ValidationError({
'address': 'Для самовывоза адрес не указывается'
})
# Проверка: время "до" должно быть позже времени "от"
if self.time_from and self.time_to and self.time_from >= self.time_to:
raise ValidationError({
'time_to': 'Время окончания доставки должно быть позже времени начала'
})
def save(self, *args, **kwargs):
"""Переопределение save для вызова валидации"""
self.full_clean()
super().save(*args, **kwargs)

View File

@@ -1,11 +1,8 @@
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
from .recipient import Recipient
@@ -31,71 +28,6 @@ class Order(models.Model):
help_text="Уникальный номер заказа"
)
# Тип доставки
is_delivery = models.BooleanField(
default=True,
verbose_name="С доставкой",
help_text="True - доставка курьером, False - самовывоз"
)
# Адрес доставки (для курьерской доставки)
delivery_address = models.ForeignKey(
Address,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='orders',
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',
@@ -135,7 +67,7 @@ class Order(models.Model):
decimal_places=2,
default=0,
verbose_name="Итоговая сумма заказа",
help_text="Общая сумма заказа включая доставку"
help_text="Общая сумма заказа"
)
# Частичная оплата
@@ -192,6 +124,7 @@ class Order(models.Model):
help_text="Комментарии и пожелания к заказу"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
@@ -222,12 +155,9 @@ class Order(models.Model):
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']
@@ -250,81 +180,6 @@ class Order(models.Model):
self.order_number = 100
super().save(*args, **kwargs)
def clean(self):
"""Валидация модели"""
super().clean()
# Проверка: для самовывоза обязателен склад
if not self.is_delivery and not self.pickup_warehouse:
raise ValidationError({
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
})
# Проверка: время окончания должно быть позже или равно времени начала
# Равные времена означают точное время доставки (например, "к 13:00")
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()
self.total_amount = items_total + self.delivery_cost
# Сохраняем изменения в БД
self.save(update_fields=['total_amount', 'delivery_cost', 'is_custom_delivery_cost'])
return self.total_amount
def recalculate_amount_paid(self):
"""
Пересчитывает оплаченную сумму на основе транзакций.
@@ -377,34 +232,28 @@ class Order(models.Model):
"""Сумма только товаров (без доставки)"""
return sum(item.get_total_price() for item in self.items.all())
@property
def delivery_cost_display(self):
def calculate_total(self):
"""
Возвращает строку для отображения стоимости доставки с пометкой.
Полезно в админке и шаблонах.
Пересчитывает итоговую сумму заказа.
total_amount = subtotal + delivery_cost
"""
cost = self.get_delivery_cost()
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
return f"{cost} руб.{suffix}"
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'])
@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:
# Если времена равны - это точное время доставки
if self.delivery_time_start == self.delivery_time_end:
return f"к {self.delivery_time_start.strftime('%H:%M')}"
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
return "Время не указано"
def reset_delivery_cost(self):
"""
Сбрасывает стоимость доставки.
Если есть Delivery, устанавливает cost = 0.
"""
if hasattr(self, 'delivery'):
self.delivery.cost = 0
self.delivery.save(update_fields=['cost'])

View File

@@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
"""
Сервис для расчета стоимости доставки.
Содержит расширяемую логику вычисления на основе различных условий.
"""
from decimal import Decimal
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from orders.models import Order
class DeliveryCostCalculator:
"""
Калькулятор стоимости доставки.
Применяет различные правила для автоматического расчета.
"""
# Константы для правил расчета
FREE_DELIVERY_THRESHOLD = Decimal('100.00') # Бесплатная доставка от суммы
BASE_DELIVERY_COST = Decimal('15.00') # Базовая стоимость доставки
MIN_DELIVERY_COST = Decimal('0.00') # Минимальная стоимость
@classmethod
def calculate(cls, order: 'Order') -> Decimal:
"""
Рассчитывает стоимость доставки на основе условий заказа.
Args:
order: Заказ для расчета
Returns:
Decimal: Рассчитанная стоимость доставки
"""
# Самовывоз - доставка бесплатная
if not order.is_delivery:
return cls.MIN_DELIVERY_COST
# Рассчитываем сумму товаров
items_total = sum(
item.get_total_price()
for item in order.items.all()
)
# Применяем правила расчета
cost = cls._apply_calculation_rules(order, items_total)
return cost
@classmethod
def _apply_calculation_rules(cls, order: 'Order', items_total: Decimal) -> Decimal:
"""
Применяет правила расчета стоимости доставки.
Этот метод легко расширить для добавления новых правил.
Args:
order: Заказ
items_total: Сумма товаров в заказе
Returns:
Decimal: Стоимость доставки
"""
# Правило 1: Бесплатная доставка при заказе от определенной суммы
if items_total >= cls.FREE_DELIVERY_THRESHOLD:
return cls.MIN_DELIVERY_COST
# Правило 2: Базовая стоимость доставки
cost = cls.BASE_DELIVERY_COST
# Правило 3: Можно добавить расчет по адресу
# if order.delivery_address:
# cost += cls._calculate_distance_cost(order.delivery_address)
# Правило 4: Можно добавить надбавку за срочность
# if cls._is_urgent_delivery(order):
# cost *= Decimal('1.5')
return cost
@classmethod
def _calculate_distance_cost(cls, address) -> Decimal:
"""
Рассчитывает надбавку за расстояние.
Placeholder для будущей реализации с геокодингом.
"""
# TODO: Интеграция с картами для расчета расстояния
return Decimal('0.00')
@classmethod
def _is_urgent_delivery(cls, order: 'Order') -> bool:
"""
Проверяет, является ли доставка срочной.
"""
# TODO: Логика определения срочности
return False

View File

@@ -8,7 +8,7 @@ from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.db import models, transaction
from decimal import Decimal
from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod
from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod, Delivery
from .forms import OrderForm, OrderItemFormSet, OrderItemForm, OrderStatusForm, TransactionForm
from .filters import OrderFilter
from .services.address_service import AddressService
@@ -22,7 +22,7 @@ def order_list(request):
"""
# Базовый queryset с оптимизацией запросов
orders = Order.objects.select_related(
'customer', 'delivery_address', 'pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
).all()
# Применяем фильтры через django-filter
@@ -48,7 +48,7 @@ def order_list(request):
def order_detail(request, order_number):
"""Детальная информация о заказе"""
order = get_object_or_404(
Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by', 'status')
Order.objects.select_related('customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'modified_by', 'status')
.prefetch_related('items__product', 'items__product_kit', 'transactions__created_by', 'transactions__payment_method'),
order_number=order_number
)
@@ -108,15 +108,6 @@ def order_create(request):
# Если покупатель является получателем
order.recipient = None
# Обрабатываем адрес доставки
if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data)
if address:
# Если адрес не существует в БД, сохраняем его
if not address.pk:
address.save()
order.delivery_address = address
# Статус берём из формы (в том числе может быть "Черновик")
order.modified_by = request.user
@@ -127,10 +118,53 @@ def order_create(request):
formset.instance = order
formset.save()
# Пересчитываем стоимость доставки если она не установлена вручную
delivery_cost = form.cleaned_data.get('delivery_cost')
if not delivery_cost or delivery_cost <= 0:
order.reset_delivery_cost()
# Проверяем, является ли заказ черновиком
is_draft = order.status and order.status.code == 'draft'
# Создаем Delivery (обязательно, кроме черновиков)
if not is_draft:
# Получаем данные из формы (уже провалидированы)
delivery_type = form.cleaned_data.get('delivery_type')
delivery_date = form.cleaned_data.get('delivery_date')
time_from = form.cleaned_data.get('time_from')
time_to = form.cleaned_data.get('time_to')
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
# Проверяем наличие обязательных полей
if not all([delivery_type, delivery_date, time_from, time_to]):
raise ValidationError('Необходимо заполнить все поля доставки')
# Обрабатываем адрес для курьерской доставки
address = None
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
# Для курьерской доставки нужен адрес
address = AddressService.process_address_from_form(order, form.cleaned_data)
if not address:
raise ValidationError('Для курьерской доставки необходимо указать адрес')
if not address.pk:
address.save()
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
# Для самовывоза нужен склад
if not pickup_warehouse:
raise ValidationError('Для самовывоза необходимо выбрать склад')
# Создаем Delivery
delivery = Delivery.objects.create(
order=order,
delivery_type=delivery_type,
delivery_date=delivery_date,
time_from=time_from,
time_to=time_to,
address=address,
pickup_warehouse=pickup_warehouse,
cost=delivery_cost if delivery_cost else Decimal('0')
)
# Пересчитываем стоимость доставки если она не установлена вручную
if not delivery.cost or delivery.cost <= 0:
order.reset_delivery_cost()
# Пересчитываем итоговую стоимость
order.calculate_total()
@@ -233,25 +267,59 @@ def order_update(request, order_number):
# Если покупатель является получателем
order.recipient = None
# Обрабатываем адрес доставки
if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data)
if address:
# Если адрес не существует в БД, сохраняем его
if not address.pk:
address.save()
order.delivery_address = address
else:
# Если режим "без адреса", очищаем адрес
order.delivery_address = None
else:
# Если не доставка, очищаем адрес
order.delivery_address = None
order.modified_by = request.user
order.save()
formset.save()
# Проверяем, является ли заказ черновиком
is_draft = order.status and order.status.code == 'draft'
# Создаем или обновляем Delivery (обязательно, кроме черновиков)
if not is_draft:
# Получаем данные из формы (уже провалидированы)
delivery_type = form.cleaned_data.get('delivery_type')
delivery_date = form.cleaned_data.get('delivery_date')
time_from = form.cleaned_data.get('time_from')
time_to = form.cleaned_data.get('time_to')
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
# Проверяем наличие обязательных полей
if not all([delivery_type, delivery_date, time_from, time_to]):
raise ValidationError('Необходимо заполнить все поля доставки')
# Обрабатываем адрес для курьерской доставки
address = None
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
# Для курьерской доставки нужен адрес
address = AddressService.process_address_from_form(order, form.cleaned_data)
if not address:
raise ValidationError('Для курьерской доставки необходимо указать адрес')
if not address.pk:
address.save()
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
# Для самовывоза нужен склад
if not pickup_warehouse:
raise ValidationError('Для самовывоза необходимо выбрать склад')
# Создаем или обновляем Delivery
delivery, created = Delivery.objects.update_or_create(
order=order,
defaults={
'delivery_type': delivery_type,
'delivery_date': delivery_date,
'time_from': time_from,
'time_to': time_to,
'address': address,
'pickup_warehouse': pickup_warehouse,
'cost': delivery_cost if delivery_cost else Decimal('0')
}
)
elif hasattr(order, 'delivery'):
# Если заказ стал черновиком, удаляем Delivery
order.delivery.delete()
# Пересчитываем итоговую стоимость
order.calculate_total()
order.update_payment_status()