Files
octopus/myproject/inventory/forms.py
Andrey Smakotin 7132d2c910 feat: Замена is_active на status для архивирования товаров
Реализована трёхуровневая система статусов товаров и комплектов:
- active (Активный) - товар доступен для продажи
- archived (Архивный) - скрыт, можно восстановить в следующем сезоне
- discontinued (Снят) - морально устарел, готов к удалению

Изменения:
1. Модели (BaseProductEntity, Product, ProductKit):
   - Заменено поле is_deleted (Boolean) на status (CharField)
   - Добавлены архивные метаданные (archived_at, archived_by)
   - Обновлены методы: archive(), restore(), discontinue(), delete()
   - Уникальное ограничение изменено на conditional (status='active')

2. Менеджеры (ActiveManager, SoftDeleteQuerySet):
   - Полиморфная поддержка обеих систем (status и is_active)
   - Использует hasattr() для совместимости с наследниками
   - Методы: archive(), restore(), discontinue(), archived_only(), active_only()

3. Формы (ProductForm, ProductKitForm):
   - Включены поле status в формы
   - Валидация уникальности по status='active'
   - CSS классы для статус-селектора

4. Admin панель:
   - DeletedFilter переименован в StatusFilter с тремя опциями
   - get_status_display() с цветным отображением статуса
   - Actions: restore_items, hard_delete_selected, delete_selected
   - Readonly поля для архивирования

5. Представления:
   - ProductListView: фильтр status вместо is_active
   - CombinedProductListView: поддержка фильтра status для товаров и комплектов
   - API views обновлены для работы со статусом

6. Шаблоны:
   - product_form.html: form.status вместо form.is_active
   - productkit_create.html: form.status вместо form.is_active
   - productkit_edit.html: form.status вместо form.is_active

7. Миграции:
   - Удалены все старые миграции (чистый перезапуск по требованию пользователя)
   - Создана новая миграция 0001_initial с полной структурой status-системы
   - Удален старый код преобразования is_deleted -> status

Проведённые проверки:
- Django system check passed ✓
- Полиморфные менеджеры работают с обеими системами
- Уникальные ограничения корректно работают с условиями
- История заказов сохраняется даже после архивирования товара (django-simple-history)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 15:30:23 +03:00

463 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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,
TransferBatch, TransferItem
)
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 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}),
}
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
# ============================================================================
# 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 __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Если есть склад по умолчанию и значение не установлено явно - предвыбираем его
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
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 __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Если есть склад по умолчанию и значение не установлено явно - предвыбираем его
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
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 IncomingModelForm(forms.ModelForm):
"""
ModelForm для редактирования отдельного товара в поступлении (Incoming).
Используется в IncomingUpdateView для редактирования существующих товаров.
"""
class Meta:
model = Incoming
fields = ['product', 'quantity', 'cost_price', 'notes']
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
'cost_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Фильтруем только активные товары
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
# ============================================================================
# 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