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

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

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57 # Generated by Django 5.0.10 on 2025-12-23 20:38
import django.contrib.auth.validators import django.contrib.auth.validators
import django.utils.timezone import django.utils.timezone

View File

@@ -213,7 +213,37 @@ def password_setup_confirm(request, token):
) )
# Перенаправить на домен тенанта # Перенаправить на домен тенанта
tenant_url = f'http://{tenant.schema_name}.localhost:8000/' # Получаем домен из базы (без порта, порт добавляется в URL только для localhost)
from tenants.models import Domain
from django.conf import settings
connection.set_schema_to_public()
try:
domain_obj = Domain.objects.filter(tenant=tenant, is_primary=True).first()
if domain_obj:
domain_name = domain_obj.domain
# Убираем порт из домена если он есть (для совместимости со старыми записями)
if ':' in domain_name:
domain_name = domain_name.split(':')[0]
else:
# Fallback если домен не найден
domain_base = settings.TENANT_DOMAIN_BASE
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
domain_name = f"{tenant.schema_name}.{domain_base}"
except:
domain_base = settings.TENANT_DOMAIN_BASE
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
domain_name = f"{tenant.schema_name}.{domain_base}"
# Формируем URL с правильным протоколом и портом
protocol = 'https' if settings.USE_HTTPS else 'http'
# Добавляем порт только для localhost
if 'localhost' in domain_name:
tenant_url = f'{protocol}://{domain_name}:8000/'
else:
tenant_url = f'{protocol}://{domain_name}/'
return redirect(tenant_url) return redirect(tenant_url)
else: else:
messages.error(request, 'Пароли не совпадают.') messages.error(request, 'Пароли не совпадают.')

View File

@@ -1,6 +1,8 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57 # Generated by Django 5.0.10 on 2025-12-23 20:38
import django.db.models.deletion
import phonenumber_field.modelfields import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -9,6 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@@ -19,8 +22,8 @@ class Migration(migrations.Migration):
('name', models.CharField(blank=True, max_length=200, verbose_name='Имя')), ('name', models.CharField(blank=True, max_length=200, verbose_name='Имя')),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, 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='Телефон')), ('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='Уровень лояльности')), ('wallet_balance', models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька')),
('total_spent', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Общая сумма покупок')), ('is_system_customer', models.BooleanField(db_index=True, default=False, help_text='Автоматически созданный клиент для анонимных покупок и наличных продаж', verbose_name='Системный клиент')),
('notes', models.TextField(blank=True, help_text='Заметки о клиенте, особые предпочтения и т.д.', null=True, verbose_name='Заметки')), ('notes', models.TextField(blank=True, help_text='Заметки о клиенте, особые предпочтения и т.д.', null=True, 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='Дата обновления')),
@@ -29,7 +32,24 @@ class Migration(migrations.Migration):
'verbose_name': 'Клиент', 'verbose_name': 'Клиент',
'verbose_name_plural': 'Клиенты', 'verbose_name_plural': 'Клиенты',
'ordering': ['-created_at'], 'ordering': ['-created_at'],
'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')], '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')],
},
),
migrations.CreateModel(
name='WalletTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма')),
('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создано пользователем')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='customers.customer', verbose_name='Клиент')),
],
options={
'verbose_name': 'Транзакция кошелька',
'verbose_name_plural': 'Транзакции кошелька',
'ordering': ['-created_at'],
}, },
), ),
] ]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-19 19:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customer',
name='is_system_customer',
field=models.BooleanField(db_index=True, default=False, help_text='Автоматически созданный клиент для анонимных покупок и наличных продаж', verbose_name='Системный клиент'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('customers', '0001_initial'),
('orders', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='wallettransaction',
name='order',
field=models.ForeignKey(blank=True, help_text='Заказ, к которому относится транзакция (если применимо)', null=True, on_delete=django.db.models.deletion.PROTECT, to='orders.order', verbose_name='Заказ'),
),
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['customer', '-created_at'], name='customers_w_custome_572f05_idx'),
),
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['transaction_type'], name='customers_w_transac_5fda02_idx'),
),
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx'),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-22 13:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('customers', '0002_customer_is_system_customer'),
]
operations = [
migrations.RemoveIndex(
model_name='customer',
name='customers_c_loyalty_5162a0_idx',
),
migrations.RemoveField(
model_name='customer',
name='loyalty_tier',
),
]

View File

@@ -1,41 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-26 11:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more'),
('orders', '0004_refactor_models_and_add_payment_method'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='customer',
name='wallet_balance',
field=models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька'),
),
migrations.CreateModel(
name='WalletTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма')),
('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создано пользователем')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='customers.customer', verbose_name='Клиент')),
('order', models.ForeignKey(blank=True, help_text='Заказ, к которому относится транзакция (если применимо)', null=True, on_delete=django.db.models.deletion.PROTECT, to='orders.order', verbose_name='Заказ')),
],
options={
'verbose_name': 'Транзакция кошелька',
'verbose_name_plural': 'Транзакции кошелька',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['customer', '-created_at'], name='customers_w_custome_572f05_idx'), models.Index(fields=['transaction_type'], name='customers_w_transac_5fda02_idx'), models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx')],
},
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-05 21:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('customers', '0004_customer_wallet_balance_wallettransaction'),
]
operations = [
migrations.RemoveField(
model_name='customer',
name='total_spent',
),
]

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57 # Generated by Django 5.0.10 on 2025-12-23 20:38
import phonenumber_field.modelfields import phonenumber_field.modelfields
from django.db import migrations, models from django.db import migrations, models
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
name='DocumentCounter', name='DocumentCounter',
fields=[ fields=[
('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')),
('counter_type', models.CharField(choices=[('transfer', 'Перемещение товара')], max_length=20, unique=True, verbose_name='Тип счетчика')), ('counter_type', models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация')], max_length=20, unique=True, verbose_name='Тип счетчика')),
('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')), ('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')),
], ],
options={ options={
@@ -44,6 +44,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('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')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')), ('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('receipt_type', models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления')),
('supplier_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Наименование поставщика')), ('supplier_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Наименование поставщика')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
@@ -55,13 +56,49 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'], 'ordering': ['-created_at'],
}, },
), ),
migrations.CreateModel(
name='IncomingDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateField(help_text='Дата, к которой относится поступление', verbose_name='Дата документа')),
('receipt_type', models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления')),
('supplier_name', models.CharField(blank=True, help_text="Заполняется для типа 'Поступление от поставщика'", max_length=200, null=True, verbose_name='Наименование поставщика')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('confirmed_at', models.DateTimeField(blank=True, null=True, 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': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='IncomingDocumentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')),
('notes', models.TextField(blank=True, null=True, 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': ['id'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Inventory', name='Inventory',
fields=[ fields=[
('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')),
('document_number', models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Номер документа')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата инвентаризации')), ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата инвентаризации')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('processing', 'В обработке'), ('completed', 'Завершена')], default='draft', max_length=20, verbose_name='Статус')), ('status', models.CharField(choices=[('draft', 'Черновик'), ('processing', 'В обработке'), ('completed', 'Завершена')], default='draft', max_length=20, verbose_name='Статус')),
('conducted_by', models.CharField(blank=True, max_length=200, null=True, verbose_name='Провел инвентаризацию')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
], ],
options={ options={
@@ -75,9 +112,13 @@ class Migration(migrations.Migration):
fields=[ fields=[
('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')),
('quantity_system', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество в системе')), ('quantity_system', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество в системе')),
('quantity_fact', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Фактическое количество')), ('quantity_fact', models.DecimalField(decimal_places=3, help_text='Количество свободных товаров, подсчитанных физически', max_digits=10, verbose_name='Подсчитано (факт, свободные)')),
('difference', models.DecimalField(decimal_places=3, default=0, editable=False, max_digits=10, verbose_name='Разница (факт - система)')), ('difference', models.DecimalField(decimal_places=3, default=0, editable=False, help_text='(Подсчитано + Зарезервировано) - Всего на складе', max_digits=10, verbose_name='Итоговая разница')),
('processed', models.BooleanField(default=False, verbose_name='Обработана (создана операция)')), ('processed', models.BooleanField(default=False, verbose_name='Обработана (создана операция)')),
('snapshot_quantity_available', models.DecimalField(blank=True, decimal_places=3, help_text='Всего на складе на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Всего на складе (snapshot)')),
('snapshot_quantity_reserved', models.DecimalField(blank=True, decimal_places=3, help_text='В резервах на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В резервах (snapshot)')),
('snapshot_quantity_system', models.DecimalField(blank=True, decimal_places=3, help_text='В системе свободно на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В системе свободно (snapshot)')),
('snapshot_difference', models.DecimalField(blank=True, decimal_places=3, help_text='Итоговая разница на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Итоговая разница (snapshot)')),
], ],
options={ options={
'verbose_name': 'Строка инвентаризации', 'verbose_name': 'Строка инвентаризации',
@@ -89,10 +130,12 @@ class Migration(migrations.Migration):
fields=[ fields=[
('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')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('status', models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу')], default='reserved', max_length=20, verbose_name='Статус')), ('status', models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание')], default='reserved', max_length=25, verbose_name='Статус')),
('reserved_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата резервирования')), ('reserved_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата резервирования')),
('released_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата освобождения')), ('released_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата освобождения')),
('converted_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата преобразования в продажу')), ('converted_at', models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования')),
('cart_lock_expires_at', models.DateTimeField(blank=True, help_text='Время истечения блокировки в корзине (для витринных комплектов)', null=True, verbose_name='Блокировка корзины истекает')),
('cart_session_id', models.CharField(blank=True, help_text='Дополнительная идентификация сессии для надежности', max_length=100, null=True, verbose_name='ID сессии корзины')),
], ],
options={ options={
'verbose_name': 'Резервирование', 'verbose_name': 'Резервирование',
@@ -128,6 +171,39 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Распределения продаж по партиям', 'verbose_name_plural': 'Распределения продаж по партиям',
}, },
), ),
migrations.CreateModel(
name='Showcase',
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, null=True, verbose_name='Описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('is_default', models.BooleanField(default=False, 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': ['warehouse', 'name'],
},
),
migrations.CreateModel(
name='ShowcaseItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('available', 'Доступен'), ('in_cart', 'В корзине'), ('sold', 'Продан'), ('dismantled', 'Разобран')], db_index=True, default='available', max_length=20, verbose_name='Статус')),
('sold_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата продажи')),
('cart_lock_expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Блокировка истекает')),
('cart_session_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='ID сессии корзины')),
('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': 'Экземпляры на витрине',
},
),
migrations.CreateModel( migrations.CreateModel(
name='Stock', name='Stock',
fields=[ fields=[
@@ -249,4 +325,38 @@ class Migration(migrations.Migration):
'ordering': ['-date'], 'ordering': ['-date'],
}, },
), ),
migrations.CreateModel(
name='WriteOffDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateField(help_text='Дата, к которой относится списание', verbose_name='Дата документа')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('confirmed_at', models.DateTimeField(blank=True, null=True, 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': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='WriteOffDocumentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('reason', models.CharField(choices=[('damage', 'Повреждение'), ('spoilage', 'Порча'), ('shortage', 'Недостача'), ('inventory', 'Инвентаризационная недостача'), ('other', 'Другое')], default='damage', max_length=20, verbose_name='Причина списания')),
('notes', models.TextField(blank=True, null=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': ['id'],
},
),
] ]

View File

@@ -1,6 +1,7 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57 # Generated by Django 5.0.10 on 2025-12-23 20:38
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -12,6 +13,8 @@ class Migration(migrations.Migration):
('inventory', '0001_initial'), ('inventory', '0001_initial'),
('orders', '0001_initial'), ('orders', '0001_initial'),
('products', '0001_initial'), ('products', '0001_initial'),
('user_roles', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@@ -25,6 +28,36 @@ class Migration(migrations.Migration):
name='batch', name='batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingbatch', verbose_name='Партия'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingbatch', verbose_name='Партия'),
), ),
migrations.AddField(
model_name='incomingdocument',
name='confirmed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл'),
),
migrations.AddField(
model_name='incomingdocument',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
),
migrations.AddField(
model_name='incomingdocumentitem',
name='document',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingdocument', verbose_name='Документ'),
),
migrations.AddField(
model_name='incomingdocumentitem',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_document_items', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='inventory',
name='conducted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventories', to='user_roles.userrole', verbose_name='Провел инвентаризацию'),
),
migrations.AddField(
model_name='incomingdocument',
name='inventory',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_documents', to='inventory.inventory', verbose_name='Инвентаризация'),
),
migrations.AddField( migrations.AddField(
model_name='inventoryline', model_name='inventoryline',
name='inventory', name='inventory',
@@ -35,6 +68,11 @@ class Migration(migrations.Migration):
name='product', name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар'),
), ),
migrations.AddField(
model_name='reservation',
name='locked_by_user',
field=models.ForeignKey(blank=True, help_text='Кассир, который добавил комплект в корзину', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cart_locks', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'),
),
migrations.AddField( migrations.AddField(
model_name='reservation', model_name='reservation',
name='order_item', name='order_item',
@@ -45,6 +83,11 @@ class Migration(migrations.Migration):
name='product', name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.product', verbose_name='Товар'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.product', verbose_name='Товар'),
), ),
migrations.AddField(
model_name='reservation',
name='product_kit',
field=models.ForeignKey(blank=True, help_text='Временный комплект, для которого создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.productkit', verbose_name='Комплект'),
),
migrations.AddField( migrations.AddField(
model_name='sale', model_name='sale',
name='order', name='order',
@@ -60,6 +103,36 @@ class Migration(migrations.Migration):
name='sale', name='sale',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='batch_allocations', to='inventory.sale', verbose_name='Продажа'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='batch_allocations', to='inventory.sale', verbose_name='Продажа'),
), ),
migrations.AddField(
model_name='reservation',
name='showcase',
field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен букет', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddField(
model_name='showcaseitem',
name='locked_by_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='locked_showcase_items', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'),
),
migrations.AddField(
model_name='showcaseitem',
name='product_kit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='products.productkit', verbose_name='Шаблон комплекта'),
),
migrations.AddField(
model_name='showcaseitem',
name='showcase',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddField(
model_name='showcaseitem',
name='sold_order_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sold_showcase_items', to='orders.orderitem', verbose_name='Позиция заказа (продажа)'),
),
migrations.AddField(
model_name='reservation',
name='showcase_item',
field=models.ForeignKey(blank=True, help_text='Для какого физического экземпляра создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcaseitem', verbose_name='Экземпляр на витрине'),
),
migrations.AddField( migrations.AddField(
model_name='stock', model_name='stock',
name='product', name='product',
@@ -162,6 +235,11 @@ class Migration(migrations.Migration):
name='warehouse', name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='inventory.warehouse', verbose_name='Склад'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='inventory.warehouse', verbose_name='Склад'),
), ),
migrations.AddField(
model_name='showcase',
name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcases', to='inventory.warehouse', verbose_name='Склад'),
),
migrations.AddField( migrations.AddField(
model_name='sale', model_name='sale',
name='warehouse', name='warehouse',
@@ -177,6 +255,11 @@ class Migration(migrations.Migration):
name='warehouse', name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventories', to='inventory.warehouse', verbose_name='Склад'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventories', to='inventory.warehouse', verbose_name='Склад'),
), ),
migrations.AddField(
model_name='incomingdocument',
name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_documents', to='inventory.warehouse', verbose_name='Склад'),
),
migrations.AddField( migrations.AddField(
model_name='incomingbatch', model_name='incomingbatch',
name='warehouse', name='warehouse',
@@ -187,6 +270,70 @@ class Migration(migrations.Migration):
name='batch', name='batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='writeoffs', to='inventory.stockbatch', verbose_name='Партия'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='writeoffs', to='inventory.stockbatch', verbose_name='Партия'),
), ),
migrations.AddField(
model_name='writeoffdocument',
name='confirmed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл'),
),
migrations.AddField(
model_name='writeoffdocument',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
),
migrations.AddField(
model_name='writeoffdocument',
name='inventory',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_documents', to='inventory.inventory', verbose_name='Инвентаризация'),
),
migrations.AddField(
model_name='writeoffdocument',
name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_documents', to='inventory.warehouse', verbose_name='Склад'),
),
migrations.AddField(
model_name='writeoffdocumentitem',
name='document',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.writeoffdocument', verbose_name='Документ'),
),
migrations.AddField(
model_name='writeoffdocumentitem',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_document_items', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='writeoffdocumentitem',
name='reservation',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_document_item_reverse', to='inventory.reservation', verbose_name='Резерв'),
),
migrations.AddField(
model_name='reservation',
name='writeoff_document_item',
field=models.ForeignKey(blank=True, help_text='Резерв для документа списания (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.writeoffdocumentitem', verbose_name='Позиция документа списания'),
),
migrations.AddIndex(
model_name='incomingdocumentitem',
index=models.Index(fields=['document'], name='inventory_i_documen_96d470_idx'),
),
migrations.AddIndex(
model_name='incomingdocumentitem',
index=models.Index(fields=['product'], name='inventory_i_product_932432_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['showcase', 'status'], name='inventory_s_showcas_116f7f_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['product_kit', 'status'], name='inventory_s_product_785870_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['status', 'cart_lock_expires_at'], name='inventory_s_status_6acf05_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['locked_by_user', 'status'], name='inventory_s_locked__88eac9_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='incoming', model_name='incoming',
index=models.Index(fields=['batch'], name='inventory_i_batch_i_c50b63_idx'), index=models.Index(fields=['batch'], name='inventory_i_batch_i_c50b63_idx'),
@@ -263,6 +410,18 @@ class Migration(migrations.Migration):
name='stock', name='stock',
unique_together={('product', 'warehouse')}, unique_together={('product', 'warehouse')},
), ),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['warehouse'], name='inventory_s_warehou_1e4a8a_idx'),
),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['is_active'], name='inventory_s_is_acti_387bfb_idx'),
),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['is_default'], name='inventory_s_is_defa_bf9a7c_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='sale', model_name='sale',
index=models.Index(fields=['product', 'warehouse'], name='inventory_s_product_084314_idx'), index=models.Index(fields=['product', 'warehouse'], name='inventory_s_product_084314_idx'),
@@ -275,6 +434,78 @@ class Migration(migrations.Migration):
model_name='sale', model_name='sale',
index=models.Index(fields=['order'], name='inventory_s_order_i_7d13ea_idx'), index=models.Index(fields=['order'], name='inventory_s_order_i_7d13ea_idx'),
), ),
migrations.AddIndex(
model_name='inventory',
index=models.Index(fields=['document_number'], name='inventory_i_documen_8df782_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['document_number'], name='inventory_i_documen_5b89ad_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['warehouse', 'status'], name='inventory_i_warehou_8f141d_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['date'], name='inventory_i_date_8ace9b_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_92f322_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['-created_at'], name='inventory_i_created_174930_idx'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['document_number'], name='inventory_i_documen_679096_idx'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['warehouse'], name='inventory_i_warehou_cc3a73_idx'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_ce70c1_idx'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['-created_at'], name='inventory_i_created_59ee8b_idx'),
),
migrations.AddIndex(
model_name='writeoff',
index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'),
),
migrations.AddIndex(
model_name='writeoff',
index=models.Index(fields=['date'], name='inventory_w_date_70c7e3_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['document_number'], name='inventory_w_documen_a9ae00_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['warehouse', 'status'], name='inventory_w_warehou_69fbf6_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['date'], name='inventory_w_date_a853cb_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['-created_at'], name='inventory_w_created_02c298_idx'),
),
migrations.AddIndex(
model_name='writeoffdocumentitem',
index=models.Index(fields=['document'], name='inventory_w_documen_d77c5e_idx'),
),
migrations.AddIndex(
model_name='writeoffdocumentitem',
index=models.Index(fields=['product'], name='inventory_w_product_6e32fc_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='reservation', model_name='reservation',
index=models.Index(fields=['product', 'warehouse'], name='inventory_r_product_fa0d33_idx'), index=models.Index(fields=['product', 'warehouse'], name='inventory_r_product_fa0d33_idx'),
@@ -288,23 +519,27 @@ class Migration(migrations.Migration):
index=models.Index(fields=['order_item'], name='inventory_r_order_i_ae991f_idx'), index=models.Index(fields=['order_item'], name='inventory_r_order_i_ae991f_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='incomingbatch', model_name='reservation',
index=models.Index(fields=['document_number'], name='inventory_i_documen_679096_idx'), index=models.Index(fields=['showcase'], name='inventory_r_showcas_bd3508_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='incomingbatch', model_name='reservation',
index=models.Index(fields=['warehouse'], name='inventory_i_warehou_cc3a73_idx'), index=models.Index(fields=['product_kit'], name='inventory_r_product_70aed5_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='incomingbatch', model_name='reservation',
index=models.Index(fields=['-created_at'], name='inventory_i_created_59ee8b_idx'), index=models.Index(fields=['cart_lock_expires_at'], name='inventory_r_cart_lo_e9b52a_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='writeoff', model_name='reservation',
index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'), index=models.Index(fields=['locked_by_user'], name='inventory_r_locked__706cbf_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='writeoff', model_name='reservation',
index=models.Index(fields=['date'], name='inventory_w_date_70c7e3_idx'), index=models.Index(fields=['product_kit', 'cart_lock_expires_at'], name='inventory_r_product_5dacdf_idx'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['showcase_item'], name='inventory_r_showcas_8cfff5_idx'),
), ),
] ]

View File

@@ -1,50 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-16 18:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0002_initial'),
('orders', '0002_initial'),
('products', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Showcase',
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, null=True, verbose_name='Описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcases', to='inventory.warehouse', verbose_name='Склад')),
],
options={
'verbose_name': 'Витрина',
'verbose_name_plural': 'Витрины',
'ordering': ['warehouse', 'name'],
},
),
migrations.AddField(
model_name='reservation',
name='showcase',
field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен букет', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['showcase'], name='inventory_r_showcas_bd3508_idx'),
),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['warehouse'], name='inventory_s_warehou_1e4a8a_idx'),
),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['is_active'], name='inventory_s_is_acti_387bfb_idx'),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-20 08:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0003_showcase_reservation_showcase_and_more'),
]
operations = [
migrations.AddField(
model_name='showcase',
name='is_default',
field=models.BooleanField(default=False, verbose_name='По умолчанию'),
),
migrations.AddIndex(
model_name='showcase',
index=models.Index(fields=['is_default'], name='inventory_s_is_defa_bf9a7c_idx'),
),
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-20 12:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0004_showcase_is_default_and_more'),
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
('products', '0008_productkit_showcase_and_more'),
]
operations = [
migrations.AddField(
model_name='reservation',
name='product_kit',
field=models.ForeignKey(blank=True, help_text='Временный комплект, для которого создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.productkit', verbose_name='Комплект'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['product_kit'], name='inventory_r_product_70aed5_idx'),
),
]

