- Remove WriteOffForm from forms.py and add comment directing to WriteOffDocumentForm - Update navigation templates to remove writeoff links and sections - Add 'Сумма' column to sale list with multiplication filter - Delete writeoff-related templates (list, form, confirm delete) - Add 'multiply' filter to inventory_filters.py for calculations - Comment out writeoff URLs in urls.py, keeping WriteOff model for automatic creation - Remove WriteOff views from __init__.py and delete writeoff.py view file This change simplifies writeoff management by removing direct individual writeoff operations and enforcing use of WriteOffDocument for all writeoffs, with WriteOff records created automatically upon document processing.
530 lines
22 KiB
Python
530 lines
22 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, Reservation, Inventory, InventoryLine, StockBatch,
|
||
TransferDocument, TransferDocumentItem, 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
|
||
|
||
|
||
# WriteOffForm удалён - используйте WriteOffDocumentForm для работы с документами списания
|
||
|
||
|
||
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 = TransferDocument
|
||
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
|
||
|