commit
This commit is contained in:
@@ -3,7 +3,10 @@ from django import forms
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from decimal import Decimal
|
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
|
from products.models import Product
|
||||||
|
|
||||||
|
|
||||||
@@ -82,42 +85,6 @@ class WriteOffForm(forms.ModelForm):
|
|||||||
return cleaned_data
|
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 ReservationForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Reservation
|
model = Reservation
|
||||||
@@ -339,3 +306,103 @@ class IncomingForm(forms.Form):
|
|||||||
'Оставьте поле пустым для автогенерации или используйте другой формат.'
|
'Оставьте поле пустым для автогенерации или используйте другой формат.'
|
||||||
)
|
)
|
||||||
return document_number
|
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
|
||||||
|
|||||||
84
myproject/inventory/migrations/0002_add_transfer_models.py
Normal file
84
myproject/inventory/migrations/0002_add_transfer_models.py
Normal file
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -454,3 +454,153 @@ class StockMovement(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.product.name}: {self.change} ({self.reason})"
|
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})"
|
||||||
|
|||||||
@@ -244,3 +244,66 @@ class StockBatchManager:
|
|||||||
|
|
||||||
batch.is_active = False
|
batch.is_active = False
|
||||||
batch.save(update_fields=['is_active'])
|
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
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Создать перемещение товара{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4 py-3" style="max-width: 1400px;">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb breadcrumb-sm mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'inventory:transfer-list' %}">Перемещения</a></li>
|
||||||
|
<li class="breadcrumb-item active">Новое перемещение</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<div id="errorContainer"></div>
|
||||||
|
|
||||||
|
<form method="post" id="transferForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- ЛЕВАЯ КОЛОНКА: Основная информация -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Склад-отгрузки и Склад-приемки -->
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="{{ form.from_warehouse.id_for_label }}" class="form-label small text-muted mb-1">
|
||||||
|
<i class="bi bi-box-seam-out me-1"></i>{{ form.from_warehouse.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.from_warehouse }}
|
||||||
|
{% if form.from_warehouse.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ form.from_warehouse.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="{{ form.to_warehouse.id_for_label }}" class="form-label small text-muted mb-1">
|
||||||
|
<i class="bi bi-box-seam-in me-1"></i>{{ form.to_warehouse.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.to_warehouse }}
|
||||||
|
{% if form.to_warehouse.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ form.to_warehouse.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Примечания -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.notes.id_for_label }}" class="form-label small text-muted mb-1">
|
||||||
|
<i class="bi bi-chat-dots me-1"></i>{{ form.notes.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.notes }}
|
||||||
|
{% if form.notes.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ form.notes.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ТОВАРЫ -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-bag-check me-1"></i>Товары для перемещения</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" id="addProductBtn">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Добавить товар
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="productsTableContainer">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0" id="productsTable">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-bottom">
|
||||||
|
<th scope="col" class="px-3 py-2">Товар</th>
|
||||||
|
<th scope="col" class="px-3 py-2" style="width: 150px;">Доступно</th>
|
||||||
|
<th scope="col" class="px-3 py-2" style="text-align: right; width: 150px;">Кол-во</th>
|
||||||
|
<th scope="col" class="px-3 py-2 text-center" style="width: 50px;">Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="productsBody">
|
||||||
|
<!-- JavaScript добавляет строки сюда -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="emptyState" class="text-center py-4 text-muted">
|
||||||
|
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mt-2 mb-0">Товары не добавлены. Нажмите "Добавить товар" для начала</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Итоги -->
|
||||||
|
<div class="row mt-3 g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 bg-light">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<p class="text-muted small mb-1">Позиций</p>
|
||||||
|
<p class="h5 mb-0" id="totalItems">0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 bg-light">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<p class="text-muted small mb-1">Всего товара</p>
|
||||||
|
<p class="h5 mb-0"><span id="totalQuantity">0</span> шт</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 bg-light">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<p class="text-muted small mb-1">Статус</p>
|
||||||
|
<p class="h5 mb-0">
|
||||||
|
<span id="formStatus" class="badge bg-secondary">Готово</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ПРАВАЯ КОЛОНКА: Справочная информация -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Справка -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-1"></i>Информация</h6>
|
||||||
|
<div class="alert alert-info alert-sm" style="font-size: 0.875rem;">
|
||||||
|
<p class="mb-1"><strong>Логика FIFO:</strong></p>
|
||||||
|
<p class="mb-0">Товар перемещается из старых партий первыми. Стоимость партий сохраняется.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Логирование -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-light py-2">
|
||||||
|
<h6 class="mb-0 text-muted"><i class="bi bi-journal-text me-1"></i>Логирование</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-3" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<div id="logContainer">
|
||||||
|
<p class="text-muted small mb-0">Логи операций появятся здесь...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky Footer -->
|
||||||
|
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
|
||||||
|
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary">
|
||||||
|
Отмена
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-success px-4" id="submitBtn">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Создать перемещение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Скрытое поле для JSON товаров -->
|
||||||
|
<input type="hidden" name="products_json" id="products_json" value="[]">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breadcrumb-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-sm {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-bottom {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1020;
|
||||||
|
}
|
||||||
|
|
||||||
|
#productsTable input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row-delete {
|
||||||
|
animation: fadeOut 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.info {
|
||||||
|
color: #0c5460;
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.error {
|
||||||
|
color: #721c24;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.success {
|
||||||
|
color: #155724;
|
||||||
|
background-color: #d4edda;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('transferForm');
|
||||||
|
const productsBody = document.getElementById('productsBody');
|
||||||
|
const productsJson = document.getElementById('products_json');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const addProductBtn = document.getElementById('addProductBtn');
|
||||||
|
const totalItemsEl = document.getElementById('totalItems');
|
||||||
|
const totalQuantityEl = document.getElementById('totalQuantity');
|
||||||
|
const logContainer = document.getElementById('logContainer');
|
||||||
|
const fromWarehouseSelect = document.querySelector('[name="from_warehouse"]');
|
||||||
|
const toWarehouseSelect = document.querySelector('[name="to_warehouse"]');
|
||||||
|
|
||||||
|
let products = [];
|
||||||
|
let rowCounter = 0;
|
||||||
|
|
||||||
|
// Логирование
|
||||||
|
function log(message, type = 'info') {
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry ${type}`;
|
||||||
|
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||||
|
logContainer.appendChild(entry);
|
||||||
|
logContainer.scrollTop = logContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавить новую строку товара
|
||||||
|
function addProductRow() {
|
||||||
|
const rowId = rowCounter++;
|
||||||
|
const html = `
|
||||||
|
<tr class="product-row" data-row-id="${rowId}">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<select class="form-control form-control-sm product-select" name="product_${rowId}" required>
|
||||||
|
<option value="">-- Выберите товар --</option>
|
||||||
|
{% for product in products %}
|
||||||
|
<option value="{{ product.id }}">{{ product.name }} ({{ product.sku }})</option>
|
||||||
|
{% empty %}
|
||||||
|
<option value="">Товары не найдены. Создайте товар в системе.</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<small class="text-muted available-qty" data-row-id="${rowId}">--</small>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<input type="number" class="form-control form-control-sm quantity-input"
|
||||||
|
name="quantity_${rowId}" value="1" step="0.001" min="0" required>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-center">
|
||||||
|
<button type="button" class="btn btn-sm btn-link text-danger p-0 delete-row-btn"
|
||||||
|
data-row-id="${rowId}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/)[0].replace(html.match(/<tr[^>]*>/)[0], '').slice(0, -5);
|
||||||
|
|
||||||
|
// Простой способ: добавляем HTML напрямую
|
||||||
|
const row = productsBody.insertAdjacentHTML('beforeend', html);
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
log(`Добавлена новая строка товара (#${rowId})`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для получения доступного количества товара
|
||||||
|
function fetchProductStock(productId, warehouseId, rowId) {
|
||||||
|
const availableQtyEl = document.querySelector(`.available-qty[data-row-id="${rowId}"]`);
|
||||||
|
|
||||||
|
if (!availableQtyEl) {
|
||||||
|
console.warn(`Element not found for rowId: ${rowId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!productId || !warehouseId) {
|
||||||
|
availableQtyEl.textContent = '--';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/inventory/api/product-stock/?product_id=${productId}&warehouse_id=${warehouseId}`;
|
||||||
|
console.log(`Fetching stock from: ${url}`);
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(response => {
|
||||||
|
console.log('Response status:', response.status);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
console.log('Response data:', data);
|
||||||
|
if (data.success) {
|
||||||
|
availableQtyEl.textContent = `${data.quantity} шт`;
|
||||||
|
} else {
|
||||||
|
availableQtyEl.textContent = `Ошибка: ${data.error || 'неизвестная ошибка'}`;
|
||||||
|
console.error('API error:', data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
availableQtyEl.textContent = '--';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить строку товара
|
||||||
|
productsBody.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.delete-row-btn')) {
|
||||||
|
const rowId = e.target.closest('.delete-row-btn').dataset.rowId;
|
||||||
|
const row = document.querySelector(`[data-row-id="${rowId}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.classList.add('product-row-delete');
|
||||||
|
setTimeout(() => {
|
||||||
|
row.remove();
|
||||||
|
updateUI();
|
||||||
|
log(`Удалена строка товара (#${rowId})`, 'info');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить JSON и UI
|
||||||
|
function updateUI() {
|
||||||
|
products = [];
|
||||||
|
let totalQty = 0;
|
||||||
|
|
||||||
|
productsBody.querySelectorAll('tr.product-row').forEach(row => {
|
||||||
|
const rowId = row.dataset.rowId;
|
||||||
|
const productSelect = row.querySelector('.product-select');
|
||||||
|
const quantityInput = row.querySelector('.quantity-input');
|
||||||
|
|
||||||
|
if (productSelect.value && quantityInput.value) {
|
||||||
|
const productId = parseInt(productSelect.value);
|
||||||
|
const quantity = parseFloat(quantityInput.value);
|
||||||
|
|
||||||
|
if (quantity > 0) {
|
||||||
|
products.push({
|
||||||
|
product_id: productId,
|
||||||
|
quantity: quantity
|
||||||
|
});
|
||||||
|
totalQty += quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
productsJson.value = JSON.stringify(products);
|
||||||
|
totalItemsEl.textContent = products.length;
|
||||||
|
totalQuantityEl.textContent = totalQty.toFixed(3);
|
||||||
|
|
||||||
|
// Показываем/скрываем пустое состояние
|
||||||
|
if (products.length === 0) {
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчики событий
|
||||||
|
addProductBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
addProductRow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Когда выбор склада отгрузки изменяется, обновляем доступные количества для всех товаров
|
||||||
|
fromWarehouseSelect.addEventListener('change', function() {
|
||||||
|
const warehouseId = this.value;
|
||||||
|
productsBody.querySelectorAll('.product-row').forEach(row => {
|
||||||
|
const rowId = row.dataset.rowId;
|
||||||
|
const productSelect = row.querySelector('.product-select');
|
||||||
|
const productId = productSelect.value;
|
||||||
|
if (productId && warehouseId) {
|
||||||
|
fetchProductStock(productId, warehouseId, rowId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем JSON при изменении значений
|
||||||
|
productsBody.addEventListener('change', function(e) {
|
||||||
|
updateUI();
|
||||||
|
// Если изменилось поле выбора товара, загружаем доступное количество
|
||||||
|
if (e.target.classList.contains('product-select')) {
|
||||||
|
const row = e.target.closest('.product-row');
|
||||||
|
const rowId = row.dataset.rowId;
|
||||||
|
const productId = e.target.value;
|
||||||
|
const warehouseId = fromWarehouseSelect.value;
|
||||||
|
console.log('Product selected:', {productId, warehouseId, rowId});
|
||||||
|
log(`Выбран товар ID ${productId}`, 'info');
|
||||||
|
fetchProductStock(productId, warehouseId, rowId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
productsBody.addEventListener('input', updateUI);
|
||||||
|
|
||||||
|
// Валидация формы перед отправкой
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Проверяем что товары есть
|
||||||
|
if (products.length === 0) {
|
||||||
|
log('Ошибка: необходимо добавить хотя бы один товар', 'error');
|
||||||
|
const errorDiv = document.getElementById('errorContainer');
|
||||||
|
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Необходимо добавить хотя бы один товар для перемещения.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем выбор складов
|
||||||
|
if (!fromWarehouseSelect.value || !toWarehouseSelect.value) {
|
||||||
|
log('Ошибка: необходимо выбрать оба склада', 'error');
|
||||||
|
const errorDiv = document.getElementById('errorContainer');
|
||||||
|
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Необходимо выбрать склад-отгрузки и склад-приемки.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromWarehouseSelect.value === toWarehouseSelect.value) {
|
||||||
|
log('Ошибка: склады не должны быть одинаковыми', 'error');
|
||||||
|
const errorDiv = document.getElementById('errorContainer');
|
||||||
|
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Склад-отгрузки и склад-приемки должны быть разными.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Отправка документа с ${products.length} товарами`, 'success');
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициальное состояние
|
||||||
|
updateUI();
|
||||||
|
log('Форма инициализирована', 'info');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Документ перемещения {{ transfer_batch.document_number }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4 py-3">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb breadcrumb-sm mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'inventory:transfer-list' %}">Перемещения</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ transfer_batch.document_number }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Основная информация о документе -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-light py-3">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-arrow-left-right me-2"></i>{{ transfer_batch.document_number }}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="text-muted small mb-1">Склад-отгрузки</p>
|
||||||
|
<p class="fw-semibold">{{ transfer_batch.from_warehouse.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="text-muted small mb-1">Склад-приемки</p>
|
||||||
|
<p class="fw-semibold">{{ transfer_batch.to_warehouse.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if transfer_batch.notes %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-muted small mb-1">Примечания</p>
|
||||||
|
<p>{{ transfer_batch.notes }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="text-muted small mb-1">Дата создания</p>
|
||||||
|
<p class="fw-semibold">{{ transfer_batch.created_at|date:"d.m.Y H:i" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="text-muted small mb-1">Последнее обновление</p>
|
||||||
|
<p class="fw-semibold">{{ transfer_batch.updated_at|date:"d.m.Y H:i" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица товаров -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-light py-3">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-table me-2"></i>Товары в документе</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-bottom">
|
||||||
|
<th scope="col" class="px-3 py-2">Товар</th>
|
||||||
|
<th scope="col" class="px-3 py-2" style="text-align: right;">Количество</th>
|
||||||
|
<th scope="col" class="px-3 py-2" style="text-align: right;">Цена партии</th>
|
||||||
|
<th scope="col" class="px-3 py-2">Исходная партия</th>
|
||||||
|
<th scope="col" class="px-3 py-2">Новая партия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
|
||||||
|
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{% if item.new_batch %}
|
||||||
|
<span class="badge bg-success">{{ item.new_batch.id }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-3 py-2 text-muted text-center">
|
||||||
|
Товаров не найдено
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Сводка справа -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Статистика -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-1"></i>Статистика</h6>
|
||||||
|
|
||||||
|
<div class="mb-3 p-2 rounded" style="background: #f8f9fa;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="text-muted small">Позиций:</span>
|
||||||
|
<span class="fw-semibold">{{ total_items }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Всего товара:</span>
|
||||||
|
<span class="fw-semibold">{{ total_qty }} шт</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Действия -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-3"><i class="bi bi-gear me-1"></i>Действия</h6>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'inventory:transfer-delete' transfer_batch.id %}" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash me-1"></i>Удалить
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breadcrumb-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,5 +1,55 @@
|
|||||||
{% extends 'inventory/base_inventory.html' %}
|
{% extends 'inventory/base_inventory.html' %}
|
||||||
{% load inventory_filters %}
|
{% load inventory_filters %}
|
||||||
{% block inventory_title %}Перемещение товаров{% endblock %}
|
{% block inventory_title %}Перемещение товаров{% endblock %}
|
||||||
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Перемещение товаров между складами <a href="{% url 'inventory:transfer-create' %}" class="btn btn-primary btn-sm float-end"><i class="bi bi-plus-circle"></i> Новое</a></h4></div><div class="card-body">{% if transfers %}<div class="table-responsive"><table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Из</th><th>В</th><th>Кол-во</th><th>Дата</th><th class="text-end">Действия</th></tr></thead><tbody>{% for t in transfers %}<tr><td>{{ t.batch.product.name }}</td><td>{{ t.from_warehouse.name }}</td><td>{{ t.to_warehouse.name }}</td><td>{{ t.quantity|smart_quantity }}</td><td>{{ t.date|date:"d.m.Y" }}</td><td class="text-end"><a href="{% url 'inventory:transfer-update' t.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a><a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></a></td></tr>{% endfor %}</tbody></table></div>{% else %}<div class="alert alert-info">Перемещений не найдено.</div>{% endif %}</div></div>
|
{% block inventory_content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0">Перемещение товаров между складами
|
||||||
|
<a href="{% url 'inventory:transfer-create' %}" class="btn btn-primary btn-sm float-end">
|
||||||
|
<i class="bi bi-plus-circle"></i> Новое
|
||||||
|
</a>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if transfers %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Номер документа</th>
|
||||||
|
<th>Из</th>
|
||||||
|
<th>В</th>
|
||||||
|
<th>Товаров</th>
|
||||||
|
<th>Дата создания</th>
|
||||||
|
<th class="text-end">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in transfers %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'inventory:transfer-detail' t.pk %}">{{ t.document_number }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ t.from_warehouse.name }}</td>
|
||||||
|
<td>{{ t.to_warehouse.name }}</td>
|
||||||
|
<td>{{ t.items.count }}</td>
|
||||||
|
<td>{{ t.created_at|date:"d.m.Y H:i" }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">Перемещений не найдено.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from .views import (
|
|||||||
# WriteOff
|
# WriteOff
|
||||||
WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView,
|
WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView,
|
||||||
# Transfer
|
# Transfer
|
||||||
TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView,
|
TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView,
|
||||||
# Reservation
|
# Reservation
|
||||||
ReservationListView, ReservationCreateView, ReservationUpdateView,
|
ReservationListView, ReservationCreateView, ReservationUpdateView,
|
||||||
# Stock
|
# Stock
|
||||||
@@ -72,9 +72,10 @@ urlpatterns = [
|
|||||||
|
|
||||||
# ==================== TRANSFER ====================
|
# ==================== TRANSFER ====================
|
||||||
path('transfers/', TransferListView.as_view(), name='transfer-list'),
|
path('transfers/', TransferListView.as_view(), name='transfer-list'),
|
||||||
path('transfers/create/', TransferCreateView.as_view(), name='transfer-create'),
|
path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения
|
||||||
path('transfers/<int:pk>/edit/', TransferUpdateView.as_view(), name='transfer-update'),
|
path('transfers/<int:pk>/', TransferDetailView.as_view(), name='transfer-detail'), # Деталь документа
|
||||||
path('transfers/<int:pk>/delete/', TransferDeleteView.as_view(), name='transfer-delete'),
|
path('transfers/<int:pk>/delete/', TransferDeleteView.as_view(), name='transfer-delete'),
|
||||||
|
path('api/product-stock/', GetProductStockView.as_view(), name='api-product-stock'), # API для получения количества товара
|
||||||
|
|
||||||
# ==================== RESERVATION ====================
|
# ==================== RESERVATION ====================
|
||||||
path('reservations/', ReservationListView.as_view(), name='reservation-list'),
|
path('reservations/', ReservationListView.as_view(), name='reservation-list'),
|
||||||
|
|||||||
12
myproject/inventory/utils/__init__.py
Normal file
12
myproject/inventory/utils/__init__.py
Normal file
@@ -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',
|
||||||
|
]
|
||||||
90
myproject/inventory/utils/document_generator.py
Normal file
90
myproject/inventory/utils/document_generator.py
Normal file
@@ -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
|
||||||
@@ -27,7 +27,7 @@ from .inventory_ops import (
|
|||||||
InventoryLineCreateBulkView
|
InventoryLineCreateBulkView
|
||||||
)
|
)
|
||||||
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView
|
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 .reservation import ReservationListView, ReservationCreateView, ReservationUpdateView
|
||||||
from .stock import StockListView, StockDetailView
|
from .stock import StockListView, StockDetailView
|
||||||
from .allocation import SaleBatchAllocationListView
|
from .allocation import SaleBatchAllocationListView
|
||||||
@@ -58,7 +58,7 @@ __all__ = [
|
|||||||
# WriteOff
|
# WriteOff
|
||||||
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
|
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
|
||||||
# Transfer
|
# Transfer
|
||||||
'TransferListView', 'TransferCreateView', 'TransferUpdateView', 'TransferDeleteView',
|
'TransferListView', 'TransferBulkCreateView', 'TransferDetailView', 'TransferDeleteView', 'GetProductStockView',
|
||||||
# Reservation
|
# Reservation
|
||||||
'ReservationListView', 'ReservationCreateView', 'ReservationUpdateView',
|
'ReservationListView', 'ReservationCreateView', 'ReservationUpdateView',
|
||||||
# Stock
|
# Stock
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.utils.decorators import method_decorator
|
|||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from ..models import Incoming, IncomingBatch, Warehouse
|
from ..models import Incoming, IncomingBatch, Warehouse
|
||||||
from ..forms import IncomingForm, IncomingLineForm
|
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
|
from products.models import Product
|
||||||
|
|
||||||
file_logger = logging.getLogger('incoming_sequence_file')
|
file_logger = logging.getLogger('incoming_sequence_file')
|
||||||
|
|||||||
@@ -3,58 +3,235 @@
|
|||||||
Transfer (Перемещение товара между складами) views
|
Transfer (Перемещение товара между складами) views
|
||||||
GROUP 2: MEDIUM PRIORITY
|
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.urls import reverse_lazy
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from ..models import Transfer
|
from django.http import JsonResponse
|
||||||
from ..forms import TransferForm
|
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):
|
class TransferListView(LoginRequiredMixin, ListView):
|
||||||
model = Transfer
|
"""
|
||||||
|
View для просмотра списка документов перемещений товаров.
|
||||||
|
"""
|
||||||
|
model = TransferBatch
|
||||||
template_name = 'inventory/transfer/transfer_list.html'
|
template_name = 'inventory/transfer/transfer_list.html'
|
||||||
context_object_name = 'transfers'
|
context_object_name = 'transfers'
|
||||||
paginate_by = 20
|
paginate_by = 20
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Transfer.objects.select_related(
|
return TransferBatch.objects.select_related(
|
||||||
'batch', 'batch__product',
|
|
||||||
'from_warehouse', 'to_warehouse'
|
'from_warehouse', 'to_warehouse'
|
||||||
).order_by('-date')
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
|
||||||
class TransferCreateView(LoginRequiredMixin, CreateView):
|
# ============================================================================
|
||||||
model = Transfer
|
# VIEWS ДЛЯ ПЕРЕМЕЩЕНИЯ ТОВАРОВ (TransferBatch + TransferItem)
|
||||||
form_class = TransferForm
|
# ============================================================================
|
||||||
template_name = 'inventory/transfer/transfer_form.html'
|
|
||||||
success_url = reverse_lazy('inventory:transfer-list')
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
class TransferBulkCreateView(LoginRequiredMixin, View):
|
||||||
messages.success(
|
"""
|
||||||
self.request,
|
View для создания документа перемещения товаров между складами с FIFO логикой.
|
||||||
f'Перемещение товара "{form.instance.batch.product.name}" успешно зарегистрировано.'
|
Один документ может содержать несколько товаров.
|
||||||
|
"""
|
||||||
|
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
|
items = transfer_batch.items.all()
|
||||||
form_class = TransferForm
|
total_items = items.count()
|
||||||
template_name = 'inventory/transfer/transfer_form.html'
|
total_qty = sum(Decimal(str(item.quantity)) for item in items)
|
||||||
success_url = reverse_lazy('inventory:transfer-list')
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
context['total_items'] = total_items
|
||||||
messages.success(self.request, f'Перемещение товара обновлено.')
|
context['total_qty'] = total_qty
|
||||||
return super().form_valid(form)
|
context['items'] = items
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class TransferDeleteView(LoginRequiredMixin, DeleteView):
|
class TransferDeleteView(LoginRequiredMixin, DeleteView):
|
||||||
model = Transfer
|
"""
|
||||||
|
View для удаления документа перемещения.
|
||||||
|
"""
|
||||||
|
model = TransferBatch
|
||||||
template_name = 'inventory/transfer/transfer_confirm_delete.html'
|
template_name = 'inventory/transfer/transfer_confirm_delete.html'
|
||||||
success_url = reverse_lazy('inventory:transfer-list')
|
success_url = reverse_lazy('inventory:transfer-list')
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
transfer = self.get_object()
|
transfer_batch = self.get_object()
|
||||||
messages.success(self.request, f'Перемещение товара отменено.')
|
messages.success(self.request, f'Документ перемещения {transfer_batch.document_number} удалён.')
|
||||||
return super().form_valid(form)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user