View File

@@ -1,45 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-20 20:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0005_reservation_product_kit_and_more'),
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
('products', '0008_productkit_showcase_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='reservation',
name='cart_lock_expires_at',
field=models.DateTimeField(blank=True, help_text='Время истечения блокировки в корзине (для витринных комплектов)', null=True, verbose_name='Блокировка корзины истекает'),
),
migrations.AddField(
model_name='reservation',
name='cart_session_id',
field=models.CharField(blank=True, help_text='Дополнительная идентификация сессии для надежности', max_length=100, null=True, verbose_name='ID сессии корзины'),
),
migrations.AddField(
model_name='reservation',
name='locked_by_user',
field=models.ForeignKey(blank=True, help_text='Кассир, который добавил комплект в корзину', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cart_locks', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['cart_lock_expires_at'], name='inventory_r_cart_lo_e9b52a_idx'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['locked_by_user'], name='inventory_r_locked__706cbf_idx'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['product_kit', 'cart_lock_expires_at'], name='inventory_r_product_5dacdf_idx'),
),
]

View File

@@ -1,63 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-09 04:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0006_reservation_cart_lock_expires_at_and_more'),
('orders', '0006_transaction_delete_payment_and_more'),
('products', '0010_alter_product_cost_price'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ShowcaseItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('available', 'Доступен'), ('in_cart', 'В корзине'), ('sold', 'Продан'), ('dismantled', 'Разобран')], db_index=True, default='available', max_length=20, verbose_name='Статус')),
('sold_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата продажи')),
('cart_lock_expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Блокировка истекает')),
('cart_session_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='ID сессии корзины')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлен')),
('locked_by_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='locked_showcase_items', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем')),
('product_kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='products.productkit', verbose_name='Шаблон комплекта')),
('showcase', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='inventory.showcase', verbose_name='Витрина')),
('sold_order_item', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sold_showcase_item', to='orders.orderitem', verbose_name='Позиция заказа (продажа)')),
],
options={
'verbose_name': 'Экземпляр на витрине',
'verbose_name_plural': 'Экземпляры на витрине',
},
),
migrations.AddField(
model_name='reservation',
name='showcase_item',
field=models.ForeignKey(blank=True, help_text='Для какого физического экземпляра создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcaseitem', verbose_name='Экземпляр на витрине'),
),
migrations.AddIndex(
model_name='reservation',
index=models.Index(fields=['showcase_item'], name='inventory_r_showcas_8cfff5_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['showcase', 'status'], name='inventory_s_showcas_116f7f_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['product_kit', 'status'], name='inventory_s_product_785870_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['status', 'cart_lock_expires_at'], name='inventory_s_status_6acf05_idx'),
),
migrations.AddIndex(
model_name='showcaseitem',
index=models.Index(fields=['locked_by_user', 'status'], name='inventory_s_locked__88eac9_idx'),
),
]

