refactor: Заменить сущность Магазин (Shop) на Склад (Warehouse)

Упрощена логика системы путём замены отдельной сущности "Магазин"
на универсальную сущность "Склад", которая может использоваться
как точка самовывоза.

Изменения:
- Расширена модель Warehouse: добавлены адрес, контакты, флаг is_pickup_point
- Модель Order: поле pickup_shop заменено на pickup_warehouse
- Обновлены все формы, сервисы, views, admin для работы со складами
- Обновлены шаблоны HTML и JavaScript код
- Удалено приложение shops полностью
- Пересозданы миграции БД
- Обновлён навбар (удалена ссылка на магазины)

Преимущества:
- Упрощена архитектура системы
- Единая точка управления складами и точками самовывоза
- Интеграция с системой инвентаризации

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-14 23:50:30 +03:00
parent d3ac875a0e
commit 4a4bd437b9
37 changed files with 99 additions and 740 deletions

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import django.contrib.auth.validators import django.contrib.auth.validators
import django.utils.timezone import django.utils.timezone

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import phonenumber_field.modelfields import phonenumber_field.modelfields
from django.db import migrations, models from django.db import migrations, models

View File

@@ -1,5 +1,6 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import phonenumber_field.modelfields
from django.db import migrations, models from django.db import migrations, models
@@ -216,8 +217,13 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')), ('name', models.CharField(max_length=200, verbose_name='Название')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')), ('description', models.TextField(blank=True, 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, max_length=128, null=True, region='BY', verbose_name='Телефон')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')), ('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('is_default', models.BooleanField(default=False, help_text='Автоматически выбирается при создании новых документов', verbose_name='Склад по умолчанию')), ('is_default', models.BooleanField(default=False, help_text='Автоматически выбирается при создании новых документов', verbose_name='Склад по умолчанию')),
('is_pickup_point', models.BooleanField(default=True, help_text='Можно ли выбрать этот склад как точку самовывоза заказа', verbose_name='Доступен для самовывоза')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
], ],

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@@ -128,6 +128,10 @@ class Migration(migrations.Migration):
model_name='warehouse', model_name='warehouse',
index=models.Index(fields=['is_default'], name='inventory_w_is_defa_4b7615_idx'), index=models.Index(fields=['is_default'], name='inventory_w_is_defa_4b7615_idx'),
), ),
migrations.AddIndex(
model_name='warehouse',
index=models.Index(fields=['is_pickup_point'], name='inventory_w_is_pick_e86268_idx'),
),
migrations.AddField( migrations.AddField(
model_name='transferbatch', model_name='transferbatch',
name='from_warehouse', name='from_warehouse',

View File

@@ -3,20 +3,38 @@ from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from decimal import Decimal from decimal import Decimal
from products.models import Product from products.models import Product
from phonenumber_field.modelfields import PhoneNumberField
class Warehouse(models.Model): class Warehouse(models.Model):
""" """
Склад (физическое или логическое место хранения). Склад (физическое или логическое место хранения).
Может использоваться как точка самовывоза для заказов.
""" """
name = models.CharField(max_length=200, verbose_name="Название") name = models.CharField(max_length=200, verbose_name="Название")
description = models.TextField(blank=True, null=True, verbose_name="Описание") description = models.TextField(blank=True, null=True, verbose_name="Описание")
# Адрес
street = models.CharField(max_length=255, blank=True, null=True, verbose_name="Улица")
building_number = models.CharField(max_length=20, blank=True, null=True, verbose_name="Номер здания")
# Контакты
phone = PhoneNumberField(region='BY', blank=True, null=True, verbose_name="Телефон")
email = models.EmailField(blank=True, null=True, verbose_name="Email")
# Настройки
is_active = models.BooleanField(default=True, verbose_name="Активен") is_active = models.BooleanField(default=True, verbose_name="Активен")
is_default = models.BooleanField( is_default = models.BooleanField(
default=False, default=False,
verbose_name="Склад по умолчанию", verbose_name="Склад по умолчанию",
help_text="Автоматически выбирается при создании новых документов" help_text="Автоматически выбирается при создании новых документов"
) )
is_pickup_point = models.BooleanField(
default=True,
verbose_name="Доступен для самовывоза",
help_text="Можно ли выбрать этот склад как точку самовывоза заказа"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
@@ -26,11 +44,24 @@ class Warehouse(models.Model):
indexes = [ indexes = [
models.Index(fields=['is_active']), models.Index(fields=['is_active']),
models.Index(fields=['is_default']), models.Index(fields=['is_default']),
models.Index(fields=['is_pickup_point']),
] ]
def __str__(self): def __str__(self):
if self.street and self.building_number:
return f"{self.name} ({self.street}, {self.building_number})"
return self.name return self.name
@property
def full_address(self):
"""Полный адрес склада"""
parts = []
if self.street:
parts.append(self.street)
if self.building_number:
parts.append(self.building_number)
return ', '.join(parts) if parts else "Адрес не указан"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Обеспечиваем что только один склад может быть по умолчанию в рамках одного тенанта""" """Обеспечиваем что только один склад может быть по умолчанию в рамках одного тенанта"""
if self.is_default: if self.is_default:

View File

@@ -68,7 +68,6 @@ TENANT_APPS = [
'nested_admin', 'nested_admin',
'django_filters', # Фильтрация данных 'django_filters', # Фильтрация данных
'customers', # Клиенты магазина 'customers', # Клиенты магазина
'shops', # Точки магазина/самовывоза
'products', # Товары и категории 'products', # Товары и категории
'orders', # Заказы 'orders', # Заказы
'inventory', # Складской учет 'inventory', # Складской учет

View File

@@ -21,7 +21,6 @@ urlpatterns = [
path('customers/', include('customers.urls')), # Управление клиентами path('customers/', include('customers.urls')), # Управление клиентами
path('inventory/', include('inventory.urls')), # Управление складом path('inventory/', include('inventory.urls')), # Управление складом
path('orders/', include('orders.urls')), # Управление заказами path('orders/', include('orders.urls')), # Управление заказами
path('shops/', include('shops.urls')), # Управление магазинами
] ]
# Serve media files during development # Serve media files during development

View File

@@ -83,7 +83,7 @@ class OrderAdmin(admin.ModelAdmin):
'is_delivery', 'is_delivery',
'customer_is_recipient', 'customer_is_recipient',
'delivery_address', 'delivery_address',
'pickup_shop', 'pickup_warehouse',
'delivery_date', 'delivery_date',
'delivery_time_start', 'delivery_time_start',
'delivery_time_end', 'delivery_time_end',

View File

@@ -3,7 +3,7 @@ from django import forms
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from .models import Order, OrderItem, Address, OrderStatus from .models import Order, OrderItem, Address, OrderStatus
from customers.models import Customer from customers.models import Customer
from shops.models import Shop from inventory.models import Warehouse
from products.models import Product, ProductKit from products.models import Product, ProductKit
@@ -92,7 +92,7 @@ class OrderForm(forms.ModelForm):
'customer', 'customer',
'is_delivery', 'is_delivery',
'delivery_address', 'delivery_address',
'pickup_shop', 'pickup_warehouse',
'delivery_date', 'delivery_date',
'delivery_time_start', 'delivery_time_start',
'delivery_time_end', 'delivery_time_end',
@@ -145,11 +145,11 @@ class OrderForm(forms.ModelForm):
}) })
self.fields['delivery_address'].required = False self.fields['delivery_address'].required = False
self.fields['pickup_shop'].widget.attrs.update({ self.fields['pickup_warehouse'].widget.attrs.update({
'class': 'form-select select2', 'class': 'form-select select2',
'data-placeholder': 'Выберите точку самовывоза' 'data-placeholder': 'Выберите склад для самовывоза'
}) })
self.fields['pickup_shop'].required = False self.fields['pickup_warehouse'].required = False
# Опциональные поля даты/времени # Опциональные поля даты/времени
self.fields['delivery_date'].required = False self.fields['delivery_date'].required = False

View File

@@ -12,7 +12,7 @@ from decimal import Decimal
from orders.models import Order, OrderItem, Address from orders.models import Order, OrderItem, Address
from customers.models import Customer from customers.models import Customer
from shops.models import Shop from inventory.models import Warehouse
from products.models import Product from products.models import Product
@@ -56,10 +56,10 @@ class Command(BaseCommand):
return return
addresses = list(Address.objects.all()) addresses = list(Address.objects.all())
shops = list(Shop.objects.all()) warehouses = list(Warehouse.objects.filter(is_pickup_point=True))
if not addresses and not shops: if not addresses and not warehouses:
self.stdout.write(self.style.ERROR('Нет ни адресов, ни магазинов! Создайте хотя бы что-то одно.')) self.stdout.write(self.style.ERROR('Нет ни адресов, ни складов для самовывоза! Создайте хотя бы что-то одно.'))
return return
# Статусы и их вероятности # Статусы и их вероятности
@@ -111,8 +111,8 @@ class Command(BaseCommand):
# Для доставки выбираем случайный адрес (адреса теперь привязаны к заказам) # Для доставки выбираем случайный адрес (адреса теперь привязаны к заказам)
order.delivery_address = random.choice(addresses) order.delivery_address = random.choice(addresses)
order.delivery_cost = Decimal(random.randint(200, 500)) order.delivery_cost = Decimal(random.randint(200, 500))
elif shops: elif warehouses:
order.pickup_shop = random.choice(shops) order.pickup_warehouse = random.choice(warehouses)
order.delivery_cost = Decimal(0) order.delivery_cost = Decimal(0)
# Дата и время # Дата и время

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import django.db.models.deletion import django.db.models.deletion
import simple_history.models import simple_history.models
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('customers', '0001_initial'), ('customers', '0001_initial'),
('shops', '0001_initial'), ('inventory', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -25,8 +25,8 @@ class Migration(migrations.Migration):
('code', models.SlugField(help_text="Уникальный идентификатор (например: 'completed', 'cancelled')", unique=True, verbose_name='Код статуса')), ('code', models.SlugField(help_text="Уникальный идентификатор (например: 'completed', 'cancelled')", unique=True, verbose_name='Код статуса')),
('label', models.CharField(blank=True, max_length=100, 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_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_positive_end', models.BooleanField(default=False, help_text='True если это финальный успешный статус (Выполнен)', verbose_name='Положительный исход сделки')),
('is_negative_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='Порядок отображения')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
('color', models.CharField(blank=True, default='#808080', help_text='Например: #FF5733', max_length=7, verbose_name='Цвет (hex)')), ('color', models.CharField(blank=True, default='#808080', help_text='Например: #FF5733', max_length=7, verbose_name='Цвет (hex)')),
('description', models.TextField(blank=True, verbose_name='Описание')), ('description', models.TextField(blank=True, verbose_name='Описание')),
@@ -112,7 +112,7 @@ class Migration(migrations.Migration):
('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='Адрес доставки')), ('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)), ('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='Изменен пользователем')), ('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='Точка самовывоза')), ('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={ options={
'verbose_name': 'historical Заказ', 'verbose_name': 'historical Заказ',
@@ -151,7 +151,7 @@ class Migration(migrations.Migration):
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент')), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', 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='Адрес доставки')), ('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='Изменен пользователем')), ('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='Точка самовывоза')), ('pickup_warehouse', models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='inventory.warehouse', verbose_name='Склад для самовывоза')),
], ],
options={ options={
'verbose_name': 'Заказ', 'verbose_name': 'Заказ',

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@@ -1,64 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-13 14:19
from django.db import migrations
def update_status_names_to_russian(apps, schema_editor):
"""Обновляем названия статусов на русский язык"""
OrderStatus = apps.get_model('orders', 'OrderStatus')
status_translations = {
'draft': 'Черновик',
'new': 'Новый',
'confirmed': 'Подтвережден',
'in_assembly': 'В сборке',
'in_delivery': 'В доставке',
'completed': 'Выполнен',
'return': 'Возврат',
'cancelled': 'Отменен',
}
for code, russian_name in status_translations.items():
try:
status = OrderStatus.objects.get(code=code)
status.name = russian_name
status.label = russian_name
status.save()
except OrderStatus.DoesNotExist:
pass
def reverse_status_names(apps, schema_editor):
"""Откатываем названия статусов обратно на английский (для отката миграции)"""
OrderStatus = apps.get_model('orders', 'OrderStatus')
status_translations = {
'draft': 'Draft',
'new': 'New',
'confirmed': 'Confirmed',
'in_assembly': 'In Assembly',
'in_delivery': 'In Delivery',
'completed': 'Completed',
'return': 'Return',
'cancelled': 'Cancelled',
}
for code, english_name in status_translations.items():
try:
status = OrderStatus.objects.get(code=code)
status.name = english_name
status.label = english_name
status.save()
except OrderStatus.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('orders', '0002_initial'),
]
operations = [
migrations.RunPython(update_status_names_to_russian, reverse_status_names),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-13 18:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_update_status_names_to_russian'),
]
operations = [
migrations.AlterField(
model_name='orderstatus',
name='is_negative_end',
field=models.BooleanField(default=False, help_text='True если это финальный отрицательный статус (Отменен)', verbose_name='Отрицательный исход сделки'),
),
migrations.AlterField(
model_name='orderstatus',
name='is_positive_end',
field=models.BooleanField(default=False, help_text='True если это финальный успешный статус (Выполнен)', verbose_name='Положительный исход сделки'),
),
]

View File

@@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
from accounts.models import CustomUser from accounts.models import CustomUser
from customers.models import Customer from customers.models import Customer
from products.models import Product, ProductKit from products.models import Product, ProductKit
from shops.models import Shop from inventory.models import Warehouse
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
@@ -283,14 +283,14 @@ class Order(models.Model):
help_text="Обязательно для курьерской доставки" help_text="Обязательно для курьерской доставки"
) )
# Пункт самовывоза (для самовывоза) # Склад для самовывоза
pickup_shop = models.ForeignKey( pickup_warehouse = models.ForeignKey(
Shop, Warehouse,
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
related_name='pickup_orders', related_name='pickup_orders',
verbose_name="Точка самовывоза", verbose_name="Склад для самовывоза",
help_text="Обязательно для самовывоза" help_text="Обязательно для самовывоза"
) )
@@ -511,10 +511,10 @@ class Order(models.Model):
'delivery_address': 'Для доставки необходимо указать адрес доставки' 'delivery_address': 'Для доставки необходимо указать адрес доставки'
}) })
# Проверка: для самовывоза обязателен пункт самовывоза # Проверка: для самовывоза обязателен склад
if not self.is_delivery and not self.pickup_shop: if not self.is_delivery and not self.pickup_warehouse:
raise ValidationError({ raise ValidationError({
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза' 'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
}) })
# Проверка: время окончания должно быть позже времени начала # Проверка: время окончания должно быть позже времени начала
@@ -618,9 +618,9 @@ class Order(models.Model):
return f"Доставка по адресу: {self.delivery_address.full_address}" return f"Доставка по адресу: {self.delivery_address.full_address}"
return "Доставка (адрес не указан)" return "Доставка (адрес не указан)"
else: else:
if self.pickup_shop: if self.pickup_warehouse:
return f"Самовывоз из: {self.pickup_shop.name}" return f"Самовывоз со склада: {self.pickup_warehouse.name}"
return "Самовывоз (точка не указана)" return "Самовывоз (склад не указан)"
@property @property
def delivery_time_window(self): def delivery_time_window(self):

View File

@@ -57,7 +57,7 @@ class DraftOrderService:
modified_by=user, modified_by=user,
is_delivery=data.get('is_delivery', True), is_delivery=data.get('is_delivery', True),
delivery_address=data.get('delivery_address'), delivery_address=data.get('delivery_address'),
pickup_shop=data.get('pickup_shop'), pickup_warehouse=data.get('pickup_warehouse'),
delivery_date=data.get('delivery_date'), delivery_date=data.get('delivery_date'),
delivery_time_start=data.get('delivery_time_start'), delivery_time_start=data.get('delivery_time_start'),
delivery_time_end=data.get('delivery_time_end'), delivery_time_end=data.get('delivery_time_end'),
@@ -97,7 +97,7 @@ class DraftOrderService:
# ForeignKey поля требуют специальной обработки # ForeignKey поля требуют специальной обработки
fk_fields = { fk_fields = {
'customer': 'customers.Customer', 'customer': 'customers.Customer',
'pickup_shop': 'shops.Shop', 'pickup_warehouse': 'inventory.Warehouse',
'status': 'orders.OrderStatus', 'status': 'orders.OrderStatus',
} }
@@ -362,7 +362,7 @@ class DraftOrderService:
drafts = Order.objects.filter( drafts = Order.objects.filter(
status__code='draft', status__code='draft',
modified_by=user modified_by=user
).select_related('customer', 'delivery_address', 'pickup_shop') ).select_related('customer', 'delivery_address', 'pickup_warehouse')
if customer: if customer:
drafts = drafts.filter(customer=customer) drafts = drafts.filter(customer=customer)

View File

@@ -147,7 +147,7 @@
'input[type="checkbox"]', 'input[type="checkbox"]',
'input[type="radio"]', 'input[type="radio"]',
'select[name="delivery_address"]', 'select[name="delivery_address"]',
'select[name="pickup_shop"]', 'select[name="pickup_warehouse"]',
// Поля адреса доставки // Поля адреса доставки
'input[name="address_street"]', 'input[name="address_street"]',
'input[name="address_building_number"]', 'input[name="address_building_number"]',
@@ -364,9 +364,9 @@
data.delivery_address = parseInt(deliveryAddressField.value); data.delivery_address = parseInt(deliveryAddressField.value);
} }
const pickupShopField = form.querySelector('select[name="pickup_shop"]'); const pickupWarehouseField = form.querySelector('select[name="pickup_warehouse"]');
if (pickupShopField && pickupShopField.value) { if (pickupWarehouseField && pickupWarehouseField.value) {
data.pickup_shop = parseInt(pickupShopField.value); data.pickup_warehouse = parseInt(pickupWarehouseField.value);
} }
// Поля адреса доставки (новая логика с прямым вводом) // Поля адреса доставки (новая логика с прямым вводом)

View File

@@ -236,9 +236,9 @@
data.delivery_address = parseInt(deliveryAddressField.value); data.delivery_address = parseInt(deliveryAddressField.value);
} }
const pickupShopField = form.querySelector('select[name="pickup_shop"]'); const pickupWarehouseField = form.querySelector('select[name="pickup_warehouse"]');
if (pickupShopField && pickupShopField.value) { if (pickupWarehouseField && pickupWarehouseField.value) {
data.pickup_shop = parseInt(pickupShopField.value); data.pickup_warehouse = parseInt(pickupWarehouseField.value);
} }
// Новая логика выбора адреса // Новая логика выбора адреса

View File

@@ -130,13 +130,13 @@
</div> </div>
{% else %} {% else %}
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-4"><strong>Точка самовывоза:</strong></div> <div class="col-md-4"><strong>Склад для самовывоза:</strong></div>
<div class="col-md-8"> <div class="col-md-8">
{% if order.pickup_shop %} {% if order.pickup_warehouse %}
{{ order.pickup_shop.name }}<br> {{ order.pickup_warehouse.name }}<br>
<small class="text-muted">{{ order.pickup_shop.address }}</small> <small class="text-muted">{{ order.pickup_warehouse.full_address }}</small>
{% else %} {% else %}
<span class="text-danger">Не указана</span> <span class="text-danger">Не указан</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -522,12 +522,12 @@
<div class="row" id="pickup-fields" style="display: none;"> <div class="row" id="pickup-fields" style="display: none;">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.pickup_shop.id_for_label }}" class="form-label"> <label for="{{ form.pickup_warehouse.id_for_label }}" class="form-label">
Точка самовывоза Склад для самовывоза
</label> </label>
{{ form.pickup_shop }} {{ form.pickup_warehouse }}
{% if form.pickup_shop.errors %} {% if form.pickup_warehouse.errors %}
<div class="text-danger">{{ form.pickup_shop.errors }}</div> <div class="text-danger">{{ form.pickup_warehouse.errors }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -22,7 +22,7 @@ def order_list(request):
""" """
# Базовый queryset с оптимизацией запросов # Базовый queryset с оптимизацией запросов
orders = Order.objects.select_related( orders = Order.objects.select_related(
'customer', 'delivery_address', 'pickup_shop' 'customer', 'delivery_address', 'pickup_warehouse'
).all() ).all()
# Применяем фильтры через django-filter # Применяем фильтры через django-filter
@@ -48,7 +48,7 @@ def order_list(request):
def order_detail(request, pk): def order_detail(request, pk):
"""Детальная информация о заказе""" """Детальная информация о заказе"""
order = get_object_or_404( order = get_object_or_404(
Order.objects.select_related('customer', 'delivery_address', 'pickup_shop', 'modified_by') Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by')
.prefetch_related('items__product', 'items__product_kit', 'payments__created_by'), .prefetch_related('items__product', 'items__product_kit', 'payments__created_by'),
pk=pk pk=pk
) )

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@@ -1,58 +0,0 @@
from django.contrib import admin
from .models import Shop
@admin.register(Shop)
class ShopAdmin(admin.ModelAdmin):
"""
Админ-панель для управления магазинами/пунктами самовывоза.
"""
list_display = [
'name',
'full_address',
'phone',
'is_active',
'is_pickup_point',
]
list_filter = [
'is_active',
'is_pickup_point',
]
search_fields = [
'name',
'street',
'building_number',
'phone',
'email',
]
fieldsets = (
('Основная информация', {
'fields': ('name', 'description')
}),
('Адрес', {
'fields': ('street', 'building_number')
}),
('Контакты', {
'fields': ('phone', 'email')
}),
('Настройки', {
'fields': ('is_active', 'is_pickup_point')
}),
)
readonly_fields = ['created_at', 'updated_at']
def get_fieldsets(self, request, obj=None):
"""Добавляем временные метки для существующих объектов"""
fieldsets = super().get_fieldsets(request, obj)
if obj: # Если объект уже существует
fieldsets = fieldsets + (
('Системная информация', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
return fieldsets

View File

@@ -1,6 +0,0 @@
from django.apps import AppConfig
class ShopsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'shops'

View File

@@ -1,56 +0,0 @@
from django import forms
from phonenumber_field.formfields import PhoneNumberField
from .models import Shop
class ShopForm(forms.ModelForm):
phone = PhoneNumberField(
region='BY',
required=False,
help_text='Формат: +375XXXXXXXXX или 80XXXXXXXXX',
widget=forms.TextInput(attrs={'placeholder': '+375XXXXXXXXX'})
)
class Meta:
model = Shop
fields = [
'name',
'description',
'street',
'building_number',
'phone',
'email',
'is_active',
'is_pickup_point',
]
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Ensure phone displays in E.164 format
if self.instance and self.instance.phone:
self.initial['phone'] = str(self.instance.phone)
# Mark name field as required with label
self.fields['name'].label = 'Название магазина *'
self.fields['name'].required = True
for field_name, field in self.fields.items():
if field_name == 'description':
# Textarea already has rows=3 from widget, just add class
field.widget.attrs.update({'class': 'form-control'})
elif field_name in ['is_active', 'is_pickup_point']:
# Checkbox fields need form-check-input class
field.widget.attrs.update({'class': 'form-check-input'})
elif field_name == 'phone':
# Phone field gets form-control class
field.widget.attrs.update({'class': 'form-control'})
else:
# Regular input fields get form-control class
field.widget.attrs.update({'class': 'form-control'})
# Add required attribute to HTML for name field
if field_name == 'name':
field.widget.attrs.update({'required': 'required'})

View File

@@ -1,37 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Shop',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, 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')),
('is_active', models.BooleanField(default=True, help_text='Работает ли магазин в данный момент', verbose_name='Активен')),
('is_pickup_point', models.BooleanField(default=True, 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': ['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')],
},
),
]

View File

@@ -1,93 +0,0 @@
from django.db import models
from phonenumber_field.modelfields import PhoneNumberField
class Shop(models.Model):
"""
Модель магазина/пункта самовывоза для цветочного магазина.
"""
name = models.CharField(
max_length=200,
verbose_name="Название магазина"
)
description = models.TextField(
blank=True,
null=True,
verbose_name="Описание",
help_text="Дополнительная информация о магазине"
)
# Адрес магазина
street = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name="Улица"
)
building_number = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name="Номер здания"
)
# Контактная информация
phone = PhoneNumberField(
blank=True,
null=True,
verbose_name="Телефон",
help_text="Контактный телефон магазина"
)
email = models.EmailField(
blank=True,
null=True,
verbose_name="Email"
)
# Статусы и настройки
is_active = models.BooleanField(
default=True,
verbose_name="Активен",
help_text="Работает ли магазин в данный момент"
)
is_pickup_point = models.BooleanField(
default=True,
verbose_name="Пункт самовывоза",
help_text="Доступен ли магазин для самовывоза заказов"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления"
)
class Meta:
verbose_name = "Магазин"
verbose_name_plural = "Магазины"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['is_pickup_point']),
]
ordering = ['name']
def __str__(self):
if self.street and self.building_number:
return f"{self.name} ({self.full_address})"
return self.name
@property
def full_address(self):
"""Полный адрес магазина"""
if self.street and self.building_number:
return f"{self.street}, {self.building_number}"
return ""

View File

@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% block title %}Удалить магазин{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12 col-md-8 col-lg-6 mx-auto">
<div class="card">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">Подтверждение удаления</h4>
</div>
<div class="card-body">
<p class="lead">Вы уверены, что хотите удалить магазин <strong>{{ object.name }}</strong>?</p>
{% if object.full_address %}
<p class="text-muted">Адрес: {{ object.full_address }}</p>
{% endif %}
<div class="alert alert-warning mt-3">
<strong>Внимание!</strong> Это действие нельзя отменить.
</div>
<form method="post" class="mt-4">
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">Да, удалить</button>
<a href="{% url 'shops:shop_list' %}" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,134 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>{{ title }}</h1>
<form method="post">
{% csrf_token %}
<div class="row">
<!-- Основная информация -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Основная информация</h5>
</div>
<div class="card-body">
<div class="mb-3">
{{ form.name.label_tag }}
{{ form.name }}
{% if form.name.errors %}
<div class="text-danger">{{ form.name.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.description.label_tag }}
{{ form.description }}
{% if form.description.errors %}
<div class="text-danger">{{ form.description.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Адрес -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Адрес</h5>
</div>
<div class="card-body">
<div class="mb-3">
{{ form.street.label_tag }}
{{ form.street }}
{% if form.street.errors %}
<div class="text-danger">{{ form.street.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.building_number.label_tag }}
{{ form.building_number }}
{% if form.building_number.errors %}
<div class="text-danger">{{ form.building_number.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Контактная информация -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Контактная информация</h5>
</div>
<div class="card-body">
<div class="mb-3">
{{ form.phone.label_tag }}
{{ form.phone }}
<div class="form-text">Введите телефон в любом формате, например: +375291234567</div>
{% if form.phone.errors %}
<div class="text-danger">{{ form.phone.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.email.label_tag }}
{{ form.email }}
{% if form.email.errors %}
<div class="text-danger">{{ form.email.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Настройки -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Настройки</h5>
</div>
<div class="card-body">
<div class="mb-3 form-check">
{{ form.is_active }}
{{ form.is_active.label_tag }}
{% if form.is_active.errors %}
<div class="text-danger">{{ form.is_active.errors }}</div>
{% endif %}
</div>
<div class="mb-3 form-check">
{{ form.is_pickup_point }}
{{ form.is_pickup_point.label_tag }}
{% if form.is_pickup_point.errors %}
<div class="text-danger">{{ form.is_pickup_point.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Действия формы -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
{{ button_text }}
</button>
<a href="{% url 'shops:shop_list' %}" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,104 +0,0 @@
{% extends "base.html" %}
{% block title %}Магазины{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Магазины</h1>
<a href="{% url 'shops:shop_create' %}" class="btn btn-primary">
Добавить магазин
</a>
</div>
{% if shops %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Название</th>
<th>Адрес</th>
<th>Телефон</th>
<th>Email</th>
<th>Пункт самовывоза</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for shop in shops %}
<tr>
<td>{{ shop.name }}</td>
<td>{{ shop.full_address|default:"—" }}</td>
<td>{{ shop.phone|default:"—" }}</td>
<td>{{ shop.email|default:"—" }}</td>
<td>
{% if shop.is_pickup_point %}
<span class="badge bg-success">Да</span>
{% else %}
<span class="badge bg-secondary">Нет</span>
{% endif %}
</td>
<td>
{% if shop.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-danger">Неактивен</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'shops:shop_update' shop.pk %}" class="btn btn-outline-primary">
Редактировать
</a>
<a href="{% url 'shops:shop_delete' shop.pk %}" class="btn btn-outline-danger">
Удалить
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_paginated %}
<nav aria-label="Page navigation">
<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 %}
<li class="page-item active">
<span class="page-link">
Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}
</span>
</li>
{% 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 %}
{% else %}
<div class="alert alert-info">
<p class="mb-0">Магазины не найдены. <a href="{% url 'shops:shop_create' %}">Создать первый магазин</a></p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,11 +0,0 @@
from django.urls import path
from . import views
app_name = 'shops'
urlpatterns = [
path('', views.ShopListView.as_view(), name='shop_list'),
path('create/', views.ShopCreateView.as_view(), name='shop_create'),
path('<int:pk>/update/', views.ShopUpdateView.as_view(), name='shop_update'),
path('<int:pk>/delete/', views.ShopDeleteView.as_view(), name='shop_delete'),
]

View File

@@ -1,52 +0,0 @@
from django.urls import reverse_lazy
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from .models import Shop
from .forms import ShopForm
class ShopListView(ListView):
"""Список всех магазинов"""
model = Shop
template_name = 'shops/shop_list.html'
context_object_name = 'shops'
paginate_by = 20
def get_queryset(self):
"""Показываем только активные магазины по умолчанию"""
queryset = super().get_queryset()
return queryset.filter(is_active=True)
class ShopCreateView(CreateView):
"""Создание нового магазина"""
model = Shop
form_class = ShopForm
template_name = 'shops/shop_form.html'
success_url = reverse_lazy('shops:shop_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = 'Создать магазин'
context['button_text'] = 'Создать'
return context
class ShopUpdateView(UpdateView):
"""Редактирование магазина"""
model = Shop
form_class = ShopForm
template_name = 'shops/shop_form.html'
success_url = reverse_lazy('shops:shop_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Редактировать: {self.object.name}'
context['button_text'] = 'Сохранить'
return context
class ShopDeleteView(DeleteView):
"""Удаление магазина"""
model = Shop
template_name = 'shops/shop_confirm_delete.html'
success_url = reverse_lazy('shops:shop_list')

View File

@@ -38,9 +38,6 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">Клиенты</a> <a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">Клиенты</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'shops' %}active{% endif %}" href="{% url 'shops:shop_list' %}">Магазины</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>
</li> </li>

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-13 13:12 # Generated by Django 5.0.10 on 2025-11-14 20:45
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion