Текущее состояние перед рефакторингом Transfer → TransferDocument. Все изменения с последнего коммита по улучшению системы поступлений. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
564 lines
24 KiB
Python
564 lines
24 KiB
Python
# -*- coding: utf-8 -*-
|
||
from django import forms
|
||
from django.core.exceptions import ValidationError
|
||
from decimal import Decimal
|
||
|
||
from .models import (
|
||
Warehouse, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
|
||
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock,
|
||
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, TransformationOutput
|
||
)
|
||
from products.models import Product
|
||
|
||
|
||
class WarehouseForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Warehouse
|
||
fields = [
|
||
'name',
|
||
'description',
|
||
'street',
|
||
'building_number',
|
||
'phone',
|
||
'email',
|
||
'is_active',
|
||
'is_default',
|
||
'is_pickup_point'
|
||
]
|
||
widgets = {
|
||
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Название склада'}),
|
||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Описание'}),
|
||
'street': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Улица'}),
|
||
'building_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Номер дома'}),
|
||
'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '+375 (29) 123-45-67'}),
|
||
'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'email@example.com'}),
|
||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'is_pickup_point': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
}
|
||
help_texts = {
|
||
'is_default': 'Автоматически выбирается при создании новых документов',
|
||
'is_pickup_point': 'Можно ли выбрать этот склад как точку самовывоза заказа',
|
||
}
|
||
|
||
|
||
class SaleForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Sale
|
||
fields = ['product', 'warehouse', 'quantity', 'sale_price', 'order', 'document_number']
|
||
widgets = {
|
||
'product': forms.Select(attrs={'class': 'form-control'}),
|
||
'warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
||
'sale_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||
'order': forms.Select(attrs={'class': 'form-control'}),
|
||
'document_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||
}
|
||
|
||
def clean_quantity(self):
|
||
quantity = self.cleaned_data.get('quantity')
|
||
if quantity and quantity <= 0:
|
||
raise ValidationError('Количество должно быть больше нуля')
|
||
return quantity
|
||
|
||
def clean_sale_price(self):
|
||
sale_price = self.cleaned_data.get('sale_price')
|
||
if sale_price and sale_price < 0:
|
||
raise ValidationError('Цена не может быть отрицательной')
|
||
return sale_price
|
||
|
||
|
||
class WriteOffForm(forms.ModelForm):
|
||
class Meta:
|
||
model = WriteOff
|
||
fields = ['batch', 'quantity', 'reason', 'document_number', 'notes']
|
||
widgets = {
|
||
'batch': forms.Select(attrs={'class': 'form-control'}),
|
||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
||
'reason': forms.Select(attrs={'class': 'form-control'}),
|
||
'document_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
# Фильтруем партии - показываем только активные
|
||
self.fields['batch'].queryset = StockBatch.objects.filter(
|
||
is_active=True
|
||
).select_related('product', 'warehouse').order_by('-created_at')
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
batch = cleaned_data.get('batch')
|
||
quantity = cleaned_data.get('quantity')
|
||
|
||
if batch and quantity:
|
||
if quantity > batch.quantity:
|
||
raise ValidationError(
|
||
f'Невозможно списать {quantity} шт из партии, '
|
||
f'где только {batch.quantity} шт. '
|
||
f'Недостаток: {quantity - batch.quantity} шт.'
|
||
)
|
||
if quantity <= 0:
|
||
raise ValidationError('Количество должно быть больше нуля')
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class InventoryForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Inventory
|
||
fields = ['warehouse', 'notes']
|
||
widgets = {
|
||
'warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Фильтруем только активные склады (исключаем скрытые)
|
||
self.fields['warehouse'].queryset = Warehouse.objects.filter(is_active=True)
|
||
|
||
# Если есть склад по умолчанию и значение не установлено явно - предвыбираем его
|
||
if not self.initial.get('warehouse'):
|
||
default_warehouse = Warehouse.objects.filter(
|
||
is_active=True,
|
||
is_default=True
|
||
).first()
|
||
if default_warehouse:
|
||
self.initial['warehouse'] = default_warehouse.id
|
||
|
||
|
||
class InventoryLineForm(forms.ModelForm):
|
||
class Meta:
|
||
model = InventoryLine
|
||
fields = ['product', 'quantity_system', 'quantity_fact']
|
||
widgets = {
|
||
'product': forms.Select(attrs={'class': 'form-control'}),
|
||
'quantity_system': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'readonly': True}),
|
||
'quantity_fact': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
||
}
|
||
|
||
def clean_quantity_fact(self):
|
||
quantity_fact = self.cleaned_data.get('quantity_fact')
|
||
if quantity_fact and quantity_fact < 0:
|
||
raise ValidationError('Количество не может быть отрицательным')
|
||
return quantity_fact
|
||
|
||
|
||
# ============================================================================
|
||
# 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(status='active').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
|
||
|
||
|
||
# ============================================================================
|
||
# WRITEOFF DOCUMENT FORMS - Документы списания
|
||
# ============================================================================
|
||
|
||
class WriteOffDocumentForm(forms.ModelForm):
|
||
"""
|
||
Форма создания/редактирования документа списания.
|
||
"""
|
||
class Meta:
|
||
model = WriteOffDocument
|
||
fields = ['warehouse', 'date', 'notes']
|
||
widgets = {
|
||
'warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||
'date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к документу'}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.fields['warehouse'].queryset = Warehouse.objects.filter(is_active=True)
|
||
|
||
# Устанавливаем дату по умолчанию - сегодня
|
||
if not self.initial.get('date'):
|
||
from django.utils import timezone
|
||
self.initial['date'] = timezone.now().date()
|
||
|
||
# Если есть склад по умолчанию - предвыбираем его
|
||
if not self.initial.get('warehouse'):
|
||
default_warehouse = Warehouse.objects.filter(
|
||
is_active=True,
|
||
is_default=True
|
||
).first()
|
||
if default_warehouse:
|
||
self.initial['warehouse'] = default_warehouse.id
|
||
|
||
|
||
class WriteOffDocumentItemForm(forms.ModelForm):
|
||
"""
|
||
Форма добавления/редактирования позиции в документ списания.
|
||
"""
|
||
class Meta:
|
||
model = WriteOffDocumentItem
|
||
fields = ['product', 'quantity', 'reason', 'notes']
|
||
widgets = {
|
||
'product': forms.Select(attrs={'class': 'form-control'}),
|
||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0.001'}),
|
||
'reason': forms.Select(attrs={'class': 'form-control'}),
|
||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Примечания'}),
|
||
}
|
||
|
||
def __init__(self, *args, document=None, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.document = document
|
||
|
||
if document:
|
||
# Фильтруем товары - только те, что есть на складе
|
||
products_with_stock = Stock.objects.filter(
|
||
warehouse=document.warehouse,
|
||
quantity_available__gt=0
|
||
).values_list('product_id', flat=True)
|
||
|
||
self.fields['product'].queryset = Product.objects.filter(
|
||
id__in=products_with_stock,
|
||
status='active'
|
||
).order_by('name')
|
||
else:
|
||
self.fields['product'].queryset = Product.objects.filter(
|
||
status='active'
|
||
).order_by('name')
|
||
|
||
def clean_quantity(self):
|
||
quantity = self.cleaned_data.get('quantity')
|
||
if quantity and quantity <= 0:
|
||
raise ValidationError('Количество должно быть больше нуля')
|
||
return quantity
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
product = cleaned_data.get('product')
|
||
quantity = cleaned_data.get('quantity')
|
||
|
||
if product and quantity and self.document:
|
||
stock = Stock.objects.filter(
|
||
product=product,
|
||
warehouse=self.document.warehouse
|
||
).first()
|
||
|
||
if not stock:
|
||
raise ValidationError({
|
||
'product': f'Товар "{product.name}" отсутствует на складе'
|
||
})
|
||
|
||
available = stock.quantity_available - stock.quantity_reserved
|
||
|
||
# Учитываем текущий резерв при редактировании
|
||
if self.instance.pk and self.instance.reservation:
|
||
available += self.instance.reservation.quantity
|
||
|
||
if quantity > available:
|
||
raise ValidationError({
|
||
'quantity': f'Недостаточно свободного товара. '
|
||
f'Доступно: {available}, запрашивается: {quantity}'
|
||
})
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class IncomingDocumentForm(forms.ModelForm):
|
||
"""
|
||
Форма создания/редактирования документа поступления.
|
||
"""
|
||
class Meta:
|
||
model = IncomingDocument
|
||
fields = ['warehouse', 'date', 'receipt_type', 'supplier_name', 'notes']
|
||
widgets = {
|
||
'warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||
'date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||
'receipt_type': forms.Select(attrs={'class': 'form-control'}),
|
||
'supplier_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Наименование поставщика'}),
|
||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к документу'}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.fields['warehouse'].queryset = Warehouse.objects.filter(is_active=True)
|
||
|
||
# Устанавливаем дату по умолчанию - сегодня
|
||
if not self.initial.get('date'):
|
||
from django.utils import timezone
|
||
self.initial['date'] = timezone.now().date()
|
||
|
||
# Если есть склад по умолчанию - предвыбираем его
|
||
if not self.initial.get('warehouse'):
|
||
default_warehouse = Warehouse.objects.filter(
|
||
is_active=True,
|
||
is_default=True
|
||
).first()
|
||
if default_warehouse:
|
||
self.initial['warehouse'] = default_warehouse.id
|
||
|
||
# Устанавливаем тип поступления по умолчанию
|
||
if not self.initial.get('receipt_type'):
|
||
self.initial['receipt_type'] = 'supplier'
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
receipt_type = cleaned_data.get('receipt_type')
|
||
supplier_name = cleaned_data.get('supplier_name')
|
||
|
||
# Для типа 'supplier' supplier_name обязателен
|
||
if receipt_type == 'supplier' and not supplier_name:
|
||
raise ValidationError({
|
||
'supplier_name': 'Для типа "Поступление от поставщика" необходимо указать наименование поставщика'
|
||
})
|
||
|
||
# Для других типов supplier_name не нужен
|
||
if receipt_type != 'supplier' and supplier_name:
|
||
cleaned_data['supplier_name'] = None
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class IncomingDocumentItemForm(forms.ModelForm):
|
||
"""
|
||
Форма добавления/редактирования позиции в документ поступления.
|
||
"""
|
||
class Meta:
|
||
model = IncomingDocumentItem
|
||
fields = ['product', 'quantity', 'cost_price', 'notes']
|
||
widgets = {
|
||
'product': forms.Select(attrs={'class': 'form-control'}),
|
||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0.001'}),
|
||
'cost_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
|
||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Примечания'}),
|
||
}
|
||
|
||
def __init__(self, *args, document=None, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.document = document
|
||
|
||
# Для поступлений можно выбрать любой активный товар (не нужно проверять наличие на складе)
|
||
self.fields['product'].queryset = Product.objects.filter(
|
||
status='active'
|
||
).order_by('name')
|
||
|
||
def clean_quantity(self):
|
||
quantity = self.cleaned_data.get('quantity')
|
||
if quantity and quantity <= 0:
|
||
raise ValidationError('Количество должно быть больше нуля')
|
||
return quantity
|
||
|
||
def clean_cost_price(self):
|
||
cost_price = self.cleaned_data.get('cost_price')
|
||
if cost_price is not None and cost_price < 0:
|
||
raise ValidationError('Закупочная цена не может быть отрицательной')
|
||
return cost_price
|
||
|
||
|
||
# ==================== TRANSFORMATION FORMS ====================
|
||
|
||
class TransformationForm(forms.ModelForm):
|
||
"""Форма для создания документа трансформации"""
|
||
|
||
class Meta:
|
||
model = Transformation
|
||
fields = ['warehouse', 'comment']
|
||
widgets = {
|
||
'warehouse': forms.Select(attrs={'class': 'form-select'}),
|
||
'comment': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 3,
|
||
'placeholder': 'Комментарий (необязательно)'
|
||
}),
|
||
}
|
||
|
||
|
||
class TransformationInputForm(forms.Form):
|
||
"""Форма для добавления входного товара в трансформацию"""
|
||
|
||
product = forms.ModelChoiceField(
|
||
queryset=Product.objects.filter(status='active').order_by('name'),
|
||
label='Товар (что списываем)',
|
||
widget=forms.Select(attrs={
|
||
'class': 'form-select',
|
||
'id': 'id_input_product'
|
||
})
|
||
)
|
||
quantity = forms.DecimalField(
|
||
label='Количество',
|
||
min_value=Decimal('0.001'),
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
widget=forms.NumberInput(attrs={
|
||
'class': 'form-control',
|
||
'step': '0.001',
|
||
'placeholder': '0.000',
|
||
'id': 'id_input_quantity'
|
||
})
|
||
)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
self.transformation = kwargs.pop('transformation', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
product = cleaned_data.get('product')
|
||
quantity = cleaned_data.get('quantity')
|
||
|
||
if product and quantity:
|
||
# Проверяем что товар еще не добавлен
|
||
if self.transformation and self.transformation.inputs.filter(product=product).exists():
|
||
raise ValidationError(f'Товар "{product.name}" уже добавлен в качестве входного')
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class TransformationOutputForm(forms.Form):
|
||
"""Форма для добавления выходного товара в трансформацию"""
|
||
|
||
product = forms.ModelChoiceField(
|
||
queryset=Product.objects.filter(status='active').order_by('name'),
|
||
label='Товар (что получаем)',
|
||
widget=forms.Select(attrs={
|
||
'class': 'form-select',
|
||
'id': 'id_output_product'
|
||
})
|
||
)
|
||
quantity = forms.DecimalField(
|
||
label='Количество',
|
||
min_value=Decimal('0.001'),
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
widget=forms.NumberInput(attrs={
|
||
'class': 'form-control',
|
||
'step': '0.001',
|
||
'placeholder': '0.000',
|
||
'id': 'id_output_quantity'
|
||
})
|
||
)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
self.transformation = kwargs.pop('transformation', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
product = cleaned_data.get('product')
|
||
quantity = cleaned_data.get('quantity')
|
||
|
||
if product and quantity:
|
||
# Проверяем что товар еще не добавлен
|
||
if self.transformation and self.transformation.outputs.filter(product=product).exists():
|
||
raise ValidationError(f'Товар "{product.name}" уже добавлен в качестве выходного')
|
||
|
||
# Проверяем что сумма выходных не превышает сумму входных
|
||
if self.transformation:
|
||
total_input = sum(
|
||
trans_input.quantity for trans_input in self.transformation.inputs.all()
|
||
)
|
||
total_output_existing = sum(
|
||
trans_output.quantity for trans_output in self.transformation.outputs.all()
|
||
)
|
||
total_output_new = total_output_existing + quantity
|
||
|
||
if total_output_new > total_input:
|
||
raise ValidationError(
|
||
f'Сумма выходных количеств ({total_output_new}) не может превышать '
|
||
f'сумму входных количеств ({total_input}). '
|
||
f'Максимально можно добавить: {total_input - total_output_existing}'
|
||
)
|
||
|
||
return cleaned_data
|
||
|