View File

@@ -1,64 +0,0 @@
# Generated manually - Data migration for ShowcaseItem
from django.db import migrations
def migrate_showcase_kits_to_items(apps, schema_editor):
"""
Для каждого существующего витринного букета (ProductKit с is_temporary=True и showcase):
1. Создать ShowcaseItem
2. Привязать существующие Reservation к этому ShowcaseItem
"""
ProductKit = apps.get_model('products', 'ProductKit')
ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem')
Reservation = apps.get_model('inventory', 'Reservation')
# Находим все витринные комплекты
showcase_kits = ProductKit.objects.filter(
is_temporary=True,
showcase__isnull=False
)
for kit in showcase_kits:
# Создаём ShowcaseItem для каждого существующего витринного букета
showcase_item = ShowcaseItem.objects.create(
showcase=kit.showcase,
product_kit=kit,
status='available'
)
# Привязываем существующие резервы к этому ShowcaseItem
Reservation.objects.filter(
product_kit=kit,
showcase=kit.showcase,
status='reserved'
).update(showcase_item=showcase_item)
def reverse_migration(apps, schema_editor):
"""
Откат: удаляем созданные ShowcaseItem и очищаем связи в Reservation
"""
ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem')
Reservation = apps.get_model('inventory', 'Reservation')
# Очищаем связи в резервах
Reservation.objects.filter(showcase_item__isnull=False).update(showcase_item=None)
# Удаляем все ShowcaseItem
ShowcaseItem.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('inventory', '0007_add_showcase_item_model'),
('products', '0001_initial'), # Убедимся что ProductKit существует
]
operations = [
migrations.RunPython(
migrate_showcase_kits_to_items,
reverse_code=reverse_migration
),
]

View File

@@ -1,65 +0,0 @@
# Generated manually - Fix ShowcaseItem status for already sold kits
from django.db import migrations
def fix_showcase_items_status(apps, schema_editor):
"""
Исправляем статус ShowcaseItem для уже проданных комплектов.
Логика:
- Если у ShowcaseItem нет активных резервов (status='reserved') →
это уже проданный/разобранный букет → удаляем ShowcaseItem
"""
ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem')
Reservation = apps.get_model('inventory', 'Reservation')
# Находим все ShowcaseItem в статусе 'available'
available_items = ShowcaseItem.objects.filter(status='available')
items_to_delete = []
for item in available_items:
# Проверяем есть ли активные резервы для этого экземпляра
has_active_reservations = Reservation.objects.filter(
showcase_item=item,
status='reserved'
).exists()
# Если резервы не привязаны к showcase_item, проверяем старым способом
if not has_active_reservations:
has_active_reservations = Reservation.objects.filter(
product_kit=item.product_kit,
showcase=item.showcase,
status='reserved'
).exists()
if not has_active_reservations:
# Нет активных резервов - этот букет уже продан/разобран
items_to_delete.append(item.id)
# Удаляем ShowcaseItem без активных резервов
if items_to_delete:
ShowcaseItem.objects.filter(id__in=items_to_delete).delete()
def reverse_migration(apps, schema_editor):
"""
Откат невозможен - удалённые ShowcaseItem не восстановить.
Но это безопасно - они относились к уже проданным букетам.
"""
pass
class Migration(migrations.Migration):
dependencies = [
('inventory', '0008_migrate_showcase_kits_to_items'),
]
operations = [
migrations.RunPython(
fix_showcase_items_status,
reverse_code=reverse_migration
),
]

View File

