From 78dc9e9801254214f5c82f329f012491cc85a960 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 20 Dec 2025 23:47:13 +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=BE=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B2=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BA=D0=BB=D0=B0=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено поле receipt_type в модель IncomingBatch с типами: supplier, inventory, adjustment - Исправлен баг в InventoryProcessor: теперь корректно создается IncomingBatch при инвентаризации - Создан IncomingAdjustmentCreateView для оприходования без инвентаризации - Обновлены формы, шаблоны и админка для поддержки разных типов поступлений - Добавлена навигация и URL для оприходования - Тип поступления отображается в списках приходов и партий --- myproject/inventory/admin.py | 20 ++- myproject/inventory/forms.py | 7 + .../0013_add_receipt_type_to_incomingbatch.py | 22 +++ myproject/inventory/models.py | 14 ++ .../inventory/services/inventory_processor.py | 22 +-- .../inventory/base_inventory_minimal.html | 1 + .../incoming/incoming_bulk_form.html | 10 +- .../inventory/incoming/incoming_list.html | 6 + .../inventory/incoming_batch/batch_list.html | 6 + myproject/inventory/urls.py | 3 +- myproject/inventory/views/__init__.py | 4 +- myproject/inventory/views/incoming.py | 143 +++++++++++++++++- 12 files changed, 238 insertions(+), 20 deletions(-) create mode 100644 myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py index f677ffd..9b7fc1b 100644 --- a/myproject/inventory/admin.py +++ b/myproject/inventory/admin.py @@ -94,13 +94,13 @@ class StockBatchAdmin(admin.ModelAdmin): # ===== INCOMING BATCH ===== @admin.register(IncomingBatch) class IncomingBatchAdmin(admin.ModelAdmin): - list_display = ('document_number', 'warehouse', 'supplier_name', 'items_count', 'created_at') - list_filter = ('warehouse', 'created_at') + list_display = ('document_number', 'warehouse', 'receipt_type_display', 'supplier_name', 'items_count', 'created_at') + list_filter = ('warehouse', 'receipt_type', 'created_at') search_fields = ('document_number', 'supplier_name') date_hierarchy = 'created_at' fieldsets = ( ('Партия поступления', { - 'fields': ('document_number', 'warehouse', 'supplier_name', 'notes') + 'fields': ('document_number', 'warehouse', 'receipt_type', 'supplier_name', 'notes') }), ('Даты', { 'fields': ('created_at', 'updated_at'), @@ -113,6 +113,20 @@ class IncomingBatchAdmin(admin.ModelAdmin): return obj.items.count() items_count.short_description = 'Товаров' + def receipt_type_display(self, obj): + colors = { + 'supplier': '#0d6efd', # primary (синий) + 'inventory': '#0dcaf0', # info (голубой) + 'adjustment': '#198754', # success (зеленый) + } + color = colors.get(obj.receipt_type, '#6c757d') + return format_html( + '{}', + color, + obj.get_receipt_type_display() + ) + receipt_type_display.short_description = 'Тип поступления' + # ===== INCOMING ===== @admin.register(Incoming) diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py index 40b8c1e..2b6ac4b 100644 --- a/myproject/inventory/forms.py +++ b/myproject/inventory/forms.py @@ -272,6 +272,13 @@ class IncomingForm(forms.Form): required=False ) + receipt_type = forms.CharField( + max_length=20, + widget=forms.HiddenInput(), + initial='supplier', + required=False + ) + supplier_name = forms.CharField( max_length=200, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}), diff --git a/myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py b/myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py new file mode 100644 index 0000000..ef2cf24 --- /dev/null +++ b/myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.10 on 2025-12-20 20:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0012_change_sold_order_item_to_fk'), + ] + + operations = [ + migrations.AddField( + model_name='incomingbatch', + name='receipt_type', + field=models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления'), + ), + migrations.AddIndex( + model_name='incomingbatch', + index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_ce70c1_idx'), + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index 7ce7ffe..c7d6bed 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -105,10 +105,23 @@ class IncomingBatch(models.Model): Партия поступления товара (один номер документа = одна партия). Содержит один номер документа и может включать несколько товаров. """ + RECEIPT_TYPE_CHOICES = [ + ('supplier', 'Поступление от поставщика'), + ('inventory', 'Оприходование при инвентаризации'), + ('adjustment', 'Оприходование без инвентаризации'), + ] + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, related_name='incoming_batches', verbose_name="Склад") document_number = models.CharField(max_length=100, unique=True, db_index=True, verbose_name="Номер документа") + receipt_type = models.CharField( + max_length=20, + choices=RECEIPT_TYPE_CHOICES, + default='supplier', + db_index=True, + verbose_name="Тип поступления" + ) supplier_name = models.CharField(max_length=200, blank=True, null=True, verbose_name="Наименование поставщика") notes = models.TextField(blank=True, null=True, verbose_name="Примечания") @@ -122,6 +135,7 @@ class IncomingBatch(models.Model): indexes = [ models.Index(fields=['document_number']), models.Index(fields=['warehouse']), + models.Index(fields=['receipt_type']), models.Index(fields=['-created_at']), ] diff --git a/myproject/inventory/services/inventory_processor.py b/myproject/inventory/services/inventory_processor.py index 067309c..4c84f40 100644 --- a/myproject/inventory/services/inventory_processor.py +++ b/myproject/inventory/services/inventory_processor.py @@ -11,10 +11,11 @@ from django.db import transaction from django.utils import timezone from inventory.models import ( - Inventory, InventoryLine, WriteOff, Incoming, + Inventory, InventoryLine, WriteOff, Incoming, IncomingBatch, StockBatch, Stock ) from inventory.services.batch_manager import StockBatchManager +from inventory.utils import generate_incoming_document_number class InventoryProcessor: @@ -142,21 +143,24 @@ class InventoryProcessor: inventory.warehouse ) - # Создаем новую партию - batch = StockBatchManager.create_batch( - line.product, - inventory.warehouse, - quantity_surplus, - cost_price + # Генерируем номер документа для поступления + document_number = generate_incoming_document_number() + + # Создаем IncomingBatch с типом 'inventory' + incoming_batch = IncomingBatch.objects.create( + warehouse=inventory.warehouse, + document_number=document_number, + receipt_type='inventory', + notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}' ) # Создаем документ Incoming + # Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch Incoming.objects.create( + batch=incoming_batch, product=line.product, - warehouse=inventory.warehouse, quantity=quantity_surplus, cost_price=cost_price, - batch=batch, notes=f'Инвентаризация {inventory.id}, строка {line.id}' ) diff --git a/myproject/inventory/templates/inventory/base_inventory_minimal.html b/myproject/inventory/templates/inventory/base_inventory_minimal.html index 1a0c8a3..9b79564 100644 --- a/myproject/inventory/templates/inventory/base_inventory_minimal.html +++ b/myproject/inventory/templates/inventory/base_inventory_minimal.html @@ -25,6 +25,7 @@
  • Склады
  • Приходы
  • Поступление товара
  • +
  • Оприходование
  • Продажи
  • Инвентаризация
  • Списания
  • diff --git a/myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html b/myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html index 2ebe81d..a2ed5e0 100644 --- a/myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html +++ b/myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html @@ -1,12 +1,12 @@ {% extends 'inventory/base_inventory_minimal.html' %} {% load inventory_filters %} -{% block inventory_title %}Массовое поступление товара{% endblock %} +{% block inventory_title %}{% if is_adjustment %}Оприходование товара{% else %}Массовое поступление товара{% endif %}{% endblock %} {% block breadcrumb_current %}Приходы{% endblock %} {% block inventory_content %}
    -

    Поступление товара от поставщика

    +

    {% if is_adjustment %}Оприходование товара{% else %}Поступление товара от поставщика{% endif %}

    @@ -48,6 +48,7 @@
    + {% if not is_adjustment %}
    {{ form.supplier_name }} @@ -55,6 +56,9 @@
    {{ form.supplier_name.errors.0 }}
    {% endif %}
    + {% endif %} + + {{ form.receipt_type }}
    @@ -120,7 +124,7 @@
    Отмена diff --git a/myproject/inventory/templates/inventory/incoming/incoming_list.html b/myproject/inventory/templates/inventory/incoming/incoming_list.html index 32cf03f..76e7c87 100644 --- a/myproject/inventory/templates/inventory/incoming/incoming_list.html +++ b/myproject/inventory/templates/inventory/incoming/incoming_list.html @@ -21,6 +21,7 @@ Товар Склад + Тип Количество Цена закупки Номер документа @@ -34,6 +35,11 @@ {{ incoming.product.name }} {{ incoming.batch.warehouse.name }} + + + {{ incoming.batch.get_receipt_type_display }} + + {{ incoming.quantity|smart_quantity }} шт {{ incoming.cost_price }} руб. diff --git a/myproject/inventory/templates/inventory/incoming_batch/batch_list.html b/myproject/inventory/templates/inventory/incoming_batch/batch_list.html index 4bfb3d1..067e166 100644 --- a/myproject/inventory/templates/inventory/incoming_batch/batch_list.html +++ b/myproject/inventory/templates/inventory/incoming_batch/batch_list.html @@ -19,6 +19,7 @@ Номер документа Склад + Тип Поставщик Товары Кол-во @@ -31,6 +32,11 @@ {{ batch.document_number }} {{ batch.warehouse.name }} + + + {{ batch.get_receipt_type_display }} + + {{ batch.supplier_name|default:"—" }} diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py index 76beddb..3686bae 100644 --- a/myproject/inventory/urls.py +++ b/myproject/inventory/urls.py @@ -4,7 +4,7 @@ from .views import ( # Warehouse WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView, # Incoming - IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView, + IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView, # IncomingBatch IncomingBatchListView, IncomingBatchDetailView, # Sale @@ -54,6 +54,7 @@ urlpatterns = [ # ==================== INCOMING ==================== path('incoming/', IncomingListView.as_view(), name='incoming-list'), path('incoming/create/', IncomingCreateView.as_view(), name='incoming-create'), + path('incoming/adjustment/create/', IncomingAdjustmentCreateView.as_view(), name='incoming-adjustment-create'), path('incoming//edit/', IncomingUpdateView.as_view(), name='incoming-update'), path('incoming//delete/', IncomingDeleteView.as_view(), name='incoming-delete'), diff --git a/myproject/inventory/views/__init__.py b/myproject/inventory/views/__init__.py index 2c8c832..7c9bff9 100644 --- a/myproject/inventory/views/__init__.py +++ b/myproject/inventory/views/__init__.py @@ -19,7 +19,7 @@ from django.shortcuts import render from django.contrib.auth.decorators import login_required from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView -from .incoming import IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView +from .incoming import IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView from .inventory_ops import ( @@ -48,7 +48,7 @@ __all__ = [ # Warehouse 'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView', # Incoming - 'IncomingListView', 'IncomingCreateView', 'IncomingUpdateView', 'IncomingDeleteView', + 'IncomingListView', 'IncomingCreateView', 'IncomingAdjustmentCreateView', 'IncomingUpdateView', 'IncomingDeleteView', # IncomingBatch 'IncomingBatchListView', 'IncomingBatchDetailView', # Sale diff --git a/myproject/inventory/views/incoming.py b/myproject/inventory/views/incoming.py index 9e7f3b1..09460b5 100644 --- a/myproject/inventory/views/incoming.py +++ b/myproject/inventory/views/incoming.py @@ -119,6 +119,7 @@ class IncomingCreateView(LoginRequiredMixin, View): # Оставляем пустой document_number как есть - модель будет генерировать при сохранении document_number = form.cleaned_data.get('document_number', '').strip() or None + receipt_type = form.cleaned_data.get('receipt_type', 'supplier') supplier_name = form.cleaned_data.get('supplier_name', '') header_notes = form.cleaned_data.get('notes', '') @@ -148,7 +149,8 @@ class IncomingCreateView(LoginRequiredMixin, View): batch = IncomingBatch.objects.create( warehouse=warehouse, document_number=document_number, - supplier_name=supplier_name, + receipt_type=receipt_type, + supplier_name=supplier_name if receipt_type == 'supplier' else '', notes=header_notes ) file_logger.info(f" ✓ Created batch: {document_number}") @@ -208,7 +210,8 @@ class IncomingCreateView(LoginRequiredMixin, View): } return render(request, self.template_name, context) - def _parse_products_from_post(self, post_data): + @staticmethod + def _parse_products_from_post(post_data): """ Парсит данные товаров из POST данных. Ожидается formato: @@ -235,3 +238,139 @@ class IncomingCreateView(LoginRequiredMixin, View): pass return products_data + + +class IncomingAdjustmentCreateView(LoginRequiredMixin, View): + """ + Создание оприходования товара на склад (без инвентаризации). + Аналогично IncomingCreateView, но с типом 'adjustment' и без поля поставщика. + """ + template_name = 'inventory/incoming/incoming_bulk_form.html' + + def get(self, request): + """Отображение формы ввода товаров.""" + form = IncomingForm(initial={'receipt_type': 'adjustment'}) + # Django-tenants автоматически фильтрует по текущей схеме + products = Product.objects.filter(status='active').order_by('name') + + # Генерируем номер документа автоматически + generated_document_number = generate_incoming_document_number() + + context = { + 'form': form, + 'products': products, + 'generated_document_number': generated_document_number, + 'is_adjustment': True, # Флаг для шаблона, чтобы скрыть supplier_name + } + return render(request, self.template_name, context) + + def post(self, request): + """Обработка формы ввода товаров.""" + # Устанавливаем receipt_type в 'adjustment' + post_data = request.POST.copy() + post_data['receipt_type'] = 'adjustment' + form = IncomingForm(post_data) + + if not form.is_valid(): + # Django-tenants автоматически фильтрует по текущей схеме + products = Product.objects.filter(status='active').order_by('name') + context = { + 'form': form, + 'products': products, + 'errors': form.errors, + 'is_adjustment': True, + } + return render(request, self.template_name, context) + + # Получаем данные header + warehouse = form.cleaned_data['warehouse'] + document_number = form.cleaned_data.get('document_number', '').strip() or None + + receipt_type = 'adjustment' # Всегда adjustment для этого view + header_notes = form.cleaned_data.get('notes', '') + + # Получаем данные товаров из POST + products_data = IncomingCreateView._parse_products_from_post(request.POST) + + if not products_data: + messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.') + products = Product.objects.filter(status='active').order_by('name') + context = { + 'form': form, + 'products': products, + 'is_adjustment': True, + } + return render(request, self.template_name, context) + + # Генерируем номер партии один раз (если не указан) + if not document_number: + document_number = generate_incoming_document_number() + + file_logger.info(f"--- POST started (adjustment) | batch_doc_number={document_number} | items_count={len(products_data)}") + + try: + # Используем транзакцию для атомарности: либо все товары, либо ничего + with transaction.atomic(): + # 1. Создаем партию (содержит номер документа и метаданные) + batch = IncomingBatch.objects.create( + warehouse=warehouse, + document_number=document_number, + receipt_type=receipt_type, + supplier_name='', # Не заполняем для adjustment + notes=header_notes + ) + file_logger.info(f" ✓ Created batch: {document_number}") + + # 2. Создаем товары в этой партии + created_count = 0 + for product_data in products_data: + incoming = Incoming.objects.create( + batch=batch, + product_id=product_data['product_id'], + quantity=product_data['quantity'], + cost_price=product_data['cost_price'], + ) + created_count += 1 + file_logger.info(f" ✓ Item: {incoming.product.name} ({incoming.quantity} шт)") + + file_logger.info(f"✓ SUCCESS: Batch {document_number} with {created_count} items") + messages.success( + request, + f'✓ Успешно создано оприходование "{document_number}" с {created_count} товарами.' + ) + return redirect('inventory:incoming-list') + + except IntegrityError as e: + file_logger.error(f"✗ IntegrityError: {str(e)} | doc_number={document_number}") + if 'document_number' in str(e): + error_msg = ( + f'❌ Номер документа "{document_number}" уже существует в системе. ' + f'Пожалуйста, оставьте поле пустым для автогенерации свободного номера. ' + f'Данные, которые вы вводили, сохранены ниже.' + ) + messages.error(request, error_msg) + else: + messages.error(request, f'Ошибка при создании оприходования: {str(e)}') + + products = Product.objects.filter(status='active').order_by('name') + context = { + 'form': form, + 'products': products, + 'products_json': request.POST.get('products_json', '[]'), + 'is_adjustment': True, + } + return render(request, self.template_name, context) + + except Exception as e: + messages.error( + request, + f'❌ Ошибка при создании оприходования: {str(e)}' + ) + products = Product.objects.filter(status='active').order_by('name') + context = { + 'form': form, + 'products': products, + 'products_json': request.POST.get('products_json', '[]'), + 'is_adjustment': True, + } + return render(request, self.template_name, context)