feat: Реализовать систему поступления товаров с партиями (IncomingBatch)
Основные изменения: - Создана модель IncomingBatch для группировки товаров по документам - Каждое поступление (Incoming) связано с одной батчем поступления - Автоматическое создание StockBatch для каждого товара в приходе - Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД - Обновлены все представления (views) для работы с новой архитектурой - Добавлены детальные страницы просмотра партий поступлений - Обновлены шаблоны для отображения информации о партиях и их товарах - Исправлена логика сигналов для создания StockBatch при приходе товара - Обновлены формы для работы с новой структурой IncomingBatch Архитектура FIFO: - IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX) - Incoming: товар в партии поступления - StockBatch: одна партия товара на складе (создается для каждого товара) Это позволяет системе правильно применять FIFO при продаже товаров. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
305
myproject/inventory/forms.py
Normal file
305
myproject/inventory/forms.py
Normal file
@@ -0,0 +1,305 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch
|
||||
from products.models import Product
|
||||
|
||||
|
||||
class WarehouseForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Warehouse
|
||||
fields = ['name', 'description', 'is_active']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
|
||||
|
||||
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 TransferForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Transfer
|
||||
fields = ['batch', 'from_warehouse', 'to_warehouse', 'quantity', 'document_number']
|
||||
widgets = {
|
||||
'batch': forms.Select(attrs={'class': 'form-control'}),
|
||||
'from_warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||||
'to_warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
||||
'document_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
batch = cleaned_data.get('batch')
|
||||
quantity = cleaned_data.get('quantity')
|
||||
from_warehouse = cleaned_data.get('from_warehouse')
|
||||
|
||||
if batch and quantity:
|
||||
if quantity > batch.quantity:
|
||||
raise ValidationError(
|
||||
f'Невозможно перенести {quantity} шт, доступно {batch.quantity} шт'
|
||||
)
|
||||
if quantity <= 0:
|
||||
raise ValidationError('Количество должно быть больше нуля')
|
||||
|
||||
# Проверяем что складской источник совпадает с складом партии
|
||||
if from_warehouse and batch.warehouse_id != from_warehouse.id:
|
||||
raise ValidationError(
|
||||
f'Партия находится на складе "{batch.warehouse.name}", '
|
||||
f'а вы выбрали "{from_warehouse.name}"'
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ReservationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Reservation
|
||||
fields = ['product', 'warehouse', 'quantity', 'order_item']
|
||||
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'}),
|
||||
'order_item': forms.Select(attrs={'class': 'form-control'}),
|
||||
}
|
||||
|
||||
def clean_quantity(self):
|
||||
quantity = self.cleaned_data.get('quantity')
|
||||
if quantity and quantity <= 0:
|
||||
raise ValidationError('Количество должно быть больше нуля')
|
||||
return quantity
|
||||
|
||||
|
||||
class InventoryForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Inventory
|
||||
fields = ['warehouse', 'conducted_by', 'notes']
|
||||
widgets = {
|
||||
'warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||||
'conducted_by': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# INCOMING FORMS - Ввод товаров (один или много) от одного поставщика
|
||||
# ============================================================================
|
||||
|
||||
class IncomingHeaderForm(forms.Form):
|
||||
"""
|
||||
Форма для общей информации при приходе товаров.
|
||||
Используется для ввода информации об источнике поступления (склад, номер документа, поставщик).
|
||||
"""
|
||||
warehouse = forms.ModelChoiceField(
|
||||
queryset=Warehouse.objects.filter(is_active=True),
|
||||
widget=forms.Select(attrs={'class': 'form-control'}),
|
||||
label="Склад",
|
||||
required=True
|
||||
)
|
||||
|
||||
document_number = forms.CharField(
|
||||
max_length=100,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'PO-2024-001 (опционально)'}),
|
||||
label="Номер документа / ПО",
|
||||
required=False
|
||||
)
|
||||
|
||||
supplier_name = forms.CharField(
|
||||
max_length=200,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}),
|
||||
label="Наименование поставщика",
|
||||
required=False
|
||||
)
|
||||
|
||||
notes = forms.CharField(
|
||||
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Дополнительная информация'}),
|
||||
label="Примечания",
|
||||
required=False
|
||||
)
|
||||
|
||||
def clean_document_number(self):
|
||||
document_number = self.cleaned_data.get('document_number', '')
|
||||
if document_number:
|
||||
document_number = document_number.strip()
|
||||
# Запретить номера, начинающиеся с "IN-" (зарезервировано для системы)
|
||||
if document_number.upper().startswith('IN-'):
|
||||
raise ValidationError(
|
||||
'Номера, начинающиеся с "IN-", зарезервированы для системы автогенерации. '
|
||||
'Оставьте поле пустым для автогенерации или используйте другой формат.'
|
||||
)
|
||||
return document_number
|
||||
|
||||
|
||||
class IncomingLineForm(forms.Form):
|
||||
"""
|
||||
Форма для одной строки товара при массовом приходе.
|
||||
Используется в formset'е для динамического ввода нескольких товаров.
|
||||
"""
|
||||
product = forms.ModelChoiceField(
|
||||
queryset=None, # Будет установлено в __init__
|
||||
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
|
||||
)
|
||||
|
||||
cost_price = forms.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
label="Цена закупки за ед.",
|
||||
required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Устанавливаем queryset товаров для поля product
|
||||
self.fields['product'].queryset = Product.objects.filter(
|
||||
is_active=True
|
||||
).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 and cost_price < 0:
|
||||
raise ValidationError('Цена не может быть отрицательной')
|
||||
return cost_price
|
||||
|
||||
|
||||
class IncomingForm(forms.Form):
|
||||
"""
|
||||
Комбинированная форма для ввода товаров (один или много).
|
||||
Содержит header информацию (склад, документ, поставщик) + динамический набор товаров.
|
||||
"""
|
||||
warehouse = forms.ModelChoiceField(
|
||||
queryset=Warehouse.objects.filter(is_active=True),
|
||||
widget=forms.Select(attrs={'class': 'form-control'}),
|
||||
label="Склад",
|
||||
required=True
|
||||
)
|
||||
|
||||
document_number = forms.CharField(
|
||||
max_length=100,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'PO-2024-001 (опционально)'}),
|
||||
label="Номер документа / ПО",
|
||||
required=False
|
||||
)
|
||||
|
||||
supplier_name = forms.CharField(
|
||||
max_length=200,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}),
|
||||
label="Наименование поставщика (опционально)",
|
||||
required=False
|
||||
)
|
||||
|
||||
notes = forms.CharField(
|
||||
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Дополнительная информация'}),
|
||||
label="Примечания",
|
||||
required=False
|
||||
)
|
||||
|
||||
def clean_document_number(self):
|
||||
document_number = self.cleaned_data.get('document_number', '')
|
||||
if document_number:
|
||||
document_number = document_number.strip()
|
||||
# Запретить номера, начинающиеся с "IN-" (зарезервировано для системы)
|
||||
if document_number.upper().startswith('IN-'):
|
||||
raise ValidationError(
|
||||
'Номера, начинающиеся с "IN-", зарезервированы для системы автогенерации. '
|
||||
'Оставьте поле пустым для автогенерации или используйте другой формат.'
|
||||
)
|
||||
return document_number
|
||||
Reference in New Issue
Block a user