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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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.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 %}
+
+ |
+ {{ item.product.name }}
+ |
+ {{ item.quantity }} |
+ {{ item.batch.cost_price }} ₽/ед. |
+
+ {{ item.batch.id }}
+ |
+
+ {% if item.new_batch %}
+ {{ item.new_batch.id }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+ {% empty %}
+
+ |
+ Товаров не найдено
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
Статистика
+
+
+
+ Позиций:
+ {{ 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 %}| {{ t.batch.product.name }} | {{ t.from_warehouse.name }} | {{ t.to_warehouse.name }} | {{ t.quantity|smart_quantity }} | {{ t.date|date:"d.m.Y" }} | |
{% endfor %}
{% else %}
Перемещений не найдено.
{% endif %}
+{% block inventory_content %}
+
+
+
+ {% if transfers %}
+
+
+
+
+ | Номер документа |
+ Из |
+ В |
+ Товаров |
+ Дата создания |
+ Действия |
+
+
+
+ {% for t in transfers %}
+
+ |
+ {{ t.document_number }}
+ |
+ {{ t.from_warehouse.name }} |
+ {{ t.to_warehouse.name }} |
+ {{ t.items.count }} |
+ {{ t.created_at|date:"d.m.Y H:i" }} |
+
+
+
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% 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)