Добавлена система трансформации товаров

Реализована полная система трансформации товаров (превращение одного товара в другой).
Пример: белая гипсофила → крашеная гипсофила.

Особенности реализации:
- Резервирование входных товаров в статусе draft
- FIFO списание входных товаров при проведении
- Автоматический расчёт себестоимости выходных товаров
- Возможность отмены как черновиков, так и проведённых трансформаций

Модели (inventory/models.py):
- Transformation: документ трансформации (draft/completed/cancelled)
- TransformationInput: входные товары (списание)
- TransformationOutput: выходные товары (оприходование)
- Добавлен статус 'converted_to_transformation' в Reservation
- Добавлен тип 'transformation' в DocumentCounter

Бизнес-логика (inventory/services/transformation_service.py):
- TransformationService с методами CRUD
- Валидация наличия товаров
- Автоматическая генерация номеров документов

Сигналы (inventory/signals.py):
- Автоматическое резервирование входных товаров
- FIFO списание при проведении
- Создание партий выходных товаров
- Откат операций при отмене

Интерфейс без Django Admin:
- Список трансформаций (list.html)
- Форма создания (form.html)
- Детальный просмотр с добавлением товаров (detail.html)
- Интеграция с компонентом поиска товаров
- 8 views для полного CRUD + проведение/отмена

Миграция:
- 0003_alter_documentcounter_counter_type_and_more.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-25 18:27:31 +03:00
parent 56850e790e
commit 30ee077963
12 changed files with 1682 additions and 4 deletions

View File

@@ -8,7 +8,8 @@ from inventory.models import (
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
Inventory, InventoryLine, Reservation, Stock, StockMovement,
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
IncomingDocument, IncomingDocumentItem
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
TransformationOutput
)
@@ -512,3 +513,69 @@ class IncomingDocumentItemAdmin(admin.ModelAdmin):
def total_cost_display(self, obj):
return f"{obj.total_cost:.2f}"
total_cost_display.short_description = 'Сумма'
# ===== TRANSFORMATION =====
class TransformationInputInline(admin.TabularInline):
model = TransformationInput
extra = 1
fields = ['product', 'quantity']
autocomplete_fields = ['product']
class TransformationOutputInline(admin.TabularInline):
model = TransformationOutput
extra = 1
fields = ['product', 'quantity', 'stock_batch']
autocomplete_fields = ['product']
readonly_fields = ['stock_batch']
@admin.register(Transformation)
class TransformationAdmin(admin.ModelAdmin):
list_display = ['document_number', 'warehouse', 'status_display', 'date', 'employee', 'inputs_count', 'outputs_count']
list_filter = ['status', 'warehouse', 'date']
search_fields = ['document_number', 'comment']
readonly_fields = ['document_number', 'date', 'created_at', 'updated_at']
inlines = [TransformationInputInline, TransformationOutputInline]
autocomplete_fields = ['warehouse', 'employee']
fieldsets = (
('Основная информация', {
'fields': ('document_number', 'warehouse', 'status', 'employee')
}),
('Детали', {
'fields': ('comment', 'date', 'created_at', 'updated_at')
}),
)
def save_model(self, request, obj, form, change):
if not obj.pk:
# Генерируем номер документа при создании
from inventory.models import DocumentCounter
next_num = DocumentCounter.get_next_value('transformation')
obj.document_number = f"TR-{next_num:05d}"
obj.employee = request.user
super().save_model(request, obj, form, change)
def status_display(self, obj):
colors = {
'draft': '#6c757d',
'completed': '#28a745',
'cancelled': '#dc3545',
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, '#6c757d'),
obj.get_status_display()
)
status_display.short_description = 'Статус'
def inputs_count(self, obj):
return obj.inputs.count()
inputs_count.short_description = 'Входов'
def outputs_count(self, obj):
return obj.outputs.count()
outputs_count.short_description = 'Выходов'

View File

@@ -6,7 +6,7 @@ from decimal import Decimal
from .models import (
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock,
IncomingDocument, IncomingDocumentItem
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, TransformationOutput
)
from products.models import Product
@@ -650,3 +650,103 @@ class IncomingDocumentItemForm(forms.ModelForm):
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}" уже добавлен в качестве выходного')
return cleaned_data

View File

