From 375ec5366a1f923a0a586895e135c744e3a000ef Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 21 Dec 2025 00:51:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BD=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Унифицирован формат номеров документов: IN-XXXXXX (6 цифр), как WO-XXXXXX и MOVE-XXXXXX - Убрано дублирование функции _extract_number_from_document_number - Оптимизирована инициализация счетчика incoming: быстрая проверка перед полной инициализацией - Удален неиспользуемый файл utils.py (функциональность перенесена в document_generator.py) - Все функции генерации номеров используют единый подход через DocumentCounter.get_next_value() --- myproject/inventory/admin.py | 77 ++- myproject/inventory/forms.py | 94 +++- ..._counter_type_incomingdocument_and_more.py | 91 +++ myproject/inventory/models.py | 203 +++++++ .../services/incoming_document_service.py | 293 ++++++++++ .../inventory/templates/inventory/home.html | 18 + .../incoming_document_detail.html | 524 ++++++++++++++++++ .../incoming_document_form.html | 102 ++++ .../incoming_document_list.html | 114 ++++ myproject/inventory/urls.py | 16 + myproject/inventory/utils.py | 81 --- .../inventory/utils/document_generator.py | 172 +++--- myproject/inventory/views/__init__.py | 18 + .../inventory/views/incoming_document.py | 217 ++++++++ 14 files changed, 1873 insertions(+), 147 deletions(-) create mode 100644 myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py create mode 100644 myproject/inventory/services/incoming_document_service.py create mode 100644 myproject/inventory/templates/inventory/incoming_document/incoming_document_detail.html create mode 100644 myproject/inventory/templates/inventory/incoming_document/incoming_document_form.html create mode 100644 myproject/inventory/templates/inventory/incoming_document/incoming_document_list.html delete mode 100644 myproject/inventory/utils.py create mode 100644 myproject/inventory/views/incoming_document.py diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py index 9b7fc1b..0da9c34 100644 --- a/myproject/inventory/admin.py +++ b/myproject/inventory/admin.py @@ -7,7 +7,8 @@ from decimal import Decimal from inventory.models import ( Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer, Inventory, InventoryLine, Reservation, Stock, StockMovement, - SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem + SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem, + IncomingDocument, IncomingDocumentItem ) @@ -428,3 +429,77 @@ class WriteOffDocumentItemAdmin(admin.ModelAdmin): list_filter = ('reason', 'document__status', 'created_at') search_fields = ('product__name', 'document__document_number') raw_id_fields = ('product', 'document', 'reservation') + + +# ===== INCOMING DOCUMENT (документы поступления) ===== +class IncomingDocumentItemInline(admin.TabularInline): + model = IncomingDocumentItem + extra = 0 + fields = ('product', 'quantity', 'cost_price', 'notes') + raw_id_fields = ('product',) + + +@admin.register(IncomingDocument) +class IncomingDocumentAdmin(admin.ModelAdmin): + list_display = ('document_number', 'warehouse', 'status_display', 'receipt_type_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at') + list_filter = ('status', 'warehouse', 'receipt_type', 'date', 'created_at') + search_fields = ('document_number', 'warehouse__name', 'supplier_name') + date_hierarchy = 'date' + readonly_fields = ('document_number', 'created_at', 'updated_at', 'confirmed_at', 'confirmed_by') + inlines = [IncomingDocumentItemInline] + + fieldsets = ( + ('Документ', { + 'fields': ('document_number', 'warehouse', 'status', 'date', 'receipt_type', 'supplier_name', 'notes') + }), + ('Аудит', { + 'fields': ('created_by', 'created_at', 'confirmed_by', 'confirmed_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def status_display(self, obj): + colors = { + 'draft': '#ff9900', + 'confirmed': '#008000', + 'cancelled': '#ff0000', + } + return format_html( + '{}', + colors.get(obj.status, '#000000'), + obj.get_status_display() + ) + status_display.short_description = 'Статус' + + def receipt_type_display(self, obj): + colors = { + 'supplier': '#0d6efd', + 'inventory': '#0dcaf0', + 'adjustment': '#198754', + } + return format_html( + '{}', + colors.get(obj.receipt_type, '#6c757d'), + obj.get_receipt_type_display() + ) + receipt_type_display.short_description = 'Тип поступления' + + def items_count(self, obj): + return obj.items.count() + items_count.short_description = 'Позиций' + + def total_quantity_display(self, obj): + return f"{obj.total_quantity} шт" + total_quantity_display.short_description = 'Всего' + + +@admin.register(IncomingDocumentItem) +class IncomingDocumentItemAdmin(admin.ModelAdmin): + list_display = ('document', 'product', 'quantity', 'cost_price', 'total_cost_display', 'created_at') + list_filter = ('document__status', 'document__receipt_type', 'created_at') + search_fields = ('product__name', 'document__document_number') + raw_id_fields = ('product', 'document') + + def total_cost_display(self, obj): + return f"{obj.total_cost:.2f}" + total_cost_display.short_description = 'Сумма' diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py index 2b6ac4b..0e8fc39 100644 --- a/myproject/inventory/forms.py +++ b/myproject/inventory/forms.py @@ -5,7 +5,8 @@ from decimal import Decimal from .models import ( Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch, - TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock + TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock, + IncomingDocument, IncomingDocumentItem ) from products.models import Product @@ -557,3 +558,94 @@ class WriteOffDocumentItemForm(forms.ModelForm): return cleaned_data + +class IncomingDocumentForm(forms.ModelForm): + """ + Форма создания/редактирования документа поступления. + """ + class Meta: + model = IncomingDocument + fields = ['warehouse', 'date', 'receipt_type', 'supplier_name', 'notes'] + widgets = { + 'warehouse': forms.Select(attrs={'class': 'form-control'}), + 'date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), + 'receipt_type': forms.Select(attrs={'class': 'form-control'}), + 'supplier_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Наименование поставщика'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к документу'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['warehouse'].queryset = Warehouse.objects.filter(is_active=True) + + # Устанавливаем дату по умолчанию - сегодня + if not self.initial.get('date'): + from django.utils import timezone + self.initial['date'] = timezone.now().date() + + # Если есть склад по умолчанию - предвыбираем его + if not self.initial.get('warehouse'): + default_warehouse = Warehouse.objects.filter( + is_active=True, + is_default=True + ).first() + if default_warehouse: + self.initial['warehouse'] = default_warehouse.id + + # Устанавливаем тип поступления по умолчанию + if not self.initial.get('receipt_type'): + self.initial['receipt_type'] = 'supplier' + + def clean(self): + cleaned_data = super().clean() + receipt_type = cleaned_data.get('receipt_type') + supplier_name = cleaned_data.get('supplier_name') + + # Для типа 'supplier' supplier_name обязателен + if receipt_type == 'supplier' and not supplier_name: + raise ValidationError({ + 'supplier_name': 'Для типа "Поступление от поставщика" необходимо указать наименование поставщика' + }) + + # Для других типов supplier_name не нужен + if receipt_type != 'supplier' and supplier_name: + cleaned_data['supplier_name'] = None + + return cleaned_data + + +class IncomingDocumentItemForm(forms.ModelForm): + """ + Форма добавления/редактирования позиции в документ поступления. + """ + class Meta: + model = IncomingDocumentItem + fields = ['product', 'quantity', 'cost_price', 'notes'] + widgets = { + 'product': forms.Select(attrs={'class': 'form-control'}), + 'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0.001'}), + 'cost_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Примечания'}), + } + + def __init__(self, *args, document=None, **kwargs): + super().__init__(*args, **kwargs) + self.document = document + + # Для поступлений можно выбрать любой активный товар (не нужно проверять наличие на складе) + self.fields['product'].queryset = Product.objects.filter( + status='active' + ).order_by('name') + + def clean_quantity(self): + quantity = self.cleaned_data.get('quantity') + if quantity and quantity <= 0: + raise ValidationError('Количество должно быть больше нуля') + return quantity + + def clean_cost_price(self): + cost_price = self.cleaned_data.get('cost_price') + if cost_price is not None and cost_price < 0: + raise ValidationError('Закупочная цена не может быть отрицательной') + return cost_price + diff --git a/myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py b/myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py new file mode 100644 index 0000000..9df6be2 --- /dev/null +++ b/myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py @@ -0,0 +1,91 @@ +# 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'), + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index c7d6bed..eb67ea5 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -761,6 +761,7 @@ class DocumentCounter(models.Model): COUNTER_TYPE_CHOICES = [ ('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), + ('incoming', 'Поступление товара'), ] counter_type = models.CharField( @@ -1100,3 +1101,205 @@ class WriteOffDocumentItem(models.Model): def total_cost(self): """Себестоимость позиции (средневзвешенная из cost_price товара)""" return self.quantity * (self.product.cost_price or Decimal('0')) + + +class IncomingDocument(models.Model): + """ + Документ поступления товара на склад. + + Сценарий использования: + 1. Создается черновик (draft) + 2. В течение дня добавляются товары (IncomingDocumentItem) + 3. В конце смены документ проводится (confirmed) → создаются IncomingBatch и Incoming + 4. Сигнал автоматически создает StockBatch и обновляет Stock + """ + STATUS_CHOICES = [ + ('draft', 'Черновик'), + ('confirmed', 'Проведён'), + ('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='incoming_documents', + verbose_name="Склад" + ) + + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='draft', + db_index=True, + verbose_name="Статус" + ) + + date = models.DateField( + verbose_name="Дата документа", + help_text="Дата, к которой относится поступление" + ) + + receipt_type = models.CharField( + max_length=20, + choices=IncomingBatch.RECEIPT_TYPE_CHOICES, + default='supplier', + db_index=True, + verbose_name="Тип поступления" + ) + + supplier_name = models.CharField( + max_length=200, + blank=True, + null=True, + verbose_name="Наименование поставщика", + help_text="Заполняется для типа 'Поступление от поставщика'" + ) + + notes = models.TextField( + blank=True, + null=True, + verbose_name="Примечания" + ) + + # Аудит + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_incoming_documents', + verbose_name="Создал" + ) + + confirmed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='confirmed_incoming_documents', + verbose_name="Провёл" + ) + + confirmed_at = models.DateTimeField( + null=True, + 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', '-created_at'] + indexes = [ + models.Index(fields=['document_number']), + models.Index(fields=['warehouse', 'status']), + models.Index(fields=['date']), + models.Index(fields=['receipt_type']), + models.Index(fields=['-created_at']), + ] + + def __str__(self): + return f"{self.document_number} ({self.get_status_display()})" + + @property + def total_quantity(self): + """Общее количество товаров в документе""" + return self.items.aggregate( + total=models.Sum('quantity') + )['total'] or Decimal('0') + + @property + def total_cost(self): + """Общая себестоимость поступления""" + return sum(item.total_cost for item in self.items.select_related('product')) + + @property + def can_edit(self): + """Можно ли редактировать документ""" + return self.status == 'draft' + + @property + def can_confirm(self): + """Можно ли провести документ""" + return self.status == 'draft' and self.items.exists() + + @property + def can_cancel(self): + """Можно ли отменить документ""" + return self.status == 'draft' + + +class IncomingDocumentItem(models.Model): + """ + Строка документа поступления. + + При создании: + - Товар добавляется в черновик документа + - Резервирование НЕ создается (товар еще не поступил) + + При проведении документа: + 1. Создается IncomingBatch с номером документа + 2. Создается Incoming запись для каждого товара + 3. Сигнал create_stock_batch_on_incoming автоматически создает StockBatch + """ + document = models.ForeignKey( + IncomingDocument, + on_delete=models.CASCADE, + related_name='items', + verbose_name="Документ" + ) + + product = models.ForeignKey( + Product, + on_delete=models.PROTECT, + related_name='incoming_document_items', + verbose_name="Товар" + ) + + quantity = models.DecimalField( + max_digits=10, + decimal_places=3, + verbose_name="Количество" + ) + + cost_price = models.DecimalField( + max_digits=10, + decimal_places=2, + 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="Обновлён") + + class Meta: + verbose_name = "Позиция документа поступления" + verbose_name_plural = "Позиции документа поступления" + ordering = ['id'] + indexes = [ + models.Index(fields=['document']), + models.Index(fields=['product']), + ] + + def __str__(self): + return f"{self.product.name}: {self.quantity} шт @ {self.cost_price}" + + @property + def total_cost(self): + """Себестоимость позиции (quantity * cost_price)""" + return self.quantity * self.cost_price diff --git a/myproject/inventory/services/incoming_document_service.py b/myproject/inventory/services/incoming_document_service.py new file mode 100644 index 0000000..4ac7bf8 --- /dev/null +++ b/myproject/inventory/services/incoming_document_service.py @@ -0,0 +1,293 @@ +""" +Сервис для работы с документами поступления (IncomingDocument). + +Обеспечивает: +- Создание документов с автонумерацией +- Добавление позиций в черновик +- Проведение документов (создание IncomingBatch и Incoming) +- Отмену документов +""" + +from decimal import Decimal +from django.db import transaction +from django.utils import timezone +from django.core.exceptions import ValidationError + +from inventory.models import ( + IncomingDocument, IncomingDocumentItem, IncomingBatch, Incoming +) +from inventory.utils.document_generator import generate_incoming_document_number + + +class IncomingDocumentService: + """ + Сервис для работы с документами поступления. + """ + + @classmethod + @transaction.atomic + def create_document(cls, warehouse, date, receipt_type='supplier', supplier_name=None, notes=None, created_by=None): + """ + Создать новый документ поступления (черновик). + + Args: + warehouse: объект Warehouse + date: дата документа (date) + receipt_type: тип поступления ('supplier', 'inventory', 'adjustment') + supplier_name: наименование поставщика (str, опционально, для типа 'supplier') + notes: примечания (str, опционально) + created_by: пользователь (User, опционально) + + Returns: + IncomingDocument + """ + document = IncomingDocument.objects.create( + document_number=generate_incoming_document_number(), + warehouse=warehouse, + status='draft', + date=date, + receipt_type=receipt_type, + supplier_name=supplier_name if receipt_type == 'supplier' else None, + notes=notes, + created_by=created_by + ) + return document + + @classmethod + @transaction.atomic + def add_item(cls, document, product, quantity, cost_price, notes=None): + """ + Добавить позицию в документ поступления. + + Args: + document: IncomingDocument + product: Product + quantity: Decimal - количество товара + cost_price: Decimal - закупочная цена + notes: str - примечания + + Returns: + IncomingDocumentItem + + Raises: + ValidationError: если документ не черновик + """ + if document.status != 'draft': + raise ValidationError( + "Нельзя добавлять позиции в проведённый или отменённый документ" + ) + + quantity = Decimal(str(quantity)) + if quantity <= 0: + raise ValidationError("Количество должно быть больше нуля") + + cost_price = Decimal(str(cost_price)) + if cost_price < 0: + raise ValidationError("Закупочная цена не может быть отрицательной") + + # Создаем позицию документа + item = IncomingDocumentItem.objects.create( + document=document, + product=product, + quantity=quantity, + cost_price=cost_price, + notes=notes + ) + + return item + + @classmethod + @transaction.atomic + def update_item(cls, item, quantity=None, cost_price=None, notes=None): + """ + Обновить позицию документа. + + Args: + item: IncomingDocumentItem + quantity: новое количество (опционально) + cost_price: новая закупочная цена (опционально) + notes: новые примечания (опционально) + + Returns: + IncomingDocumentItem + + Raises: + ValidationError: если документ не черновик + """ + if item.document.status != 'draft': + raise ValidationError( + "Нельзя редактировать позиции проведённого или отменённого документа" + ) + + if quantity is not None: + quantity = Decimal(str(quantity)) + if quantity <= 0: + raise ValidationError("Количество должно быть больше нуля") + item.quantity = quantity + + if cost_price is not None: + cost_price = Decimal(str(cost_price)) + if cost_price < 0: + raise ValidationError("Закупочная цена не может быть отрицательной") + item.cost_price = cost_price + + if notes is not None: + item.notes = notes + + item.save() + return item + + @classmethod + @transaction.atomic + def remove_item(cls, item): + """ + Удалить позицию из документа. + + Args: + item: IncomingDocumentItem + + Raises: + ValidationError: если документ не черновик + """ + if item.document.status != 'draft': + raise ValidationError( + "Нельзя удалять позиции из проведённого или отменённого документа" + ) + + # Удаляем позицию + item.delete() + + @classmethod + @transaction.atomic + def confirm_document(cls, document, confirmed_by=None): + """ + Провести документ поступления. + + Процесс: + 1. Проверяем что документ - черновик и имеет позиции + 2. Создаем IncomingBatch с номером документа + 3. Для каждой позиции создаем Incoming запись + 4. Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch + 5. Меняем статус документа на 'confirmed' + + Args: + document: IncomingDocument + confirmed_by: User - кто проводит документ + + Returns: + dict: результат проведения + + Raises: + ValidationError: если документ нельзя провести + """ + if document.status != 'draft': + raise ValidationError( + f"Документ уже проведён или отменён (статус: {document.get_status_display()})" + ) + + if not document.items.exists(): + raise ValidationError("Нельзя провести пустой документ") + + # Создаем IncomingBatch + incoming_batch = IncomingBatch.objects.create( + warehouse=document.warehouse, + document_number=document.document_number, + receipt_type=document.receipt_type, + supplier_name=document.supplier_name if document.receipt_type == 'supplier' else '', + notes=document.notes + ) + + # Создаем Incoming записи для каждого товара + incomings_created = [] + total_cost = Decimal('0') + + for item in document.items.select_related('product'): + incoming = Incoming.objects.create( + batch=incoming_batch, + product=item.product, + quantity=item.quantity, + cost_price=item.cost_price, + notes=item.notes + ) + incomings_created.append(incoming) + total_cost += item.total_cost + + # Обновляем статус документа + document.status = 'confirmed' + document.confirmed_by = confirmed_by + document.confirmed_at = timezone.now() + document.save(update_fields=['status', 'confirmed_by', 'confirmed_at', 'updated_at']) + + return { + 'document': document, + 'incoming_batch': incoming_batch, + 'incomings_created': len(incomings_created), + 'total_quantity': document.total_quantity, + 'total_cost': total_cost + } + + @classmethod + @transaction.atomic + def cancel_document(cls, document): + """ + Отменить документ поступления (черновик). + + Args: + document: IncomingDocument + + Returns: + IncomingDocument + + Raises: + ValidationError: если документ уже проведён + """ + if document.status == 'confirmed': + raise ValidationError( + "Нельзя отменить проведённый документ. " + "Создайте новый документ для корректировки." + ) + + if document.status == 'cancelled': + raise ValidationError("Документ уже отменён") + + # Обновляем статус документа + document.status = 'cancelled' + document.save(update_fields=['status', 'updated_at']) + + return document + + @staticmethod + def get_draft_documents(warehouse=None): + """ + Получить все черновики документов поступления. + + Args: + warehouse: фильтр по складу (опционально) + + Returns: + QuerySet[IncomingDocument] + """ + qs = IncomingDocument.objects.filter(status='draft') + if warehouse: + qs = qs.filter(warehouse=warehouse) + return qs.select_related('warehouse', 'created_by').prefetch_related('items') + + @staticmethod + def get_today_drafts(warehouse): + """ + Получить черновики за сегодня для склада. + + Args: + warehouse: Warehouse + + Returns: + QuerySet[IncomingDocument] + """ + today = timezone.now().date() + + return IncomingDocument.objects.filter( + warehouse=warehouse, + status='draft', + date=today + ).select_related('warehouse', 'created_by') + diff --git a/myproject/inventory/templates/inventory/home.html b/myproject/inventory/templates/inventory/home.html index dd6271b..f910ece 100644 --- a/myproject/inventory/templates/inventory/home.html +++ b/myproject/inventory/templates/inventory/home.html @@ -125,6 +125,24 @@ + +
+ +
+
+
+ +
+
+
Документы поступления
+ Коллективное поступление +
+ +
+
+
+
+
diff --git a/myproject/inventory/templates/inventory/incoming_document/incoming_document_detail.html b/myproject/inventory/templates/inventory/incoming_document/incoming_document_detail.html new file mode 100644 index 0000000..d8563f7 --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming_document/incoming_document_detail.html @@ -0,0 +1,524 @@ +{% extends 'base.html' %} +{% load static %} +{% load inventory_filters %} + +{% block title %}Документ поступления {{ document.document_number }}{% endblock %} + +{% block content %} + + + +
+ + + + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ +
+ +
+
+
+ {{ document.document_number }} + {% if document.status == 'draft' %} + Черновик + {% elif document.status == 'confirmed' %} + Проведён + {% elif document.status == 'cancelled' %} + Отменён + {% endif %} +
+ {% if document.can_edit %} +
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
+
+
+
+