@@ -1,91 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-10 19:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0009_fix_showcase_items_status'),
('products', '0010_alter_product_cost_price'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='documentcounter',
name='counter_type',
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
migrations.CreateModel(
name='WriteOffDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateField(help_text='Дата, к которой относится списание', verbose_name='Дата документа')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата проведения')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_documents', to='inventory.warehouse', verbose_name='Склад')),
],
options={
'verbose_name': 'Документ списания',
'verbose_name_plural': 'Документы списания',
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='WriteOffDocumentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('reason', models.CharField(choices=[('damage', 'Повреждение'), ('spoilage', 'Порча'), ('shortage', 'Недостача'), ('inventory', 'Инвентаризационная недостача'), ('other', 'Другое')], default='damage', max_length=20, verbose_name='Причина списания')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.writeoffdocument', verbose_name='Документ')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_document_items', to='products.product', verbose_name='Товар')),
('reservation', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_document_item_reverse', to='inventory.reservation', verbose_name='Резерв')),
],
options={
'verbose_name': 'Позиция документа списания',
'verbose_name_plural': 'Позиции документа списания',
'ordering': ['id'],
},
),
migrations.AddField(
model_name='reservation',
name='writeoff_document_item',
field=models.ForeignKey(blank=True, help_text='Резерв для документа списания (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.writeoffdocumentitem', verbose_name='Позиция документа списания'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['document_number'], name='inventory_w_documen_a9ae00_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['warehouse', 'status'], name='inventory_w_warehou_69fbf6_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['date'], name='inventory_w_date_a853cb_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['-created_at'], name='inventory_w_created_02c298_idx'),
),
migrations.AddIndex(
model_name='writeoffdocumentitem',
index=models.Index(fields=['document'], name='inventory_w_documen_d77c5e_idx'),
),
migrations.AddIndex(
model_name='writeoffdocumentitem',
index=models.Index(fields=['product'], name='inventory_w_product_6e32fc_idx'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-11 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0010_writeoff_document'),
]
operations = [
migrations.AlterField(
model_name='reservation',
name='converted_at',
field=models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования'),
),
migrations.AlterField(
model_name='reservation',
name='status',
field=models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание')], default='reserved', max_length=25, verbose_name='Статус'),
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-11 19:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0011_add_writeoff_status_to_reservation'),
('orders', '0006_transaction_delete_payment_and_more'),
]
operations = [
migrations.AlterField(
model_name='showcaseitem',
name='sold_order_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sold_showcase_items', to='orders.orderitem', verbose_name='Позиция заказа (продажа)'),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-20 20:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0012_change_sold_order_item_to_fk'),
]
operations = [
migrations.AddField(
model_name='incomingbatch',
name='receipt_type',
field=models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_ce70c1_idx'),
),
]

View File

@@ -1,91 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-20 21:10
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0013_add_receipt_type_to_incomingbatch'),
('products', '0010_alter_product_cost_price'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='documentcounter',
name='counter_type',
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
migrations.CreateModel(
name='IncomingDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateField(help_text='Дата, к которой относится поступление', verbose_name='Дата документа')),
('receipt_type', models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления')),
('supplier_name', models.CharField(blank=True, help_text="Заполняется для типа 'Поступление от поставщика'", max_length=200, null=True, verbose_name='Наименование поставщика')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата проведения')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_documents', to='inventory.warehouse', verbose_name='Склад')),
],
options={
'verbose_name': 'Документ поступления',
'verbose_name_plural': 'Документы поступления',
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='IncomingDocumentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingdocument', verbose_name='Документ')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_document_items', to='products.product', verbose_name='Товар')),
],
options={
'verbose_name': 'Позиция документа поступления',
'verbose_name_plural': 'Позиции документа поступления',
'ordering': ['id'],
},
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['document_number'], name='inventory_i_documen_5b89ad_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['warehouse', 'status'], name='inventory_i_warehou_8f141d_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['date'], name='inventory_i_date_8ace9b_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_92f322_idx'),
),
migrations.AddIndex(
model_name='incomingdocument',
index=models.Index(fields=['-created_at'], name='inventory_i_created_174930_idx'),
),
migrations.AddIndex(
model_name='incomingdocumentitem',
index=models.Index(fields=['document'], name='inventory_i_documen_96d470_idx'),
),
migrations.AddIndex(
model_name='incomingdocumentitem',
index=models.Index(fields=['product'], name='inventory_i_product_932432_idx'),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-21 18:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0014_alter_documentcounter_counter_type_incomingdocument_and_more'),
]
operations = [
migrations.AddField(
model_name='incomingdocument',
name='inventory',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_documents', to='inventory.inventory', verbose_name='Инвентаризация'),
),
migrations.AddField(
model_name='writeoffdocument',
name='inventory',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_documents', to='inventory.inventory', verbose_name='Инвентаризация'),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-21 19:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0015_add_inventory_foreign_keys'),
]
operations = [
migrations.AddField(
model_name='inventory',
name='document_number',
field=models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Номер документа'),
),
migrations.AlterField(
model_name='documentcounter',
name='counter_type',
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
migrations.AddIndex(
model_name='inventory',
index=models.Index(fields=['document_number'], name='inventory_i_documen_8df782_idx'),
),
]

View File

@@ -1,101 +0,0 @@
# Generated manually - Change conducted_by from CharField to ForeignKey
from django.db import migrations, models
import django.db.models.deletion
def migrate_conducted_by_data(apps, schema_editor):
"""
Миграция данных: для существующих записей инвентаризации
найти первого пользователя с ролью "owner" (владелец) в текущем тенанте
и проставить его UserRole. Если владельца нет - оставить NULL.
"""
Inventory = apps.get_model('inventory', 'Inventory')
UserRole = apps.get_model('user_roles', 'UserRole')
Role = apps.get_model('user_roles', 'Role')
# Находим первого владельца в текущем тенанте
try:
owner_role = Role.objects.get(code='owner', is_system=True)
owner_user_role = UserRole.objects.filter(
role=owner_role,
is_active=True
).first()
if owner_user_role:
# Обновляем все существующие инвентаризации, у которых есть старое текстовое значение
# Используем conducted_by_old (переименованное поле) для проверки
Inventory.objects.exclude(conducted_by_old__isnull=True).exclude(conducted_by_old='').update(
conducted_by_new=owner_user_role
)
except Role.DoesNotExist:
# Если нет роли - оставляем NULL
pass
def reverse_migration(apps, schema_editor):
"""
Откат: очищаем новое поле
"""
Inventory = apps.get_model('inventory', 'Inventory')
Inventory.objects.filter(conducted_by_new__isnull=False).update(conducted_by_new=None)
class Migration(migrations.Migration):
dependencies = [
('inventory', '0016_add_document_number_to_inventory'),
('user_roles', '0001_initial'),
]
operations = [
# Шаг 1: Переименовываем старое поле в conducted_by_old
migrations.RenameField(
model_name='inventory',
old_name='conducted_by',
new_name='conducted_by_old',
),
# Шаг 2: Добавляем новое поле как ForeignKey
migrations.AddField(
model_name='inventory',
name='conducted_by_new',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='inventories_new',
to='user_roles.userrole',
verbose_name='Провел инвентаризацию'
),
),
# Шаг 3: Миграция данных
migrations.RunPython(
migrate_conducted_by_data,
reverse_code=reverse_migration
),
# Шаг 4: Удаляем старое поле
migrations.RemoveField(
model_name='inventory',
name='conducted_by_old',
),
# Шаг 5: Переименовываем новое поле в conducted_by
migrations.RenameField(
model_name='inventory',
old_name='conducted_by_new',
new_name='conducted_by',
),
# Шаг 6: Исправляем related_name
migrations.AlterField(
model_name='inventory',
name='conducted_by',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='inventories',
to='user_roles.userrole',
verbose_name='Провел инвентаризацию'
),
),
]

View File