@@ -0,0 +1,90 @@
# Generated by Django 5.0.10 on 2025-12-25 14:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0002_initial'),
('products', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='documentcounter',
name='counter_type',
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация'), ('transformation', 'Трансформация товара')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
migrations.AlterField(
model_name='reservation',
name='status',
field=models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание'), ('converted_to_transformation', 'Преобразован в трансформацию')], default='reserved', max_length=30, verbose_name='Статус'),
),
migrations.CreateModel(
name='Transformation',
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='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('completed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('comment', models.TextField(blank=True, verbose_name='Комментарий')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
('employee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformations', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformations', to='inventory.warehouse', verbose_name='Склад')),
],
options={
'verbose_name': 'Трансформация товара',
'verbose_name_plural': 'Трансформации товаров',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='TransformationInput',
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='Количество')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_inputs', to='products.product', verbose_name='Товар')),
('transformation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inputs', to='inventory.transformation', verbose_name='Трансформация')),
],
options={
'verbose_name': 'Входной товар трансформации',
'verbose_name_plural': 'Входные товары трансформации',
},
),
migrations.AddField(
model_name='reservation',
name='transformation_input',
field=models.ForeignKey(blank=True, help_text='Резерв для входного товара трансформации (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.transformationinput', verbose_name='Входной товар трансформации'),
),
migrations.CreateModel(
name='TransformationOutput',
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='Количество')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_outputs', to='products.product', verbose_name='Товар')),
('stock_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformation_outputs', to='inventory.stockbatch', verbose_name='Созданная партия')),
('transformation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outputs', to='inventory.transformation', verbose_name='Трансформация')),
],
options={
'verbose_name': 'Выходной товар трансформации',
'verbose_name_plural': 'Выходные товары трансформации',
},
),
migrations.AddIndex(
model_name='transformation',
index=models.Index(fields=['document_number'], name='inventory_t_documen_559778_idx'),
),
migrations.AddIndex(
model_name='transformation',
index=models.Index(fields=['warehouse', 'status'], name='inventory_t_warehou_934275_idx'),
),
migrations.AddIndex(
model_name='transformation',
index=models.Index(fields=['-date'], name='inventory_t_date_65cfab_idx'),
),
]

View File