Склад

+

{{ document.warehouse.name }}

+
+
+

Дата документа

+

{{ document.date|date:"d.m.Y" }}

+
+
+

Тип поступления

+

{{ document.get_receipt_type_display }}

+
+
+

Создан

+

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

+
+
+ + {% if document.supplier_name %} +
+

Поставщик

+

{{ document.supplier_name }}

+
+ {% endif %} + + {% if document.notes %} +
+

Примечания

+

{{ document.notes }}

+
+ {% endif %} + + {% if document.confirmed_at %} +
+
+

Проведён

+

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

+
+
+

Провёл

+

{% if document.confirmed_by %}{{ document.confirmed_by.username }}{% else %}-{% endif %}

+
+
+ {% endif %} +
+
+ + + {% if document.can_edit %} +
+
+
Добавить позицию в документ
+
+
+ +
+ {% include 'products/components/product_search_picker.html' with container_id='incoming-document-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %} +
+ + + + + +
+ {% csrf_token %} + +
+
+ + {{ item_form.product }} + {% if item_form.product.errors %} +
{{ item_form.product.errors.0 }}
+ {% endif %} +
+ +
+ + {{ item_form.quantity }} + {% if item_form.quantity.errors %} +
{{ item_form.quantity.errors.0 }}
+ {% endif %} +
+ +
+ + {{ item_form.cost_price }} + {% if item_form.cost_price.errors %} +
{{ item_form.cost_price.errors.0 }}
+ {% endif %} +
+ +
+ + {{ item_form.notes }} +
+
+ +
+ + + Используйте поиск выше для быстрого выбора товара + +
+
+
+
+ {% endif %} + + +
+
+
Позиции документа
+
+
+
+ + + + + + + + + {% if document.can_edit %} + + {% endif %} + + + + {% for item in document.items.all %} + + + + + + + {% if document.can_edit %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + + {% if document.items.exists %} + + + + + + + + + {% endif %} +
ТоварКоличествоЗакупочная ценаСуммаПримечания
+ {{ item.product.name }} + + {{ item.quantity|smart_quantity }} + {% if document.can_edit %} + + {% endif %} + + {{ item.cost_price|floatformat:2 }} + {% if document.can_edit %} + + {% endif %} + + {{ item.total_cost|floatformat:2 }} + + {% if item.notes %}{{ item.notes|truncatechars:50 }}{% else %}-{% endif %} + {% if document.can_edit %} + + {% endif %} + +
+ + +
+ + +
+ + Позиций пока нет +
Итого:{{ document.total_quantity|smart_quantity }}{{ document.total_cost|floatformat:2 }}
+
+
+
+
+
+
+ + + + +{% endblock %} + diff --git a/myproject/inventory/templates/inventory/incoming_document/incoming_document_form.html b/myproject/inventory/templates/inventory/incoming_document/incoming_document_form.html new file mode 100644 index 0000000..5d83bbc --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming_document/incoming_document_form.html @@ -0,0 +1,102 @@ +{% extends 'base.html' %} + +{% block title %}Создать документ поступления{% endblock %} + +{% block content %} +
+ + + +
+
+
+
+
+ Новый документ поступления +
+
+
+
+ {% csrf_token %} + +
+ + {{ form.warehouse }} + {% if form.warehouse.errors %} +
{{ form.warehouse.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.date }} + {% if form.date.errors %} +
{{ form.date.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.receipt_type }} + {% if form.receipt_type.errors %} +
{{ form.receipt_type.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.supplier_name }} + {% if form.supplier_name.errors %} +
{{ form.supplier_name.errors.0 }}
+ {% endif %} + Заполняется для типа "Поступление от поставщика" +
+ +
+ + {{ form.notes }} +
+ +
+ + + Отмена + +
+
+
+
+
+
+
+ + +{% endblock %} + diff --git a/myproject/inventory/templates/inventory/incoming_document/incoming_document_list.html b/myproject/inventory/templates/inventory/incoming_document/incoming_document_list.html new file mode 100644 index 0000000..c576e1a --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming_document/incoming_document_list.html @@ -0,0 +1,114 @@ +{% extends 'base.html' %} + +{% block title %}Документы поступления{% endblock %} + +{% block content %} +
+ +
+

+ Документы поступления +

+ + Создать документ + +
+ + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + + +
+
+
+ + + + + + + + + + + + + + + + {% for doc in documents %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
НомерДатаСкладТипСтатусПозицийКоличествоСоздал
+ + {{ doc.document_number }} + + {{ doc.date|date:"d.m.Y" }}{{ doc.warehouse.name }} + {{ doc.get_receipt_type_display }} + + {% if doc.status == 'draft' %} + Черновик + {% elif doc.status == 'confirmed' %} + Проведён + {% elif doc.status == 'cancelled' %} + Отменён + {% endif %} + {{ doc.items.count }}{{ doc.total_quantity }} + {% if doc.created_by %}{{ doc.created_by.username }}{% else %}-{% endif %} + + + + +
+ + Документов поступления пока нет +
+
+
+
+ + + {% if page_obj.has_other_pages %} + + {% endif %} +
+{% endblock %} + diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py index 3686bae..207294d 100644 --- a/myproject/inventory/urls.py +++ b/myproject/inventory/urls.py @@ -34,6 +34,12 @@ from .views.writeoff_document import ( WriteOffDocumentAddItemView, WriteOffDocumentUpdateItemView, WriteOffDocumentRemoveItemView, WriteOffDocumentConfirmView, WriteOffDocumentCancelView ) +# Incoming Document views +from .views.incoming_document import ( + IncomingDocumentListView, IncomingDocumentCreateView, IncomingDocumentDetailView, + IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView, + IncomingDocumentConfirmView, IncomingDocumentCancelView +) # Debug views from .views.debug_views import debug_inventory_page from . import views @@ -91,6 +97,16 @@ urlpatterns = [ path('writeoff-documents//confirm/', WriteOffDocumentConfirmView.as_view(), name='writeoff-document-confirm'), path('writeoff-documents//cancel/', WriteOffDocumentCancelView.as_view(), name='writeoff-document-cancel'), + # ==================== INCOMING DOCUMENT (документы поступления) ==================== + path('incoming-documents/', IncomingDocumentListView.as_view(), name='incoming-document-list'), + path('incoming-documents/create/', IncomingDocumentCreateView.as_view(), name='incoming-document-create'), + path('incoming-documents//', IncomingDocumentDetailView.as_view(), name='incoming-document-detail'), + path('incoming-documents//add-item/', IncomingDocumentAddItemView.as_view(), name='incoming-document-add-item'), + path('incoming-documents//update-item//', IncomingDocumentUpdateItemView.as_view(), name='incoming-document-update-item'), + path('incoming-documents//remove-item//', IncomingDocumentRemoveItemView.as_view(), name='incoming-document-remove-item'), + path('incoming-documents//confirm/', IncomingDocumentConfirmView.as_view(), name='incoming-document-confirm'), + path('incoming-documents//cancel/', IncomingDocumentCancelView.as_view(), name='incoming-document-cancel'), + # ==================== TRANSFER ==================== path('transfers/', TransferListView.as_view(), name='transfer-list'), path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения diff --git a/myproject/inventory/utils.py b/myproject/inventory/utils.py deleted file mode 100644 index 2826c1f..0000000 --- a/myproject/inventory/utils.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Утилиты для модуля inventory (склад, приходы, продажи, партии). -""" -import re -import os -import logging -from datetime import datetime - - -# Настройка логирования в файл -LOG_FILE = os.path.join(os.path.dirname(__file__), 'logs', 'incoming_sequence.log') -os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) - -# Создаём файловый логгер -file_logger = logging.getLogger('incoming_sequence_file') -if not file_logger.handlers: - handler = logging.FileHandler(LOG_FILE, encoding='utf-8') - formatter = logging.Formatter( - '%(asctime)s | %(levelname)s | %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - handler.setFormatter(formatter) - file_logger.addHandler(handler) - file_logger.setLevel(logging.DEBUG) - - -def generate_incoming_document_number(): - """ - Генерирует номер документа поступления вида 'IN-XXXX-XXXX'. - - Алгоритм: - 1. Ищет максимальный номер в БД с префиксом 'IN-' - 2. Извлекает числовое значение из последней части (IN-XXXX-XXXX) - 3. Увеличивает на 1 и форматирует в 'IN-XXXX-XXXX' - - Преимущества: - - Работает без SEQUENCE (не требует миграций) - - Гарантирует уникальность через unique constraint в модели - - Простая логика, легко отладить - - Работает с любым тенантом (django-tenants совместимо) - - Возвращает: - str: Номер вида 'IN-0000-0001', 'IN-0000-0002', итд - """ - from inventory.models import IncomingBatch - import logging - - logger = logging.getLogger('inventory.incoming') - - try: - # Найти все номера с префиксом IN- - existing_batches = IncomingBatch.objects.filter( - document_number__startswith='IN-' - ).values_list('document_number', flat=True).order_by('document_number') - - if not existing_batches: - # Если нет номеров - начинаем с 1 - next_num = 1 - file_logger.info(f"✓ No existing batches found, starting from 1") - else: - # Берем последний номер, извлекаем цифру и увеличиваем - last_number = existing_batches.last() # 'IN-0000-0005' - # Извлекаем последние 4 цифры - last_digits = int(last_number.split('-')[-1]) # 5 - next_num = last_digits + 1 - file_logger.info(f"✓ Last number was {last_number}, next: {next_num}") - - # Форматируем в IN-XXXX-XXXX - combined_str = f"{next_num:08d}" # Гарантируем 8 цифр - first_part = combined_str[:4] # '0000' или '0001' - second_part = combined_str[4:] # '0001' или '0002' - - result = f"IN-{first_part}-{second_part}" - file_logger.info(f"✓ Generated: {result}") - - return result - - except Exception as e: - file_logger.error(f"✗ Error generating number: {str(e)}") - raise diff --git a/myproject/inventory/utils/document_generator.py b/myproject/inventory/utils/document_generator.py index 261e7f7..f2deb23 100644 --- a/myproject/inventory/utils/document_generator.py +++ b/myproject/inventory/utils/document_generator.py @@ -32,73 +32,117 @@ def generate_transfer_document_number(): return f"MOVE-{next_number:06d}" +def _extract_number_from_document_number(doc_number): + """ + Извлекает числовое значение из номера документа. + Поддерживает оба формата: IN-0000-0003 и IN-000002. + + Для старого формата IN-XXXX-YYYY извлекается YYYY (последние 4 цифры). + Для нового формата IN-XXXXXX извлекается XXXXXX (6 цифр). + + Args: + doc_number: строка номера документа (например, 'IN-0000-0003' или 'IN-000002') + + Returns: + int: числовое значение или 0 если не удалось распарсить + """ + try: + # Убираем префикс 'IN-' + if not doc_number.startswith('IN-'): + return 0 + + parts = doc_number[3:].split('-') # ['0000', '0003'] или ['000002'] + + if len(parts) == 2: + # Старый формат: IN-0000-0003 + # Берем последнюю часть (0003) и конвертируем в число + return int(parts[1]) + elif len(parts) == 1: + # Новый формат: IN-000002 + return int(parts[0]) + else: + return 0 + except (ValueError, IndexError): + return 0 + + +def _initialize_incoming_counter_if_needed(): + """ + Инициализирует DocumentCounter для 'incoming' максимальным номером + из существующих документов, если счетчик еще не инициализирован. + + Вызывается только если счетчик равен 0 (не инициализирован). + Thread-safe через select_for_update. + """ + from inventory.models import IncomingBatch, IncomingDocument + from django.db import transaction + + # Быстрая проверка без блокировки - если счетчик существует и > 0, выходим + if DocumentCounter.objects.filter( + counter_type='incoming', + current_value__gt=0 + ).exists(): + return + + # Только если счетчик не инициализирован - делаем полную проверку с блокировкой + with transaction.atomic(): + counter = DocumentCounter.objects.select_for_update().filter( + counter_type='incoming' + ).first() + + # Двойная проверка: возможно другой поток уже инициализировал + if counter and counter.current_value > 0: + return + + # Собираем все номера документов + all_numbers = [] + + # Номера из IncomingBatch + batch_numbers = IncomingBatch.objects.filter( + document_number__startswith='IN-' + ).values_list('document_number', flat=True) + all_numbers.extend(batch_numbers) + + # Номера из IncomingDocument + doc_numbers = IncomingDocument.objects.filter( + document_number__startswith='IN-' + ).values_list('document_number', flat=True) + all_numbers.extend(doc_numbers) + + if all_numbers: + # Извлекаем максимальный номер из всех форматов + max_number = max(_extract_number_from_document_number(num) for num in all_numbers) + else: + # Нет существующих документов - начинаем с 0 + max_number = 0 + + # Создаем или обновляем счетчик + if not counter: + DocumentCounter.objects.create( + counter_type='incoming', + current_value=max_number + ) + elif counter.current_value == 0: + counter.current_value = max_number + counter.save(update_fields=['current_value']) + + def generate_incoming_document_number(): """ - Генерирует номер документа поступления вида 'IN-XXXX-XXXX'. + Генерирует уникальный номер документа поступления. - Алгоритм: - 1. Ищет максимальный номер в БД с префиксом 'IN-' - 2. Извлекает числовое значение из последней части (IN-XXXX-XXXX) - 3. Увеличивает на 1 и форматирует в 'IN-XXXX-XXXX' + Формат: IN-XXXXXX (6 цифр) - унифицирован с WO-000001 и MOVE-000001. + Thread-safe через DocumentCounter. - Преимущества: - - Работает без SEQUENCE (не требует миграций) - - Гарантирует уникальность через unique constraint в модели - - Простая логика, легко отладить - - Работает с любым тенантом (django-tenants совместимо) + При первом использовании автоматически инициализирует DocumentCounter + максимальным номером из существующих документов (IncomingBatch и IncomingDocument). - Возвращает: - str: Номер вида 'IN-0000-0001', 'IN-0000-0002', итд + Returns: + str: Сгенерированный номер документа (например, IN-000001) """ - from inventory.models import IncomingBatch - import logging - import os - - # Настройка логирования - LOG_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs', 'incoming_sequence.log') - os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) - - file_logger = logging.getLogger('incoming_sequence_file') - if not file_logger.handlers: - handler = logging.FileHandler(LOG_FILE, encoding='utf-8') - formatter = logging.Formatter( - '%(asctime)s | %(levelname)s | %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - handler.setFormatter(formatter) - file_logger.addHandler(handler) - file_logger.setLevel(logging.DEBUG) - - logger = logging.getLogger('inventory.incoming') - - try: - # Найти все номера с префиксом IN- - existing_batches = IncomingBatch.objects.filter( - document_number__startswith='IN-' - ).values_list('document_number', flat=True).order_by('document_number') - - if not existing_batches: - # Если нет номеров - начинаем с 1 - next_num = 1 - file_logger.info(f"✓ No existing batches found, starting from 1") - else: - # Берем последний номер, извлекаем цифру и увеличиваем - last_number = existing_batches.last() # 'IN-0000-0005' - # Извлекаем последние 4 цифры - last_digits = int(last_number.split('-')[-1]) # 5 - next_num = last_digits + 1 - file_logger.info(f"✓ Last number was {last_number}, next: {next_num}") - - # Форматируем в IN-XXXX-XXXX - combined_str = f"{next_num:08d}" # Гарантируем 8 цифр - first_part = combined_str[:4] # '0000' или '0001' - second_part = combined_str[4:] # '0001' или '0002' - - result = f"IN-{first_part}-{second_part}" - file_logger.info(f"✓ Generated: {result}") - - return result - - except Exception as e: - file_logger.error(f"✗ Error generating number: {str(e)}") - raise + # Инициализируем счетчик, если нужно (только если он равен 0) + _initialize_incoming_counter_if_needed() + + # Используем стандартный метод, как и другие функции + next_number = DocumentCounter.get_next_value('incoming') + return f"IN-{next_number:06d}" diff --git a/myproject/inventory/views/__init__.py b/myproject/inventory/views/__init__.py index 7c9bff9..4aa8de5 100644 --- a/myproject/inventory/views/__init__.py +++ b/myproject/inventory/views/__init__.py @@ -27,6 +27,16 @@ from .inventory_ops import ( InventoryLineCreateBulkView ) from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView +from .writeoff_document import ( + WriteOffDocumentListView, WriteOffDocumentCreateView, WriteOffDocumentDetailView, + WriteOffDocumentAddItemView, WriteOffDocumentUpdateItemView, WriteOffDocumentRemoveItemView, + WriteOffDocumentConfirmView, WriteOffDocumentCancelView +) +from .incoming_document import ( + IncomingDocumentListView, IncomingDocumentCreateView, IncomingDocumentDetailView, + IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView, + IncomingDocumentConfirmView, IncomingDocumentCancelView +) from .transfer import TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView from .reservation import ReservationListView from .stock import StockListView, StockDetailView @@ -57,6 +67,14 @@ __all__ = [ 'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView', # WriteOff 'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView', + # WriteOffDocument + 'WriteOffDocumentListView', 'WriteOffDocumentCreateView', 'WriteOffDocumentDetailView', + 'WriteOffDocumentAddItemView', 'WriteOffDocumentUpdateItemView', 'WriteOffDocumentRemoveItemView', + 'WriteOffDocumentConfirmView', 'WriteOffDocumentCancelView', + # IncomingDocument + 'IncomingDocumentListView', 'IncomingDocumentCreateView', 'IncomingDocumentDetailView', + 'IncomingDocumentAddItemView', 'IncomingDocumentUpdateItemView', 'IncomingDocumentRemoveItemView', + 'IncomingDocumentConfirmView', 'IncomingDocumentCancelView', # Transfer 'TransferListView', 'TransferBulkCreateView', 'TransferDetailView', 'TransferDeleteView', 'GetProductStockView', # Reservation diff --git a/myproject/inventory/views/incoming_document.py b/myproject/inventory/views/incoming_document.py new file mode 100644 index 0000000..02313d4 --- /dev/null +++ b/myproject/inventory/views/incoming_document.py @@ -0,0 +1,217 @@ +""" +Views для работы с документами поступления (IncomingDocument). +""" + +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 IncomingDocument, IncomingDocumentItem +from inventory.forms import IncomingDocumentForm, IncomingDocumentItemForm +from inventory.services.incoming_document_service import IncomingDocumentService + + +class IncomingDocumentListView(LoginRequiredMixin, ListView): + """Список документов поступления""" + model = IncomingDocument + template_name = 'inventory/incoming_document/incoming_document_list.html' + context_object_name = 'documents' + paginate_by = 20 + + def get_queryset(self): + return IncomingDocument.objects.select_related( + 'warehouse', 'created_by', 'confirmed_by' + ).prefetch_related('items').order_by('-date', '-created_at') + + +class IncomingDocumentCreateView(LoginRequiredMixin, CreateView): + """Создание документа поступления""" + model = IncomingDocument + form_class = IncomingDocumentForm + template_name = 'inventory/incoming_document/incoming_document_form.html' + + def form_valid(self, form): + document = IncomingDocumentService.create_document( + warehouse=form.cleaned_data['warehouse'], + date=form.cleaned_data['date'], + receipt_type=form.cleaned_data['receipt_type'], + supplier_name=form.cleaned_data.get('supplier_name'), + notes=form.cleaned_data.get('notes'), + created_by=self.request.user + ) + messages.success(self.request, f'Документ {document.document_number} создан') + return redirect('inventory:incoming-document-detail', pk=document.pk) + + +class IncomingDocumentDetailView(LoginRequiredMixin, DetailView): + """Детальный просмотр документа поступления""" + model = IncomingDocument + template_name = 'inventory/incoming_document/incoming_document_detail.html' + context_object_name = 'document' + + def get_queryset(self): + return IncomingDocument.objects.select_related( + 'warehouse', 'created_by', 'confirmed_by' + ).prefetch_related('items__product') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['item_form'] = IncomingDocumentItemForm(document=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 IncomingDocumentAddItemView(LoginRequiredMixin, View): + """Добавление позиции в документ поступления""" + + @transaction.atomic + def post(self, request, pk): + document = get_object_or_404(IncomingDocument, pk=pk) + form = IncomingDocumentItemForm(request.POST, document=document) + + if form.is_valid(): + try: + item = IncomingDocumentService.add_item( + document=document, + product=form.cleaned_data['product'], + quantity=form.cleaned_data['quantity'], + cost_price=form.cleaned_data['cost_price'], + notes=form.cleaned_data.get('notes') + ) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'item_id': item.id, + 'message': f'Добавлено: {item.product.name}' + }) + + messages.success(request, f'Добавлено: {item.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:incoming-document-detail', pk=pk) + + +class IncomingDocumentUpdateItemView(LoginRequiredMixin, View): + """Обновление позиции документа поступления""" + + @transaction.atomic + def post(self, request, pk, item_pk): + document = get_object_or_404(IncomingDocument, pk=pk) + item = get_object_or_404(IncomingDocumentItem, pk=item_pk, document=document) + + try: + quantity = request.POST.get('quantity') + cost_price = request.POST.get('cost_price') + notes = request.POST.get('notes') + + IncomingDocumentService.update_item( + item, + quantity=quantity if quantity else None, + cost_price=cost_price if cost_price else None, + notes=notes if notes else None + ) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Обновлено: {item.product.name}' + }) + + messages.success(request, f'Обновлено: {item.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:incoming-document-detail', pk=pk) + + +class IncomingDocumentRemoveItemView(LoginRequiredMixin, View): + """Удаление позиции из документа поступления""" + + @transaction.atomic + def post(self, request, pk, item_pk): + document = get_object_or_404(IncomingDocument, pk=pk) + item = get_object_or_404(IncomingDocumentItem, pk=item_pk, document=document) + + try: + product_name = item.product.name + IncomingDocumentService.remove_item(item) + + 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:incoming-document-detail', pk=pk) + + +class IncomingDocumentConfirmView(LoginRequiredMixin, View): + """Проведение документа поступления""" + + @transaction.atomic + def post(self, request, pk): + document = get_object_or_404(IncomingDocument, pk=pk) + + try: + result = IncomingDocumentService.confirm_document( + document, + confirmed_by=request.user + ) + messages.success( + request, + f'Документ {document.document_number} проведён. ' + f'Оприходовано {result["total_quantity"]} шт на сумму {result["total_cost"]:.2f}' + ) + except ValidationError as e: + messages.error(request, str(e)) + + return redirect('inventory:incoming-document-detail', pk=pk) + + +class IncomingDocumentCancelView(LoginRequiredMixin, View): + """Отмена документа поступления""" + + @transaction.atomic + def post(self, request, pk): + document = get_object_or_404(IncomingDocument, pk=pk) + + try: + IncomingDocumentService.cancel_document(document) + messages.success(request, f'Документ {document.document_number} отменён') + except ValidationError as e: + messages.error(request, str(e)) + + return redirect('inventory:incoming-document-list') +