@@ -1,43 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-22 10:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0017_change_conducted_by_to_fk'),
]
operations = [
migrations.AddField(
model_name='inventoryline',
name='snapshot_difference',
field=models.DecimalField(blank=True, decimal_places=3, help_text='Итоговая разница на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Итоговая разница (snapshot)'),
),
migrations.AddField(
model_name='inventoryline',
name='snapshot_quantity_available',
field=models.DecimalField(blank=True, decimal_places=3, help_text='Всего на складе на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Всего на складе (snapshot)'),
),
migrations.AddField(
model_name='inventoryline',
name='snapshot_quantity_reserved',
field=models.DecimalField(blank=True, decimal_places=3, help_text='В резервах на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В резервах (snapshot)'),
),
migrations.AddField(
model_name='inventoryline',
name='snapshot_quantity_system',
field=models.DecimalField(blank=True, decimal_places=3, help_text='В системе свободно на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В системе свободно (snapshot)'),
),
migrations.AlterField(
model_name='inventoryline',
name='difference',
field=models.DecimalField(decimal_places=3, default=0, editable=False, help_text='(Подсчитано + Зарезервировано) - Всего на складе', max_digits=10, verbose_name='Итоговая разница'),
),
migrations.AlterField(
model_name='inventoryline',
name='quantity_fact',
field=models.DecimalField(decimal_places=3, help_text='Количество свободных товаров, подсчитанных физически', max_digits=10, verbose_name='Подсчитано (факт, свободные)'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,8 @@
from django.db import models from django.db import models
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 inventory.models import Warehouse
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from .status import OrderStatus from .status import OrderStatus
from .address import Address
from .recipient import Recipient from .recipient import Recipient
@@ -31,71 +28,6 @@ class Order(models.Model):
help_text="Уникальный номер заказа" help_text="Уникальный номер заказа"
) )
# Тип доставки
is_delivery = models.BooleanField(
default=True,
verbose_name="С доставкой",
help_text="True - доставка курьером, False - самовывоз"
)
# Адрес доставки (для курьерской доставки)
delivery_address = models.ForeignKey(
Address,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='orders',
verbose_name="Адрес доставки",
help_text="Обязательно для курьерской доставки"
)
# Склад для самовывоза
pickup_warehouse = models.ForeignKey(
Warehouse,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='pickup_orders',
verbose_name="Склад для самовывоза",
help_text="Обязательно для самовывоза"
)
# Дата и время доставки/самовывоза
delivery_date = models.DateField(
null=True,
blank=True,
verbose_name="Дата доставки/самовывоза",
help_text="Может быть заполнено позже"
)
delivery_time_start = models.TimeField(
null=True,
blank=True,
verbose_name="Время от",
help_text="Начало временного интервала"
)
delivery_time_end = models.TimeField(
null=True,
blank=True,
verbose_name="Время до",
help_text="Конец временного интервала"
)
delivery_cost = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Стоимость доставки",
help_text="0 для самовывоза"
)
is_custom_delivery_cost = models.BooleanField(
default=False,
verbose_name="Стоимость доставки установлена вручную",
help_text="True если стоимость доставки была изменена вручную"
)
# Статус заказа # Статус заказа
status = models.ForeignKey( status = models.ForeignKey(
'OrderStatus', 'OrderStatus',
@@ -135,7 +67,7 @@ class Order(models.Model):
decimal_places=2, decimal_places=2,
default=0, default=0,
verbose_name="Итоговая сумма заказа", verbose_name="Итоговая сумма заказа",
help_text="Общая сумма заказа включая доставку" help_text="Общая сумма заказа"
) )
# Частичная оплата # Частичная оплата
@@ -192,6 +124,7 @@ class Order(models.Model):
help_text="Комментарии и пожелания к заказу" help_text="Комментарии и пожелания к заказу"
) )
# Временные метки # Временные метки
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
@@ -222,12 +155,9 @@ class Order(models.Model):
indexes = [ indexes = [
models.Index(fields=['customer']), models.Index(fields=['customer']),
models.Index(fields=['status']), models.Index(fields=['status']),
models.Index(fields=['delivery_date']),
models.Index(fields=['is_delivery']),
models.Index(fields=['payment_status']), models.Index(fields=['payment_status']),
models.Index(fields=['created_at']), models.Index(fields=['created_at']),
models.Index(fields=['order_number']), models.Index(fields=['order_number']),
models.Index(fields=['is_custom_delivery_cost']),
] ]
ordering = ['-created_at'] ordering = ['-created_at']
@@ -250,81 +180,6 @@ class Order(models.Model):
self.order_number = 100 self.order_number = 100
super().save(*args, **kwargs) super().save(*args, **kwargs)
def clean(self):
"""Валидация модели"""
super().clean()
# Проверка: для самовывоза обязателен склад
if not self.is_delivery and not self.pickup_warehouse:
raise ValidationError({
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
})
# Проверка: время окончания должно быть позже или равно времени начала
# Равные времена означают точное время доставки (например, "к 13:00")
if self.delivery_time_start and self.delivery_time_end:
if self.delivery_time_end < self.delivery_time_start:
raise ValidationError({
'delivery_time_end': 'Время окончания не может быть раньше времени начала'
})
def get_delivery_cost(self):
"""
Возвращает стоимость доставки:
- Если установлена вручную - использует ручное значение
- Если автоматическая - вычисляет на основе правил
Returns:
Decimal: Стоимость доставки
"""
if self.is_custom_delivery_cost:
return self.delivery_cost
else:
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
return DeliveryCostCalculator.calculate(self)
def set_delivery_cost(self, cost, is_custom=True):
"""
Устанавливает стоимость доставки.
Args:
cost: Новая стоимость доставки (Decimal)
is_custom: True если устанавливается вручную, False если автоматически
"""
self.delivery_cost = cost
self.is_custom_delivery_cost = is_custom
def reset_delivery_cost(self):
"""
Сбрасывает стоимость доставки на автоматический расчет.
"""
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
self.delivery_cost = DeliveryCostCalculator.calculate(self)
self.is_custom_delivery_cost = False
def recalculate_delivery_cost(self):
"""
Пересчитывает стоимость доставки, если она не установлена вручную.
Используется при изменении параметров заказа (товаров, адреса и т.д.)
"""
if not self.is_custom_delivery_cost:
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
self.delivery_cost = DeliveryCostCalculator.calculate(self)
def calculate_total(self):
"""Рассчитывает итоговую сумму заказа и сохраняет её в БД"""
items_total = sum(item.get_total_price() for item in self.items.all())
# Пересчитываем стоимость доставки если она автоматическая
self.recalculate_delivery_cost()
self.total_amount = items_total + self.delivery_cost
# Сохраняем изменения в БД
self.save(update_fields=['total_amount', 'delivery_cost', 'is_custom_delivery_cost'])
return self.total_amount
def recalculate_amount_paid(self): def recalculate_amount_paid(self):
""" """
Пересчитывает оплаченную сумму на основе транзакций. Пересчитывает оплаченную сумму на основе транзакций.
@@ -377,34 +232,28 @@ class Order(models.Model):
"""Сумма только товаров (без доставки)""" """Сумма только товаров (без доставки)"""
return sum(item.get_total_price() for item in self.items.all()) return sum(item.get_total_price() for item in self.items.all())
@property def calculate_total(self):
def delivery_cost_display(self):
""" """
Возвращает строку для отображения стоимости доставки с пометкой. Пересчитывает итоговую сумму заказа.
Полезно в админке и шаблонах. total_amount = subtotal + delivery_cost
""" """
cost = self.get_delivery_cost() from decimal import Decimal
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
return f"{cost} руб.{suffix}"
@property subtotal = self.subtotal
def delivery_info(self): delivery_cost = Decimal('0')
"""Информация о доставке для отображения"""
if self.is_delivery:
if self.delivery_address:
return f"Доставка по адресу: {self.delivery_address.full_address}"
return "Доставка (адрес не указан)"
else:
if self.pickup_warehouse:
return f"Самовывоз со склада: {self.pickup_warehouse.name}"
return "Самовывоз (склад не указан)"
@property # Получаем стоимость доставки из связанной модели Delivery
def delivery_time_window(self): if hasattr(self, 'delivery'):
"""Временное окно доставки""" delivery_cost = self.delivery.cost
if self.delivery_time_start and self.delivery_time_end:
# Если времена равны - это точное время доставки self.total_amount = subtotal + delivery_cost
if self.delivery_time_start == self.delivery_time_end: self.save(update_fields=['total_amount'])
return f"к {self.delivery_time_start.strftime('%H:%M')}"
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}" def reset_delivery_cost(self):
return "Время не указано" """
Сбрасывает стоимость доставки.
Если есть Delivery, устанавливает cost = 0.
"""
if hasattr(self, 'delivery'):
self.delivery.cost = 0
self.delivery.save(update_fields=['cost'])

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57 # Generated by Django 5.0.10 on 2025-12-23 20:38
import django.db.models.deletion import django.db.models.deletion
import products.models.photos
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -10,6 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('inventory', '0001_initial'),
('orders', '0001_initial'), ('orders', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -65,6 +67,67 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Счетчики артикулов', 'verbose_name_plural': 'Счетчики артикулов',
}, },
), ),
migrations.CreateModel(
name='ConfigurableKitProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
],
options={
'verbose_name': 'Вариативный товар (из комплектов)',
'verbose_name_plural': 'Вариативные товары (из комплектов)',
},
),
migrations.CreateModel(
name='ConfigurableKitOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attributes', models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта')),
('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (вариативный товар)')),
],
options={
'verbose_name': 'Вариант комплекта',
'verbose_name_plural': 'Варианты комплектов',
},
),
migrations.CreateModel(
name='ConfigurableKitProductAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Например: Цвет, Размер, Длина', max_length=150, verbose_name='Название атрибута')),
('option', models.CharField(help_text='Например: Красный, M, 60см', max_length=150, verbose_name='Значение опции')),
('position', models.PositiveIntegerField(default=0, help_text='Меньше = выше в списке', verbose_name='Порядок отображения')),
('visible', models.BooleanField(default=True, help_text='Показывать ли атрибут на странице товара', verbose_name='Видимый на витрине')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_attributes', to='products.configurablekitproduct', verbose_name='Родительский товар')),
],
options={
'verbose_name': 'Атрибут вариативного товара',
'verbose_name_plural': 'Атрибуты вариативных товаров',
'ordering': ['parent', 'position', 'name', 'option'],
},
),
migrations.CreateModel(
name='ConfigurableKitOptionAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attributes_set', to='products.configurablekitoption', verbose_name='Вариант')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.configurablekitproductattribute', verbose_name='Значение атрибута')),
],
options={
'verbose_name': 'Атрибут варианта',
'verbose_name_plural': 'Атрибуты варианта',
},
),
migrations.CreateModel( migrations.CreateModel(
name='PhotoProcessingStatus', name='PhotoProcessingStatus',
fields=[ fields=[
@@ -102,7 +165,7 @@ class Migration(migrations.Migration):
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')), ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')), ('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')), ('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
('cost_price', models.DecimalField(decimal_places=2, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, verbose_name='Себестоимость')), ('cost_price', models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, null=True, verbose_name='Себестоимость')),
('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')), ('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')),
('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')), ('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')),
('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')), ('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')),
@@ -133,6 +196,25 @@ class Migration(migrations.Migration):
name='product', name='product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items_direct', to='products.product', verbose_name='Конкретный товар'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items_direct', to='products.product', verbose_name='Конкретный товар'),
), ),
migrations.CreateModel(
name='CostPriceHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('old_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Старая себестоимость')),
('new_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Новая себестоимость')),
('reason', models.CharField(choices=[('incoming', 'Поступление товара'), ('batch_edit', 'Редактирование партии'), ('batch_delete', 'Удаление партии'), ('recalculation', 'Пересчет себестоимости'), ('system', 'Системная корректировка')], max_length=20, verbose_name='Причина изменения')),
('related_object_id', models.IntegerField(blank=True, help_text='Например, ID партии (StockBatch) для поступлений', null=True, verbose_name='ID связанного объекта')),
('related_object_type', models.CharField(blank=True, help_text="Например, 'StockBatch' для партий", max_length=50, verbose_name='Тип связанного объекта')),
('notes', models.TextField(blank=True, help_text='Дополнительная информация об изменении', verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время изменения')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cost_price_history', to='products.product', verbose_name='Товар')),
],
options={
'verbose_name': 'История себестоимости',
'verbose_name_plural': 'Истории себестоимости',
'ordering': ['-created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='ProductCategory', name='ProductCategory',
fields=[ fields=[
@@ -164,7 +246,7 @@ 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')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('image', models.ImageField(upload_to='categories/temp/', verbose_name='Оригинальное фото')), ('image', models.ImageField(upload_to=products.models.photos.get_category_photo_upload_path, verbose_name='Оригинальное фото')),
('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')), ('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')),
('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления')), ('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')), ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')),
@@ -197,6 +279,7 @@ class Migration(migrations.Migration):
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')), ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')), ('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')),
('order', models.ForeignKey(blank=True, help_text='Заказ, для которого создан временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='orders.order', verbose_name='Заказ')), ('order', models.ForeignKey(blank=True, help_text='Заказ, для которого создан временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='orders.order', verbose_name='Заказ')),
('showcase', models.ForeignKey(blank=True, help_text='Витрина, на которой выложен временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='inventory.showcase', verbose_name='Витрина')),
], ],
options={ options={
'verbose_name': 'Комплект', 'verbose_name': 'Комплект',
@@ -208,13 +291,23 @@ class Migration(migrations.Migration):
name='kit', name='kit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект'),
), ),
migrations.AddField(
model_name='configurablekitproductattribute',
name='kit',
field=models.ForeignKey(blank=True, help_text='Какой ProductKit связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.productkit', verbose_name='Комплект для этого значения'),
),
migrations.AddField(
model_name='configurablekitoption',
name='kit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)'),
),
migrations.CreateModel( migrations.CreateModel(
name='ProductKitPhoto', name='ProductKitPhoto',
fields=[ fields=[
('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')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('image', models.ImageField(upload_to='kits/temp/', verbose_name='Оригинальное фото')), ('image', models.ImageField(upload_to=products.models.photos.get_kit_photo_upload_path, verbose_name='Оригинальное фото')),
('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')), ('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')),
('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления')), ('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления')),
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')), ('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')),
@@ -231,7 +324,7 @@ 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')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('image', models.ImageField(upload_to='products/temp/', verbose_name='Оригинальное фото')), ('image', models.ImageField(upload_to=products.models.photos.get_product_photo_upload_path, verbose_name='Оригинальное фото')),
('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')), ('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')),
('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт (poor или very_poor)', verbose_name='Требует обновления')), ('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт (poor или very_poor)', verbose_name='Требует обновления')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')), ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')),
@@ -292,10 +385,30 @@ class Migration(migrations.Migration):
name='variant_group', name='variant_group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'),
), ),
migrations.AddIndex(
model_name='configurablekitoptionattribute',
index=models.Index(fields=['option'], name='products_co_option__93b9f7_idx'),
),
migrations.AddIndex(
model_name='configurablekitoptionattribute',
index=models.Index(fields=['attribute'], name='products_co_attribu_ccc6d9_idx'),
),
migrations.AlterUniqueTogether(
name='configurablekitoptionattribute',
unique_together={('option', 'attribute')},
),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='kititempriority', name='kititempriority',
unique_together={('kit_item', 'product')}, unique_together={('kit_item', 'product')},
), ),
migrations.AddIndex(
model_name='costpricehistory',
index=models.Index(fields=['product', '-created_at'], name='products_co_product_3320c9_idx'),
),
migrations.AddIndex(
model_name='costpricehistory',
index=models.Index(fields=['reason'], name='products_co_reason_959ee1_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='productcategory', model_name='productcategory',
index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'), index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'),
@@ -324,6 +437,38 @@ class Migration(migrations.Migration):
model_name='productcategoryphoto', model_name='productcategoryphoto',
index=models.Index(fields=['quality_warning', 'category'], name='products_pr_quality_fc505a_idx'), index=models.Index(fields=['quality_warning', 'category'], name='products_pr_quality_fc505a_idx'),
), ),
migrations.AddIndex(
model_name='configurablekitproductattribute',
index=models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'),
),
migrations.AddIndex(
model_name='configurablekitproductattribute',
index=models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx'),
),
migrations.AddIndex(
model_name='configurablekitproductattribute',
index=models.Index(fields=['kit'], name='products_co_kit_id_c5d506_idx'),
),
migrations.AlterUniqueTogether(
name='configurablekitproductattribute',
unique_together={('parent', 'name', 'option', 'kit')},
),
migrations.AddIndex(
model_name='configurablekitoption',
index=models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'),
),
migrations.AddIndex(
model_name='configurablekitoption',
index=models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'),
),
migrations.AddIndex(
model_name='configurablekitoption',
index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx'),
),
migrations.AlterUniqueTogether(
name='configurablekitoption',
unique_together={('parent', 'kit')},
),
migrations.AddIndex( migrations.AddIndex(
model_name='productkitphoto', model_name='productkitphoto',
index=models.Index(fields=['quality_level'], name='products_pr_quality_b03c5c_idx'), index=models.Index(fields=['quality_level'], name='products_pr_quality_b03c5c_idx'),
@@ -356,6 +501,10 @@ class Migration(migrations.Migration):
model_name='productkit', model_name='productkit',
index=models.Index(fields=['order'], name='products_pr_order_i_2b5675_idx'), index=models.Index(fields=['order'], name='products_pr_order_i_2b5675_idx'),
), ),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['showcase'], name='products_pr_showcas_08c1ca_idx'),
),
migrations.AddConstraint( migrations.AddConstraint(
model_name='productkit', model_name='productkit',
constraint=models.UniqueConstraint(condition=models.Q(('is_temporary', False), ('status', 'active')), fields=('name',), name='unique_active_kit_name'), constraint=models.UniqueConstraint(condition=models.Q(('is_temporary', False), ('status', 'active')), fields=('name',), name='unique_active_kit_name'),

View File

@@ -1,52 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-17 19:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ConfigurableKitProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
],
options={
'verbose_name': 'Конфигурируемый товар (из комплектов)',
'verbose_name_plural': 'Конфигурируемые товары (из комплектов)',
},
),
migrations.CreateModel(
name='ConfigurableKitOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attributes', models.TextField(blank=True, verbose_name='Атрибуты варианта (для внешних площадок)')),
('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')),
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (конфигурируемый товар)')),
],
options={
'verbose_name': 'Вариант комплекта',
'verbose_name_plural': 'Варианты комплектов',
'indexes': [models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'), models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'), models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx')],
'unique_together': {('parent', 'kit')},
},
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-17 19:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_configurablekitproduct_configurablekitoption'),
]
operations = [
migrations.AlterModelOptions(
name='configurablekitproduct',
options={'verbose_name': 'Вариативный товар (из комплектов)', 'verbose_name_plural': 'Вариативные товары (из комплектов)'},
),
migrations.AlterField(
model_name='configurablekitoption',
name='parent',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (вариативный товар)'),
),
]