@@ -487,6 +487,7 @@ class Reservation(models.Model):
('released', 'Освобожден'),
('converted_to_sale', 'Преобразован в продажу'),
('converted_to_writeoff', 'Преобразован в списание'),
('converted_to_transformation', 'Преобразован в трансформацию'),
]
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
@@ -505,7 +506,7 @@ class Reservation(models.Model):
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='reservations', verbose_name="Склад")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
status = models.CharField(max_length=25, choices=STATUS_CHOICES,
status = models.CharField(max_length=30, choices=STATUS_CHOICES,
default='reserved', verbose_name="Статус")
reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования")
released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения")
@@ -556,6 +557,17 @@ class Reservation(models.Model):
help_text="Резерв для документа списания (черновик)"
)
# Связь с входным товаром трансформации (для резервирования в черновике)
transformation_input = models.ForeignKey(
'TransformationInput',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='reservations',
verbose_name="Входной товар трансформации",
help_text="Резерв для входного товара трансформации (черновик)"
)
class Meta:
verbose_name = "Резервирование"
verbose_name_plural = "Резервирования"
@@ -831,6 +843,7 @@ class DocumentCounter(models.Model):
('writeoff', 'Списание товара'),
('incoming', 'Поступление товара'),
('inventory', 'Инвентаризация'),
('transformation', 'Трансформация товара'),
]
counter_type = models.CharField(
@@ -1392,3 +1405,150 @@ class IncomingDocumentItem(models.Model):
def total_cost(self):
"""Себестоимость позиции (quantity * cost_price)"""
return self.quantity * self.cost_price
class Transformation(models.Model):
"""
Документ трансформации товара (превращение одного товара в другой).
Пример: белая гипсофила → крашеная гипсофила
"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('completed', 'Проведён'),
('cancelled', 'Отменён'),
]
document_number = models.CharField(
max_length=100,
unique=True,
db_index=True,
verbose_name="Номер документа"
)
warehouse = models.ForeignKey(
Warehouse,
on_delete=models.PROTECT,
related_name='transformations',
verbose_name="Склад"
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='draft',
db_index=True,
verbose_name="Статус"
)
date = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transformations',
verbose_name="Сотрудник"
)
comment = models.TextField(
blank=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 = ['-date']
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['warehouse', 'status']),
models.Index(fields=['-date']),
]
def __str__(self):
return f"{self.document_number} ({self.get_status_display()})"
class TransformationInput(models.Model):
"""
Входной товар трансформации (что списываем).
"""
transformation = models.ForeignKey(
Transformation,
on_delete=models.CASCADE,
related_name='inputs',
verbose_name="Трансформация"
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
related_name='transformation_inputs',
verbose_name="Товар"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
# Резерв (создается автоматически при draft)
# Связь через Reservation.transformation_input
class Meta:
verbose_name = "Входной товар трансформации"
verbose_name_plural = "Входные товары трансформации"
def __str__(self):
return f"{self.product.name}: {self.quantity}"
class TransformationOutput(models.Model):
"""
Выходной товар трансформации (что получаем).
"""
transformation = models.ForeignKey(
Transformation,
on_delete=models.CASCADE,
related_name='outputs',
verbose_name="Трансформация"
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
related_name='transformation_outputs',
verbose_name="Товар"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
# Ссылка на созданную партию (после проведения)
stock_batch = models.ForeignKey(
StockBatch,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transformation_outputs',
verbose_name="Созданная партия"
)
class Meta:
verbose_name = "Выходной товар трансформации"
verbose_name_plural = "Выходные товары трансформации"
def __str__(self):
return f"{self.product.name}: {self.quantity}"

View File

@@ -0,0 +1,279 @@
"""
Сервис для работы с трансформациями товаров (Transformation).
Обеспечивает:
- Создание документов трансформации с автонумерацией
- Добавление входных/выходных товаров с автоматическим резервированием
- Проведение трансформации (FIFO списание + оприходование)
- Отмену трансформации (откат операций)
"""
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import ValidationError
from inventory.models import (
Transformation, TransformationInput, TransformationOutput,
Reservation, Stock, StockBatch, DocumentCounter
)
from inventory.services.batch_manager import StockBatchManager
class TransformationService:
"""
Сервис для работы с трансформациями товаров.
"""
@classmethod
def generate_document_number(cls):
"""Генерация номера документа трансформации"""
next_num = DocumentCounter.get_next_value('transformation')
return f"TR-{next_num:05d}"
@classmethod
@transaction.atomic
def create_transformation(cls, warehouse, comment=None, employee=None):
"""
Создать новый документ трансформации (черновик).
Args:
warehouse: объект Warehouse
comment: комментарий (str, опционально)
employee: сотрудник (User, опционально)
Returns:
Transformation
"""
transformation = Transformation.objects.create(
document_number=cls.generate_document_number(),
warehouse=warehouse,
status='draft',
comment=comment or '',
employee=employee
)
return transformation
@classmethod
@transaction.atomic
def add_input(cls, transformation, product, quantity):
"""
Добавить входной товар в трансформацию.
Автоматически создает резерв (через сигнал).
Args:
transformation: Transformation
product: Product
quantity: Decimal - количество для списания
Returns:
TransformationInput
Raises:
ValidationError: если трансформация не черновик или недостаточно товара
"""
if transformation.status != 'draft':
raise ValidationError(
"Нельзя добавлять позиции в проведённую или отменённую трансформацию"
)
quantity = Decimal(str(quantity))
if quantity <= 0:
raise ValidationError("Количество должно быть больше нуля")
# Проверяем что товар еще не добавлен
if transformation.inputs.filter(product=product).exists():
raise ValidationError(
f"Товар '{product.name}' уже добавлен в качестве входного"
)
# Проверяем доступное количество
stock = Stock.objects.filter(
product=product,
warehouse=transformation.warehouse
).first()
if not stock:
raise ValidationError(
f"Товар '{product.name}' отсутствует на складе '{transformation.warehouse.name}'"
)
# quantity_free = quantity_available - quantity_reserved
available = stock.quantity_available - stock.quantity_reserved
if quantity > available:
raise ValidationError(
f"Недостаточно свободного товара '{product.name}'. "
f"Доступно: {available}, запрашивается: {quantity}"
)
# Создаем входной товар (резерв создастся автоматически через сигнал)
trans_input = TransformationInput.objects.create(
transformation=transformation,
product=product,
quantity=quantity
)
return trans_input
@classmethod
@transaction.atomic
def add_output(cls, transformation, product, quantity):
"""
Добавить выходной товар в трансформацию.
Args:
transformation: Transformation
product: Product
quantity: Decimal - количество получаемого товара
Returns:
TransformationOutput
Raises:
ValidationError: если трансформация не черновик
"""
if transformation.status != 'draft':
raise ValidationError(
"Нельзя добавлять позиции в проведённую или отменённую трансформацию"
)
quantity = Decimal(str(quantity))
if quantity <= 0:
raise ValidationError("Количество должно быть больше нуля")
# Проверяем что товар еще не добавлен
if transformation.outputs.filter(product=product).exists():
raise ValidationError(
f"Товар '{product.name}' уже добавлен в качестве выходного"
)
# Создаем выходной товар
trans_output = TransformationOutput.objects.create(
transformation=transformation,
product=product,
quantity=quantity
)
return trans_output
@classmethod
@transaction.atomic
def remove_input(cls, trans_input):
"""
Удалить входной товар из трансформации.
Автоматически освобождает резерв (через сигнал).
Args:
trans_input: TransformationInput
Raises:
ValidationError: если трансформация не черновик
"""
if trans_input.transformation.status != 'draft':
raise ValidationError(
"Нельзя удалять позиции из проведённой или отменённой трансформации"
)
trans_input.delete()
@classmethod
@transaction.atomic
def remove_output(cls, trans_output):
"""
Удалить выходной товар из трансформации.
Args:
trans_output: TransformationOutput
Raises:
ValidationError: если трансформация не черновик
"""
if trans_output.transformation.status != 'draft':
raise ValidationError(
"Нельзя удалять позиции из проведённой или отменённой трансформации"
)
trans_output.delete()
@classmethod
@transaction.atomic
def confirm(cls, transformation):
"""
Провести трансформацию (completed).
FIFO списание входных товаров и оприходование выходных.
Выполняется через сигнал process_transformation_on_complete.
Args:
transformation: Transformation
Raises:
ValidationError: если документ не готов к проведению
"""
if transformation.status != 'draft':
raise ValidationError("Документ уже проведён или отменён")
if not transformation.inputs.exists():
raise ValidationError("Добавьте хотя бы один входной товар")
if not transformation.outputs.exists():
raise ValidationError("Добавьте хотя бы один выходной товар")
# Проверяем наличие всех входных товаров
for trans_input in transformation.inputs.all():
stock = Stock.objects.filter(
product=trans_input.product,
warehouse=transformation.warehouse
).first()
if not stock:
raise ValidationError(
f"Товар '{trans_input.product.name}' отсутствует на складе"
)
# Учитываем резервы (включая резерв этой трансформации)
reserved_qty = Reservation.objects.filter(
product=trans_input.product,
warehouse=transformation.warehouse,
status='reserved'
).exclude(
transformation_input__transformation=transformation
).aggregate(total=models.Sum('quantity'))['total'] or Decimal('0')
available = stock.quantity_available - reserved_qty
if trans_input.quantity > available:
raise ValidationError(
f"Недостаточно свободного товара '{trans_input.product.name}'. "
f"Доступно: {available}, требуется: {trans_input.quantity}"
)
# Меняем статус (списание и оприходование происходит через сигнал)
transformation.status = 'completed'
transformation.save(update_fields=['status', 'updated_at'])
return transformation
@classmethod
@transaction.atomic
def cancel(cls, transformation):
"""
Отменить трансформацию.
Откатывает операции если была проведена (через сигнал).
Args:
transformation: Transformation
Raises:
ValidationError: если уже отменена
"""
if transformation.status == 'cancelled':
raise ValidationError("Трансформация уже отменена")
# Меняем статус (откат через сигнал если было completed)
transformation.status = 'cancelled'
transformation.save(update_fields=['status', 'updated_at'])
return transformation
# Импорт models для использования в методе confirm
from django.db import models

View File

@@ -13,7 +13,7 @@ from decimal import Decimal
from django.core.exceptions import ValidationError
from orders.models import Order, OrderItem
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem, Transformation, TransformationInput, TransformationOutput
from inventory.services import SaleProcessor
from inventory.services.batch_manager import StockBatchManager
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
@@ -1524,3 +1524,169 @@ def release_reservation_on_writeoff_item_delete(sender, instance, **kwargs):
instance.reservation.status = 'released'
instance.reservation.released_at = timezone.now()
instance.reservation.save(update_fields=['status', 'released_at'])
# ==================== TRANSFORMATION SIGNALS ====================
@receiver(post_save, sender=TransformationInput)
def reserve_on_transformation_input_create(sender, instance, created, **kwargs):
"""
При создании входного товара в черновике - резервируем его.
"""
# Резервируем только если трансформация в draft
if instance.transformation.status != 'draft':
return
# Создаем или обновляем резерв
Reservation.objects.update_or_create(
transformation_input=instance,
product=instance.product,
warehouse=instance.transformation.warehouse,
defaults={
'quantity': instance.quantity,
'status': 'reserved'
}
)
@receiver(pre_delete, sender=TransformationInput)
def release_reservation_on_input_delete(sender, instance, **kwargs):
"""
При удалении входного товара - освобождаем резерв.
"""
Reservation.objects.filter(
transformation_input=instance
).update(status='released', released_at=timezone.now())
@receiver(post_save, sender=Transformation)
@transaction.atomic
def process_transformation_on_complete(sender, instance, created, **kwargs):
"""
При переходе в статус 'completed':
1. FIFO списываем Input
2. Создаем партии Output с рассчитанной себестоимостью
3. Обновляем резервы в 'converted_to_transformation'
"""
if instance.status != 'completed':
return
# Проверяем что уже не обработано
if instance.outputs.filter(stock_batch__isnull=False).exists():
return # Уже проведено
# 1. Списываем Input по FIFO
total_input_cost = Decimal('0')
for trans_input in instance.inputs.all():
allocations = StockBatchManager.write_off_by_fifo(
product=trans_input.product,
warehouse=instance.warehouse,
quantity_to_write_off=trans_input.quantity
)
# Суммируем себестоимость списанного
for batch, qty in allocations:
total_input_cost += batch.cost_price * qty
# Обновляем резерв
Reservation.objects.filter(
transformation_input=trans_input,
status='reserved'
).update(
status='converted_to_transformation',
converted_at=timezone.now()
)
# 2. Создаем партии Output
for trans_output in instance.outputs.all():
# Рассчитываем себестоимость: сумма Input / количество Output
if trans_output.quantity > 0:
output_cost_price = total_input_cost / trans_output.quantity
else:
output_cost_price = Decimal('0')
# Создаем партию
batch = StockBatchManager.create_batch(
product=trans_output.product,
warehouse=instance.warehouse,
quantity=trans_output.quantity,
cost_price=output_cost_price
)
# Сохраняем ссылку на партию
trans_output.stock_batch = batch
trans_output.save(update_fields=['stock_batch'])
@receiver(post_save, sender=Transformation)
@transaction.atomic
def rollback_transformation_on_cancel(sender, instance, **kwargs):
"""
При отмене проведенной трансформации:
1. Удаляем партии Output
2. Восстанавливаем партии Input (обратное FIFO списание)
3. Возвращаем резервы в 'reserved'
"""
if instance.status != 'cancelled':
return
# Проверяем что была проведена (есть партии Output)
if not instance.outputs.filter(stock_batch__isnull=False).exists():
# Это был черновик - обрабатывается другим сигналом
return
# 1. Удаляем партии Output
for trans_output in instance.outputs.all():
if trans_output.stock_batch:
# Восстанавливаем количество из партии в Stock (автоматически через сигналы)
# Просто удаляем партию - остатки пересчитаются
batch = trans_output.stock_batch
batch.delete()
trans_output.stock_batch = None
trans_output.save(update_fields=['stock_batch'])
# 2. Восстанавливаем Input партии
# УПРОЩЕНИЕ: создаем новые партии с той же себестоимостью что была
# (в идеале нужно хранить SaleBatchAllocation-подобную таблицу)
for trans_input in instance.inputs.all():
# Получаем среднюю себестоимость товара
cost = trans_input.product.cost_price or Decimal('0')
# Создаем восстановленную партию
StockBatchManager.create_batch(
product=trans_input.product,
warehouse=instance.warehouse,
quantity=trans_input.quantity,
cost_price=cost
)
# Возвращаем резерв в reserved
Reservation.objects.filter(
transformation_input=trans_input
).update(
status='reserved',
converted_at=None
)
@receiver(post_save, sender=Transformation)
def release_reservations_on_draft_cancel(sender, instance, **kwargs):
"""
При отмене черновика (draft → cancelled) - освобождаем резервы.
"""
if instance.status != 'cancelled':
return
# Проверяем что это был черновик (нет созданных партий)
if instance.outputs.filter(stock_batch__isnull=False).exists():
return # Это была проведенная трансформация, обрабатывается другим сигналом
# Освобождаем все резервы
Reservation.objects.filter(
transformation_input__transformation=instance,
status='reserved'
).update(
status='released',
released_at=timezone.now()
)

View File

@@ -160,6 +160,24 @@
</div>
</a>
</div>
<!-- Трансформации -->
<div class="col-md-6 col-lg-4">
<a href="{% url 'inventory:transformation-list' %}" class="card shadow-sm h-100 text-decoration-none">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-purple bg-opacity-10 p-3 me-3">
<i class="bi bi-arrow-repeat text-purple" style="font-size: 1.5rem;"></i>
</div>
<div class="flex-grow-1">
<h6 class="mb-0 text-dark">Трансформации</h6>
<small class="text-muted">Превращение товаров</small>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
</div>
</a>
</div>
</div>
</div>
@@ -293,5 +311,13 @@
.card-body {
cursor: pointer;
}
.bg-purple {
background-color: #6f42c1 !important;
}
.text-purple {
color: #6f42c1 !important;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,346 @@
{% extends 'base.html' %}
{% load static %}
{% load inventory_filters %}
{% block title %}Трансформация {{ transformation.document_number }}{% endblock %}
{% block content %}
<!-- CSS для компонента поиска -->
<link rel="stylesheet" href="{% static 'products/css/product-search-picker.css' %}">
<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:transformation-list' %}">Трансформации</a></li>
<li class="breadcrumb-item active">{{ transformation.document_number }}</li>
</ol>
</nav>
<!-- Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<div class="row g-3">
<!-- Основной контент - одна колонка -->
<div class="col-12">
<!-- Информация о трансформации -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-arrow-repeat me-2"></i>{{ transformation.document_number }}
{% if transformation.status == 'draft' %}
<span class="badge bg-warning text-dark ms-2">Черновик</span>
{% elif transformation.status == 'completed' %}
<span class="badge bg-success ms-2">Проведён</span>
{% elif transformation.status == 'cancelled' %}
<span class="badge bg-secondary ms-2">Отменён</span>
{% endif %}
</h5>
{% if transformation.status == 'draft' %}
<div class="btn-group">
<form method="post" action="{% url 'inventory:transformation-confirm' transformation.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-success btn-sm" {% if not transformation.inputs.exists or not transformation.outputs.exists %}disabled{% endif %}>
<i class="bi bi-check-lg me-1"></i>Провести
</button>
</form>
<form method="post" action="{% url 'inventory:transformation-cancel' transformation.pk %}" class="d-inline ms-2">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Отменить трансформацию?')">
<i class="bi bi-x-lg me-1"></i>Отменить
</button>
</form>
</div>
{% elif transformation.status == 'completed' %}
<div class="btn-group">
<form method="post" action="{% url 'inventory:transformation-cancel' transformation.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Отменить проведенную трансформацию? Операции будут отменены.')">
<i class="bi bi-x-lg me-1"></i>Отменить
</button>
</form>
</div>
{% endif %}
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3">
<p class="text-muted small mb-1">Склад</p>
<p class="fw-semibold">{{ transformation.warehouse.name }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Дата</p>
<p class="fw-semibold">{{ transformation.date|date:"d.m.Y H:i" }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Создан</p>
<p class="fw-semibold">{{ transformation.created_at|date:"d.m.Y H:i" }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Сотрудник</p>
<p class="fw-semibold">{% if transformation.employee %}{{ transformation.employee.username }}{% else %}-{% endif %}</p>
</div>
</div>
{% if transformation.comment %}
<div class="mb-0">
<p class="text-muted small mb-1">Комментарий</p>
<p>{{ transformation.comment }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Добавление входного товара -->
{% if transformation.status == 'draft' %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3">
<h6 class="mb-0"><i class="bi bi-box-arrow-in-down me-2 text-danger"></i>Добавить входной товар (что списываем)</h6>
</div>
<div class="card-body">
<!-- Компонент поиска товаров -->
<div class="mb-3">
{% include 'products/components/product_search_picker.html' with container_id='input-product-picker' title='Найти входной товар' warehouse_id=transformation.warehouse.id filter_in_stock_only=True categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
</div>
<!-- Форма добавления входного товара -->
<form method="post" action="{% url 'inventory:transformation-add-input' transformation.pk %}" id="add-input-form">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-6">
<label for="id_input_product" class="form-label">Товар <span class="text-danger">*</span></label>
{{ input_form.product }}
{% if input_form.product.errors %}
<div class="text-danger small">{{ input_form.product.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="id_input_quantity" class="form-label">Количество <span class="text-danger">*</span></label>
{{ input_form.quantity }}
{% if input_form.quantity.errors %}
<div class="text-danger small">{{ input_form.quantity.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-danger w-100">
<i class="bi bi-check-circle me-1"></i>Добавить
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Таблица входных товаров -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3">
<h6 class="mb-0"><i class="bi bi-box-arrow-in-down me-2 text-danger"></i>Входные товары (списание)</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th scope="col" class="px-3 py-2">Товар</th>
<th scope="col" class="px-3 py-2 text-end" style="width: 150px;">Количество</th>
{% if transformation.status == 'draft' %}
<th scope="col" class="px-3 py-2 text-end" style="width: 100px;"></th>
{% endif %}
</tr>
</thead>
<tbody>
{% for input in transformation.inputs.all %}
<tr>
<td class="px-3 py-2">
<a href="{% url 'products:product-detail' input.product.id %}">{{ input.product.name }}</a>
</td>
<td class="px-3 py-2 text-end">{{ input.quantity|smart_quantity }}</td>
{% if transformation.status == 'draft' %}
<td class="px-3 py-2 text-end">
<form method="post" action="{% url 'inventory:transformation-remove-input' transformation.pk input.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Удалить входной товар?');"
title="Удалить">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if transformation.status == 'draft' %}3{% else %}2{% endif %}" class="px-3 py-4 text-center text-muted">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
Входные товары не добавлены
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Добавление выходного товара -->
{% if transformation.status == 'draft' %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3">
<h6 class="mb-0"><i class="bi bi-box-arrow-up me-2 text-success"></i>Добавить выходной товар (что получаем)</h6>
</div>
<div class="card-body">
<!-- Компонент поиска товаров -->
<div class="mb-3">
{% include 'products/components/product_search_picker.html' with container_id='output-product-picker' title='Найти выходной товар' categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
</div>
<!-- Форма добавления выходного товара -->
<form method="post" action="{% url 'inventory:transformation-add-output' transformation.pk %}" id="add-output-form">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-6">
<label for="id_output_product" class="form-label">Товар <span class="text-danger">*</span></label>
{{ output_form.product }}
{% if output_form.product.errors %}
<div class="text-danger small">{{ output_form.product.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="id_output_quantity" class="form-label">Количество <span class="text-danger">*</span></label>
{{ output_form.quantity }}
{% if output_form.quantity.errors %}
<div class="text-danger small">{{ output_form.quantity.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-check-circle me-1"></i>Добавить
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Таблица выходных товаров -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3">
<h6 class="mb-0"><i class="bi bi-box-arrow-up me-2 text-success"></i>Выходные товары (оприходование)</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th scope="col" class="px-3 py-2">Товар</th>
<th scope="col" class="px-3 py-2 text-end" style="width: 150px;">Количество</th>
{% if transformation.status == 'draft' %}
<th scope="col" class="px-3 py-2 text-end" style="width: 100px;"></th>
{% endif %}
</tr>
</thead>
<tbody>
{% for output in transformation.outputs.all %}
<tr>
<td class="px-3 py-2">
<a href="{% url 'products:product-detail' output.product.id %}">{{ output.product.name }}</a>
</td>
<td class="px-3 py-2 text-end">{{ output.quantity|smart_quantity }}</td>
{% if transformation.status == 'draft' %}
<td class="px-3 py-2 text-end">
<form method="post" action="{% url 'inventory:transformation-remove-output' transformation.pk output.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Удалить выходной товар?');"
title="Удалить">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if transformation.status == 'draft' %}3{% else %}2{% endif %}" class="px-3 py-4 text-center text-muted">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
Выходные товары не добавлены
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JS для компонента поиска -->
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация компонента поиска для входных товаров
if (document.getElementById('input-product-picker')) {
ProductSearchPicker.init('#input-product-picker', {
onAddSelected: function(product, instance) {
// Заполняем форму входного товара
const productSelect = document.getElementById('id_input_product');
if (productSelect) {
// Добавляем опцию если её нет
let option = productSelect.querySelector(`option[value="${product.id}"]`);
if (!option) {
option = document.createElement('option');
option.value = product.id;
option.text = product.text;
productSelect.appendChild(option);
}
productSelect.value = product.id;
}
// Очищаем выбор в пикере
instance.clearSelection();
}
});
}
// Инициализация компонента поиска для выходных товаров
if (document.getElementById('output-product-picker')) {
ProductSearchPicker.init('#output-product-picker', {
onAddSelected: function(product, instance) {
// Заполняем форму выходного товара
const productSelect = document.getElementById('id_output_product');
if (productSelect) {
// Добавляем опцию если её нет
let option = productSelect.querySelector(`option[value="${product.id}"]`);
if (!option) {
option = document.createElement('option');
option.value = product.id;
option.text = product.text;
productSelect.appendChild(option);
}
productSelect.value = product.id;
}
// Очищаем выбор в пикере
instance.clearSelection();
}
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% block title %}Создать трансформацию{% 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:transformation-list' %}">Трансформации</a></li>
<li class="breadcrumb-item active">Создать</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-3">
<h5 class="mb-0">
<i class="bi bi-arrow-repeat me-2"></i>Новая трансформация товара
</h5>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="id_warehouse" class="form-label">Склад <span class="text-danger">*</span></label>
{{ form.warehouse }}
{% if form.warehouse.errors %}
<div class="text-danger small">{{ form.warehouse.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="id_comment" class="form-label">Комментарий</label>
{{ form.comment }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Создать
</button>
<a href="{% url 'inventory:transformation-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,135 @@
{% extends 'base.html' %}
{% block title %}Трансформации товаров{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-3">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">
<i class="bi bi-arrow-repeat me-2"></i>Трансформации товаров
</h4>
<a href="{% url 'inventory:transformation-create' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Создать трансформацию
</a>
</div>
<!-- Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<!-- Table -->
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th scope="col" class="px-3 py-2">Номер</th>
<th scope="col" class="px-3 py-2">Дата</th>
<th scope="col" class="px-3 py-2">Склад</th>
<th scope="col" class="px-3 py-2">Статус</th>
<th scope="col" class="px-3 py-2">Входной товар</th>
<th scope="col" class="px-3 py-2">Выходной товар</th>
<th scope="col" class="px-3 py-2">Сотрудник</th>
<th scope="col" class="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{% for transformation in transformations %}
<tr>
<td class="px-3 py-2">
<a href="{% url 'inventory:transformation-detail' transformation.pk %}" class="fw-semibold text-decoration-none">
{{ transformation.document_number }}
</a>
</td>
<td class="px-3 py-2">{{ transformation.date|date:"d.m.Y H:i" }}</td>
<td class="px-3 py-2">{{ transformation.warehouse.name }}</td>
<td class="px-3 py-2">
{% if transformation.status == 'draft' %}
<span class="badge bg-warning text-dark">Черновик</span>
{% elif transformation.status == 'completed' %}
<span class="badge bg-success">Проведён</span>
{% elif transformation.status == 'cancelled' %}
<span class="badge bg-secondary">Отменён</span>
{% endif %}
</td>
<td class="px-3 py-2">
{% for input in transformation.inputs.all %}
<div class="small">{{ input.product.name }} - {{ input.quantity }} шт</div>
{% empty %}
<span class="text-muted">-</span>
{% endfor %}
</td>
<td class="px-3 py-2">
{% for output in transformation.outputs.all %}
<div class="small">{{ output.product.name }} - {{ output.quantity }} шт</div>
{% empty %}
<span class="text-muted">-</span>
{% endfor %}
</td>
<td class="px-3 py-2">
{% if transformation.employee %}{{ transformation.employee.username }}{% else %}-{% endif %}
</td>
<td class="px-3 py-2 text-end">
<a href="{% url 'inventory:transformation-detail' transformation.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="px-3 py-4 text-center text-muted">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
Трансформаций пока нет
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Page navigation" class="mt-3">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}
</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}

View File

@@ -42,6 +42,13 @@ from .views.incoming_document import (
IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView,
IncomingDocumentConfirmView, IncomingDocumentCancelView
)
# Transformation views
from .views.transformation import (
TransformationListView, TransformationCreateView, TransformationDetailView,
TransformationAddInputView, TransformationAddOutputView,
TransformationRemoveInputView, TransformationRemoveOutputView,
TransformationConfirmView, TransformationCancelView
)
# Debug views
from .views.debug_views import debug_inventory_page
from . import views
@@ -146,6 +153,17 @@ urlpatterns = [
path('showcases/<int:pk>/delete/', ShowcaseDeleteView.as_view(), name='showcase-delete'),
path('showcases/<int:pk>/set-default/', SetDefaultShowcaseView.as_view(), name='showcase-set-default'),
# ==================== TRANSFORMATION ====================
path('transformations/', TransformationListView.as_view(), name='transformation-list'),
path('transformations/create/', TransformationCreateView.as_view(), name='transformation-create'),
path('transformations/<int:pk>/', TransformationDetailView.as_view(), name='transformation-detail'),
path('transformations/<int:pk>/add-input/', TransformationAddInputView.as_view(), name='transformation-add-input'),
path('transformations/<int:pk>/add-output/', TransformationAddOutputView.as_view(), name='transformation-add-output'),
path('transformations/<int:pk>/remove-input/<int:item_pk>/', TransformationRemoveInputView.as_view(), name='transformation-remove-input'),
path('transformations/<int:pk>/remove-output/<int:item_pk>/', TransformationRemoveOutputView.as_view(), name='transformation-remove-output'),
path('transformations/<int:pk>/confirm/', TransformationConfirmView.as_view(), name='transformation-confirm'),
path('transformations/<int:pk>/cancel/', TransformationCancelView.as_view(), name='transformation-cancel'),
# ==================== DEBUG (SUPERUSER ONLY) ====================
path('debug/', debug_inventory_page, name='debug_page'),
]

View File

@@ -0,0 +1,237 @@
"""
Views для работы с трансформациями товаров (Transformation).
"""
from django.views.generic import ListView, CreateView, DetailView, View
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.shortcuts import get_object_or_404, redirect
from django.contrib import messages
from django.http import JsonResponse
from django.db import transaction
from django.core.exceptions import ValidationError
from inventory.models import Transformation, TransformationInput, TransformationOutput
from inventory.forms import TransformationForm, TransformationInputForm, TransformationOutputForm
from inventory.services.transformation_service import TransformationService
class TransformationListView(LoginRequiredMixin, ListView):
"""Список трансформаций товаров"""
model = Transformation
template_name = 'inventory/transformation/list.html'
context_object_name = 'transformations'
paginate_by = 20
def get_queryset(self):
return Transformation.objects.select_related(
'warehouse', 'employee'
).prefetch_related('inputs__product', 'outputs__product').order_by('-date')
class TransformationCreateView(LoginRequiredMixin, CreateView):
"""Создание трансформации"""
model = Transformation
form_class = TransformationForm
template_name = 'inventory/transformation/form.html'
def form_valid(self, form):
transformation = TransformationService.create_transformation(
warehouse=form.cleaned_data['warehouse'],
comment=form.cleaned_data.get('comment'),
employee=self.request.user
)
messages.success(self.request, f'Трансформация {transformation.document_number} создана')
return redirect('inventory:transformation-detail', pk=transformation.pk)
class TransformationDetailView(LoginRequiredMixin, DetailView):
"""Детальный просмотр трансформации"""
model = Transformation
template_name = 'inventory/transformation/detail.html'
context_object_name = 'transformation'
def get_queryset(self):
return Transformation.objects.select_related(
'warehouse', 'employee'
).prefetch_related('inputs__product', 'outputs__product')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['input_form'] = TransformationInputForm(transformation=self.object)
context['output_form'] = TransformationOutputForm(transformation=self.object)
# Добавляем категории и теги для компонента поиска товаров
from products.models import ProductCategory, ProductTag
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
return context
class TransformationAddInputView(LoginRequiredMixin, View):
"""Добавление входного товара в трансформацию"""
@transaction.atomic
def post(self, request, pk):
transformation = get_object_or_404(Transformation, pk=pk)
form = TransformationInputForm(request.POST, transformation=transformation)
if form.is_valid():
try:
trans_input = TransformationService.add_input(
transformation=transformation,
product=form.cleaned_data['product'],
quantity=form.cleaned_data['quantity']
)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'item_id': trans_input.id,
'message': f'Добавлено: {trans_input.product.name}'
})
messages.success(request, f'Добавлено: {trans_input.product.name}')
except ValidationError as e:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': False, 'error': str(e)}, status=400)
messages.error(request, str(e))
else:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
errors = '; '.join([f"{k}: {v[0]}" for k, v in form.errors.items()])
return JsonResponse({'success': False, 'error': errors}, status=400)
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f'{field}: {error}')
return redirect('inventory:transformation-detail', pk=pk)
class TransformationAddOutputView(LoginRequiredMixin, View):
"""Добавление выходного товара в трансформацию"""
@transaction.atomic
def post(self, request, pk):
transformation = get_object_or_404(Transformation, pk=pk)
form = TransformationOutputForm(request.POST, transformation=transformation)
if form.is_valid():
try:
trans_output = TransformationService.add_output(
transformation=transformation,
product=form.cleaned_data['product'],
quantity=form.cleaned_data['quantity']
)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'item_id': trans_output.id,
'message': f'Добавлено: {trans_output.product.name}'
})
messages.success(request, f'Добавлено: {trans_output.product.name}')
except ValidationError as e:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': False, 'error': str(e)}, status=400)
messages.error(request, str(e))
else:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
errors = '; '.join([f"{k}: {v[0]}" for k, v in form.errors.items()])
return JsonResponse({'success': False, 'error': errors}, status=400)
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f'{field}: {error}')
return redirect('inventory:transformation-detail', pk=pk)
class TransformationRemoveInputView(LoginRequiredMixin, View):
"""Удаление входного товара из трансформации"""
@transaction.atomic
def post(self, request, pk, item_pk):
transformation = get_object_or_404(Transformation, pk=pk)
trans_input = get_object_or_404(TransformationInput, pk=item_pk, transformation=transformation)
try:
product_name = trans_input.product.name
TransformationService.remove_input(trans_input)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'message': f'Удалено: {product_name}'
})
messages.success(request, f'Удалено: {product_name}')
except ValidationError as e:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': False, 'error': str(e)}, status=400)
messages.error(request, str(e))
return redirect('inventory:transformation-detail', pk=pk)
class TransformationRemoveOutputView(LoginRequiredMixin, View):
"""Удаление выходного товара из трансформации"""
@transaction.atomic
def post(self, request, pk, item_pk):
transformation = get_object_or_404(Transformation, pk=pk)
trans_output = get_object_or_404(TransformationOutput, pk=item_pk, transformation=transformation)
try:
product_name = trans_output.product.name
TransformationService.remove_output(trans_output)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'message': f'Удалено: {product_name}'
})
messages.success(request, f'Удалено: {product_name}')
except ValidationError as e:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': False, 'error': str(e)}, status=400)
messages.error(request, str(e))
return redirect('inventory:transformation-detail', pk=pk)
class TransformationConfirmView(LoginRequiredMixin, View):
"""Проведение трансформации"""
@transaction.atomic
def post(self, request, pk):
transformation = get_object_or_404(Transformation, pk=pk)
try:
TransformationService.confirm(transformation)
messages.success(request, f'Трансформация {transformation.document_number} проведена')
except ValidationError as e:
messages.error(request, str(e))
return redirect('inventory:transformation-detail', pk=pk)
class TransformationCancelView(LoginRequiredMixin, View):
"""Отмена трансформации"""
@transaction.atomic
def post(self, request, pk):
transformation = get_object_or_404(Transformation, pk=pk)
try:
TransformationService.cancel(transformation)
messages.success(request, f'Трансформация {transformation.document_number} отменена')
except ValidationError as e:
messages.error(request, str(e))
return redirect('inventory:transformation-list')