From 30ee0779631c7613d26245f30b2f4d9aa382bb0a Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 25 Dec 2025 18:27:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0=20?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D1=81=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована полная система трансформации товаров (превращение одного товара в другой). Пример: белая гипсофила → крашеная гипсофила. Особенности реализации: - Резервирование входных товаров в статусе draft - FIFO списание входных товаров при проведении - Автоматический расчёт себестоимости выходных товаров - Возможность отмены как черновиков, так и проведённых трансформаций Модели (inventory/models.py): - Transformation: документ трансформации (draft/completed/cancelled) - TransformationInput: входные товары (списание) - TransformationOutput: выходные товары (оприходование) - Добавлен статус 'converted_to_transformation' в Reservation - Добавлен тип 'transformation' в DocumentCounter Бизнес-логика (inventory/services/transformation_service.py): - TransformationService с методами CRUD - Валидация наличия товаров - Автоматическая генерация номеров документов Сигналы (inventory/signals.py): - Автоматическое резервирование входных товаров - FIFO списание при проведении - Создание партий выходных товаров - Откат операций при отмене Интерфейс без Django Admin: - Список трансформаций (list.html) - Форма создания (form.html) - Детальный просмотр с добавлением товаров (detail.html) - Интеграция с компонентом поиска товаров - 8 views для полного CRUD + проведение/отмена Миграция: - 0003_alter_documentcounter_counter_type_and_more.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- myproject/inventory/admin.py | 69 +++- myproject/inventory/forms.py | 102 +++++- ...r_documentcounter_counter_type_and_more.py | 90 +++++ myproject/inventory/models.py | 162 +++++++- .../services/transformation_service.py | 279 ++++++++++++++ myproject/inventory/signals.py | 168 ++++++++- .../inventory/templates/inventory/home.html | 26 ++ .../inventory/transformation/detail.html | 346 ++++++++++++++++++ .../inventory/transformation/form.html | 54 +++ .../inventory/transformation/list.html | 135 +++++++ myproject/inventory/urls.py | 18 + myproject/inventory/views/transformation.py | 237 ++++++++++++ 12 files changed, 1682 insertions(+), 4 deletions(-) create mode 100644 myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py create mode 100644 myproject/inventory/services/transformation_service.py create mode 100644 myproject/inventory/templates/inventory/transformation/detail.html create mode 100644 myproject/inventory/templates/inventory/transformation/form.html create mode 100644 myproject/inventory/templates/inventory/transformation/list.html create mode 100644 myproject/inventory/views/transformation.py diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py index c7a154c..0d413fc 100644 --- a/myproject/inventory/admin.py +++ b/myproject/inventory/admin.py @@ -8,7 +8,8 @@ from inventory.models import ( Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer, Inventory, InventoryLine, Reservation, Stock, StockMovement, SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem, - IncomingDocument, IncomingDocumentItem + IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, + TransformationOutput ) @@ -512,3 +513,69 @@ class IncomingDocumentItemAdmin(admin.ModelAdmin): def total_cost_display(self, obj): return f"{obj.total_cost:.2f}" total_cost_display.short_description = 'Сумма' + + +# ===== TRANSFORMATION ===== + +class TransformationInputInline(admin.TabularInline): + model = TransformationInput + extra = 1 + fields = ['product', 'quantity'] + autocomplete_fields = ['product'] + + +class TransformationOutputInline(admin.TabularInline): + model = TransformationOutput + extra = 1 + fields = ['product', 'quantity', 'stock_batch'] + autocomplete_fields = ['product'] + readonly_fields = ['stock_batch'] + + +@admin.register(Transformation) +class TransformationAdmin(admin.ModelAdmin): + list_display = ['document_number', 'warehouse', 'status_display', 'date', 'employee', 'inputs_count', 'outputs_count'] + list_filter = ['status', 'warehouse', 'date'] + search_fields = ['document_number', 'comment'] + readonly_fields = ['document_number', 'date', 'created_at', 'updated_at'] + inlines = [TransformationInputInline, TransformationOutputInline] + autocomplete_fields = ['warehouse', 'employee'] + + fieldsets = ( + ('Основная информация', { + 'fields': ('document_number', 'warehouse', 'status', 'employee') + }), + ('Детали', { + 'fields': ('comment', 'date', 'created_at', 'updated_at') + }), + ) + + def save_model(self, request, obj, form, change): + if not obj.pk: + # Генерируем номер документа при создании + from inventory.models import DocumentCounter + next_num = DocumentCounter.get_next_value('transformation') + obj.document_number = f"TR-{next_num:05d}" + obj.employee = request.user + super().save_model(request, obj, form, change) + + def status_display(self, obj): + colors = { + 'draft': '#6c757d', + 'completed': '#28a745', + 'cancelled': '#dc3545', + } + return format_html( + '{}', + colors.get(obj.status, '#6c757d'), + obj.get_status_display() + ) + status_display.short_description = 'Статус' + + def inputs_count(self, obj): + return obj.inputs.count() + inputs_count.short_description = 'Входов' + + def outputs_count(self, obj): + return obj.outputs.count() + outputs_count.short_description = 'Выходов' diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py index d5a14c3..5e7709f 100644 --- a/myproject/inventory/forms.py +++ b/myproject/inventory/forms.py @@ -6,7 +6,7 @@ from decimal import Decimal from .models import ( Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch, TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock, - IncomingDocument, IncomingDocumentItem + IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, TransformationOutput ) from products.models import Product @@ -650,3 +650,103 @@ class IncomingDocumentItemForm(forms.ModelForm): raise ValidationError('Закупочная цена не может быть отрицательной') return cost_price + +# ==================== TRANSFORMATION FORMS ==================== + +class TransformationForm(forms.ModelForm): + """Форма для создания документа трансформации""" + + class Meta: + model = Transformation + fields = ['warehouse', 'comment'] + widgets = { + 'warehouse': forms.Select(attrs={'class': 'form-select'}), + 'comment': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Комментарий (необязательно)' + }), + } + + +class TransformationInputForm(forms.Form): + """Форма для добавления входного товара в трансформацию""" + + product = forms.ModelChoiceField( + queryset=Product.objects.filter(status='active').order_by('name'), + label='Товар (что списываем)', + widget=forms.Select(attrs={ + 'class': 'form-select', + 'id': 'id_input_product' + }) + ) + quantity = forms.DecimalField( + label='Количество', + min_value=Decimal('0.001'), + max_digits=10, + decimal_places=3, + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.001', + 'placeholder': '0.000', + 'id': 'id_input_quantity' + }) + ) + + def __init__(self, *args, **kwargs): + self.transformation = kwargs.pop('transformation', None) + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + product = cleaned_data.get('product') + quantity = cleaned_data.get('quantity') + + if product and quantity: + # Проверяем что товар еще не добавлен + if self.transformation and self.transformation.inputs.filter(product=product).exists(): + raise ValidationError(f'Товар "{product.name}" уже добавлен в качестве входного') + + return cleaned_data + + +class TransformationOutputForm(forms.Form): + """Форма для добавления выходного товара в трансформацию""" + + product = forms.ModelChoiceField( + queryset=Product.objects.filter(status='active').order_by('name'), + label='Товар (что получаем)', + widget=forms.Select(attrs={ + 'class': 'form-select', + 'id': 'id_output_product' + }) + ) + quantity = forms.DecimalField( + label='Количество', + min_value=Decimal('0.001'), + max_digits=10, + decimal_places=3, + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.001', + 'placeholder': '0.000', + 'id': 'id_output_quantity' + }) + ) + + def __init__(self, *args, **kwargs): + self.transformation = kwargs.pop('transformation', None) + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + product = cleaned_data.get('product') + quantity = cleaned_data.get('quantity') + + if product and quantity: + # Проверяем что товар еще не добавлен + if self.transformation and self.transformation.outputs.filter(product=product).exists(): + raise ValidationError(f'Товар "{product.name}" уже добавлен в качестве выходного') + + return cleaned_data + diff --git a/myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py b/myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py new file mode 100644 index 0000000..796890f --- /dev/null +++ b/myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py @@ -0,0 +1,90 @@ +# Generated by Django 5.0.10 on 2025-12-25 14:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0002_initial'), + ('products', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='documentcounter', + name='counter_type', + field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация'), ('transformation', 'Трансформация товара')], max_length=20, unique=True, verbose_name='Тип счетчика'), + ), + migrations.AlterField( + model_name='reservation', + name='status', + field=models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание'), ('converted_to_transformation', 'Преобразован в трансформацию')], default='reserved', max_length=30, verbose_name='Статус'), + ), + migrations.CreateModel( + name='Transformation', + 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', 'Черновик'), ('completed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('comment', models.TextField(blank=True, verbose_name='Комментарий')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')), + ('employee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformations', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformations', to='inventory.warehouse', verbose_name='Склад')), + ], + options={ + 'verbose_name': 'Трансформация товара', + 'verbose_name_plural': 'Трансформации товаров', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='TransformationInput', + 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='Количество')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_inputs', to='products.product', verbose_name='Товар')), + ('transformation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inputs', to='inventory.transformation', verbose_name='Трансформация')), + ], + options={ + 'verbose_name': 'Входной товар трансформации', + 'verbose_name_plural': 'Входные товары трансформации', + }, + ), + migrations.AddField( + model_name='reservation', + name='transformation_input', + field=models.ForeignKey(blank=True, help_text='Резерв для входного товара трансформации (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.transformationinput', verbose_name='Входной товар трансформации'), + ), + migrations.CreateModel( + name='TransformationOutput', + 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='Количество')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_outputs', to='products.product', verbose_name='Товар')), + ('stock_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformation_outputs', to='inventory.stockbatch', verbose_name='Созданная партия')), + ('transformation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outputs', to='inventory.transformation', verbose_name='Трансформация')), + ], + options={ + 'verbose_name': 'Выходной товар трансформации', + 'verbose_name_plural': 'Выходные товары трансформации', + }, + ), + migrations.AddIndex( + model_name='transformation', + index=models.Index(fields=['document_number'], name='inventory_t_documen_559778_idx'), + ), + migrations.AddIndex( + model_name='transformation', + index=models.Index(fields=['warehouse', 'status'], name='inventory_t_warehou_934275_idx'), + ), + migrations.AddIndex( + model_name='transformation', + index=models.Index(fields=['-date'], name='inventory_t_date_65cfab_idx'), + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index 6b7ab7e..2619389 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -487,6 +487,7 @@ class Reservation(models.Model): ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание'), + ('converted_to_transformation', 'Преобразован в трансформацию'), ] order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE, @@ -505,7 +506,7 @@ class Reservation(models.Model): warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, related_name='reservations', verbose_name="Склад") quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") - status = models.CharField(max_length=25, choices=STATUS_CHOICES, + status = models.CharField(max_length=30, choices=STATUS_CHOICES, default='reserved', verbose_name="Статус") reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования") released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения") @@ -556,6 +557,17 @@ class Reservation(models.Model): help_text="Резерв для документа списания (черновик)" ) + # Связь с входным товаром трансформации (для резервирования в черновике) + transformation_input = models.ForeignKey( + 'TransformationInput', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='reservations', + verbose_name="Входной товар трансформации", + help_text="Резерв для входного товара трансформации (черновик)" + ) + class Meta: verbose_name = "Резервирование" verbose_name_plural = "Резервирования" @@ -831,6 +843,7 @@ class DocumentCounter(models.Model): ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация'), + ('transformation', 'Трансформация товара'), ] counter_type = models.CharField( @@ -1392,3 +1405,150 @@ class IncomingDocumentItem(models.Model): def total_cost(self): """Себестоимость позиции (quantity * cost_price)""" return self.quantity * self.cost_price + + +class Transformation(models.Model): + """ + Документ трансформации товара (превращение одного товара в другой). + + Пример: белая гипсофила → крашеная гипсофила + """ + STATUS_CHOICES = [ + ('draft', 'Черновик'), + ('completed', 'Проведён'), + ('cancelled', 'Отменён'), + ] + + document_number = models.CharField( + max_length=100, + unique=True, + db_index=True, + verbose_name="Номер документа" + ) + + warehouse = models.ForeignKey( + Warehouse, + on_delete=models.PROTECT, + related_name='transformations', + verbose_name="Склад" + ) + + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='draft', + db_index=True, + verbose_name="Статус" + ) + + date = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания" + ) + + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transformations', + verbose_name="Сотрудник" + ) + + comment = models.TextField( + blank=True, + verbose_name="Комментарий" + ) + + 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 = ['-date'] + indexes = [ + models.Index(fields=['document_number']), + models.Index(fields=['warehouse', 'status']), + models.Index(fields=['-date']), + ] + + def __str__(self): + return f"{self.document_number} ({self.get_status_display()})" + + +class TransformationInput(models.Model): + """ + Входной товар трансформации (что списываем). + """ + transformation = models.ForeignKey( + Transformation, + on_delete=models.CASCADE, + related_name='inputs', + verbose_name="Трансформация" + ) + + product = models.ForeignKey( + Product, + on_delete=models.PROTECT, + related_name='transformation_inputs', + verbose_name="Товар" + ) + + quantity = models.DecimalField( + max_digits=10, + decimal_places=3, + verbose_name="Количество" + ) + + # Резерв (создается автоматически при draft) + # Связь через Reservation.transformation_input + + class Meta: + verbose_name = "Входной товар трансформации" + verbose_name_plural = "Входные товары трансформации" + + def __str__(self): + return f"{self.product.name}: {self.quantity}" + + +class TransformationOutput(models.Model): + """ + Выходной товар трансформации (что получаем). + """ + transformation = models.ForeignKey( + Transformation, + on_delete=models.CASCADE, + related_name='outputs', + verbose_name="Трансформация" + ) + + product = models.ForeignKey( + Product, + on_delete=models.PROTECT, + related_name='transformation_outputs', + verbose_name="Товар" + ) + + quantity = models.DecimalField( + max_digits=10, + decimal_places=3, + verbose_name="Количество" + ) + + # Ссылка на созданную партию (после проведения) + stock_batch = models.ForeignKey( + StockBatch, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transformation_outputs', + verbose_name="Созданная партия" + ) + + class Meta: + verbose_name = "Выходной товар трансформации" + verbose_name_plural = "Выходные товары трансформации" + + def __str__(self): + return f"{self.product.name}: {self.quantity}" diff --git a/myproject/inventory/services/transformation_service.py b/myproject/inventory/services/transformation_service.py new file mode 100644 index 0000000..6e1e90d --- /dev/null +++ b/myproject/inventory/services/transformation_service.py @@ -0,0 +1,279 @@ +""" +Сервис для работы с трансформациями товаров (Transformation). + +Обеспечивает: +- Создание документов трансформации с автонумерацией +- Добавление входных/выходных товаров с автоматическим резервированием +- Проведение трансформации (FIFO списание + оприходование) +- Отмену трансформации (откат операций) +""" + +from decimal import Decimal +from django.db import transaction +from django.utils import timezone +from django.core.exceptions import ValidationError + +from inventory.models import ( + Transformation, TransformationInput, TransformationOutput, + Reservation, Stock, StockBatch, DocumentCounter +) +from inventory.services.batch_manager import StockBatchManager + + +class TransformationService: + """ + Сервис для работы с трансформациями товаров. + """ + + @classmethod + def generate_document_number(cls): + """Генерация номера документа трансформации""" + next_num = DocumentCounter.get_next_value('transformation') + return f"TR-{next_num:05d}" + + @classmethod + @transaction.atomic + def create_transformation(cls, warehouse, comment=None, employee=None): + """ + Создать новый документ трансформации (черновик). + + Args: + warehouse: объект Warehouse + comment: комментарий (str, опционально) + employee: сотрудник (User, опционально) + + Returns: + Transformation + """ + transformation = Transformation.objects.create( + document_number=cls.generate_document_number(), + warehouse=warehouse, + status='draft', + comment=comment or '', + employee=employee + ) + return transformation + + @classmethod + @transaction.atomic + def add_input(cls, transformation, product, quantity): + """ + Добавить входной товар в трансформацию. + Автоматически создает резерв (через сигнал). + + Args: + transformation: Transformation + product: Product + quantity: Decimal - количество для списания + + Returns: + TransformationInput + + Raises: + ValidationError: если трансформация не черновик или недостаточно товара + """ + if transformation.status != 'draft': + raise ValidationError( + "Нельзя добавлять позиции в проведённую или отменённую трансформацию" + ) + + quantity = Decimal(str(quantity)) + if quantity <= 0: + raise ValidationError("Количество должно быть больше нуля") + + # Проверяем что товар еще не добавлен + if transformation.inputs.filter(product=product).exists(): + raise ValidationError( + f"Товар '{product.name}' уже добавлен в качестве входного" + ) + + # Проверяем доступное количество + stock = Stock.objects.filter( + product=product, + warehouse=transformation.warehouse + ).first() + + if not stock: + raise ValidationError( + f"Товар '{product.name}' отсутствует на складе '{transformation.warehouse.name}'" + ) + + # quantity_free = quantity_available - quantity_reserved + available = stock.quantity_available - stock.quantity_reserved + if quantity > available: + raise ValidationError( + f"Недостаточно свободного товара '{product.name}'. " + f"Доступно: {available}, запрашивается: {quantity}" + ) + + # Создаем входной товар (резерв создастся автоматически через сигнал) + trans_input = TransformationInput.objects.create( + transformation=transformation, + product=product, + quantity=quantity + ) + + return trans_input + + @classmethod + @transaction.atomic + def add_output(cls, transformation, product, quantity): + """ + Добавить выходной товар в трансформацию. + + Args: + transformation: Transformation + product: Product + quantity: Decimal - количество получаемого товара + + Returns: + TransformationOutput + + Raises: + ValidationError: если трансформация не черновик + """ + if transformation.status != 'draft': + raise ValidationError( + "Нельзя добавлять позиции в проведённую или отменённую трансформацию" + ) + + quantity = Decimal(str(quantity)) + if quantity <= 0: + raise ValidationError("Количество должно быть больше нуля") + + # Проверяем что товар еще не добавлен + if transformation.outputs.filter(product=product).exists(): + raise ValidationError( + f"Товар '{product.name}' уже добавлен в качестве выходного" + ) + + # Создаем выходной товар + trans_output = TransformationOutput.objects.create( + transformation=transformation, + product=product, + quantity=quantity + ) + + return trans_output + + @classmethod + @transaction.atomic + def remove_input(cls, trans_input): + """ + Удалить входной товар из трансформации. + Автоматически освобождает резерв (через сигнал). + + Args: + trans_input: TransformationInput + + Raises: + ValidationError: если трансформация не черновик + """ + if trans_input.transformation.status != 'draft': + raise ValidationError( + "Нельзя удалять позиции из проведённой или отменённой трансформации" + ) + + trans_input.delete() + + @classmethod + @transaction.atomic + def remove_output(cls, trans_output): + """ + Удалить выходной товар из трансформации. + + Args: + trans_output: TransformationOutput + + Raises: + ValidationError: если трансформация не черновик + """ + if trans_output.transformation.status != 'draft': + raise ValidationError( + "Нельзя удалять позиции из проведённой или отменённой трансформации" + ) + + trans_output.delete() + + @classmethod + @transaction.atomic + def confirm(cls, transformation): + """ + Провести трансформацию (completed). + FIFO списание входных товаров и оприходование выходных. + Выполняется через сигнал process_transformation_on_complete. + + Args: + transformation: Transformation + + Raises: + ValidationError: если документ не готов к проведению + """ + if transformation.status != 'draft': + raise ValidationError("Документ уже проведён или отменён") + + if not transformation.inputs.exists(): + raise ValidationError("Добавьте хотя бы один входной товар") + + if not transformation.outputs.exists(): + raise ValidationError("Добавьте хотя бы один выходной товар") + + # Проверяем наличие всех входных товаров + for trans_input in transformation.inputs.all(): + stock = Stock.objects.filter( + product=trans_input.product, + warehouse=transformation.warehouse + ).first() + + if not stock: + raise ValidationError( + f"Товар '{trans_input.product.name}' отсутствует на складе" + ) + + # Учитываем резервы (включая резерв этой трансформации) + reserved_qty = Reservation.objects.filter( + product=trans_input.product, + warehouse=transformation.warehouse, + status='reserved' + ).exclude( + transformation_input__transformation=transformation + ).aggregate(total=models.Sum('quantity'))['total'] or Decimal('0') + + available = stock.quantity_available - reserved_qty + if trans_input.quantity > available: + raise ValidationError( + f"Недостаточно свободного товара '{trans_input.product.name}'. " + f"Доступно: {available}, требуется: {trans_input.quantity}" + ) + + # Меняем статус (списание и оприходование происходит через сигнал) + transformation.status = 'completed' + transformation.save(update_fields=['status', 'updated_at']) + + return transformation + + @classmethod + @transaction.atomic + def cancel(cls, transformation): + """ + Отменить трансформацию. + Откатывает операции если была проведена (через сигнал). + + Args: + transformation: Transformation + + Raises: + ValidationError: если уже отменена + """ + if transformation.status == 'cancelled': + raise ValidationError("Трансформация уже отменена") + + # Меняем статус (откат через сигнал если было completed) + transformation.status = 'cancelled' + transformation.save(update_fields=['status', 'updated_at']) + + return transformation + + +# Импорт models для использования в методе confirm +from django.db import models diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 041d08c..ec740b5 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -13,7 +13,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError from orders.models import Order, OrderItem -from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem +from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem, Transformation, TransformationInput, TransformationOutput from inventory.services import SaleProcessor from inventory.services.batch_manager import StockBatchManager # InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view @@ -1524,3 +1524,169 @@ def release_reservation_on_writeoff_item_delete(sender, instance, **kwargs): instance.reservation.status = 'released' instance.reservation.released_at = timezone.now() instance.reservation.save(update_fields=['status', 'released_at']) + + +# ==================== TRANSFORMATION SIGNALS ==================== + +@receiver(post_save, sender=TransformationInput) +def reserve_on_transformation_input_create(sender, instance, created, **kwargs): + """ + При создании входного товара в черновике - резервируем его. + """ + # Резервируем только если трансформация в draft + if instance.transformation.status != 'draft': + return + + # Создаем или обновляем резерв + Reservation.objects.update_or_create( + transformation_input=instance, + product=instance.product, + warehouse=instance.transformation.warehouse, + defaults={ + 'quantity': instance.quantity, + 'status': 'reserved' + } + ) + + +@receiver(pre_delete, sender=TransformationInput) +def release_reservation_on_input_delete(sender, instance, **kwargs): + """ + При удалении входного товара - освобождаем резерв. + """ + Reservation.objects.filter( + transformation_input=instance + ).update(status='released', released_at=timezone.now()) + + +@receiver(post_save, sender=Transformation) +@transaction.atomic +def process_transformation_on_complete(sender, instance, created, **kwargs): + """ + При переходе в статус 'completed': + 1. FIFO списываем Input + 2. Создаем партии Output с рассчитанной себестоимостью + 3. Обновляем резервы в 'converted_to_transformation' + """ + if instance.status != 'completed': + return + + # Проверяем что уже не обработано + if instance.outputs.filter(stock_batch__isnull=False).exists(): + return # Уже проведено + + # 1. Списываем Input по FIFO + total_input_cost = Decimal('0') + + for trans_input in instance.inputs.all(): + allocations = StockBatchManager.write_off_by_fifo( + product=trans_input.product, + warehouse=instance.warehouse, + quantity_to_write_off=trans_input.quantity + ) + + # Суммируем себестоимость списанного + for batch, qty in allocations: + total_input_cost += batch.cost_price * qty + + # Обновляем резерв + Reservation.objects.filter( + transformation_input=trans_input, + status='reserved' + ).update( + status='converted_to_transformation', + converted_at=timezone.now() + ) + + # 2. Создаем партии Output + for trans_output in instance.outputs.all(): + # Рассчитываем себестоимость: сумма Input / количество Output + if trans_output.quantity > 0: + output_cost_price = total_input_cost / trans_output.quantity + else: + output_cost_price = Decimal('0') + + # Создаем партию + batch = StockBatchManager.create_batch( + product=trans_output.product, + warehouse=instance.warehouse, + quantity=trans_output.quantity, + cost_price=output_cost_price + ) + + # Сохраняем ссылку на партию + trans_output.stock_batch = batch + trans_output.save(update_fields=['stock_batch']) + + +@receiver(post_save, sender=Transformation) +@transaction.atomic +def rollback_transformation_on_cancel(sender, instance, **kwargs): + """ + При отмене проведенной трансформации: + 1. Удаляем партии Output + 2. Восстанавливаем партии Input (обратное FIFO списание) + 3. Возвращаем резервы в 'reserved' + """ + if instance.status != 'cancelled': + return + + # Проверяем что была проведена (есть партии Output) + if not instance.outputs.filter(stock_batch__isnull=False).exists(): + # Это был черновик - обрабатывается другим сигналом + return + + # 1. Удаляем партии Output + for trans_output in instance.outputs.all(): + if trans_output.stock_batch: + # Восстанавливаем количество из партии в Stock (автоматически через сигналы) + # Просто удаляем партию - остатки пересчитаются + batch = trans_output.stock_batch + batch.delete() + trans_output.stock_batch = None + trans_output.save(update_fields=['stock_batch']) + + # 2. Восстанавливаем Input партии + # УПРОЩЕНИЕ: создаем новые партии с той же себестоимостью что была + # (в идеале нужно хранить SaleBatchAllocation-подобную таблицу) + for trans_input in instance.inputs.all(): + # Получаем среднюю себестоимость товара + cost = trans_input.product.cost_price or Decimal('0') + + # Создаем восстановленную партию + StockBatchManager.create_batch( + product=trans_input.product, + warehouse=instance.warehouse, + quantity=trans_input.quantity, + cost_price=cost + ) + + # Возвращаем резерв в reserved + Reservation.objects.filter( + transformation_input=trans_input + ).update( + status='reserved', + converted_at=None + ) + + +@receiver(post_save, sender=Transformation) +def release_reservations_on_draft_cancel(sender, instance, **kwargs): + """ + При отмене черновика (draft → cancelled) - освобождаем резервы. + """ + if instance.status != 'cancelled': + return + + # Проверяем что это был черновик (нет созданных партий) + if instance.outputs.filter(stock_batch__isnull=False).exists(): + return # Это была проведенная трансформация, обрабатывается другим сигналом + + # Освобождаем все резервы + Reservation.objects.filter( + transformation_input__transformation=instance, + status='reserved' + ).update( + status='released', + released_at=timezone.now() + ) diff --git a/myproject/inventory/templates/inventory/home.html b/myproject/inventory/templates/inventory/home.html index f910ece..eea6f8e 100644 --- a/myproject/inventory/templates/inventory/home.html +++ b/myproject/inventory/templates/inventory/home.html @@ -160,6 +160,24 @@ + + + @@ -293,5 +311,13 @@ .card-body { cursor: pointer; } + +.bg-purple { + background-color: #6f42c1 !important; +} + +.text-purple { + color: #6f42c1 !important; +} {% endblock %} diff --git a/myproject/inventory/templates/inventory/transformation/detail.html b/myproject/inventory/templates/inventory/transformation/detail.html new file mode 100644 index 0000000..6180058 --- /dev/null +++ b/myproject/inventory/templates/inventory/transformation/detail.html @@ -0,0 +1,346 @@ +{% extends 'base.html' %} +{% load static %} +{% load inventory_filters %} + +{% block title %}Трансформация {{ transformation.document_number }}{% endblock %} + +{% block content %} + + + +
+ + + + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ +
+ +
+
+
+ {{ transformation.document_number }} + {% if transformation.status == 'draft' %} + Черновик + {% elif transformation.status == 'completed' %} + Проведён + {% elif transformation.status == 'cancelled' %} + Отменён + {% endif %} +
+ {% if transformation.status == 'draft' %} +
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ {% elif transformation.status == 'completed' %} +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
+
+
+
+

