Implement flexible order status management system
Features: - Created OrderStatus model for managing statuses per tenant - Added system-level statuses: draft, new, confirmed, in_assembly, in_delivery, completed, return, cancelled - Implemented CRUD views for managing order statuses - Created OrderStatusService with status transitions and business logic hooks - Updated Order model to use ForeignKey to OrderStatus - Added is_returned flag for tracking returned orders - Updated filters to work with new OrderStatus model - Created management command for status initialization - Added HTML templates for status list, form, and confirmation - Fixed views.py to use OrderStatus instead of removed STATUS_CHOICES 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -18,7 +17,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=200, verbose_name='Имя')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='Email')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='Email')),
|
||||
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, unique=True, verbose_name='Телефон')),
|
||||
('loyalty_tier', models.CharField(choices=[('no_discount', 'Без скидки'), ('bronze', 'Бронза'), ('silver', 'Серебро'), ('gold', 'Золото'), ('platinum', 'Платина')], default='no_discount', max_length=20, verbose_name='Уровень лояльности')),
|
||||
('total_spent', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Общая сумма покупок')),
|
||||
@@ -33,28 +32,4 @@ class Migration(migrations.Migration):
|
||||
'indexes': [models.Index(fields=['name'], name='customers_c_name_f018e2_idx'), models.Index(fields=['email'], name='customers_c_email_4fdeb3_idx'), models.Index(fields=['phone'], name='customers_c_phone_8493fa_idx'), models.Index(fields=['created_at'], name='customers_c_created_1ed0f4_idx'), models.Index(fields=['loyalty_tier'], name='customers_c_loyalty_5162a0_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Address',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('recipient_name', models.CharField(help_text='Имя человека, которому будет доставлен заказ', max_length=200, verbose_name='Имя получателя')),
|
||||
('recipient_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=128, null=True, region=None, verbose_name='Телефон получателя')),
|
||||
('street', models.CharField(max_length=255, verbose_name='Улица')),
|
||||
('building_number', models.CharField(max_length=20, verbose_name='Номер здания')),
|
||||
('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')),
|
||||
('district', models.CharField(blank=True, help_text='Район в Минске для удобства доставки', max_length=100, null=True, verbose_name='Район')),
|
||||
('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера (домофон, подъезд и т.д.)', null=True, verbose_name='Инструкции для доставки')),
|
||||
('confirm_address_with_recipient', models.BooleanField(default=False, help_text='Курьер должен уточнить адрес у получателя перед доставкой', verbose_name='Уточнить адрес у получателя')),
|
||||
('is_default', models.BooleanField(default=False, help_text='Использовать этот адрес для доставки по умолчанию', verbose_name='Адрес по умолчанию')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='customers.customer', verbose_name='Клиент')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Адрес доставки',
|
||||
'verbose_name_plural': 'Адреса доставки',
|
||||
'ordering': ['-is_default', '-created_at'],
|
||||
'indexes': [models.Index(fields=['customer'], name='customers_a_custome_53b543_idx'), models.Index(fields=['is_default'], name='customers_a_is_defa_631851_idx'), models.Index(fields=['district'], name='customers_a_distric_ac47d5_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-10 23:09
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0001_initial'),
|
||||
('orders', '0003_remove_address_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Address',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-11 14:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0002_remove_address_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customer',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-11 14:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0003_alter_customer_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customer',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='Email'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import django_filters
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from .models import Order
|
||||
from .models import Order, OrderStatus
|
||||
|
||||
|
||||
class OrderFilter(django_filters.FilterSet):
|
||||
@@ -74,8 +74,8 @@ class OrderFilter(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
# Фильтр по статусу
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=Order.STATUS_CHOICES,
|
||||
status = django_filters.ModelChoiceFilter(
|
||||
queryset=OrderStatus.objects.all().order_by('order', 'name'),
|
||||
empty_label='Все статусы',
|
||||
label='Статус',
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from .models import Order, OrderItem, Address
|
||||
from .models import Order, OrderItem, Address, OrderStatus
|
||||
from customers.models import Customer
|
||||
from shops.models import Shop
|
||||
from products.models import Product, ProductKit
|
||||
@@ -281,6 +281,81 @@ OrderItemFormSet = inlineformset_factory(
|
||||
)
|
||||
|
||||
|
||||
# === СТАТУСЫ ЗАКАЗОВ ===
|
||||
|
||||
class OrderStatusForm(forms.ModelForm):
|
||||
"""Форма для создания и редактирования статусов заказов"""
|
||||
|
||||
class Meta:
|
||||
model = OrderStatus
|
||||
fields = [
|
||||
'name',
|
||||
'code',
|
||||
'label',
|
||||
'color',
|
||||
'description',
|
||||
'is_positive_end',
|
||||
'is_negative_end',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Например: Выполнен, В процессе'
|
||||
}),
|
||||
'code': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Например: completed, in_progress'
|
||||
}),
|
||||
'label': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Метка для отображения (опционально)'
|
||||
}),
|
||||
'color': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'color'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Описание статуса (опционально)'
|
||||
}),
|
||||
'is_positive_end': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
'is_negative_end': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Нельзя быть одновременно положительным и отрицательным концом
|
||||
if cleaned_data.get('is_positive_end') and cleaned_data.get('is_negative_end'):
|
||||
raise forms.ValidationError(
|
||||
"Статус не может быть одновременно положительным и отрицательным концом"
|
||||
)
|
||||
|
||||
# Системные статусы нельзя редактировать код
|
||||
if self.instance.pk and self.instance.is_system:
|
||||
original_code = OrderStatus.objects.get(pk=self.instance.pk).code
|
||||
new_code = cleaned_data.get('code')
|
||||
if original_code != new_code:
|
||||
raise forms.ValidationError(
|
||||
"Нельзя менять код системного статуса"
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Если редактируем системный статус - делаем код readonly
|
||||
if self.instance.pk and self.instance.is_system:
|
||||
self.fields['code'].widget.attrs['readonly'] = True
|
||||
self.fields['code'].help_text = "Код системного статуса нельзя менять"
|
||||
|
||||
|
||||
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||||
|
||||
class TemporaryKitForm(forms.ModelForm):
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Management command для создания системных статусов заказов.
|
||||
|
||||
Использование:
|
||||
python manage.py create_order_statuses
|
||||
python manage.py create_order_statuses --reset
|
||||
python manage.py create_order_statuses --schema grach
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django_tenants.utils import get_tenant_model
|
||||
from orders.services.order_status_service import OrderStatusService
|
||||
from orders.models import OrderStatus
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Создает системные статусы заказов для текущего тенанта.
|
||||
"""
|
||||
help = 'Создает системные статусы заказов для текущего тенанта'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Добавляем опциональные аргументы"""
|
||||
parser.add_argument(
|
||||
'--reset',
|
||||
action='store_true',
|
||||
help='Удалить все статусы перед созданием (осторожно!)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--schema',
|
||||
type=str,
|
||||
help='Имя схемы тенанта (по умолчанию текущий тенант)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Основной обработчик команды"""
|
||||
# Если указана схема, переходим на нее
|
||||
schema_name = options.get('schema')
|
||||
if schema_name:
|
||||
Tenant = get_tenant_model()
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name=schema_name)
|
||||
connection.set_tenant(tenant)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Переключился на тенант: {tenant.name}')
|
||||
)
|
||||
except Tenant.DoesNotExist:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Тенант со схемой {schema_name} не найден')
|
||||
)
|
||||
return
|
||||
|
||||
if options['reset']:
|
||||
count = OrderStatus.objects.count()
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Удаляю {count} статусов...')
|
||||
)
|
||||
OrderStatus.objects.all().delete()
|
||||
|
||||
self.stdout.write('Создаю системные статусы...')
|
||||
|
||||
try:
|
||||
OrderStatusService.create_default_statuses()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('Системные статусы успешно созданы')
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Ошибка при создании статусов: {str(e)}')
|
||||
)
|
||||
return
|
||||
|
||||
# Выводим информацию о созданных статусах
|
||||
statuses = OrderStatus.objects.all().order_by('order')
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nВсего создано {statuses.count()} статусов:')
|
||||
)
|
||||
|
||||
for status in statuses:
|
||||
status_type = 'Системный' if status.is_system else 'Пользовательский'
|
||||
end_type = ''
|
||||
if status.is_positive_end:
|
||||
end_type = ' [Успешный]'
|
||||
elif status.is_negative_end:
|
||||
end_type = ' [Отрицательный]'
|
||||
|
||||
self.stdout.write(
|
||||
f' - {status.name:<20} ({status.code:<15}) - {status_type}{end_type}'
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import simple_history.models
|
||||
@@ -17,6 +17,28 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrderStatus',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Название статуса')),
|
||||
('code', models.SlugField(help_text="Уникальный идентификатор (например: 'completed', 'cancelled')", unique=True, verbose_name='Код статуса')),
|
||||
('label', models.CharField(blank=True, max_length=100, verbose_name='Метка для отображения')),
|
||||
('is_system', models.BooleanField(default=False, help_text='True для встроенных статусов (draft, completed, cancelled)', verbose_name='Системный статус')),
|
||||
('is_positive_end', models.BooleanField(default=False, help_text='True если это финальный успешный статус (Выполнен)', verbose_name='Положительный конец')),
|
||||
('is_negative_end', models.BooleanField(default=False, help_text='True если это финальный отрицательный статус (Отменен)', verbose_name='Отрицательный конец')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
|
||||
('color', models.CharField(blank=True, default='#808080', help_text='Например: #FF5733', max_length=7, verbose_name='Цвет (hex)')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Статус заказа',
|
||||
'verbose_name_plural': 'Статусы заказов',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Payment',
|
||||
fields=[
|
||||
@@ -32,6 +54,30 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['-payment_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='Номер квартиры/офиса')),
|
||||
('entrance', models.CharField(blank=True, help_text='Номер подъезда/входа', max_length=20, null=True, verbose_name='Подъезд')),
|
||||
('floor', models.CharField(blank=True, max_length=20, null=True, verbose_name='Этаж')),
|
||||
('intercom_code', models.CharField(blank=True, help_text='Код домофона для входа в здание', max_length=100, null=True, verbose_name='Код домофона')),
|
||||
('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера', null=True, verbose_name='Инструкции для доставки')),
|
||||
('confirm_address_with_recipient', models.BooleanField(default=False, help_text='Курьер должен уточнить адрес у получателя перед доставкой', 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=['created_at'], name='orders_addr_created_98ad97_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HistoricalOrder',
|
||||
fields=[
|
||||
@@ -42,7 +88,8 @@ class Migration(migrations.Migration):
|
||||
('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='Стоимость доставки')),
|
||||
('status', models.CharField(choices=[('draft', 'Черновик'), ('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, 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='Оплачен')),
|
||||
@@ -62,7 +109,7 @@ 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='customers.address', 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_shop', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shops.shop', verbose_name='Точка самовывоза')),
|
||||
@@ -85,7 +132,8 @@ class Migration(migrations.Migration):
|
||||
('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='Стоимость доставки')),
|
||||
('status', models.CharField(choices=[('draft', 'Черновик'), ('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, 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='Оплачен')),
|
||||
@@ -101,7 +149,7 @@ class Migration(migrations.Migration):
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент')),
|
||||
('delivery_address', models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.address', verbose_name='Адрес доставки')),
|
||||
('delivery_address', models.OneToOneField(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='orders.address', verbose_name='Адрес доставки')),
|
||||
('modified_by', 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='Изменен пользователем')),
|
||||
('pickup_shop', models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='shops.shop', verbose_name='Точка самовывоза')),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -26,6 +26,26 @@ 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='orderstatus',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_order_statuses', to=settings.AUTH_USER_MODEL, verbose_name='Создано'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderstatus',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_order_statuses', to=settings.AUTH_USER_MODEL, verbose_name='Последнее изменение'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='status',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='orders.orderstatus', verbose_name='Статус заказа'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorder',
|
||||
name='status',
|
||||
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',
|
||||
name='created_by',
|
||||
@@ -36,13 +56,37 @@ class Migration(migrations.Migration):
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='orders.order', verbose_name='Заказ'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['product'], name='orders_orde_product_32ff41_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderstatus',
|
||||
index=models.Index(fields=['code'], name='orders_orde_code_5e1ef7_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderstatus',
|
||||
index=models.Index(fields=['is_system'], name='orders_orde_is_syst_2f5b85_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderstatus',
|
||||
index=models.Index(fields=['order'], name='orders_orde_order_2e2930_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['status'], name='orders_orde_status_c6dd84_idx'),
|
||||
index=models.Index(fields=['status'], name='orders_orde_status__eb4f00_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
@@ -65,16 +109,8 @@ class Migration(migrations.Migration):
|
||||
index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['product'], name='orders_orde_product_32ff41_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'),
|
||||
model_name='order',
|
||||
index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='payment',
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-10 23:09
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('orders', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Address',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('recipient_name', models.CharField(help_text='Имя человека, которому будет доставлен заказ', max_length=200, verbose_name='Имя получателя')),
|
||||
('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=20, null=True, verbose_name='Телефон получателя')),
|
||||
('street', models.CharField(max_length=255, verbose_name='Улица')),
|
||||
('building_number', models.CharField(max_length=20, verbose_name='Номер здания')),
|
||||
('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')),
|
||||
('district', models.CharField(blank=True, help_text='Район в Минске для удобства доставки', max_length=100, null=True, verbose_name='Район')),
|
||||
('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера (домофон, подъезд и т.д.)', null=True, verbose_name='Инструкции для доставки')),
|
||||
('confirm_address_with_recipient', models.BooleanField(default=False, help_text='Курьер должен уточнить адрес у получателя перед доставкой', 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=['district'], name='orders_addr_distric_fd94e9_idx')],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalorder',
|
||||
name='delivery_address',
|
||||
field=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='Адрес доставки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
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='Адрес доставки'),
|
||||
),
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-10 23:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('orders', '0003_remove_address_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='address',
|
||||
name='orders_addr_distric_fd94e9_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='address',
|
||||
name='district',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='address',
|
||||
name='entrance',
|
||||
field=models.CharField(blank=True, help_text='Номер подъезда/входа', max_length=20, null=True, verbose_name='Подъезд'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='address',
|
||||
name='floor',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Этаж'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='address',
|
||||
name='intercom_code',
|
||||
field=models.CharField(blank=True, help_text='Код домофона для входа в здание', max_length=100, null=True, verbose_name='Код домофона'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='address',
|
||||
name='delivery_instructions',
|
||||
field=models.TextField(blank=True, help_text='Дополнительные инструкции для курьера', null=True, verbose_name='Инструкции для доставки'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='address',
|
||||
index=models.Index(fields=['created_at'], name='orders_addr_created_98ad97_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-11 09:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0002_remove_address_model'),
|
||||
('orders', '0004_remove_address_orders_addr_distric_fd94e9_idx_and_more'),
|
||||
('shops', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalorder',
|
||||
name='is_custom_delivery_cost',
|
||||
field=models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='is_custom_delivery_cost',
|
||||
field=models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='address',
|
||||
name='building_number',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер здания'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='address',
|
||||
name='recipient_name',
|
||||
field=models.CharField(blank=True, help_text='Имя человека, которому будет доставлен заказ', max_length=200, null=True, verbose_name='Имя получателя'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='address',
|
||||
name='street',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Улица'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'),
|
||||
),
|
||||
]
|
||||
@@ -7,6 +7,99 @@ from shops.models import Shop
|
||||
from simple_history.models import HistoricalRecords
|
||||
|
||||
|
||||
class OrderStatus(models.Model):
|
||||
"""
|
||||
Статус заказа, управляется отдельно для каждого тенанта.
|
||||
Благодаря django-tenants в TENANT_APPS, данные изолированы по схемам.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Название статуса"
|
||||
)
|
||||
|
||||
code = models.SlugField(
|
||||
unique=True,
|
||||
verbose_name="Код статуса",
|
||||
help_text="Уникальный идентификатор (например: 'completed', 'cancelled')"
|
||||
)
|
||||
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Метка для отображения",
|
||||
blank=True
|
||||
)
|
||||
|
||||
is_system = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Системный статус",
|
||||
help_text="True для встроенных статусов (draft, completed, cancelled)"
|
||||
)
|
||||
|
||||
is_positive_end = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Положительный конец",
|
||||
help_text="True если это финальный успешный статус (Выполнен)"
|
||||
)
|
||||
|
||||
is_negative_end = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Отрицательный конец",
|
||||
help_text="True если это финальный отрицательный статус (Отменен)"
|
||||
)
|
||||
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Порядок отображения"
|
||||
)
|
||||
|
||||
color = models.CharField(
|
||||
max_length=7,
|
||||
blank=True,
|
||||
default='#808080',
|
||||
verbose_name="Цвет (hex)",
|
||||
help_text="Например: #FF5733"
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_order_statuses',
|
||||
verbose_name="Создано"
|
||||
)
|
||||
|
||||
updated_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='updated_order_statuses',
|
||||
verbose_name="Последнее изменение"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Статус заказа"
|
||||
verbose_name_plural = "Статусы заказов"
|
||||
ordering = ['order', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=['code']),
|
||||
models.Index(fields=['is_system']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Address(models.Model):
|
||||
"""
|
||||
Модель адреса доставки для заказа цветочного магазина в Минске.
|
||||
@@ -233,23 +326,22 @@ class Order(models.Model):
|
||||
)
|
||||
|
||||
# Статус заказа
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Черновик'),
|
||||
('new', 'Новый'),
|
||||
('confirmed', 'Подтвержден'),
|
||||
('in_assembly', 'В сборке'),
|
||||
('in_delivery', 'В доставке'),
|
||||
('delivered', 'Доставлен'),
|
||||
('cancelled', 'Отменен'),
|
||||
]
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='new',
|
||||
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,
|
||||
@@ -496,7 +588,7 @@ class Order(models.Model):
|
||||
|
||||
def is_draft(self):
|
||||
"""Проверяет, является ли заказ черновиком"""
|
||||
return self.status == 'draft'
|
||||
return self.status and self.status.code == 'draft'
|
||||
|
||||
@property
|
||||
def amount_due(self):
|
||||
|
||||
220
myproject/orders/services/order_status_service.py
Normal file
220
myproject/orders/services/order_status_service.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Сервис для управления статусами заказов.
|
||||
Содержит бизнес-логику для работы со статусами и их переходами.
|
||||
"""
|
||||
from django.db import transaction
|
||||
from orders.models import OrderStatus, Order
|
||||
|
||||
|
||||
class OrderStatusService:
|
||||
"""Сервис для работы со статусами заказов"""
|
||||
|
||||
@staticmethod
|
||||
def get_default_status():
|
||||
"""
|
||||
Возвращает статус по умолчанию для новых заказов ('new')
|
||||
"""
|
||||
try:
|
||||
return OrderStatus.objects.get(code='new', is_system=True)
|
||||
except OrderStatus.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_draft_status():
|
||||
"""Возвращает системный статус 'draft' (черновик)"""
|
||||
try:
|
||||
return OrderStatus.objects.get(code='draft', is_system=True)
|
||||
except OrderStatus.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_system_status(code):
|
||||
"""Получить системный статус по коду"""
|
||||
try:
|
||||
return OrderStatus.objects.get(code=code, is_system=True)
|
||||
except OrderStatus.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def create_default_statuses():
|
||||
"""
|
||||
Создает системные статусы для тенанта.
|
||||
Вызывается при первом использовании или миграции.
|
||||
"""
|
||||
default_statuses = [
|
||||
{
|
||||
'code': 'draft',
|
||||
'name': 'Черновик',
|
||||
'label': 'Черновик',
|
||||
'is_system': True,
|
||||
'order': 0,
|
||||
'color': '#9E9E9E',
|
||||
'description': 'Заказ находится в процессе создания/редактирования'
|
||||
},
|
||||
{
|
||||
'code': 'new',
|
||||
'name': 'Новый',
|
||||
'label': 'Новый',
|
||||
'is_system': True,
|
||||
'order': 10,
|
||||
'color': '#2196F3',
|
||||
'description': 'Новый заказ, ожидающий обработки'
|
||||
},
|
||||
{
|
||||
'code': 'confirmed',
|
||||
'name': 'Подтвержден',
|
||||
'label': 'Подтвержден',
|
||||
'is_system': True,
|
||||
'order': 20,
|
||||
'color': '#FF9800',
|
||||
'description': 'Заказ подтвержден и одобрен'
|
||||
},
|
||||
{
|
||||
'code': 'in_assembly',
|
||||
'name': 'В сборке',
|
||||
'label': 'В сборке',
|
||||
'is_system': True,
|
||||
'order': 30,
|
||||
'color': '#FF9800',
|
||||
'description': 'Заказ находится в процессе сборки/подготовки'
|
||||
},
|
||||
{
|
||||
'code': 'in_delivery',
|
||||
'name': 'В доставке',
|
||||
'label': 'В доставке',
|
||||
'is_system': True,
|
||||
'order': 40,
|
||||
'color': '#9C27B0',
|
||||
'description': 'Заказ в пути к клиенту'
|
||||
},
|
||||
{
|
||||
'code': 'completed',
|
||||
'name': 'Выполнен',
|
||||
'label': 'Выполнен',
|
||||
'is_system': True,
|
||||
'is_positive_end': True,
|
||||
'order': 50,
|
||||
'color': '#4CAF50',
|
||||
'description': 'Заказ успешно доставлен/выполнен'
|
||||
},
|
||||
{
|
||||
'code': 'return',
|
||||
'name': 'Возврат',
|
||||
'label': 'Возврат',
|
||||
'is_system': True,
|
||||
'order': 60,
|
||||
'color': '#FF5722',
|
||||
'description': 'Заказ возвращен клиентом'
|
||||
},
|
||||
{
|
||||
'code': 'cancelled',
|
||||
'name': 'Отменен',
|
||||
'label': 'Отменен',
|
||||
'is_system': True,
|
||||
'is_negative_end': True,
|
||||
'order': 70,
|
||||
'color': '#F44336',
|
||||
'description': 'Заказ отменен'
|
||||
},
|
||||
]
|
||||
|
||||
for status_data in default_statuses:
|
||||
OrderStatus.objects.get_or_create(
|
||||
code=status_data['code'],
|
||||
defaults=status_data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def change_order_status(order, new_status, user, notes=""):
|
||||
"""
|
||||
Меняет статус заказа и выполняет соответствующую бизнес-логику.
|
||||
|
||||
Args:
|
||||
order (Order): Экземпляр заказа
|
||||
new_status (OrderStatus): Новый статус
|
||||
user (CustomUser): Пользователь, делающий изменение
|
||||
notes (str): Опциональные заметки
|
||||
|
||||
Returns:
|
||||
Order: Обновленный экземпляр заказа
|
||||
|
||||
Raises:
|
||||
ValueError: Если статус не может быть применен
|
||||
"""
|
||||
old_status = order.status
|
||||
order.status = new_status
|
||||
order.modified_by = user
|
||||
order.save()
|
||||
|
||||
# Запустить бизнес-логику в зависимости от нового статуса
|
||||
if new_status.code == 'completed':
|
||||
_handle_order_completion(order, user)
|
||||
|
||||
elif new_status.code == 'cancelled':
|
||||
_handle_order_cancellation(order, old_status, user)
|
||||
|
||||
elif new_status.code == 'return':
|
||||
_handle_order_return(order, user)
|
||||
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def get_all_statuses():
|
||||
"""Возвращает все статусы, отсортированные по порядку"""
|
||||
return OrderStatus.objects.all().order_by('order', 'name')
|
||||
|
||||
@staticmethod
|
||||
def get_system_statuses():
|
||||
"""Возвращает только системные статусы"""
|
||||
return OrderStatus.objects.filter(is_system=True).order_by('order')
|
||||
|
||||
@staticmethod
|
||||
def get_custom_statuses():
|
||||
"""Возвращает только пользовательские статусы"""
|
||||
return OrderStatus.objects.filter(is_system=False).order_by('order', 'name')
|
||||
|
||||
|
||||
def _handle_order_completion(order, user):
|
||||
"""
|
||||
Обработка при переводе в статус 'Выполнен'.
|
||||
Здесь происходит списание товаров со склада.
|
||||
|
||||
TODO: Интеграция с inventory приложением
|
||||
"""
|
||||
# from inventory.services import InventoryService
|
||||
# InventoryService.process_order_completion(order)
|
||||
pass
|
||||
|
||||
|
||||
def _handle_order_cancellation(order, old_status, user):
|
||||
"""
|
||||
Обработка при переводе в статус 'Отменен'.
|
||||
Если заказ был выполнен - возвращаем товары и деньги.
|
||||
"""
|
||||
if old_status and old_status.code == 'completed':
|
||||
# Заказ был выполнен - нужно вернуть товары и деньги
|
||||
order.is_returned = True
|
||||
order.save()
|
||||
|
||||
# TODO: Интеграция с inventory - возврат товаров
|
||||
# InventoryService.process_order_return(order)
|
||||
|
||||
# TODO: Интеграция с платежами - создать возврат
|
||||
# PaymentService.create_refund(order)
|
||||
else:
|
||||
# Заказ отменен до выполнения - просто отменить резервы
|
||||
# TODO: InventoryService.cancel_order_reservation(order)
|
||||
pass
|
||||
|
||||
|
||||
def _handle_order_return(order, user):
|
||||
"""
|
||||
Обработка при переводе в статус 'Возврат'.
|
||||
Это промежуточный статус перед окончательной отменой.
|
||||
"""
|
||||
order.is_returned = True
|
||||
order.save()
|
||||
|
||||
# TODO: Интеграция с inventory - вернуть товары на склад
|
||||
# InventoryService.process_order_return(order)
|
||||
129
myproject/orders/templates/orders/status_confirm_delete.html
Normal file
129
myproject/orders/templates/orders/status_confirm_delete.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Удалить статус{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1>Удалить статус</h1>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{% url 'orders:status_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Вернуться к статусам
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-exclamation-triangle"></i> Подтвердите удаление
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning mb-4">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<strong>Внимание!</strong> Это действие необратимо.
|
||||
</div>
|
||||
|
||||
<p>Вы собираетесь удалить статус:</p>
|
||||
<div class="mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5>
|
||||
<span style="display: inline-block; width: 20px; height: 20px; background-color: {{ object.color }}; border-radius: 3px; margin-right: 10px; vertical-align: middle;"></span>
|
||||
{{ object.name }}
|
||||
</h5>
|
||||
<p class="text-muted mb-2">
|
||||
<strong>Код:</strong> <code>{{ object.code }}</code>
|
||||
</p>
|
||||
{% if object.description %}
|
||||
<p class="mb-0">
|
||||
<strong>Описание:</strong><br>
|
||||
{{ object.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if orders_count > 0 %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-ban"></i>
|
||||
<strong>Невозможно удалить!</strong>
|
||||
В этом статусе находится {{ orders_count }} заказ(ов).
|
||||
Пожалуйста, измените статус этих заказов перед удалением.
|
||||
</div>
|
||||
|
||||
<a href="{% url 'orders:status_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Вернуться к статусам
|
||||
</a>
|
||||
{% else %}
|
||||
<form method="post" class="mt-4">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Да, удалить статус
|
||||
</button>
|
||||
<a href="{% url 'orders:status_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> Отменить
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0">Информация о статусе</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-5">Название:</dt>
|
||||
<dd class="col-sm-7">{{ object.name }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Код:</dt>
|
||||
<dd class="col-sm-7"><code>{{ object.code }}</code></dd>
|
||||
|
||||
<dt class="col-sm-5">Тип:</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if object.is_system %}
|
||||
<span class="badge bg-info">Системный</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Пользовательский</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-5">Статус:</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if object.is_positive_end %}
|
||||
<span class="badge bg-success">✓ Успешный</span>
|
||||
{% elif object.is_negative_end %}
|
||||
<span class="badge bg-danger">✗ Отрицательный</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Промежуточный</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-5">Заказов:</dt>
|
||||
<dd class="col-sm-7"><span class="badge bg-light text-dark">{{ orders_count }}</span></dd>
|
||||
|
||||
<dt class="col-sm-5">Создано:</dt>
|
||||
<dd class="col-sm-7"><small>{{ object.created_at|date:"d.m.Y H:i" }}</small></dd>
|
||||
|
||||
<dt class="col-sm-5">Изменено:</dt>
|
||||
<dd class="col-sm-7"><small>{{ object.updated_at|date:"d.m.Y H:i" }}</small></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
295
myproject/orders/templates/orders/status_form.html
Normal file
295
myproject/orders/templates/orders/status_form.html
Normal file
@@ -0,0 +1,295 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{% if form.instance.pk %}Редактировать{% else %}Создать{% endif %} статус{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1>
|
||||
{% if form.instance.pk %}
|
||||
Редактировать статус: {{ form.instance.name }}
|
||||
{% else %}
|
||||
Создать новый статус
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if is_system %}
|
||||
<div class="alert alert-info mt-2">
|
||||
<i class="fas fa-info-circle"></i> Это системный статус. Некоторые поля не могут быть изменены.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{% url 'orders:status_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Вернуться к статусам
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">Ошибка!</h4>
|
||||
{% for error in form.non_field_errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">
|
||||
{{ form.name.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.name.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">
|
||||
Например: Выполнен, В процессе, Возврат
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.code.id_for_label }}" class="form-label">
|
||||
{{ form.code.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.code }}
|
||||
{% if form.code.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.code.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">
|
||||
{% if form.code.field.help_text %}
|
||||
{{ form.code.field.help_text|safe }}
|
||||
{% else %}
|
||||
Латинские буквы, цифры и подчеркивания
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.label.id_for_label }}" class="form-label">
|
||||
{{ form.label.label }}
|
||||
</label>
|
||||
{{ form.label }}
|
||||
{% if form.label.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.label.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">
|
||||
Для отображения в интерфейсе (опционально)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.color.id_for_label }}" class="form-label">
|
||||
{{ form.color.label }}
|
||||
</label>
|
||||
<div class="input-group">
|
||||
{{ form.color }}
|
||||
<span class="input-group-text" id="color-preview" style="width: 60px; background-color: #808080;"></span>
|
||||
</div>
|
||||
{% if form.color.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.color.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||
{{ form.description.label }}
|
||||
</label>
|
||||
{{ form.description }}
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.description.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">
|
||||
Описание для пользователей (опционально)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
{{ form.is_positive_end }}
|
||||
<label class="form-check-label" for="{{ form.is_positive_end.id_for_label }}">
|
||||
{{ form.is_positive_end.label }}
|
||||
</label>
|
||||
<small class="d-block text-muted mt-1">
|
||||
Отметьте, если это успешный финальный статус (Выполнен)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
{{ form.is_negative_end }}
|
||||
<label class="form-check-label" for="{{ form.is_negative_end.id_for_label }}">
|
||||
{{ form.is_negative_end.label }}
|
||||
</label>
|
||||
<small class="d-block text-muted mt-1">
|
||||
Отметьте, если это отрицательный финальный статус (Отменен)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
{% if form.instance.pk %}
|
||||
Сохранить изменения
|
||||
{% else %}
|
||||
Создать статус
|
||||
{% endif %}
|
||||
</button>
|
||||
<a href="{% url 'orders:status_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> Отменить
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0">Предпросмотр</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название статуса</label>
|
||||
<p class="lead">
|
||||
<span id="preview-name">{{ form.instance.name|default:"Название" }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Код статуса</label>
|
||||
<code id="preview-code">{{ form.instance.code|default:"код" }}</code>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Внешний вид</label>
|
||||
<div style="padding: 10px; border-radius: 4px; background-color: {{ form.instance.color|default:'#808080' }}; color: white; text-align: center;" id="preview-color">
|
||||
<strong id="preview-color-text">{{ form.instance.name|default:"Статус" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Тип статуса</label>
|
||||
<p id="preview-type">
|
||||
{% if form.instance.is_system %}
|
||||
<span class="badge bg-info">Системный</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Пользовательский</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Финальный статус</label>
|
||||
<p id="preview-end">
|
||||
{% if form.instance.is_positive_end %}
|
||||
<span class="badge bg-success">✓ Успешный конец</span>
|
||||
{% elif form.instance.is_negative_end %}
|
||||
<span class="badge bg-danger">✗ Отрицательный конец</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Промежуточный</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const nameInput = document.getElementById('{{ form.name.id_for_label }}');
|
||||
const codeInput = document.getElementById('{{ form.code.id_for_label }}');
|
||||
const colorInput = document.getElementById('{{ form.color.id_for_label }}');
|
||||
const positiveEndCheckbox = document.getElementById('{{ form.is_positive_end.id_for_label }}');
|
||||
const negativeEndCheckbox = document.getElementById('{{ form.is_negative_end.id_for_label }}');
|
||||
|
||||
const previewName = document.getElementById('preview-name');
|
||||
const previewCode = document.getElementById('preview-code');
|
||||
const previewColor = document.getElementById('preview-color');
|
||||
const previewColorText = document.getElementById('preview-color-text');
|
||||
const colorPreview = document.getElementById('color-preview');
|
||||
const previewEnd = document.getElementById('preview-end');
|
||||
|
||||
function updatePreview() {
|
||||
// Обновляем название
|
||||
previewName.textContent = nameInput.value || 'Название';
|
||||
previewColorText.textContent = nameInput.value || 'Статус';
|
||||
|
||||
// Обновляем код
|
||||
previewCode.textContent = codeInput.value || 'код';
|
||||
|
||||
// Обновляем цвет
|
||||
const color = colorInput.value || '#808080';
|
||||
previewColor.style.backgroundColor = color;
|
||||
colorPreview.style.backgroundColor = color;
|
||||
|
||||
// Обновляем тип конца
|
||||
if (positiveEndCheckbox.checked) {
|
||||
previewEnd.innerHTML = '<span class="badge bg-success">✓ Успешный конец</span>';
|
||||
} else if (negativeEndCheckbox.checked) {
|
||||
previewEnd.innerHTML = '<span class="badge bg-danger">✗ Отрицательный конец</span>';
|
||||
} else {
|
||||
previewEnd.innerHTML = '<span class="badge bg-secondary">Промежуточный</span>';
|
||||
}
|
||||
}
|
||||
|
||||
nameInput.addEventListener('input', updatePreview);
|
||||
codeInput.addEventListener('input', updatePreview);
|
||||
colorInput.addEventListener('change', updatePreview);
|
||||
positiveEndCheckbox.addEventListener('change', updatePreview);
|
||||
negativeEndCheckbox.addEventListener('change', updatePreview);
|
||||
|
||||
// Инициальное обновление
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
173
myproject/orders/templates/orders/status_list.html
Normal file
173
myproject/orders/templates/orders/status_list.html
Normal file
@@ -0,0 +1,173 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Статусы заказов{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h1>Статусы заказов</h1>
|
||||
<p class="text-muted">Управление статусами для заказов вашего магазина</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<a href="{% url 'orders:status_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Создать новый статус
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px;">№</th>
|
||||
<th style="width: 200px;">Название</th>
|
||||
<th style="width: 150px;">Код</th>
|
||||
<th style="width: 150px;">Тип</th>
|
||||
<th style="width: 100px;">Конец</th>
|
||||
<th style="width: 80px;">Цвет</th>
|
||||
<th style="width: 80px;">Заказов</th>
|
||||
<th style="width: 150px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for status in statuses %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ status.order }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ status.name }}</strong>
|
||||
{% if status.description %}
|
||||
<br>
|
||||
<small class="text-muted">{{ status.description|truncatewords:10 }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ status.code }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{% if status.is_system %}
|
||||
<span class="badge bg-info">Системный</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Пользовательский</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if status.is_positive_end %}
|
||||
<span class="badge bg-success">✓ Успешный</span>
|
||||
{% elif status.is_negative_end %}
|
||||
<span class="badge bg-danger">✗ Отрицательный</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div style="width: 40px; height: 30px; background-color: {{ status.color }}; border-radius: 4px; border: 1px solid #ccc;" title="{{ status.color }}"></div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">{{ status.orders_count }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{% url 'orders:status_edit' status.pk %}" class="btn btn-outline-primary" title="Редактировать">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if not status.is_system and status.orders_count == 0 %}
|
||||
<a href="{% url 'orders:status_delete' status.pk %}" class="btn btn-outline-danger" title="Удалить">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-secondary" disabled title="{% if status.is_system %}Системный статус{% else %}В статусе есть заказы{% endif %}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if not forloop.first %}
|
||||
<form method="post" action="{% url 'orders:status_move' status.pk %}" class="d-inline" style="display: none;" id="move-up-form-{{ status.pk }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="direction" value="up">
|
||||
</form>
|
||||
<button class="btn btn-outline-secondary" onclick="document.getElementById('move-up-form-{{ status.pk }}').submit();" title="Вверх">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if not forloop.last %}
|
||||
<form method="post" action="{% url 'orders:status_move' status.pk %}" class="d-inline" style="display: none;" id="move-down-form-{{ status.pk }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="direction" value="down">
|
||||
</form>
|
||||
<button class="btn btn-outline-secondary" onclick="document.getElementById('move-down-form-{{ status.pk }}').submit();" title="Вниз">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
<i class="fas fa-inbox"></i> Статусы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1">Первая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -15,4 +15,10 @@ urlpatterns = [
|
||||
path('<int:pk>/autosave/', views.autosave_draft_order, name='order-autosave'),
|
||||
path('create-draft/', views.create_draft_from_form, name='order-create-draft'),
|
||||
path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'),
|
||||
|
||||
# Order Status Management URLs
|
||||
path('statuses/', views.order_status_list, name='status_list'),
|
||||
path('statuses/create/', views.order_status_create, name='status_create'),
|
||||
path('statuses/<int:pk>/edit/', views.order_status_update, name='status_edit'),
|
||||
path('statuses/<int:pk>/delete/', views.order_status_delete, name='status_delete'),
|
||||
]
|
||||
|
||||
@@ -6,8 +6,9 @@ from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import Order, OrderItem, Address
|
||||
from .forms import OrderForm, OrderItemFormSet
|
||||
from django.db import models
|
||||
from .models import Order, OrderItem, Address, OrderStatus
|
||||
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm
|
||||
from .filters import OrderFilter
|
||||
from .services import DraftOrderService
|
||||
from .services.address_service import AddressService
|
||||
@@ -38,7 +39,7 @@ def order_list(request):
|
||||
context = {
|
||||
'filter': order_filter,
|
||||
'page_obj': page_obj,
|
||||
'status_choices': Order.STATUS_CHOICES,
|
||||
'status_choices': OrderStatus.objects.all().order_by('order'),
|
||||
}
|
||||
|
||||
return render(request, 'orders/order_list.html', context)
|
||||
@@ -79,7 +80,8 @@ def order_create(request):
|
||||
|
||||
# Если нажата кнопка "Сохранить как черновик", создаем черновик
|
||||
if 'save_as_draft' in request.POST:
|
||||
order.status = 'draft'
|
||||
from .services.order_status_service import OrderStatusService
|
||||
order.status = OrderStatusService.get_draft_status()
|
||||
order.modified_by = request.user
|
||||
|
||||
order.save()
|
||||
@@ -471,6 +473,121 @@ def get_customer_address_history(request):
|
||||
}, status=500)
|
||||
|
||||
|
||||
# === УПРАВЛЕНИЕ СТАТУСАМИ ЗАКАЗОВ ===
|
||||
|
||||
@login_required
|
||||
def order_status_list(request):
|
||||
"""Список всех статусов заказов"""
|
||||
statuses = OrderStatus.objects.all().order_by('order', 'name')
|
||||
|
||||
# Добавляем информацию о количестве заказов для каждого статуса
|
||||
for status in statuses:
|
||||
status.orders_count = Order.objects.filter(status=status).count()
|
||||
|
||||
context = {
|
||||
'statuses': statuses,
|
||||
'title': 'Статусы заказов'
|
||||
}
|
||||
|
||||
return render(request, 'orders/status_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def order_status_create(request):
|
||||
"""Создание нового статуса"""
|
||||
if request.method == 'POST':
|
||||
form = OrderStatusForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
status = form.save(commit=False)
|
||||
status.created_by = request.user
|
||||
status.updated_by = request.user
|
||||
|
||||
# Если не указан порядок - делаем его последним
|
||||
if not status.order:
|
||||
max_order = OrderStatus.objects.aggregate(models.Max('order'))['order__max'] or 0
|
||||
status.order = max_order + 10
|
||||
|
||||
status.save()
|
||||
messages.success(request, f'Статус "{status.name}" успешно создан')
|
||||
return redirect('orders:status_list')
|
||||
else:
|
||||
form = OrderStatusForm()
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'title': 'Создать новый статус',
|
||||
'button_text': 'Создать'
|
||||
}
|
||||
|
||||
return render(request, 'orders/status_form.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def order_status_update(request, pk):
|
||||
"""Редактирование статуса"""
|
||||
status = get_object_or_404(OrderStatus, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = OrderStatusForm(request.POST, instance=status)
|
||||
|
||||
if form.is_valid():
|
||||
status = form.save(commit=False)
|
||||
status.updated_by = request.user
|
||||
status.save()
|
||||
messages.success(request, f'Статус "{status.name}" успешно обновлен')
|
||||
return redirect('orders:status_list')
|
||||
else:
|
||||
form = OrderStatusForm(instance=status)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'status': status,
|
||||
'title': f'Редактировать статус: {status.name}',
|
||||
'button_text': 'Сохранить',
|
||||
'is_system': status.is_system
|
||||
}
|
||||
|
||||
return render(request, 'orders/status_form.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def order_status_delete(request, pk):
|
||||
"""Удаление статуса"""
|
||||
status = get_object_or_404(OrderStatus, pk=pk)
|
||||
|
||||
if status.is_system:
|
||||
messages.error(request, f'Нельзя удалить системный статус "{status.name}"')
|
||||
return redirect('orders:status_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
# Проверяем, что статус не используется в заказах
|
||||
orders_count = Order.objects.filter(status=status).count()
|
||||
|
||||
if orders_count > 0:
|
||||
messages.error(
|
||||
request,
|
||||
f'Невозможно удалить статус. Есть {orders_count} заказов с этим статусом.'
|
||||
)
|
||||
return redirect('orders:status_list')
|
||||
|
||||
status_name = status.name
|
||||
status.delete()
|
||||
messages.success(request, f'Статус "{status_name}" успешно удален')
|
||||
return redirect('orders:status_list')
|
||||
|
||||
# Информация для подтверждения удаления
|
||||
orders_count = Order.objects.filter(status=status).count()
|
||||
|
||||
context = {
|
||||
'status': status,
|
||||
'orders_count': orders_count,
|
||||
'title': 'Удалить статус'
|
||||
}
|
||||
|
||||
return render(request, 'orders/status_confirm_delete.html', context)
|
||||
|
||||
|
||||
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||||
# УДАЛЕНО: Логика создания временных комплектов перенесена в products.services.kit_service
|
||||
# Используйте API endpoint: products:api-temporary-kit-create
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -177,15 +177,14 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Название')),
|
||||
('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL-идентификатор')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активен')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления')),
|
||||
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')),
|
||||
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_tags', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тег товара',
|
||||
'verbose_name_plural': 'Теги товаров',
|
||||
'indexes': [models.Index(fields=['is_active'], name='products_pr_is_acti_7f288f_idx')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -294,14 +293,6 @@ class Migration(migrations.Migration):
|
||||
model_name='productphoto',
|
||||
index=models.Index(fields=['quality_warning', 'product'], name='products_pr_quality_6e8b51_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='producttag',
|
||||
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_ea9be0_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='producttag',
|
||||
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_bc2d9c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productkit',
|
||||
index=models.Index(fields=['is_temporary'], name='products_pr_is_temp_e407a2_idx'),
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-11 18:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='producttag',
|
||||
name='products_pr_is_dele_ea9be0_idx',
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='producttag',
|
||||
name='products_pr_is_dele_bc2d9c_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='producttag',
|
||||
name='deleted_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='producttag',
|
||||
name='deleted_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='producttag',
|
||||
name='is_deleted',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='producttag',
|
||||
name='is_active',
|
||||
field=models.BooleanField(db_index=True, default=True, verbose_name='Активен'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='producttag',
|
||||
index=models.Index(fields=['is_active'], name='products_pr_is_acti_7f288f_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
@@ -17,20 +17,13 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
||||
('street', models.CharField(max_length=255, verbose_name='Улица')),
|
||||
('building_number', models.CharField(max_length=20, verbose_name='Номер здания')),
|
||||
('district', models.CharField(blank=True, help_text='Район в Минске', max_length=100, null=True, verbose_name='Район')),
|
||||
('phone', phonenumber_field.modelfields.PhoneNumberField(help_text='Контактный телефон магазина', max_length=128, region=None, verbose_name='Телефон')),
|
||||
('description', models.TextField(blank=True, help_text='Дополнительная информация о магазине', 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='Номер здания')),
|
||||
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Контактный телефон магазина', max_length=128, null=True, region=None, verbose_name='Телефон')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')),
|
||||
('opening_time', models.TimeField(help_text='Время начала работы магазина', verbose_name='Время открытия')),
|
||||
('closing_time', models.TimeField(help_text='Время окончания работы магазина', verbose_name='Время закрытия')),
|
||||
('working_days', models.CharField(default='Пн-Вс', help_text='Например: Пн-Пт, Пн-Вс, Пн-Сб', max_length=100, verbose_name='Рабочие дни')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Работает ли магазин в данный момент', verbose_name='Активен')),
|
||||
('is_pickup_point', models.BooleanField(default=True, help_text='Доступен ли магазин для самовывоза заказов', verbose_name='Пункт самовывоза')),
|
||||
('description', models.TextField(blank=True, help_text='Дополнительная информация о магазине', null=True, verbose_name='Описание')),
|
||||
('delivery_instructions', models.TextField(blank=True, help_text='Как найти магазин, где припарковаться и т.д.', null=True, verbose_name='Инструкции для клиентов')),
|
||||
('latitude', models.DecimalField(blank=True, decimal_places=6, help_text='Координаты для отображения на карте', max_digits=9, null=True, verbose_name='Широта')),
|
||||
('longitude', models.DecimalField(blank=True, decimal_places=6, help_text='Координаты для отображения на карте', max_digits=9, null=True, verbose_name='Долгота')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
],
|
||||
@@ -38,7 +31,7 @@ class Migration(migrations.Migration):
|
||||
'verbose_name': 'Магазин',
|
||||
'verbose_name_plural': 'Магазины',
|
||||
'ordering': ['name'],
|
||||
'indexes': [models.Index(fields=['is_active'], name='shops_shop_is_acti_bbb154_idx'), models.Index(fields=['is_pickup_point'], name='shops_shop_is_pick_a04981_idx'), models.Index(fields=['district'], name='shops_shop_distric_04626c_idx')],
|
||||
'indexes': [models.Index(fields=['is_active'], name='shops_shop_is_acti_bbb154_idx'), models.Index(fields=['is_pickup_point'], name='shops_shop_is_pick_a04981_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-11 20:55
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shops', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='shop',
|
||||
name='shops_shop_distric_04626c_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shop',
|
||||
name='closing_time',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shop',
|
||||
name='delivery_instructions',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shop',
|
||||
name='district',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shop',
|
||||
name='latitude',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shop',
|
||||
name='longitude',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shop',
|
||||
name='opening_time',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shop',
|
||||
name='working_days',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shop',
|
||||
name='building_number',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер здания'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shop',
|
||||
name='phone',
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Контактный телефон магазина', max_length=128, null=True, region=None, verbose_name='Телефон'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shop',
|
||||
name='street',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Улица'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-09 22:18
|
||||
# Generated by Django 5.0.10 on 2025-11-13 13:12
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
|
||||
Reference in New Issue
Block a user