View File

@@ -1,32 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-17 21:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0003_alter_configurablekitproduct_options_and_more'),
]
operations = [
migrations.CreateModel(
name='ConfigurableKitProductAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Например: Цвет, Размер, Длина', max_length=150, verbose_name='Название атрибута')),
('option', models.CharField(help_text='Например: Красный, M, 60см', max_length=150, verbose_name='Значение опции')),
('position', models.PositiveIntegerField(default=0, help_text='Меньше = выше в списке', verbose_name='Порядок отображения')),
('visible', models.BooleanField(default=True, help_text='Показывать ли атрибут на странице товара', verbose_name='Видимый на витрине')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_attributes', to='products.configurablekitproduct', verbose_name='Родительский товар')),
],
options={
'verbose_name': 'Атрибут вариативного товара',
'verbose_name_plural': 'Атрибуты вариативных товаров',
'ordering': ['parent', 'position', 'name', 'option'],
'indexes': [models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'), models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx')],
'unique_together': {('parent', 'name', 'option')},
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-18 15:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0004_configurablekitproductattribute'),
]
operations = [
migrations.AlterField(
model_name='configurablekitoption',
name='attributes',
field=models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта'),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-18 16:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0005_alter_configurablekitoption_attributes'),
]
operations = [
migrations.CreateModel(
name='ConfigurableKitOptionAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.configurablekitproductattribute', verbose_name='Значение атрибута')),
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attributes_set', to='products.configurablekitoption', verbose_name='Вариант')),
],
options={
'verbose_name': 'Атрибут варианта',
'verbose_name_plural': 'Атрибуты варианта',
'unique_together': {('option', 'attribute')},
'indexes': [models.Index(fields=['option'], name='products_co_option__93b9f7_idx'), models.Index(fields=['attribute'], name='products_co_attribu_ccc6d9_idx')],
},
),
]