Склад

+

{{ transformation.warehouse.name }}

+
+
+

Дата

+

{{ transformation.date|date:"d.m.Y H:i" }}

+
+
+

Создан

+

{{ transformation.created_at|date:"d.m.Y H:i" }}

+
+
+

Сотрудник

+

{% if transformation.employee %}{{ transformation.employee.username }}{% else %}-{% endif %}

+
+
+ + {% if transformation.comment %} +
+

Комментарий

+

{{ transformation.comment }}

+
+ {% endif %} +
+
+ + + {% if transformation.status == 'draft' %} +
+
+
Добавить входной товар (что списываем)
+
+
+ +
+ {% include 'products/components/product_search_picker.html' with container_id='input-product-picker' title='Найти входной товар' warehouse_id=transformation.warehouse.id filter_in_stock_only=True categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %} +
+ + +
+ {% csrf_token %} + +
+
+ + {{ input_form.product }} + {% if input_form.product.errors %} +
{{ input_form.product.errors.0 }}
+ {% endif %} +
+ +
+ + {{ input_form.quantity }} + {% if input_form.quantity.errors %} +
{{ input_form.quantity.errors.0 }}
+ {% endif %} +
+ +
+ +
+
+
+
+
+ {% endif %} + + +
+
+
Входные товары (списание)
+
+
+
+ + + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + + + {% for input in transformation.inputs.all %} + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
ТоварКоличество
+ {{ input.product.name }} + {{ input.quantity|smart_quantity }} +
+ {% csrf_token %} + +
+
+ + Входные товары не добавлены +
+
+
+
+ + + {% if transformation.status == 'draft' %} +
+
+
Добавить выходной товар (что получаем)
+
+
+ +
+ {% include 'products/components/product_search_picker.html' with container_id='output-product-picker' title='Найти выходной товар' categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %} +
+ + +
+ {% csrf_token %} + +
+
+ + {{ output_form.product }} + {% if output_form.product.errors %} +
{{ output_form.product.errors.0 }}
+ {% endif %} +
+ +
+ + {{ output_form.quantity }} + {% if output_form.quantity.errors %} +
{{ output_form.quantity.errors.0 }}
+ {% endif %} +
+ +
+ +
+
+
+
+
+ {% endif %} + + +
+
+
Выходные товары (оприходование)
+
+
+
+ + + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + + + {% for output in transformation.outputs.all %} + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
ТоварКоличество
+ {{ output.product.name }} + {{ output.quantity|smart_quantity }} +
+ {% csrf_token %} + +
+
+ + Выходные товары не добавлены +
+
+
+
+
+
+
+ + + + + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/transformation/form.html b/myproject/inventory/templates/inventory/transformation/form.html new file mode 100644 index 0000000..639d202 --- /dev/null +++ b/myproject/inventory/templates/inventory/transformation/form.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} + +{% block title %}Создать трансформацию{% endblock %} + +{% block content %} +
+ + + +
+
+
+
+
+ Новая трансформация товара +
+
+
+
+ {% csrf_token %} + +
+ + {{ form.warehouse }} + {% if form.warehouse.errors %} +
{{ form.warehouse.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.comment }} +
+ +
+ + + Отмена + +
+
+
+
+
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/transformation/list.html b/myproject/inventory/templates/inventory/transformation/list.html new file mode 100644 index 0000000..99ec9e7 --- /dev/null +++ b/myproject/inventory/templates/inventory/transformation/list.html @@ -0,0 +1,135 @@ +{% extends 'base.html' %} + +{% block title %}Трансформации товаров{% endblock %} + +{% block content %} +
+ +
+

