From b24d5bcdee7d7bd69130b6c8f1a72c9b73972cd6 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 4 Nov 2025 11:00:05 +0300 Subject: [PATCH] commit --- myproject/inventory/forms.py | 141 ++++-- .../migrations/0002_add_transfer_models.py | 84 ++++ myproject/inventory/models.py | 150 ++++++ myproject/inventory/services/batch_manager.py | 63 +++ .../transfer/transfer_bulk_form.html | 462 ++++++++++++++++++ .../inventory/transfer/transfer_detail.html | 155 ++++++ .../inventory/transfer/transfer_list.html | 52 +- myproject/inventory/urls.py | 7 +- myproject/inventory/utils/__init__.py | 12 + .../inventory/utils/document_generator.py | 90 ++++ myproject/inventory/views/__init__.py | 4 +- myproject/inventory/views/incoming.py | 2 +- myproject/inventory/views/transfer.py | 233 +++++++-- 13 files changed, 1383 insertions(+), 72 deletions(-) create mode 100644 myproject/inventory/migrations/0002_add_transfer_models.py create mode 100644 myproject/inventory/templates/inventory/transfer/transfer_bulk_form.html create mode 100644 myproject/inventory/templates/inventory/transfer/transfer_detail.html create mode 100644 myproject/inventory/utils/__init__.py create mode 100644 myproject/inventory/utils/document_generator.py diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py index b90872f..a6ac3a8 100644 --- a/myproject/inventory/forms.py +++ b/myproject/inventory/forms.py @@ -3,7 +3,10 @@ from django import forms from django.core.exceptions import ValidationError from decimal import Decimal -from .models import Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch +from .models import ( + Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch, + TransferBatch, TransferItem +) from products.models import Product @@ -82,42 +85,6 @@ class WriteOffForm(forms.ModelForm): return cleaned_data -class TransferForm(forms.ModelForm): - class Meta: - model = Transfer - fields = ['batch', 'from_warehouse', 'to_warehouse', 'quantity', 'document_number'] - widgets = { - 'batch': forms.Select(attrs={'class': 'form-control'}), - 'from_warehouse': forms.Select(attrs={'class': 'form-control'}), - 'to_warehouse': forms.Select(attrs={'class': 'form-control'}), - 'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), - 'document_number': forms.TextInput(attrs={'class': 'form-control'}), - } - - def clean(self): - cleaned_data = super().clean() - batch = cleaned_data.get('batch') - quantity = cleaned_data.get('quantity') - from_warehouse = cleaned_data.get('from_warehouse') - - if batch and quantity: - if quantity > batch.quantity: - raise ValidationError( - f'Невозможно перенести {quantity} шт, доступно {batch.quantity} шт' - ) - if quantity <= 0: - raise ValidationError('Количество должно быть больше нуля') - - # Проверяем что складской источник совпадает с складом партии - if from_warehouse and batch.warehouse_id != from_warehouse.id: - raise ValidationError( - f'Партия находится на складе "{batch.warehouse.name}", ' - f'а вы выбрали "{from_warehouse.name}"' - ) - - return cleaned_data - - class ReservationForm(forms.ModelForm): class Meta: model = Reservation @@ -339,3 +306,103 @@ class IncomingForm(forms.Form): 'Оставьте поле пустым для автогенерации или используйте другой формат.' ) return document_number + + +# ============================================================================ +# TRANSFER FORMS - Перемещение товаров между складами +# ============================================================================ + +class TransferHeaderForm(forms.ModelForm): + """ + Форма заголовка документа перемещения товара между складами. + Содержит информацию о складах-источнике и складе-назначении, примечания. + """ + class Meta: + model = TransferBatch + fields = ['from_warehouse', 'to_warehouse', 'notes'] + widgets = { + 'from_warehouse': forms.Select(attrs={'class': 'form-control'}), + 'to_warehouse': forms.Select(attrs={'class': 'form-control'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к перемещению'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Фильтруем только активные склады + self.fields['from_warehouse'].queryset = Warehouse.objects.filter(is_active=True) + self.fields['to_warehouse'].queryset = Warehouse.objects.filter(is_active=True) + + def clean(self): + cleaned_data = super().clean() + from_warehouse = cleaned_data.get('from_warehouse') + to_warehouse = cleaned_data.get('to_warehouse') + + if from_warehouse and to_warehouse: + if from_warehouse.id == to_warehouse.id: + raise ValidationError('Склад-источник и склад-назначение должны быть разными') + + return cleaned_data + + +class TransferLineForm(forms.Form): + """ + Форма для одной строки товара при массовом перемещении. + Используется в динамической таблице для ввода нескольких товаров. + """ + product = forms.ModelChoiceField( + queryset=Product.objects.filter(is_active=True).order_by('name'), + widget=forms.Select(attrs={'class': 'form-control'}), + label="Товар", + required=True + ) + + quantity = forms.DecimalField( + max_digits=10, + decimal_places=3, + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), + label="Количество", + required=True + ) + + def clean_quantity(self): + quantity = self.cleaned_data.get('quantity') + if quantity and quantity <= 0: + raise ValidationError('Количество должно быть больше нуля') + return quantity + + +class TransferBulkForm(forms.Form): + """ + Комбинированная форма для ввода перемещения товаров. + Содержит header информацию (склад-источник, склад-назначение, примечания) + динамический набор товаров. + """ + from_warehouse = forms.ModelChoiceField( + queryset=Warehouse.objects.filter(is_active=True), + widget=forms.Select(attrs={'class': 'form-control'}), + label="Склад-отгрузки", + required=True + ) + + to_warehouse = forms.ModelChoiceField( + queryset=Warehouse.objects.filter(is_active=True), + widget=forms.Select(attrs={'class': 'form-control'}), + label="Склад-приемки", + required=True + ) + + notes = forms.CharField( + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к перемещению'}), + label="Примечания", + required=False + ) + + def clean(self): + cleaned_data = super().clean() + from_warehouse = cleaned_data.get('from_warehouse') + to_warehouse = cleaned_data.get('to_warehouse') + + if from_warehouse and to_warehouse: + if from_warehouse.id == to_warehouse.id: + raise ValidationError('Склад-источник и склад-назначение должны быть разными') + + return cleaned_data diff --git a/myproject/inventory/migrations/0002_add_transfer_models.py b/myproject/inventory/migrations/0002_add_transfer_models.py new file mode 100644 index 0000000..deeb22e --- /dev/null +++ b/myproject/inventory/migrations/0002_add_transfer_models.py @@ -0,0 +1,84 @@ +# Generated by Django 5.0.10 on 2025-11-02 20:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0001_initial'), + ('products', '0005_remove_kititem_notes'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentCounter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('counter_type', models.CharField(choices=[('transfer', 'Перемещение товара')], max_length=20, unique=True, verbose_name='Тип счетчика')), + ('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')), + ], + options={ + 'verbose_name': 'Счетчик документов', + 'verbose_name_plural': 'Счетчики документов', + }, + ), + migrations.CreateModel( + name='TransferBatch', + 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='Номер документа')), + ('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='Дата обновления')), + ('from_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_batches_from', to='inventory.warehouse', verbose_name='Склад-отгрузки')), + ('to_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_batches_to', to='inventory.warehouse', verbose_name='Склад-приемки')), + ], + options={ + 'verbose_name': 'Документ перемещения', + 'verbose_name_plural': 'Документы перемещения', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='TransferItem', + 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='Количество')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_items', to='inventory.stockbatch', verbose_name='Исходная партия (FIFO)')), + ('new_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_items_created', to='inventory.stockbatch', verbose_name='Созданная партия на целевом складе')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_items', to='products.product', verbose_name='Товар')), + ('transfer_batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.transferbatch', verbose_name='Документ перемещения')), + ], + options={ + 'verbose_name': 'Строка перемещения', + 'verbose_name_plural': 'Строки перемещения', + 'ordering': ['id'], + }, + ), + migrations.AddIndex( + model_name='transferbatch', + index=models.Index(fields=['document_number'], name='inventory_t_documen_143275_idx'), + ), + migrations.AddIndex( + model_name='transferbatch', + index=models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_2a41f1_idx'), + ), + migrations.AddIndex( + model_name='transferbatch', + index=models.Index(fields=['-created_at'], name='inventory_t_created_b6fd05_idx'), + ), + migrations.AddIndex( + model_name='transferitem', + index=models.Index(fields=['transfer_batch'], name='inventory_t_transfe_f7479b_idx'), + ), + migrations.AddIndex( + model_name='transferitem', + index=models.Index(fields=['product'], name='inventory_t_product_0e0ec9_idx'), + ), + migrations.AlterUniqueTogether( + name='transferitem', + unique_together={('transfer_batch', 'batch')}, + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index f93bc41..1e1655c 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -454,3 +454,153 @@ class StockMovement(models.Model): def __str__(self): return f"{self.product.name}: {self.change} ({self.reason})" + + +class DocumentCounter(models.Model): + """ + Счетчик номеров документов для различных операций. + Используется для генерации уникальных номеров документов. + """ + COUNTER_TYPE_CHOICES = [ + ('transfer', 'Перемещение товара'), + ] + + counter_type = models.CharField( + max_length=20, + choices=COUNTER_TYPE_CHOICES, + unique=True, + verbose_name="Тип счетчика" + ) + current_value = models.IntegerField( + default=0, + verbose_name="Текущее значение" + ) + + class Meta: + verbose_name = "Счетчик документов" + verbose_name_plural = "Счетчики документов" + + def __str__(self): + return f"Счетчик {self.get_counter_type_display()}: {self.current_value}" + + @classmethod + def get_next_value(cls, counter_type): + """ + Получить следующее значение для счетчика. + Thread-safe операция с использованием select_for_update. + """ + from django.db import transaction + + with transaction.atomic(): + obj, _ = cls.objects.select_for_update().get_or_create( + counter_type=counter_type + ) + obj.current_value += 1 + obj.save(update_fields=['current_value']) + return obj.current_value + + +class TransferBatch(models.Model): + """ + Документ перемещения товара между складами. + Один номер документа = одна операция перемещения множественных товаров. + """ + from_warehouse = models.ForeignKey( + Warehouse, + on_delete=models.CASCADE, + related_name='transfer_batches_from', + verbose_name="Склад-отгрузки" + ) + to_warehouse = models.ForeignKey( + Warehouse, + on_delete=models.CASCADE, + related_name='transfer_batches_to', + verbose_name="Склад-приемки" + ) + document_number = models.CharField( + max_length=100, + unique=True, + db_index=True, + 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 = ['-created_at'] + indexes = [ + models.Index(fields=['document_number']), + models.Index(fields=['from_warehouse', 'to_warehouse']), + models.Index(fields=['-created_at']), + ] + + def __str__(self): + total_items = self.items.count() + total_qty = self.items.aggregate( + models.Sum('quantity') + )['quantity__sum'] or Decimal('0') + return f"Перемещение {self.document_number}: {total_items} товаров, {total_qty} шт ({self.from_warehouse} → {self.to_warehouse})" + + +class TransferItem(models.Model): + """ + Строка документа перемещения (товар в перемещении). + Связь между документом и товарами. + """ + transfer_batch = models.ForeignKey( + TransferBatch, + on_delete=models.CASCADE, + related_name='items', + verbose_name="Документ перемещения" + ) + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='transfer_items', + verbose_name="Товар" + ) + batch = models.ForeignKey( + StockBatch, + on_delete=models.CASCADE, + related_name='transfer_items', + verbose_name="Исходная партия (FIFO)" + ) + quantity = models.DecimalField( + max_digits=10, + decimal_places=3, + verbose_name="Количество" + ) + new_batch = models.ForeignKey( + StockBatch, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transfer_items_created', + verbose_name="Созданная партия на целевом складе" + ) + + class Meta: + verbose_name = "Строка перемещения" + verbose_name_plural = "Строки перемещения" + unique_together = [['transfer_batch', 'batch']] + ordering = ['id'] + indexes = [ + models.Index(fields=['transfer_batch']), + models.Index(fields=['product']), + ] + + def __str__(self): + return f"{self.product.name}: {self.quantity} шт (партия {self.batch.id})" diff --git a/myproject/inventory/services/batch_manager.py b/myproject/inventory/services/batch_manager.py index 9688675..f624ba8 100644 --- a/myproject/inventory/services/batch_manager.py +++ b/myproject/inventory/services/batch_manager.py @@ -244,3 +244,66 @@ class StockBatchManager: batch.is_active = False batch.save(update_fields=['is_active']) + + @staticmethod + @transaction.atomic + def transfer_product_by_fifo(product, from_warehouse, to_warehouse, quantity): + """ + Переместить товар с одного склада на другой по FIFO логике. + Старые партии перемещаются первыми. + + Args: + product: объект Product + from_warehouse: объект Warehouse (источник) + to_warehouse: объект Warehouse (назначение) + quantity: Decimal - количество товара для перемещения + + Returns: + list: [(source_batch, qty_transferred, new_batch), ...] + список кортежей с исходной партией, количеством и созданной партией + + Raises: + ValueError: если недостаточно товара на складе-источнике + """ + # Получаем партии по FIFO (старые первыми) + allocations = StockBatchManager.get_batches_for_fifo(product, from_warehouse) + + result = [] + remaining = quantity + + for batch in allocations: + if remaining <= 0: + break + + # Определяем сколько перемещаем из этой партии + qty_to_transfer = min(batch.quantity, remaining) + + # Уменьшаем исходную партию + batch.quantity -= qty_to_transfer + if batch.quantity <= 0: + batch.is_active = False + batch.save(update_fields=['quantity', 'is_active', 'updated_at']) + + # Создаем новую партию на целевом складе с СОХРАНЕНИЕМ cost_price + new_batch = StockBatch.objects.create( + product=product, + warehouse=to_warehouse, + quantity=qty_to_transfer, + cost_price=batch.cost_price # ВАЖНО: сохраняем цену! + ) + + result.append((batch, qty_to_transfer, new_batch)) + remaining -= qty_to_transfer + + # Проверяем что было достаточно товара + if remaining > 0: + raise ValueError( + f"Недостаточно товара '{product.name}' на складе '{from_warehouse.name}'. " + f"Не хватает {remaining} шт из запрашиваемых {quantity} шт" + ) + + # Обновляем кеш остатков на обоих складах + StockBatchManager.refresh_stock_cache(product, from_warehouse) + StockBatchManager.refresh_stock_cache(product, to_warehouse) + + return result diff --git a/myproject/inventory/templates/inventory/transfer/transfer_bulk_form.html b/myproject/inventory/templates/inventory/transfer/transfer_bulk_form.html new file mode 100644 index 0000000..111a21a --- /dev/null +++ b/myproject/inventory/templates/inventory/transfer/transfer_bulk_form.html @@ -0,0 +1,462 @@ +{% extends 'base.html' %} + +{% block title %}Создать перемещение товара{% endblock %} + +{% block content %} +
+ + + + +
+ +
+ {% csrf_token %} + +
+ +
+ +
+
+ + {{ form.from_warehouse }} + {% if form.from_warehouse.errors %} +
{{ form.from_warehouse.errors }}
+ {% endif %} +
+
+ + {{ form.to_warehouse }} + {% if form.to_warehouse.errors %} +
{{ form.to_warehouse.errors }}
+ {% endif %} +
+
+ + +
+ + {{ form.notes }} + {% if form.notes.errors %} +
{{ form.notes.errors }}
+ {% endif %} +
+ + +
+
+
Товары для перемещения
+ +
+
+
+
+ + + + + + + + + + + + +
ТоварДоступноКол-воДействие
+
+
+ +

Товары не добавлены. Нажмите "Добавить товар" для начала

+
+
+
+
+ + +
+
+
+
+

Позиций

+

0

+
+
+
+
+
+
+

Всего товара

+

0 шт

+
+
+
+
+
+
+

Статус

+

+ Готово +

+
+
+
+
+
+ + +
+ +
+
+
Информация
+
+

Логика FIFO:

+

Товар перемещается из старых партий первыми. Стоимость партий сохраняется.

+
+
+
+ + +
+
+
Логирование
+
+
+
+

Логи операций появятся здесь...

+
+
+
+
+
+ + +
+ + Отмена + + +
+ + + +
+
+ + + + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/transfer/transfer_detail.html b/myproject/inventory/templates/inventory/transfer/transfer_detail.html new file mode 100644 index 0000000..06fef60 --- /dev/null +++ b/myproject/inventory/templates/inventory/transfer/transfer_detail.html @@ -0,0 +1,155 @@ +{% extends 'base.html' %} + +{% block title %}Документ перемещения {{ transfer_batch.document_number }}{% endblock %} + +{% block content %} +
+ + + +
+ +
+
+
+
+ {{ transfer_batch.document_number }} +
+
+
+
+
+

Склад-отгрузки

+

{{ transfer_batch.from_warehouse.name }}

+
+
+

Склад-приемки

+

{{ transfer_batch.to_warehouse.name }}

+
+
+ + {% if transfer_batch.notes %} +
+

Примечания

+

{{ transfer_batch.notes }}

+
+ {% endif %} + +
+
+

Дата создания

+

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

+
+
+

Последнее обновление

+

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

+
+
+
+
+ + +
+
+
Товары в документе
+
+
+
+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
ТоварКоличествоЦена партииИсходная партияНовая партия
+ {{ item.product.name }} + {{ item.quantity }}{{ item.batch.cost_price }} ₽/ед. + {{ item.batch.id }} + + {% if item.new_batch %} + {{ item.new_batch.id }} + {% else %} + + {% endif %} +
+ Товаров не найдено +
+
+
+
+
+ + +
+ +
+
+
Статистика
+ +
+
+ Позиций: + {{ total_items }} +
+
+ Всего товара: + {{ total_qty }} шт +
+
+
+
+ + + +
+
+
+ + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/transfer/transfer_list.html b/myproject/inventory/templates/inventory/transfer/transfer_list.html index 97f7611..57e831a 100644 --- a/myproject/inventory/templates/inventory/transfer/transfer_list.html +++ b/myproject/inventory/templates/inventory/transfer/transfer_list.html @@ -1,5 +1,55 @@ {% extends 'inventory/base_inventory.html' %} {% load inventory_filters %} {% block inventory_title %}Перемещение товаров{% endblock %} -{% block inventory_content %}

Перемещение товаров между складами Новое

{% if transfers %}
{% for t in transfers %}{% endfor %}
ТоварИзВКол-воДатаДействия
{{ t.batch.product.name }}{{ t.from_warehouse.name }}{{ t.to_warehouse.name }}{{ t.quantity|smart_quantity }}{{ t.date|date:"d.m.Y" }}
{% else %}
Перемещений не найдено.
{% endif %}
+{% block inventory_content %} +
+
+

Перемещение товаров между складами + + Новое + +

+
+
+ {% if transfers %} +
+ + + + + + + + + + + + + {% for t in transfers %} + + + + + + + + + {% endfor %} + +
Номер документаИзВТоваровДата созданияДействия
+ {{ t.document_number }} + {{ t.from_warehouse.name }}{{ t.to_warehouse.name }}{{ t.items.count }}{{ t.created_at|date:"d.m.Y H:i" }} + + + + + + +
+
+ {% else %} +
Перемещений не найдено.
+ {% endif %} +
+
{% endblock %} diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py index e7559f5..0b57ee2 100644 --- a/myproject/inventory/urls.py +++ b/myproject/inventory/urls.py @@ -14,7 +14,7 @@ from .views import ( # WriteOff WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView, # Transfer - TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView, + TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView, # Reservation ReservationListView, ReservationCreateView, ReservationUpdateView, # Stock @@ -72,9 +72,10 @@ urlpatterns = [ # ==================== TRANSFER ==================== path('transfers/', TransferListView.as_view(), name='transfer-list'), - path('transfers/create/', TransferCreateView.as_view(), name='transfer-create'), - path('transfers//edit/', TransferUpdateView.as_view(), name='transfer-update'), + path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения + path('transfers//', TransferDetailView.as_view(), name='transfer-detail'), # Деталь документа path('transfers//delete/', TransferDeleteView.as_view(), name='transfer-delete'), + path('api/product-stock/', GetProductStockView.as_view(), name='api-product-stock'), # API для получения количества товара # ==================== RESERVATION ==================== path('reservations/', ReservationListView.as_view(), name='reservation-list'), diff --git a/myproject/inventory/utils/__init__.py b/myproject/inventory/utils/__init__.py new file mode 100644 index 0000000..619c006 --- /dev/null +++ b/myproject/inventory/utils/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +""" +Утилиты для модуля inventory. +Доступны функции для генерации номеров документов и другие вспомогательные функции. +""" + +from .document_generator import generate_transfer_document_number, generate_incoming_document_number + +__all__ = [ + 'generate_transfer_document_number', + 'generate_incoming_document_number', +] diff --git a/myproject/inventory/utils/document_generator.py b/myproject/inventory/utils/document_generator.py new file mode 100644 index 0000000..cb8d333 --- /dev/null +++ b/myproject/inventory/utils/document_generator.py @@ -0,0 +1,90 @@ +""" +Генератор номеров документов для различных операций в inventory. +""" + +from inventory.models import DocumentCounter + + +def generate_transfer_document_number(): + """ + Генерирует уникальный номер документа перемещения. + + Формат: MOVE-XXXXXX (6 цифр) + + Returns: + str: Сгенерированный номер документа (например, MOVE-000001) + """ + next_number = DocumentCounter.get_next_value('transfer') + return f"MOVE-{next_number:06d}" + + +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 + 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 diff --git a/myproject/inventory/views/__init__.py b/myproject/inventory/views/__init__.py index 8eb322a..d9f8c6f 100644 --- a/myproject/inventory/views/__init__.py +++ b/myproject/inventory/views/__init__.py @@ -27,7 +27,7 @@ from .inventory_ops import ( InventoryLineCreateBulkView ) from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView -from .transfer import TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView +from .transfer import TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView from .reservation import ReservationListView, ReservationCreateView, ReservationUpdateView from .stock import StockListView, StockDetailView from .allocation import SaleBatchAllocationListView @@ -58,7 +58,7 @@ __all__ = [ # WriteOff 'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView', # Transfer - 'TransferListView', 'TransferCreateView', 'TransferUpdateView', 'TransferDeleteView', + 'TransferListView', 'TransferBulkCreateView', 'TransferDetailView', 'TransferDeleteView', 'GetProductStockView', # Reservation 'ReservationListView', 'ReservationCreateView', 'ReservationUpdateView', # Stock diff --git a/myproject/inventory/views/incoming.py b/myproject/inventory/views/incoming.py index 576a3db..992b257 100644 --- a/myproject/inventory/views/incoming.py +++ b/myproject/inventory/views/incoming.py @@ -11,7 +11,7 @@ from django.utils.decorators import method_decorator from django.db import IntegrityError, transaction from ..models import Incoming, IncomingBatch, Warehouse from ..forms import IncomingForm, IncomingLineForm -from ..utils import generate_incoming_document_number +from inventory.utils import generate_incoming_document_number from products.models import Product file_logger = logging.getLogger('incoming_sequence_file') diff --git a/myproject/inventory/views/transfer.py b/myproject/inventory/views/transfer.py index a9558cb..8caec94 100644 --- a/myproject/inventory/views/transfer.py +++ b/myproject/inventory/views/transfer.py @@ -3,58 +3,235 @@ Transfer (Перемещение товара между складами) views GROUP 2: MEDIUM PRIORITY """ -from django.views.generic import ListView, CreateView, UpdateView, DeleteView +import json +from decimal import Decimal +from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView +from django.views import View from django.urls import reverse_lazy from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib import messages -from ..models import Transfer -from ..forms import TransferForm +from django.http import JsonResponse +from django.db import transaction +from django.shortcuts import redirect, render +from ..models import TransferBatch, TransferItem, Stock +from ..forms import TransferBulkForm +from inventory.utils.document_generator import generate_transfer_document_number +from inventory.services.batch_manager import StockBatchManager +from products.models import Product class TransferListView(LoginRequiredMixin, ListView): - model = Transfer + """ + View для просмотра списка документов перемещений товаров. + """ + model = TransferBatch template_name = 'inventory/transfer/transfer_list.html' context_object_name = 'transfers' paginate_by = 20 def get_queryset(self): - return Transfer.objects.select_related( - 'batch', 'batch__product', + return TransferBatch.objects.select_related( 'from_warehouse', 'to_warehouse' - ).order_by('-date') + ).order_by('-created_at') -class TransferCreateView(LoginRequiredMixin, CreateView): - model = Transfer - form_class = TransferForm - template_name = 'inventory/transfer/transfer_form.html' - success_url = reverse_lazy('inventory:transfer-list') +# ============================================================================ +# VIEWS ДЛЯ ПЕРЕМЕЩЕНИЯ ТОВАРОВ (TransferBatch + TransferItem) +# ============================================================================ - def form_valid(self, form): - messages.success( - self.request, - f'Перемещение товара "{form.instance.batch.product.name}" успешно зарегистрировано.' +class TransferBulkCreateView(LoginRequiredMixin, View): + """ + View для создания документа перемещения товаров между складами с FIFO логикой. + Один документ может содержать несколько товаров. + """ + template_name = 'inventory/transfer/transfer_bulk_form.html' + + def get(self, request): + from products.models import Product + form = TransferBulkForm() + products = Product.objects.filter(is_active=True).values('id', 'name', 'sku').order_by('name') + return render(request, self.template_name, { + 'form': form, + 'products': products + }) + + def post(self, request): + form = TransferBulkForm(request.POST) + + if not form.is_valid(): + messages.error(request, 'Ошибка при заполнении формы') + return render(request, self.template_name, {'form': form}, status=400) + + # Получаем данные из формы + from_warehouse = form.cleaned_data.get('from_warehouse') + to_warehouse = form.cleaned_data.get('to_warehouse') + notes = form.cleaned_data.get('notes', '') + + # Парсим JSON с товарами + products_json = request.POST.get('products_json', '[]') + try: + products_data = json.loads(products_json) + except json.JSONDecodeError: + messages.error(request, 'Ошибка при парсинге товаров. Пожалуйста, проверьте данные.') + return render(request, self.template_name, {'form': form}, status=400) + + # Проверяем что товары есть + if not products_data: + messages.error(request, 'Необходимо добавить хотя бы один товар для перемещения.') + return render(request, self.template_name, {'form': form}, status=400) + + # Валидируем товары + products = [] + for item in products_data: + product_id = item.get('product_id') + quantity = Decimal(str(item.get('quantity', 0))) + + if quantity <= 0: + messages.error(request, f'Количество товара должно быть больше нуля') + return render(request, self.template_name, {'form': form}, status=400) + + try: + product = Product.objects.get(id=product_id, is_active=True) + products.append((product, quantity)) + except Product.DoesNotExist: + messages.error(request, f'Товар с ID {product_id} не найден') + return render(request, self.template_name, {'form': form}, status=400) + + # Начинаем транзакцию + try: + with transaction.atomic(): + # 1. Создаем документ TransferBatch + transfer_batch = TransferBatch.objects.create( + from_warehouse=from_warehouse, + to_warehouse=to_warehouse, + document_number=generate_transfer_document_number(), + notes=notes + ) + + # 2. Для каждого товара выполняем FIFO перемещение + for product, quantity in products: + try: + # Получаем список распределений по FIFO + transfers = StockBatchManager.transfer_product_by_fifo( + product=product, + from_warehouse=from_warehouse, + to_warehouse=to_warehouse, + quantity=quantity + ) + + # Создаем TransferItem для каждого использованного batch + for source_batch, qty_transferred, new_batch in transfers: + TransferItem.objects.create( + transfer_batch=transfer_batch, + product=product, + batch=source_batch, + quantity=qty_transferred, + new_batch=new_batch + ) + + except ValueError as e: + messages.error(request, f'Ошибка при перемещении товара "{product.name}": {str(e)}') + raise # Откатываем транзакцию + + # 3. Успешно создали документ + messages.success( + request, + f'Документ перемещения {transfer_batch.document_number} успешно создан. ' + f'Перемещено {len(products)} видов товаров.' + ) + + return redirect('inventory:transfer-detail', pk=transfer_batch.id) + + except Exception as e: + messages.error(request, f'Ошибка при создании документа перемещения: {str(e)}') + return render(request, self.template_name, {'form': form}, status=400) + + +class TransferDetailView(LoginRequiredMixin, DetailView): + """ + View для просмотра деталей документа перемещения. + """ + model = TransferBatch + template_name = 'inventory/transfer/transfer_detail.html' + context_object_name = 'transfer_batch' + + def get_queryset(self): + return TransferBatch.objects.select_related( + 'from_warehouse', 'to_warehouse' + ).prefetch_related( + 'items__product', 'items__batch', 'items__new_batch' ) - return super().form_valid(form) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + transfer_batch = self.object -class TransferUpdateView(LoginRequiredMixin, UpdateView): - model = Transfer - form_class = TransferForm - template_name = 'inventory/transfer/transfer_form.html' - success_url = reverse_lazy('inventory:transfer-list') + # Собираем статистику по документу + items = transfer_batch.items.all() + total_items = items.count() + total_qty = sum(Decimal(str(item.quantity)) for item in items) - def form_valid(self, form): - messages.success(self.request, f'Перемещение товара обновлено.') - return super().form_valid(form) + context['total_items'] = total_items + context['total_qty'] = total_qty + context['items'] = items + + return context class TransferDeleteView(LoginRequiredMixin, DeleteView): - model = Transfer + """ + View для удаления документа перемещения. + """ + model = TransferBatch template_name = 'inventory/transfer/transfer_confirm_delete.html' success_url = reverse_lazy('inventory:transfer-list') def form_valid(self, form): - transfer = self.get_object() - messages.success(self.request, f'Перемещение товара отменено.') + transfer_batch = self.get_object() + messages.success(self.request, f'Документ перемещения {transfer_batch.document_number} удалён.') return super().form_valid(form) + + +class GetProductStockView(LoginRequiredMixin, View): + """ + API endpoint для получения доступного количества товара на конкретном складе. + GET параметры: product_id, warehouse_id + Возвращает JSON: {"quantity": "100.000", "warehouse_name": "Основной склад"} + """ + def get(self, request): + product_id = request.GET.get('product_id') + warehouse_id = request.GET.get('warehouse_id') + + if not product_id or not warehouse_id: + return JsonResponse({ + 'error': 'Missing required parameters: product_id, warehouse_id' + }, status=400) + + try: + product_id = int(product_id) + warehouse_id = int(warehouse_id) + except ValueError: + return JsonResponse({ + 'error': 'Invalid parameter values' + }, status=400) + + try: + stock = Stock.objects.get(product_id=product_id, warehouse_id=warehouse_id) + return JsonResponse({ + 'quantity': str(stock.quantity_available), + 'warehouse_name': stock.warehouse.name, + 'success': True + }) + except Stock.DoesNotExist: + return JsonResponse({ + 'quantity': '0.000', + 'warehouse_name': '', + 'success': True + }) + except Exception as e: + import traceback + traceback.print_exc() + return JsonResponse({ + 'error': str(e), + 'success': False + }, status=500)