View File

@@ -1,31 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-18 18:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0006_add_configurablekitoptionattribute'),
]
operations = [
migrations.AlterUniqueTogether(
name='configurablekitproductattribute',
unique_together=set(),
),
migrations.AddField(
model_name='configurablekitproductattribute',
name='kit',
field=models.ForeignKey(blank=True, help_text='Какой ProductKit связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.productkit', verbose_name='Комплект для этого значения'),
),
migrations.AlterUniqueTogether(
name='configurablekitproductattribute',
unique_together={('parent', 'name', 'option', 'kit')},
),
migrations.AddIndex(
model_name='configurablekitproductattribute',
index=models.Index(fields=['kit'], name='products_co_kit_id_c5d506_idx'),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-20 11:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0004_showcase_is_default_and_more'),
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
('products', '0007_add_kit_to_attribute'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='productkit',
name='showcase',
field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='inventory.showcase', verbose_name='Витрина'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['showcase'], name='products_pr_showcas_08c1ca_idx'),
),
]

View File

@@ -1,50 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-23 19:15
import django.db.models.deletion
import products.models.photos
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0008_productkit_showcase_and_more'),
]
operations = [
migrations.AlterField(
model_name='productcategoryphoto',
name='image',
field=models.ImageField(upload_to=products.models.photos.get_category_photo_upload_path, verbose_name='Оригинальное фото'),
),
migrations.AlterField(
model_name='productkitphoto',
name='image',
field=models.ImageField(upload_to=products.models.photos.get_kit_photo_upload_path, verbose_name='Оригинальное фото'),
),
migrations.AlterField(
model_name='productphoto',
name='image',
field=models.ImageField(upload_to=products.models.photos.get_product_photo_upload_path, verbose_name='Оригинальное фото'),
),
migrations.CreateModel(
name='CostPriceHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('old_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Старая себестоимость')),
('new_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Новая себестоимость')),
('reason', models.CharField(choices=[('incoming', 'Поступление товара'), ('batch_edit', 'Редактирование партии'), ('batch_delete', 'Удаление партии'), ('recalculation', 'Пересчет себестоимости'), ('system', 'Системная корректировка')], max_length=20, verbose_name='Причина изменения')),
('related_object_id', models.IntegerField(blank=True, help_text='Например, ID партии (StockBatch) для поступлений', null=True, verbose_name='ID связанного объекта')),
('related_object_type', models.CharField(blank=True, help_text="Например, 'StockBatch' для партий", max_length=50, verbose_name='Тип связанного объекта')),
('notes', models.TextField(blank=True, help_text='Дополнительная информация об изменении', verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время изменения')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cost_price_history', to='products.product', verbose_name='Товар')),
],
options={
'verbose_name': 'История себестоимости',
'verbose_name_plural': 'Истории себестоимости',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['product', '-created_at'], name='products_co_product_3320c9_idx'), models.Index(fields=['reason'], name='products_co_reason_959ee1_idx')],
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.10 on 2025-11-23 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0009_alter_productcategoryphoto_image_and_more'),
]
operations = [
migrations.AlterField(
model_name='product',
name='cost_price',
field=models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, null=True, verbose_name='Себестоимость'),
),
]

View File

@@ -17,6 +17,8 @@
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
{% if user.is_authenticated %} {% if user.is_authenticated %}
{% comment %}Показываем меню tenant приложений только если мы не на странице setup-password (public схема){% endcomment %}
{% if 'setup-password' not in request.path %}
<!-- 📦 Товары --> <!-- 📦 Товары -->
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'products' %}active{% endif %}" href="#" id="productsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'products' %}active{% endif %}" href="#" id="productsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
@@ -69,6 +71,7 @@
💰 Касса 💰 Касса
</a> </a>
</li> </li>
{% endif %}
<!-- ⚙️ Настройки (только для owner/superuser) --> <!-- ⚙️ Настройки (только для owner/superuser) -->
{% if request.user.is_owner or request.user.is_superuser %} {% if request.user.is_owner or request.user.is_superuser %}

View File

@@ -289,10 +289,13 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
logger.info(f"Тенант создан: {client.id}") logger.info(f"Тенант создан: {client.id}")
# Создаем домен (динамически определяется из настроек) # Создаем домен (динамически определяется из настроек)
# Локально: schema_name.localhost:8000 # Локально: schema_name.localhost (без порта!)
# Продакшен: schema_name.mix.smaa.by # Продакшен: schema_name.mix.smaa.by
from django.conf import settings from django.conf import settings
domain_base = settings.TENANT_DOMAIN_BASE domain_base = settings.TENANT_DOMAIN_BASE
# Убираем порт из домена (django-tenants ищет по hostname без порта)
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
domain_name = f"{registration.schema_name}.{domain_base}" domain_name = f"{registration.schema_name}.{domain_base}"
logger.info(f"Создание домена: {domain_name}") logger.info(f"Создание домена: {domain_name}")
domain = Domain.objects.create( domain = Domain.objects.create(

View File

@@ -98,6 +98,9 @@ class Command(BaseCommand):
# Создаем домен # Создаем домен
domain_base = getattr(settings, 'TENANT_DOMAIN_BASE', 'localhost') domain_base = getattr(settings, 'TENANT_DOMAIN_BASE', 'localhost')
# Убираем порт из домена (django-tenants ищет по hostname без порта)
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
domain_name = f"{registration.schema_name}.{domain_base}" domain_name = f"{registration.schema_name}.{domain_base}"
self.stdout.write(f'Создание домена: {domain_name}') self.stdout.write(f'Создание домена: {domain_name}')
domain = Domain.objects.create( domain = Domain.objects.create(

View File

@@ -123,6 +123,9 @@ class Command(BaseCommand):
"""Получить доменное имя""" """Получить доменное имя"""
while True: while True:
domain_base = getattr(settings, 'TENANT_DOMAIN_BASE', 'localhost') domain_base = getattr(settings, 'TENANT_DOMAIN_BASE', 'localhost')
# Убираем порт из домена (django-tenants ищет по hostname без порта)
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
default_domain = f'{default_subdomain}.{domain_base}' default_domain = f'{default_subdomain}.{domain_base}'
domain = input(f'Доменное имя [{default_domain}]: ').strip().lower() domain = input(f'Доменное имя [{default_domain}]: ').strip().lower()

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-11-15 11:57 # Generated by Django 5.0.10 on 2025-12-23 20:38
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
@@ -81,6 +81,9 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')),
('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')), ('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')),
('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')), ('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')),
('password_setup_token', models.UUIDField(blank=True, help_text='UUID токен для ссылки установки пароля владельцем', null=True, unique=True, verbose_name='Токен установки пароля')),
('password_setup_token_created_at', models.DateTimeField(blank=True, help_text='Когда был создан токен установки пароля (действителен 7 дней)', null=True, verbose_name='Дата создания токена')),
('owner_notified_at', models.DateTimeField(blank=True, help_text='Когда было отправлено письмо владельцу с ссылкой установки пароля', null=True, verbose_name='Дата уведомления владельца')),
('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')), ('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')),
('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')), ('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')),
], ],

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-01 18:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tenants', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='tenantregistration',
name='owner_notified_at',
field=models.DateTimeField(blank=True, help_text='Когда было отправлено письмо владельцу с ссылкой установки пароля', null=True, verbose_name='Дата уведомления владельца'),
),
migrations.AddField(
model_name='tenantregistration',
name='password_setup_token',
field=models.UUIDField(blank=True, help_text='UUID токен для ссылки установки пароля владельцем', null=True, unique=True, verbose_name='Токен установки пароля'),
),
migrations.AddField(
model_name='tenantregistration',
name='password_setup_token_created_at',
field=models.DateTimeField(blank=True, help_text='Когда был создан токен установки пароля (действителен 7 дней)', null=True, verbose_name='Дата создания токена'),
),
]

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-01 15:03 # Generated by Django 5.0.10 on 2025-12-23 20:38
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings