Рефакторинг: отделение 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:
@@ -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.utils.timezone
|
||||
|
||||
@@ -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)
|
||||
else:
|
||||
messages.error(request, 'Пароли не совпадают.')
|
||||
|
||||
@@ -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
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -9,6 +11,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -19,8 +22,8 @@ class Migration(migrations.Migration):
|
||||
('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')),
|
||||
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, unique=True, verbose_name='Телефон')),
|
||||
('loyalty_tier', models.CharField(choices=[('no_discount', 'Без скидки'), ('bronze', 'Бронза'), ('silver', 'Серебро'), ('gold', 'Золото'), ('platinum', 'Платина')], default='no_discount', max_length=20, verbose_name='Уровень лояльности')),
|
||||
('total_spent', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Общая сумма покупок')),
|
||||
('wallet_balance', models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', 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='Заметки')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
@@ -29,7 +32,24 @@ class Migration(migrations.Migration):
|
||||
'verbose_name': 'Клиент',
|
||||
'verbose_name_plural': 'Клиенты',
|
||||
'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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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='Системный клиент'),
|
||||
),
|
||||
]
|
||||
34
myproject/customers/migrations/0002_initial.py
Normal file
34
myproject/customers/migrations/0002_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
from django.db import migrations, models
|
||||
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
name='DocumentCounter',
|
||||
fields=[
|
||||
('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='Текущее значение')),
|
||||
],
|
||||
options={
|
||||
@@ -44,6 +44,7 @@ class Migration(migrations.Migration):
|
||||
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='Номер документа')),
|
||||
('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='Наименование поставщика')),
|
||||
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
@@ -55,13 +56,49 @@ class Migration(migrations.Migration):
|
||||
'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(
|
||||
name='Inventory',
|
||||
fields=[
|
||||
('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='Дата инвентаризации')),
|
||||
('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='Примечания')),
|
||||
],
|
||||
options={
|
||||
@@ -75,9 +112,13 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('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_fact', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Фактическое количество')),
|
||||
('difference', models.DecimalField(decimal_places=3, default=0, editable=False, 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, help_text='(Подсчитано + Зарезервировано) - Всего на складе', max_digits=10, 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={
|
||||
'verbose_name': 'Строка инвентаризации',
|
||||
@@ -89,10 +130,12 @@ class Migration(migrations.Migration):
|
||||
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='Количество')),
|
||||
('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='Дата резервирования')),
|
||||
('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={
|
||||
'verbose_name': 'Резервирование',
|
||||
@@ -128,6 +171,39 @@ class Migration(migrations.Migration):
|
||||
'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(
|
||||
name='Stock',
|
||||
fields=[
|
||||
@@ -249,4 +325,38 @@ class Migration(migrations.Migration):
|
||||
'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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -12,6 +13,8 @@ class Migration(migrations.Migration):
|
||||
('inventory', '0001_initial'),
|
||||
('orders', '0001_initial'),
|
||||
('products', '0001_initial'),
|
||||
('user_roles', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -25,6 +28,36 @@ class Migration(migrations.Migration):
|
||||
name='batch',
|
||||
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(
|
||||
model_name='inventoryline',
|
||||
name='inventory',
|
||||
@@ -35,6 +68,11 @@ class Migration(migrations.Migration):
|
||||
name='product',
|
||||
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(
|
||||
model_name='reservation',
|
||||
name='order_item',
|
||||
@@ -45,6 +83,11 @@ class Migration(migrations.Migration):
|
||||
name='product',
|
||||
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(
|
||||
model_name='sale',
|
||||
name='order',
|
||||
@@ -60,6 +103,36 @@ class Migration(migrations.Migration):
|
||||
name='sale',
|
||||
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(
|
||||
model_name='stock',
|
||||
name='product',
|
||||
@@ -162,6 +235,11 @@ class Migration(migrations.Migration):
|
||||
name='warehouse',
|
||||
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(
|
||||
model_name='sale',
|
||||
name='warehouse',
|
||||
@@ -177,6 +255,11 @@ class Migration(migrations.Migration):
|
||||
name='warehouse',
|
||||
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(
|
||||
model_name='incomingbatch',
|
||||
name='warehouse',
|
||||
@@ -187,6 +270,70 @@ class Migration(migrations.Migration):
|
||||
name='batch',
|
||||
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(
|
||||
model_name='incoming',
|
||||
index=models.Index(fields=['batch'], name='inventory_i_batch_i_c50b63_idx'),
|
||||
@@ -263,6 +410,18 @@ class Migration(migrations.Migration):
|
||||
name='stock',
|
||||
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(
|
||||
model_name='sale',
|
||||
index=models.Index(fields=['product', 'warehouse'], name='inventory_s_product_084314_idx'),
|
||||
@@ -275,6 +434,78 @@ class Migration(migrations.Migration):
|
||||
model_name='sale',
|
||||
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(
|
||||
model_name='reservation',
|
||||
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'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingbatch',
|
||||
index=models.Index(fields=['document_number'], name='inventory_i_documen_679096_idx'),
|
||||
model_name='reservation',
|
||||
index=models.Index(fields=['showcase'], name='inventory_r_showcas_bd3508_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingbatch',
|
||||
index=models.Index(fields=['warehouse'], name='inventory_i_warehou_cc3a73_idx'),
|
||||
model_name='reservation',
|
||||
index=models.Index(fields=['product_kit'], name='inventory_r_product_70aed5_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingbatch',
|
||||
index=models.Index(fields=['-created_at'], name='inventory_i_created_59ee8b_idx'),
|
||||
model_name='reservation',
|
||||
index=models.Index(fields=['cart_lock_expires_at'], name='inventory_r_cart_lo_e9b52a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='writeoff',
|
||||
index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'),
|
||||
model_name='reservation',
|
||||
index=models.Index(fields=['locked_by_user'], name='inventory_r_locked__706cbf_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='writeoff',
|
||||
index=models.Index(fields=['date'], name='inventory_w_date_70c7e3_idx'),
|
||||
model_name='reservation',
|
||||
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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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='Статус'),
|
||||
),
|
||||
]
|
||||
@@ -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='Позиция заказа (продажа)'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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='Инвентаризация'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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='Провел инвентаризацию'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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='Подсчитано (факт, свободные)'),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient
|
||||
from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient, Delivery
|
||||
|
||||
|
||||
class TransactionInline(admin.TabularInline):
|
||||
@@ -31,6 +31,18 @@ class OrderItemInline(admin.TabularInline):
|
||||
return []
|
||||
|
||||
|
||||
class DeliveryInline(admin.StackedInline):
|
||||
"""
|
||||
Inline для управления доставкой заказа.
|
||||
"""
|
||||
model = Delivery
|
||||
extra = 0
|
||||
max_num = 1
|
||||
fields = ['delivery_type', 'address', 'pickup_warehouse', 'cost']
|
||||
verbose_name = 'Доставка'
|
||||
verbose_name_plural = 'Доставка'
|
||||
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
@@ -39,8 +51,6 @@ class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'order_number',
|
||||
'customer',
|
||||
'is_delivery',
|
||||
'delivery_date',
|
||||
'status',
|
||||
'total_amount',
|
||||
'payment_status',
|
||||
@@ -50,9 +60,7 @@ class OrderAdmin(admin.ModelAdmin):
|
||||
|
||||
list_filter = [
|
||||
'status',
|
||||
'is_delivery',
|
||||
'payment_status',
|
||||
'delivery_date',
|
||||
'created_at',
|
||||
]
|
||||
|
||||
@@ -62,15 +70,12 @@ class OrderAdmin(admin.ModelAdmin):
|
||||
'customer__phone',
|
||||
'customer__email',
|
||||
'recipient__name',
|
||||
'delivery_address__street',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'order_number',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'delivery_info',
|
||||
'delivery_time_window',
|
||||
'amount_due',
|
||||
'payment_status',
|
||||
]
|
||||
@@ -79,18 +84,10 @@ class OrderAdmin(admin.ModelAdmin):
|
||||
('Основная информация', {
|
||||
'fields': ('order_number', 'customer', 'status')
|
||||
}),
|
||||
('Доставка', {
|
||||
('Получатель', {
|
||||
'fields': (
|
||||
'is_delivery',
|
||||
'customer_is_recipient',
|
||||
'delivery_address',
|
||||
'pickup_warehouse',
|
||||
'delivery_date',
|
||||
'delivery_time_start',
|
||||
'delivery_time_end',
|
||||
'delivery_cost',
|
||||
'delivery_info',
|
||||
'delivery_time_window',
|
||||
'recipient',
|
||||
)
|
||||
}),
|
||||
('Оплата', {
|
||||
@@ -111,7 +108,7 @@ class OrderAdmin(admin.ModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
inlines = [OrderItemInline, TransactionInline]
|
||||
inlines = [OrderItemInline, DeliveryInline, TransactionInline]
|
||||
|
||||
actions = [
|
||||
'mark_as_confirmed',
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient
|
||||
from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient, Delivery
|
||||
from customers.models import Customer
|
||||
from inventory.models import Warehouse
|
||||
from products.models import Product, ProductKit
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -123,17 +122,54 @@ class OrderForm(forms.ModelForm):
|
||||
label='Уточнить адрес у получателя'
|
||||
)
|
||||
|
||||
# Поля для доставки
|
||||
delivery_type = forms.ChoiceField(
|
||||
choices=Delivery.DELIVERY_TYPE_CHOICES,
|
||||
required=True,
|
||||
widget=forms.RadioSelect(attrs={'class': 'form-check-input'}),
|
||||
label='Способ доставки',
|
||||
initial=Delivery.DELIVERY_TYPE_COURIER
|
||||
)
|
||||
|
||||
delivery_date = forms.DateField(
|
||||
required=True,
|
||||
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
label='Дата доставки'
|
||||
)
|
||||
|
||||
time_from = forms.TimeField(
|
||||
required=True,
|
||||
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
|
||||
label='Время доставки от'
|
||||
)
|
||||
|
||||
time_to = forms.TimeField(
|
||||
required=True,
|
||||
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
|
||||
label='Время доставки до'
|
||||
)
|
||||
|
||||
pickup_warehouse = forms.ModelChoiceField(
|
||||
queryset=None, # Будет установлен в __init__
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
label='Склад самовывоза',
|
||||
empty_label='Выберите склад'
|
||||
)
|
||||
|
||||
delivery_cost = forms.DecimalField(
|
||||
required=False,
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
|
||||
label='Стоимость доставки',
|
||||
initial=0
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = [
|
||||
'customer',
|
||||
'is_delivery',
|
||||
'delivery_address',
|
||||
'pickup_warehouse',
|
||||
'delivery_date',
|
||||
'delivery_time_start',
|
||||
'delivery_time_end',
|
||||
'delivery_cost',
|
||||
'customer_is_recipient',
|
||||
'recipient',
|
||||
'status',
|
||||
@@ -141,9 +177,6 @@ class OrderForm(forms.ModelForm):
|
||||
'special_instructions',
|
||||
]
|
||||
widgets = {
|
||||
'delivery_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'delivery_time_start': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'),
|
||||
'delivery_time_end': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'),
|
||||
'special_instructions': forms.Textarea(attrs={'rows': 3}),
|
||||
}
|
||||
|
||||
@@ -199,36 +232,12 @@ class OrderForm(forms.ModelForm):
|
||||
'data-placeholder': 'Начните вводить имя, телефон или email'
|
||||
})
|
||||
|
||||
self.fields['delivery_address'].widget.attrs.update({
|
||||
'class': 'form-select select2',
|
||||
'data-placeholder': 'Выберите адрес доставки'
|
||||
})
|
||||
# Адрес доставки не обязателен при редактировании (создаётся из отдельных полей)
|
||||
self.fields['delivery_address'].required = False
|
||||
|
||||
self.fields['pickup_warehouse'].widget.attrs.update({
|
||||
'class': 'form-select select2',
|
||||
'data-placeholder': 'Выберите склад для самовывоза'
|
||||
})
|
||||
self.fields['pickup_warehouse'].required = False
|
||||
|
||||
# Опциональные поля даты/времени
|
||||
self.fields['delivery_date'].required = False
|
||||
self.fields['delivery_time_start'].required = False
|
||||
self.fields['delivery_time_end'].required = False
|
||||
|
||||
# Подсказки
|
||||
self.fields['is_delivery'].label = 'С доставкой'
|
||||
self.fields['customer_is_recipient'].label = 'Покупатель = получатель'
|
||||
|
||||
# Поле получателя опционально
|
||||
self.fields['recipient'].required = False
|
||||
|
||||
# Поле ручной стоимости доставки опционально
|
||||
self.fields['delivery_cost'].required = False
|
||||
self.fields['delivery_cost'].label = 'Ручная стоимость доставки'
|
||||
self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета'
|
||||
|
||||
# Инициализируем queryset для recipient_from_history
|
||||
if self.instance.pk and self.instance.customer:
|
||||
# При редактировании заказа загружаем историю получателей этого клиента
|
||||
@@ -240,62 +249,94 @@ class OrderForm(forms.ModelForm):
|
||||
orders__in=customer_orders
|
||||
).distinct().order_by('-created_at')
|
||||
|
||||
# Инициализируем queryset для address_from_history
|
||||
# Это будет переопределено в представлении после выбора клиента
|
||||
if self.instance.pk and self.instance.customer:
|
||||
# При редактировании заказа загружаем историю адресов этого клиента
|
||||
customer_orders = Order.objects.filter(
|
||||
customer=self.instance.customer,
|
||||
delivery_address__isnull=False
|
||||
).order_by('-created_at')
|
||||
self.fields['address_from_history'].queryset = Address.objects.filter(
|
||||
orders__in=customer_orders
|
||||
).distinct().order_by('-created_at')
|
||||
|
||||
# Инициализируем поля получателя из существующего recipient
|
||||
if self.instance.pk and self.instance.recipient:
|
||||
recipient = self.instance.recipient
|
||||
self.fields['recipient_name'].initial = recipient.name or ''
|
||||
self.fields['recipient_phone'].initial = recipient.phone or ''
|
||||
|
||||
# Инициализируем поля адреса из существующего delivery_address
|
||||
if self.instance.pk and self.instance.delivery_address:
|
||||
address = self.instance.delivery_address
|
||||
self.fields['address_street'].initial = address.street or ''
|
||||
self.fields['address_building_number'].initial = address.building_number or ''
|
||||
self.fields['address_apartment_number'].initial = address.apartment_number or ''
|
||||
self.fields['address_entrance'].initial = address.entrance or ''
|
||||
self.fields['address_floor'].initial = address.floor or ''
|
||||
self.fields['address_intercom_code'].initial = address.intercom_code or ''
|
||||
self.fields['address_delivery_instructions'].initial = address.delivery_instructions or ''
|
||||
self.fields['address_confirm_with_recipient'].initial = address.confirm_address_with_recipient
|
||||
# Инициализируем queryset для pickup_warehouse
|
||||
from inventory.models import Warehouse
|
||||
self.fields['pickup_warehouse'].queryset = Warehouse.objects.filter(is_active=True).order_by('name')
|
||||
|
||||
# Инициализируем поля доставки из существующей Delivery
|
||||
if self.instance.pk and hasattr(self.instance, 'delivery'):
|
||||
delivery = self.instance.delivery
|
||||
self.fields['delivery_type'].initial = delivery.delivery_type
|
||||
self.fields['delivery_date'].initial = delivery.delivery_date
|
||||
self.fields['time_from'].initial = delivery.time_from
|
||||
self.fields['time_to'].initial = delivery.time_to
|
||||
self.fields['pickup_warehouse'].initial = delivery.pickup_warehouse
|
||||
self.fields['delivery_cost'].initial = delivery.cost
|
||||
|
||||
def clean(self):
|
||||
"""Валидация формы заказа, включая обязательные поля доставки"""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Проверяем, является ли заказ черновиком
|
||||
status = cleaned_data.get('status')
|
||||
is_draft = status and hasattr(status, 'code') and status.code == 'draft'
|
||||
|
||||
# Для черновиков Delivery не обязательна
|
||||
if is_draft:
|
||||
return cleaned_data
|
||||
|
||||
# Для не-черновиков Delivery обязательна
|
||||
delivery_type = cleaned_data.get('delivery_type')
|
||||
delivery_date = cleaned_data.get('delivery_date')
|
||||
time_from = cleaned_data.get('time_from')
|
||||
time_to = cleaned_data.get('time_to')
|
||||
pickup_warehouse = cleaned_data.get('pickup_warehouse')
|
||||
|
||||
# Проверяем обязательные поля доставки
|
||||
if not delivery_type:
|
||||
raise forms.ValidationError({'delivery_type': 'Необходимо выбрать способ доставки'})
|
||||
|
||||
if not delivery_date:
|
||||
raise forms.ValidationError({'delivery_date': 'Необходимо указать дату доставки'})
|
||||
|
||||
if not time_from:
|
||||
raise forms.ValidationError({'time_from': 'Необходимо указать время начала доставки'})
|
||||
|
||||
if not time_to:
|
||||
raise forms.ValidationError({'time_to': 'Необходимо указать время окончания доставки'})
|
||||
|
||||
# Проверяем, что время "до" позже времени "от"
|
||||
if time_from and time_to and time_from >= time_to:
|
||||
raise forms.ValidationError({
|
||||
'time_to': 'Время окончания доставки должно быть позже времени начала'
|
||||
})
|
||||
|
||||
# Проверяем специфичные требования для каждого типа доставки
|
||||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
||||
# Для курьерской доставки нужен адрес
|
||||
address_mode = cleaned_data.get('address_mode')
|
||||
address_from_history = cleaned_data.get('address_from_history')
|
||||
address_street = cleaned_data.get('address_street', '').strip()
|
||||
|
||||
has_address = (
|
||||
(address_mode == 'history' and address_from_history) or
|
||||
(address_mode == 'new' and address_street)
|
||||
)
|
||||
|
||||
if not has_address:
|
||||
raise forms.ValidationError({
|
||||
'address_mode': 'Для курьерской доставки необходимо указать адрес'
|
||||
})
|
||||
|
||||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
||||
# Для самовывоза нужен склад
|
||||
if not pickup_warehouse:
|
||||
raise forms.ValidationError({
|
||||
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
||||
})
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
"""
|
||||
Сохраняет форму с учетом автоматического/ручного расчета стоимости доставки.
|
||||
Логика:
|
||||
- Если delivery_cost заполнено → используется ручное значение (is_custom_delivery_cost = True)
|
||||
- Если delivery_cost пустое → автоматический расчет (is_custom_delivery_cost = False)
|
||||
|
||||
ВАЖНО: reset_delivery_cost() вызывается только при commit=True,
|
||||
т.к. требует наличия сохраненных items в БД.
|
||||
"""
|
||||
"""Сохраняет форму заказа."""
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Получаем значение ручной стоимости доставки
|
||||
delivery_cost = self.cleaned_data.get('delivery_cost')
|
||||
|
||||
if delivery_cost is not None and delivery_cost > 0:
|
||||
# Ручное значение указано
|
||||
instance.set_delivery_cost(delivery_cost, is_custom=True)
|
||||
else:
|
||||
# Пустое поле или 0 → помечаем что нужен автоматический расчет
|
||||
# НО не вызываем reset_delivery_cost() если commit=False!
|
||||
instance.is_custom_delivery_cost = False
|
||||
if commit:
|
||||
# Автоматический расчет только при commit=True
|
||||
instance.reset_delivery_cost()
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-15 11:57
|
||||
# Generated by Django 5.0.10 on 2025-12-23 20:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import simple_history.models
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -17,28 +18,54 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='KitItemSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('product_name', models.CharField(blank=True, max_length=200, verbose_name='Название товара')),
|
||||
('product_sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул товара')),
|
||||
('product_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Цена товара')),
|
||||
('variant_group_name', models.CharField(blank=True, max_length=200, verbose_name='Группа вариантов')),
|
||||
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Снимок компонента',
|
||||
'verbose_name_plural': 'Снимки компонентов',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='KitSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('base_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Базовая цена')),
|
||||
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Итоговая цена')),
|
||||
('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена со скидкой')),
|
||||
('price_adjustment_type', models.CharField(default='none', max_length=20, verbose_name='Тип корректировки')),
|
||||
('price_adjustment_value', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Значение корректировки')),
|
||||
('is_temporary', models.BooleanField(default=False, verbose_name='Временный комплект')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Снимок комплекта',
|
||||
'verbose_name_plural': 'Снимки комплектов',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order_number', models.PositiveIntegerField(editable=False, help_text='Уникальный номер заказа', unique=True, verbose_name='Номер заказа')),
|
||||
('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')),
|
||||
('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')),
|
||||
('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')),
|
||||
('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')),
|
||||
('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
|
||||
('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')),
|
||||
('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')),
|
||||
('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')),
|
||||
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')),
|
||||
('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')),
|
||||
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')),
|
||||
('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')),
|
||||
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')),
|
||||
('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')),
|
||||
('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')),
|
||||
('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')),
|
||||
('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')),
|
||||
('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')),
|
||||
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
|
||||
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
@@ -54,9 +81,12 @@ class Migration(migrations.Migration):
|
||||
name='OrderItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
|
||||
('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')),
|
||||
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
|
||||
('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
|
||||
('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
||||
],
|
||||
options={
|
||||
@@ -87,26 +117,59 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Payment',
|
||||
name='PaymentMethod',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма платежа')),
|
||||
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], max_length=20, verbose_name='Способ оплаты')),
|
||||
('payment_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время платежа')),
|
||||
('notes', models.TextField(blank=True, help_text='Дополнительная информация о платеже', null=True, verbose_name='Примечания')),
|
||||
('code', models.SlugField(help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')", unique=True, verbose_name='Код способа оплаты')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Название способа оплаты')),
|
||||
('description', models.TextField(blank=True, help_text='Дополнительная информация о способе оплаты', verbose_name='Описание')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Отключенные способы оплаты не отображаются при создании заказа', verbose_name='Активен')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
|
||||
('is_system', models.BooleanField(default=False, help_text='Системные способы оплаты нельзя удалить через интерфейс', verbose_name='Системный')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Платеж',
|
||||
'verbose_name_plural': 'Платежи',
|
||||
'ordering': ['-payment_date'],
|
||||
'verbose_name': 'Способ оплаты',
|
||||
'verbose_name_plural': 'Способы оплаты',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Recipient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='ФИО или название организации получателя', max_length=200, verbose_name='Имя получателя')),
|
||||
('phone', models.CharField(help_text='Контактный телефон для связи с получателем', max_length=20, verbose_name='Телефон получателя')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Получатель',
|
||||
'verbose_name_plural': 'Получатели',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('transaction_type', models.CharField(choices=[('payment', 'Платёж'), ('refund', 'Возврат')], default='payment', max_length=20, verbose_name='Тип транзакции')),
|
||||
('amount', models.DecimalField(decimal_places=2, help_text="Всегда положительная. Для возврата используется transaction_type='refund'", max_digits=10, verbose_name='Сумма')),
|
||||
('transaction_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время транзакции')),
|
||||
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
|
||||
('reason', models.CharField(blank=True, help_text='Причина возврата или особенности платежа', max_length=255, null=True, verbose_name='Причина')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Транзакция',
|
||||
'verbose_name_plural': 'Транзакции',
|
||||
'ordering': ['-transaction_date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Address',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('recipient_name', models.CharField(blank=True, help_text='Имя человека, которому будет доставлен заказ', max_length=200, null=True, verbose_name='Имя получателя')),
|
||||
('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=20, null=True, verbose_name='Телефон получателя')),
|
||||
('street', models.CharField(blank=True, max_length=255, null=True, verbose_name='Улица')),
|
||||
('building_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер здания')),
|
||||
('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')),
|
||||
@@ -125,28 +188,38 @@ class Migration(migrations.Migration):
|
||||
'indexes': [models.Index(fields=['created_at'], name='orders_addr_created_98ad97_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Delivery',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('delivery_type', models.CharField(choices=[('courier', 'Доставка курьером'), ('pickup', 'Самовывоз')], db_index=True, default='courier', max_length=20, verbose_name='Способ доставки')),
|
||||
('delivery_date', models.DateField(help_text='Дата, когда должна быть выполнена доставка', verbose_name='Дата доставки')),
|
||||
('time_from', models.TimeField(help_text='Начальное время временного интервала доставки', verbose_name='Время доставки от')),
|
||||
('time_to', models.TimeField(help_text='Конечное время временного интервала доставки', verbose_name='Время доставки до')),
|
||||
('cost', models.DecimalField(decimal_places=2, default=0, help_text='Стоимость доставки в рублях. 0 для бесплатной доставки/самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('address', models.ForeignKey(blank=True, help_text='Адрес для курьерской доставки. На один адрес может быть много доставок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deliveries', to='orders.address', verbose_name='Адрес доставки')),
|
||||
('pickup_warehouse', models.ForeignKey(blank=True, help_text='Склад для самовывоза заказа', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='deliveries', to='inventory.warehouse', verbose_name='Склад самовывоза')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Доставка',
|
||||
'verbose_name_plural': 'Доставки',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HistoricalOrder',
|
||||
fields=[
|
||||
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
||||
('order_number', models.PositiveIntegerField(db_index=True, editable=False, help_text='Уникальный номер заказа', verbose_name='Номер заказа')),
|
||||
('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')),
|
||||
('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')),
|
||||
('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')),
|
||||
('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')),
|
||||
('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
|
||||
('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')),
|
||||
('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')),
|
||||
('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')),
|
||||
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')),
|
||||
('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')),
|
||||
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')),
|
||||
('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')),
|
||||
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')),
|
||||
('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')),
|
||||
('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')),
|
||||
('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')),
|
||||
('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')),
|
||||
('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')),
|
||||
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
|
||||
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
|
||||
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата создания')),
|
||||
@@ -156,10 +229,8 @@ class Migration(migrations.Migration):
|
||||
('history_change_reason', models.CharField(max_length=100, null=True)),
|
||||
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
|
||||
('customer', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='customers.customer', verbose_name='Клиент')),
|
||||
('delivery_address', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.address', verbose_name='Адрес доставки')),
|
||||
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('modified_by', models.ForeignKey(blank=True, db_constraint=False, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем')),
|
||||
('pickup_warehouse', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.warehouse', verbose_name='Склад для самовывоза')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'historical Заказ',
|
||||
@@ -173,9 +244,12 @@ class Migration(migrations.Migration):
|
||||
name='HistoricalOrderItem',
|
||||
fields=[
|
||||
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
||||
('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
|
||||
('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')),
|
||||
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
|
||||
('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
|
||||
('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')),
|
||||
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата добавления')),
|
||||
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('history_date', models.DateTimeField(db_index=True)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-15 11:57
|
||||
# Generated by Django 5.0.10 on 2025-12-23 20:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('customers', '0001_initial'),
|
||||
('customers', '0002_initial'),
|
||||
('inventory', '0002_initial'),
|
||||
('orders', '0001_initial'),
|
||||
('products', '0001_initial'),
|
||||
@@ -29,30 +29,55 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.productkit', verbose_name='Комплект товаров'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='customer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент'),
|
||||
model_name='historicalorderitem',
|
||||
name='showcase',
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.showcase', verbose_name='Витрина'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='kititemsnapshot',
|
||||
name='original_product',
|
||||
field=models.ForeignKey(blank=True, help_text='Ссылка на товар для резервирования на складе', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.product', verbose_name='Оригинальный товар'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='kitsnapshot',
|
||||
name='original_kit',
|
||||
field=models.ForeignKey(blank=True, help_text='Ссылка на комплект, с которого создан снимок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='snapshots', to='products.productkit', verbose_name='Оригинальный комплект'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='kititemsnapshot',
|
||||
name='kit_snapshot',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='kit_snapshot',
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='delivery_address',
|
||||
field=models.OneToOneField(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='orders.address', verbose_name='Адрес доставки'),
|
||||
name='customer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='modified_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_orders', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='pickup_warehouse',
|
||||
field=models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='inventory.warehouse', verbose_name='Склад для самовывоза'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='order',
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.order', verbose_name='Заказ'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='delivery',
|
||||
name='order',
|
||||
field=models.OneToOneField(help_text='Заказ, к которому относится доставка', on_delete=django.db.models.deletion.CASCADE, related_name='delivery', to='orders.order', verbose_name='Заказ'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='kit_snapshot',
|
||||
field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='order',
|
||||
@@ -68,6 +93,11 @@ class Migration(migrations.Migration):
|
||||
name='product_kit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.productkit', verbose_name='Комплект товаров'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='showcase',
|
||||
field=models.ForeignKey(blank=True, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='inventory.showcase', verbose_name='Витрина'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderstatus',
|
||||
name='created_by',
|
||||
@@ -89,14 +119,83 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.orderstatus', verbose_name='Статус заказа'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
model_name='paymentmethod',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments_created', to=settings.AUTH_USER_MODEL, verbose_name='Принял платеж'),
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_payment_methods', to=settings.AUTH_USER_MODEL, verbose_name='Создано'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='recipient',
|
||||
index=models.Index(fields=['phone'], name='orders_reci_phone_735356_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='recipient',
|
||||
index=models.Index(fields=['name'], name='orders_reci_name_e52d5b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='recipient',
|
||||
index=models.Index(fields=['created_at'], name='orders_reci_created_34a391_idx'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
model_name='order',
|
||||
name='recipient',
|
||||
field=models.ForeignKey(blank=True, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.recipient', verbose_name='Получатель'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorder',
|
||||
name='recipient',
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.recipient', verbose_name='Получатель'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='orders.order', verbose_name='Заказ'),
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='orders.order', verbose_name='Заказ'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='payment_method',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='orders.paymentmethod', verbose_name='Способ оплаты/возврата'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='related_payment',
|
||||
field=models.ForeignKey(blank=True, help_text='Для возвратов - на какой платёж ссылается этот возврат', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refunds', to='orders.transaction', verbose_name='Связанный платёж'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kitsnapshot',
|
||||
index=models.Index(fields=['original_kit'], name='orders_kits_origina_f8d311_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kitsnapshot',
|
||||
index=models.Index(fields=['created_at'], name='orders_kits_created_70de88_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kititemsnapshot',
|
||||
index=models.Index(fields=['kit_snapshot'], name='orders_kiti_kit_sna_bf307e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='delivery',
|
||||
index=models.Index(fields=['delivery_type'], name='orders_deli_deliver_ac3dc8_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='delivery',
|
||||
index=models.Index(fields=['created_at'], name='orders_deli_created_1a3ff3_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='delivery',
|
||||
index=models.Index(fields=['delivery_date'], name='orders_deli_deliver_e898e4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='delivery',
|
||||
index=models.Index(fields=['time_from'], name='orders_deli_time_fr_916f57_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='delivery',
|
||||
index=models.Index(fields=['time_to'], name='orders_deli_time_to_7f2573_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
@@ -110,6 +209,14 @@ class Migration(migrations.Migration):
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['is_from_showcase'], name='orders_orde_is_from_32d8f7_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['showcase'], name='orders_orde_showcas_aa97bd_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderstatus',
|
||||
index=models.Index(fields=['code'], name='orders_orde_code_5e1ef7_idx'),
|
||||
@@ -122,6 +229,18 @@ class Migration(migrations.Migration):
|
||||
model_name='orderstatus',
|
||||
index=models.Index(fields=['order'], name='orders_orde_order_2e2930_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentmethod',
|
||||
index=models.Index(fields=['code'], name='orders_paym_code_f40d7e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentmethod',
|
||||
index=models.Index(fields=['is_active'], name='orders_paym_is_acti_e2be69_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentmethod',
|
||||
index=models.Index(fields=['order'], name='orders_paym_order_94e282_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'),
|
||||
@@ -130,14 +249,6 @@ class Migration(migrations.Migration):
|
||||
model_name='order',
|
||||
index=models.Index(fields=['status'], name='orders_orde_status__eb4f00_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['delivery_date'], name='orders_orde_deliver_e4274f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['is_delivery'], name='orders_orde_is_deli_07c9c0_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['payment_status'], name='orders_orde_payment_bc131d_idx'),
|
||||
@@ -151,15 +262,19 @@ class Migration(migrations.Migration):
|
||||
index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'),
|
||||
model_name='transaction',
|
||||
index=models.Index(fields=['order', '-transaction_date'], name='orders_tran_order_i_dc90ee_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='payment',
|
||||
index=models.Index(fields=['order'], name='orders_paym_order_i_8c8d98_idx'),
|
||||
model_name='transaction',
|
||||
index=models.Index(fields=['transaction_type'], name='orders_tran_transac_3d971d_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='payment',
|
||||
index=models.Index(fields=['payment_date'], name='orders_paym_payment_9e5ac0_idx'),
|
||||
model_name='transaction',
|
||||
index=models.Index(fields=['payment_method'], name='orders_tran_payment_7e354c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='transaction',
|
||||
index=models.Index(fields=['transaction_date'], name='orders_tran_transac_1bae48_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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='Снимок комплекта'),
|
||||
),
|
||||
]
|
||||
@@ -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='Оригинальный товар'),
|
||||
),
|
||||
]
|
||||
@@ -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='Получатель'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -27,6 +27,7 @@ from .order import Order
|
||||
from .kit_snapshot import KitSnapshot, KitItemSnapshot
|
||||
from .order_item import OrderItem
|
||||
from .transaction import Transaction
|
||||
from .delivery import Delivery
|
||||
|
||||
__all__ = [
|
||||
'OrderStatus',
|
||||
@@ -38,4 +39,5 @@ __all__ = [
|
||||
'Transaction',
|
||||
'KitSnapshot',
|
||||
'KitItemSnapshot',
|
||||
'Delivery',
|
||||
]
|
||||
|
||||
151
myproject/orders/models/delivery.py
Normal file
151
myproject/orders/models/delivery.py
Normal 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)
|
||||
@@ -1,11 +1,8 @@
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from accounts.models import CustomUser
|
||||
from customers.models import Customer
|
||||
from inventory.models import Warehouse
|
||||
from simple_history.models import HistoricalRecords
|
||||
from .status import OrderStatus
|
||||
from .address import Address
|
||||
from .recipient import Recipient
|
||||
|
||||
|
||||
@@ -31,71 +28,6 @@ class Order(models.Model):
|
||||
help_text="Уникальный номер заказа"
|
||||
)
|
||||
|
||||
# Тип доставки
|
||||
is_delivery = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="С доставкой",
|
||||
help_text="True - доставка курьером, False - самовывоз"
|
||||
)
|
||||
|
||||
# Адрес доставки (для курьерской доставки)
|
||||
delivery_address = models.ForeignKey(
|
||||
Address,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='orders',
|
||||
verbose_name="Адрес доставки",
|
||||
help_text="Обязательно для курьерской доставки"
|
||||
)
|
||||
|
||||
# Склад для самовывоза
|
||||
pickup_warehouse = models.ForeignKey(
|
||||
Warehouse,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='pickup_orders',
|
||||
verbose_name="Склад для самовывоза",
|
||||
help_text="Обязательно для самовывоза"
|
||||
)
|
||||
|
||||
# Дата и время доставки/самовывоза
|
||||
delivery_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата доставки/самовывоза",
|
||||
help_text="Может быть заполнено позже"
|
||||
)
|
||||
|
||||
delivery_time_start = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Время от",
|
||||
help_text="Начало временного интервала"
|
||||
)
|
||||
|
||||
delivery_time_end = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Время до",
|
||||
help_text="Конец временного интервала"
|
||||
)
|
||||
|
||||
delivery_cost = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Стоимость доставки",
|
||||
help_text="0 для самовывоза"
|
||||
)
|
||||
|
||||
is_custom_delivery_cost = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Стоимость доставки установлена вручную",
|
||||
help_text="True если стоимость доставки была изменена вручную"
|
||||
)
|
||||
|
||||
# Статус заказа
|
||||
status = models.ForeignKey(
|
||||
'OrderStatus',
|
||||
@@ -135,7 +67,7 @@ class Order(models.Model):
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Итоговая сумма заказа",
|
||||
help_text="Общая сумма заказа включая доставку"
|
||||
help_text="Общая сумма заказа"
|
||||
)
|
||||
|
||||
# Частичная оплата
|
||||
@@ -192,6 +124,7 @@ class Order(models.Model):
|
||||
help_text="Комментарии и пожелания к заказу"
|
||||
)
|
||||
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
@@ -222,12 +155,9 @@ class Order(models.Model):
|
||||
indexes = [
|
||||
models.Index(fields=['customer']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['delivery_date']),
|
||||
models.Index(fields=['is_delivery']),
|
||||
models.Index(fields=['payment_status']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['order_number']),
|
||||
models.Index(fields=['is_custom_delivery_cost']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
@@ -250,81 +180,6 @@ class Order(models.Model):
|
||||
self.order_number = 100
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Валидация модели"""
|
||||
super().clean()
|
||||
|
||||
# Проверка: для самовывоза обязателен склад
|
||||
if not self.is_delivery and not self.pickup_warehouse:
|
||||
raise ValidationError({
|
||||
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
||||
})
|
||||
|
||||
# Проверка: время окончания должно быть позже или равно времени начала
|
||||
# Равные времена означают точное время доставки (например, "к 13:00")
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
if self.delivery_time_end < self.delivery_time_start:
|
||||
raise ValidationError({
|
||||
'delivery_time_end': 'Время окончания не может быть раньше времени начала'
|
||||
})
|
||||
|
||||
def get_delivery_cost(self):
|
||||
"""
|
||||
Возвращает стоимость доставки:
|
||||
- Если установлена вручную - использует ручное значение
|
||||
- Если автоматическая - вычисляет на основе правил
|
||||
|
||||
Returns:
|
||||
Decimal: Стоимость доставки
|
||||
"""
|
||||
if self.is_custom_delivery_cost:
|
||||
return self.delivery_cost
|
||||
else:
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
return DeliveryCostCalculator.calculate(self)
|
||||
|
||||
def set_delivery_cost(self, cost, is_custom=True):
|
||||
"""
|
||||
Устанавливает стоимость доставки.
|
||||
|
||||
Args:
|
||||
cost: Новая стоимость доставки (Decimal)
|
||||
is_custom: True если устанавливается вручную, False если автоматически
|
||||
"""
|
||||
self.delivery_cost = cost
|
||||
self.is_custom_delivery_cost = is_custom
|
||||
|
||||
def reset_delivery_cost(self):
|
||||
"""
|
||||
Сбрасывает стоимость доставки на автоматический расчет.
|
||||
"""
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||
self.is_custom_delivery_cost = False
|
||||
|
||||
def recalculate_delivery_cost(self):
|
||||
"""
|
||||
Пересчитывает стоимость доставки, если она не установлена вручную.
|
||||
Используется при изменении параметров заказа (товаров, адреса и т.д.)
|
||||
"""
|
||||
if not self.is_custom_delivery_cost:
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||
|
||||
def calculate_total(self):
|
||||
"""Рассчитывает итоговую сумму заказа и сохраняет её в БД"""
|
||||
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||
|
||||
# Пересчитываем стоимость доставки если она автоматическая
|
||||
self.recalculate_delivery_cost()
|
||||
|
||||
self.total_amount = items_total + self.delivery_cost
|
||||
|
||||
# Сохраняем изменения в БД
|
||||
self.save(update_fields=['total_amount', 'delivery_cost', 'is_custom_delivery_cost'])
|
||||
|
||||
return self.total_amount
|
||||
|
||||
def recalculate_amount_paid(self):
|
||||
"""
|
||||
Пересчитывает оплаченную сумму на основе транзакций.
|
||||
@@ -377,34 +232,28 @@ class Order(models.Model):
|
||||
"""Сумма только товаров (без доставки)"""
|
||||
return sum(item.get_total_price() for item in self.items.all())
|
||||
|
||||
@property
|
||||
def delivery_cost_display(self):
|
||||
def calculate_total(self):
|
||||
"""
|
||||
Возвращает строку для отображения стоимости доставки с пометкой.
|
||||
Полезно в админке и шаблонах.
|
||||
Пересчитывает итоговую сумму заказа.
|
||||
total_amount = subtotal + delivery_cost
|
||||
"""
|
||||
cost = self.get_delivery_cost()
|
||||
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
|
||||
return f"{cost} руб.{suffix}"
|
||||
from decimal import Decimal
|
||||
|
||||
subtotal = self.subtotal
|
||||
delivery_cost = Decimal('0')
|
||||
|
||||
# Получаем стоимость доставки из связанной модели Delivery
|
||||
if hasattr(self, 'delivery'):
|
||||
delivery_cost = self.delivery.cost
|
||||
|
||||
self.total_amount = subtotal + delivery_cost
|
||||
self.save(update_fields=['total_amount'])
|
||||
|
||||
@property
|
||||
def delivery_info(self):
|
||||
"""Информация о доставке для отображения"""
|
||||
if self.is_delivery:
|
||||
if self.delivery_address:
|
||||
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||||
return "Доставка (адрес не указан)"
|
||||
else:
|
||||
if self.pickup_warehouse:
|
||||
return f"Самовывоз со склада: {self.pickup_warehouse.name}"
|
||||
return "Самовывоз (склад не указан)"
|
||||
|
||||
@property
|
||||
def delivery_time_window(self):
|
||||
"""Временное окно доставки"""
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
# Если времена равны - это точное время доставки
|
||||
if self.delivery_time_start == self.delivery_time_end:
|
||||
return f"к {self.delivery_time_start.strftime('%H:%M')}"
|
||||
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||
return "Время не указано"
|
||||
def reset_delivery_cost(self):
|
||||
"""
|
||||
Сбрасывает стоимость доставки.
|
||||
Если есть Delivery, устанавливает cost = 0.
|
||||
"""
|
||||
if hasattr(self, 'delivery'):
|
||||
self.delivery.cost = 0
|
||||
self.delivery.save(update_fields=['cost'])
|
||||
|
||||
@@ -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
|
||||
@@ -8,7 +8,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from decimal import Decimal
|
||||
from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod
|
||||
from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod, Delivery
|
||||
from .forms import OrderForm, OrderItemFormSet, OrderItemForm, OrderStatusForm, TransactionForm
|
||||
from .filters import OrderFilter
|
||||
from .services.address_service import AddressService
|
||||
@@ -22,7 +22,7 @@ def order_list(request):
|
||||
"""
|
||||
# Базовый queryset с оптимизацией запросов
|
||||
orders = Order.objects.select_related(
|
||||
'customer', 'delivery_address', 'pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
|
||||
'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
|
||||
).all()
|
||||
|
||||
# Применяем фильтры через django-filter
|
||||
@@ -48,7 +48,7 @@ def order_list(request):
|
||||
def order_detail(request, order_number):
|
||||
"""Детальная информация о заказе"""
|
||||
order = get_object_or_404(
|
||||
Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by', 'status')
|
||||
Order.objects.select_related('customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'modified_by', 'status')
|
||||
.prefetch_related('items__product', 'items__product_kit', 'transactions__created_by', 'transactions__payment_method'),
|
||||
order_number=order_number
|
||||
)
|
||||
@@ -108,15 +108,6 @@ def order_create(request):
|
||||
# Если покупатель является получателем
|
||||
order.recipient = None
|
||||
|
||||
# Обрабатываем адрес доставки
|
||||
if order.is_delivery:
|
||||
address = AddressService.process_address_from_form(order, form.cleaned_data)
|
||||
if address:
|
||||
# Если адрес не существует в БД, сохраняем его
|
||||
if not address.pk:
|
||||
address.save()
|
||||
order.delivery_address = address
|
||||
|
||||
# Статус берём из формы (в том числе может быть "Черновик")
|
||||
order.modified_by = request.user
|
||||
|
||||
@@ -127,10 +118,53 @@ def order_create(request):
|
||||
formset.instance = order
|
||||
formset.save()
|
||||
|
||||
# Пересчитываем стоимость доставки если она не установлена вручную
|
||||
delivery_cost = form.cleaned_data.get('delivery_cost')
|
||||
if not delivery_cost or delivery_cost <= 0:
|
||||
order.reset_delivery_cost()
|
||||
# Проверяем, является ли заказ черновиком
|
||||
is_draft = order.status and order.status.code == 'draft'
|
||||
|
||||
# Создаем Delivery (обязательно, кроме черновиков)
|
||||
if not is_draft:
|
||||
# Получаем данные из формы (уже провалидированы)
|
||||
delivery_type = form.cleaned_data.get('delivery_type')
|
||||
delivery_date = form.cleaned_data.get('delivery_date')
|
||||
time_from = form.cleaned_data.get('time_from')
|
||||
time_to = form.cleaned_data.get('time_to')
|
||||
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
|
||||
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
|
||||
|
||||
# Проверяем наличие обязательных полей
|
||||
if not all([delivery_type, delivery_date, time_from, time_to]):
|
||||
raise ValidationError('Необходимо заполнить все поля доставки')
|
||||
|
||||
# Обрабатываем адрес для курьерской доставки
|
||||
address = None
|
||||
|
||||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
||||
# Для курьерской доставки нужен адрес
|
||||
address = AddressService.process_address_from_form(order, form.cleaned_data)
|
||||
if not address:
|
||||
raise ValidationError('Для курьерской доставки необходимо указать адрес')
|
||||
if not address.pk:
|
||||
address.save()
|
||||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
||||
# Для самовывоза нужен склад
|
||||
if not pickup_warehouse:
|
||||
raise ValidationError('Для самовывоза необходимо выбрать склад')
|
||||
|
||||
# Создаем Delivery
|
||||
delivery = Delivery.objects.create(
|
||||
order=order,
|
||||
delivery_type=delivery_type,
|
||||
delivery_date=delivery_date,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
address=address,
|
||||
pickup_warehouse=pickup_warehouse,
|
||||
cost=delivery_cost if delivery_cost else Decimal('0')
|
||||
)
|
||||
|
||||
# Пересчитываем стоимость доставки если она не установлена вручную
|
||||
if not delivery.cost or delivery.cost <= 0:
|
||||
order.reset_delivery_cost()
|
||||
|
||||
# Пересчитываем итоговую стоимость
|
||||
order.calculate_total()
|
||||
@@ -233,25 +267,59 @@ def order_update(request, order_number):
|
||||
# Если покупатель является получателем
|
||||
order.recipient = None
|
||||
|
||||
# Обрабатываем адрес доставки
|
||||
if order.is_delivery:
|
||||
address = AddressService.process_address_from_form(order, form.cleaned_data)
|
||||
if address:
|
||||
# Если адрес не существует в БД, сохраняем его
|
||||
if not address.pk:
|
||||
address.save()
|
||||
order.delivery_address = address
|
||||
else:
|
||||
# Если режим "без адреса", очищаем адрес
|
||||
order.delivery_address = None
|
||||
else:
|
||||
# Если не доставка, очищаем адрес
|
||||
order.delivery_address = None
|
||||
|
||||
order.modified_by = request.user
|
||||
order.save()
|
||||
formset.save()
|
||||
|
||||
# Проверяем, является ли заказ черновиком
|
||||
is_draft = order.status and order.status.code == 'draft'
|
||||
|
||||
# Создаем или обновляем Delivery (обязательно, кроме черновиков)
|
||||
if not is_draft:
|
||||
# Получаем данные из формы (уже провалидированы)
|
||||
delivery_type = form.cleaned_data.get('delivery_type')
|
||||
delivery_date = form.cleaned_data.get('delivery_date')
|
||||
time_from = form.cleaned_data.get('time_from')
|
||||
time_to = form.cleaned_data.get('time_to')
|
||||
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
|
||||
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
|
||||
|
||||
# Проверяем наличие обязательных полей
|
||||
if not all([delivery_type, delivery_date, time_from, time_to]):
|
||||
raise ValidationError('Необходимо заполнить все поля доставки')
|
||||
|
||||
# Обрабатываем адрес для курьерской доставки
|
||||
address = None
|
||||
|
||||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
||||
# Для курьерской доставки нужен адрес
|
||||
address = AddressService.process_address_from_form(order, form.cleaned_data)
|
||||
if not address:
|
||||
raise ValidationError('Для курьерской доставки необходимо указать адрес')
|
||||
if not address.pk:
|
||||
address.save()
|
||||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
||||
# Для самовывоза нужен склад
|
||||
if not pickup_warehouse:
|
||||
raise ValidationError('Для самовывоза необходимо выбрать склад')
|
||||
|
||||
# Создаем или обновляем Delivery
|
||||
delivery, created = Delivery.objects.update_or_create(
|
||||
order=order,
|
||||
defaults={
|
||||
'delivery_type': delivery_type,
|
||||
'delivery_date': delivery_date,
|
||||
'time_from': time_from,
|
||||
'time_to': time_to,
|
||||
'address': address,
|
||||
'pickup_warehouse': pickup_warehouse,
|
||||
'cost': delivery_cost if delivery_cost else Decimal('0')
|
||||
}
|
||||
)
|
||||
elif hasattr(order, 'delivery'):
|
||||
# Если заказ стал черновиком, удаляем Delivery
|
||||
order.delivery.delete()
|
||||
|
||||
# Пересчитываем итоговую стоимость
|
||||
order.calculate_total()
|
||||
order.update_payment_status()
|
||||
|
||||
@@ -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 products.models.photos
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -10,6 +11,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0001_initial'),
|
||||
('orders', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
@@ -65,6 +67,67 @@ class Migration(migrations.Migration):
|
||||
'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(
|
||||
name='PhotoProcessingStatus',
|
||||
fields=[
|
||||
@@ -102,7 +165,7 @@ class Migration(migrations.Migration):
|
||||
('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='Суффикс варианта')),
|
||||
('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='Основная цена')),
|
||||
('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='В наличии')),
|
||||
@@ -133,6 +196,25 @@ class Migration(migrations.Migration):
|
||||
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='Конкретный товар'),
|
||||
),
|
||||
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(
|
||||
name='ProductCategory',
|
||||
fields=[
|
||||
@@ -164,7 +246,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveIntegerField(default=0, 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_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='Категория')),
|
||||
@@ -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='Архивировано пользователем')),
|
||||
('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='Заказ')),
|
||||
('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={
|
||||
'verbose_name': 'Комплект',
|
||||
@@ -208,13 +291,23 @@ class Migration(migrations.Migration):
|
||||
name='kit',
|
||||
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(
|
||||
name='ProductKitPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveIntegerField(default=0, 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_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='Комплект')),
|
||||
@@ -231,7 +324,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveIntegerField(default=0, 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_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='Товар')),
|
||||
@@ -292,10 +385,30 @@ class Migration(migrations.Migration):
|
||||
name='variant_group',
|
||||
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(
|
||||
name='kititempriority',
|
||||
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(
|
||||
model_name='productcategory',
|
||||
index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'),
|
||||
@@ -324,6 +437,38 @@ class Migration(migrations.Migration):
|
||||
model_name='productcategoryphoto',
|
||||
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(
|
||||
model_name='productkitphoto',
|
||||
index=models.Index(fields=['quality_level'], name='products_pr_quality_b03c5c_idx'),
|
||||
@@ -356,6 +501,10 @@ class Migration(migrations.Migration):
|
||||
model_name='productkit',
|
||||
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(
|
||||
model_name='productkit',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_temporary', False), ('status', 'active')), fields=('name',), name='unique_active_kit_name'),
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Родитель (вариативный товар)'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Атрибуты варианта'),
|
||||
),
|
||||
]
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Себестоимость'),
|
||||
),
|
||||
]
|
||||
@@ -17,58 +17,61 @@
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if user.is_authenticated %}
|
||||
<!-- 📦 Товары -->
|
||||
<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>
|
||||
<ul class="dropdown-menu" aria-labelledby="productsDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'products:all-products' %}">Все товары</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:catalog' %}"><i class="bi bi-grid-3x3-gap"></i> Каталог</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:configurablekit-list' %}">Вариативные товары</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:tag-list' %}">Теги</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:variantgroup-list' %}">Варианты (группы)</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% comment %}Показываем меню tenant приложений только если мы не на странице setup-password (public схема){% endcomment %}
|
||||
{% if 'setup-password' not in request.path %}
|
||||
<!-- 📦 Товары -->
|
||||
<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>
|
||||
<ul class="dropdown-menu" aria-labelledby="productsDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'products:all-products' %}">Все товары</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:catalog' %}"><i class="bi bi-grid-3x3-gap"></i> Каталог</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:configurablekit-list' %}">Вариативные товары</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:tag-list' %}">Теги</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:variantgroup-list' %}">Варианты (группы)</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- 📋 Заказы -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'orders' %}active{% endif %}" href="{% url 'orders:order-list' %}" id="ordersDropdown">
|
||||
📋 Заказы
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="ordersDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'orders:order-list' %}">Список заказов</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'orders:status_list' %}">Статусы заказов</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- 📋 Заказы -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'orders' %}active{% endif %}" href="{% url 'orders:order-list' %}" id="ordersDropdown">
|
||||
📋 Заказы
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="ordersDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'orders:order-list' %}">Список заказов</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'orders:status_list' %}">Статусы заказов</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- 👥 Клиенты -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">
|
||||
👥 Клиенты
|
||||
</a>
|
||||
</li>
|
||||
<!-- 👥 Клиенты -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">
|
||||
👥 Клиенты
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- 📦 Склад -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'inventory' %}active{% endif %}" href="#" id="inventoryDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
🏭 Склад
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="inventoryDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:inventory-home' %}">Управление складом</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:showcase-list' %}">Витрины</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- 📦 Склад -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'inventory' %}active{% endif %}" href="#" id="inventoryDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
🏭 Склад
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="inventoryDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:inventory-home' %}">Управление складом</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:showcase-list' %}">Витрины</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- 💰 Касса -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'pos' %}active{% endif %}" href="{% url 'pos:terminal' %}">
|
||||
💰 Касса
|
||||
</a>
|
||||
</li>
|
||||
<!-- 💰 Касса -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'pos' %}active{% endif %}" href="{% url 'pos:terminal' %}">
|
||||
💰 Касса
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- ⚙️ Настройки (только для owner/superuser) -->
|
||||
{% if request.user.is_owner or request.user.is_superuser %}
|
||||
|
||||
@@ -289,10 +289,13 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
|
||||
logger.info(f"Тенант создан: {client.id}")
|
||||
|
||||
# Создаем домен (динамически определяется из настроек)
|
||||
# Локально: schema_name.localhost:8000
|
||||
# Локально: schema_name.localhost (без порта!)
|
||||
# Продакшен: schema_name.mix.smaa.by
|
||||
from django.conf import settings
|
||||
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}"
|
||||
logger.info(f"Создание домена: {domain_name}")
|
||||
domain = Domain.objects.create(
|
||||
|
||||
@@ -98,6 +98,9 @@ class Command(BaseCommand):
|
||||
|
||||
# Создаем домен
|
||||
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}"
|
||||
self.stdout.write(f'Создание домена: {domain_name}')
|
||||
domain = Domain.objects.create(
|
||||
|
||||
@@ -123,6 +123,9 @@ class Command(BaseCommand):
|
||||
"""Получить доменное имя"""
|
||||
while True:
|
||||
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}'
|
||||
domain = input(f'Доменное имя [{default_domain}]: ').strip().lower()
|
||||
|
||||
|
||||
@@ -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.db.models.deletion
|
||||
@@ -81,6 +81,9 @@ class Migration(migrations.Migration):
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')),
|
||||
('processed_at', models.DateTimeField(blank=True, null=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='Обработал')),
|
||||
('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')),
|
||||
],
|
||||
|
||||
@@ -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='Дата создания токена'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
from django.conf import settings
|
||||
|
||||
Reference in New Issue
Block a user