+ Трансформации товаров +

+ + Создать трансформацию + +
+ + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + + +
+
+
+ + + + + + + + + + + + + + + {% for transformation in transformations %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
НомерДатаСкладСтатусВходной товарВыходной товарСотрудник
+ + {{ transformation.document_number }} + + {{ transformation.date|date:"d.m.Y H:i" }}{{ transformation.warehouse.name }} + {% if transformation.status == 'draft' %} + Черновик + {% elif transformation.status == 'completed' %} + Проведён + {% elif transformation.status == 'cancelled' %} + Отменён + {% endif %} + + {% for input in transformation.inputs.all %} +
{{ input.product.name }} - {{ input.quantity }} шт
+ {% empty %} + - + {% endfor %} +
+ {% for output in transformation.outputs.all %} +
{{ output.product.name }} - {{ output.quantity }} шт
+ {% empty %} + - + {% endfor %} +
+ {% if transformation.employee %}{{ transformation.employee.username }}{% else %}-{% endif %} + + + + +
+ + Трансформаций пока нет +
+
+
+
+ + + {% if is_paginated %} + + {% endif %} +
+{% endblock %} diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py index a3d3db5..69d03fb 100644 --- a/myproject/inventory/urls.py +++ b/myproject/inventory/urls.py @@ -42,6 +42,13 @@ from .views.incoming_document import ( IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView, IncomingDocumentConfirmView, IncomingDocumentCancelView ) +# Transformation views +from .views.transformation import ( + TransformationListView, TransformationCreateView, TransformationDetailView, + TransformationAddInputView, TransformationAddOutputView, + TransformationRemoveInputView, TransformationRemoveOutputView, + TransformationConfirmView, TransformationCancelView +) # Debug views from .views.debug_views import debug_inventory_page from . import views @@ -146,6 +153,17 @@ urlpatterns = [ path('showcases//delete/', ShowcaseDeleteView.as_view(), name='showcase-delete'), path('showcases//set-default/', SetDefaultShowcaseView.as_view(), name='showcase-set-default'), + # ==================== TRANSFORMATION ==================== + path('transformations/', TransformationListView.as_view(), name='transformation-list'), + path('transformations/create/', TransformationCreateView.as_view(), name='transformation-create'), + path('transformations//', TransformationDetailView.as_view(), name='transformation-detail'), + path('transformations//add-input/', TransformationAddInputView.as_view(), name='transformation-add-input'), + path('transformations//add-output/', TransformationAddOutputView.as_view(), name='transformation-add-output'), + path('transformations//remove-input//', TransformationRemoveInputView.as_view(), name='transformation-remove-input'), + path('transformations//remove-output//', TransformationRemoveOutputView.as_view(), name='transformation-remove-output'), + path('transformations//confirm/', TransformationConfirmView.as_view(), name='transformation-confirm'), + path('transformations//cancel/', TransformationCancelView.as_view(), name='transformation-cancel'), + # ==================== DEBUG (SUPERUSER ONLY) ==================== path('debug/', debug_inventory_page, name='debug_page'), ] diff --git a/myproject/inventory/views/transformation.py b/myproject/inventory/views/transformation.py new file mode 100644 index 0000000..d4bbad9 --- /dev/null +++ b/myproject/inventory/views/transformation.py @@ -0,0 +1,237 @@ +""" +Views для работы с трансформациями товаров (Transformation). +""" + +from django.views.generic import ListView, CreateView, DetailView, View +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse +from django.shortcuts import get_object_or_404, redirect +from django.contrib import messages +from django.http import JsonResponse +from django.db import transaction +from django.core.exceptions import ValidationError + +from inventory.models import Transformation, TransformationInput, TransformationOutput +from inventory.forms import TransformationForm, TransformationInputForm, TransformationOutputForm +from inventory.services.transformation_service import TransformationService + + +class TransformationListView(LoginRequiredMixin, ListView): + """Список трансформаций товаров""" + model = Transformation + template_name = 'inventory/transformation/list.html' + context_object_name = 'transformations' + paginate_by = 20 + + def get_queryset(self): + return Transformation.objects.select_related( + 'warehouse', 'employee' + ).prefetch_related('inputs__product', 'outputs__product').order_by('-date') + + +class TransformationCreateView(LoginRequiredMixin, CreateView): + """Создание трансформации""" + model = Transformation + form_class = TransformationForm + template_name = 'inventory/transformation/form.html' + + def form_valid(self, form): + transformation = TransformationService.create_transformation( + warehouse=form.cleaned_data['warehouse'], + comment=form.cleaned_data.get('comment'), + employee=self.request.user + ) + messages.success(self.request, f'Трансформация {transformation.document_number} создана') + return redirect('inventory:transformation-detail', pk=transformation.pk) + + +class TransformationDetailView(LoginRequiredMixin, DetailView): + """Детальный просмотр трансформации""" + model = Transformation + template_name = 'inventory/transformation/detail.html' + context_object_name = 'transformation' + + def get_queryset(self): + return Transformation.objects.select_related( + 'warehouse', 'employee' + ).prefetch_related('inputs__product', 'outputs__product') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['input_form'] = TransformationInputForm(transformation=self.object) + context['output_form'] = TransformationOutputForm(transformation=self.object) + + # Добавляем категории и теги для компонента поиска товаров + from products.models import ProductCategory, ProductTag + context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name') + context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name') + + return context + + +class TransformationAddInputView(LoginRequiredMixin, View): + """Добавление входного товара в трансформацию""" + + @transaction.atomic + def post(self, request, pk): + transformation = get_object_or_404(Transformation, pk=pk) + form = TransformationInputForm(request.POST, transformation=transformation) + + if form.is_valid(): + try: + trans_input = TransformationService.add_input( + transformation=transformation, + product=form.cleaned_data['product'], + quantity=form.cleaned_data['quantity'] + ) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'item_id': trans_input.id, + 'message': f'Добавлено: {trans_input.product.name}' + }) + + messages.success(request, f'Добавлено: {trans_input.product.name}') + + except ValidationError as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'error': str(e)}, status=400) + messages.error(request, str(e)) + else: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + errors = '; '.join([f"{k}: {v[0]}" for k, v in form.errors.items()]) + return JsonResponse({'success': False, 'error': errors}, status=400) + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f'{field}: {error}') + + return redirect('inventory:transformation-detail', pk=pk) + + +class TransformationAddOutputView(LoginRequiredMixin, View): + """Добавление выходного товара в трансформацию""" + + @transaction.atomic + def post(self, request, pk): + transformation = get_object_or_404(Transformation, pk=pk) + form = TransformationOutputForm(request.POST, transformation=transformation) + + if form.is_valid(): + try: + trans_output = TransformationService.add_output( + transformation=transformation, + product=form.cleaned_data['product'], + quantity=form.cleaned_data['quantity'] + ) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'item_id': trans_output.id, + 'message': f'Добавлено: {trans_output.product.name}' + }) + + messages.success(request, f'Добавлено: {trans_output.product.name}') + + except ValidationError as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'error': str(e)}, status=400) + messages.error(request, str(e)) + else: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + errors = '; '.join([f"{k}: {v[0]}" for k, v in form.errors.items()]) + return JsonResponse({'success': False, 'error': errors}, status=400) + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f'{field}: {error}') + + return redirect('inventory:transformation-detail', pk=pk) + + +class TransformationRemoveInputView(LoginRequiredMixin, View): + """Удаление входного товара из трансформации""" + + @transaction.atomic + def post(self, request, pk, item_pk): + transformation = get_object_or_404(Transformation, pk=pk) + trans_input = get_object_or_404(TransformationInput, pk=item_pk, transformation=transformation) + + try: + product_name = trans_input.product.name + TransformationService.remove_input(trans_input) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Удалено: {product_name}' + }) + + messages.success(request, f'Удалено: {product_name}') + + except ValidationError as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'error': str(e)}, status=400) + messages.error(request, str(e)) + + return redirect('inventory:transformation-detail', pk=pk) + + +class TransformationRemoveOutputView(LoginRequiredMixin, View): + """Удаление выходного товара из трансформации""" + + @transaction.atomic + def post(self, request, pk, item_pk): + transformation = get_object_or_404(Transformation, pk=pk) + trans_output = get_object_or_404(TransformationOutput, pk=item_pk, transformation=transformation) + + try: + product_name = trans_output.product.name + TransformationService.remove_output(trans_output) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Удалено: {product_name}' + }) + + messages.success(request, f'Удалено: {product_name}') + + except ValidationError as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'error': str(e)}, status=400) + messages.error(request, str(e)) + + return redirect('inventory:transformation-detail', pk=pk) + + +class TransformationConfirmView(LoginRequiredMixin, View): + """Проведение трансформации""" + + @transaction.atomic + def post(self, request, pk): + transformation = get_object_or_404(Transformation, pk=pk) + + try: + TransformationService.confirm(transformation) + messages.success(request, f'Трансформация {transformation.document_number} проведена') + except ValidationError as e: + messages.error(request, str(e)) + + return redirect('inventory:transformation-detail', pk=pk) + + +class TransformationCancelView(LoginRequiredMixin, View): + """Отмена трансформации""" + + @transaction.atomic + def post(self, request, pk): + transformation = get_object_or_404(Transformation, pk=pk) + + try: + TransformationService.cancel(transformation) + messages.success(request, f'Трансформация {transformation.document_number} отменена') + except ValidationError as e: + messages.error(request, str(e)) + + return redirect('inventory:transformation-list')