Добавлена система трансформации товаров
Реализована полная система трансформации товаров (превращение одного товара в другой). Пример: белая гипсофила → крашеная гипсофила. Особенности реализации: - Резервирование входных товаров в статусе 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:
@@ -8,7 +8,8 @@ from inventory.models import (
|
|||||||
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
|
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
|
||||||
Inventory, InventoryLine, Reservation, Stock, StockMovement,
|
Inventory, InventoryLine, Reservation, Stock, StockMovement,
|
||||||
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
|
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):
|
def total_cost_display(self, obj):
|
||||||
return f"{obj.total_cost:.2f}"
|
return f"{obj.total_cost:.2f}"
|
||||||
total_cost_display.short_description = 'Сумма'
|
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 = 'Выходов'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from decimal import Decimal
|
|||||||
from .models import (
|
from .models import (
|
||||||
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
|
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
|
||||||
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock,
|
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock,
|
||||||
IncomingDocument, IncomingDocumentItem
|
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, TransformationOutput
|
||||||
)
|
)
|
||||||
from products.models import Product
|
from products.models import Product
|
||||||
|
|
||||||
@@ -650,3 +650,103 @@ class IncomingDocumentItemForm(forms.ModelForm):
|
|||||||
raise ValidationError('Закупочная цена не может быть отрицательной')
|
raise ValidationError('Закупочная цена не может быть отрицательной')
|
||||||
return cost_price
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -487,6 +487,7 @@ class Reservation(models.Model):
|
|||||||
('released', 'Освобожден'),
|
('released', 'Освобожден'),
|
||||||
('converted_to_sale', 'Преобразован в продажу'),
|
('converted_to_sale', 'Преобразован в продажу'),
|
||||||
('converted_to_writeoff', 'Преобразован в списание'),
|
('converted_to_writeoff', 'Преобразован в списание'),
|
||||||
|
('converted_to_transformation', 'Преобразован в трансформацию'),
|
||||||
]
|
]
|
||||||
|
|
||||||
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
|
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,
|
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||||
related_name='reservations', verbose_name="Склад")
|
related_name='reservations', verbose_name="Склад")
|
||||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, 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="Статус")
|
default='reserved', verbose_name="Статус")
|
||||||
reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования")
|
reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования")
|
||||||
released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения")
|
released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения")
|
||||||
@@ -556,6 +557,17 @@ class Reservation(models.Model):
|
|||||||
help_text="Резерв для документа списания (черновик)"
|
help_text="Резерв для документа списания (черновик)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Связь с входным товаром трансформации (для резервирования в черновике)
|
||||||
|
transformation_input = models.ForeignKey(
|
||||||
|
'TransformationInput',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='reservations',
|
||||||
|
verbose_name="Входной товар трансформации",
|
||||||
|
help_text="Резерв для входного товара трансформации (черновик)"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Резервирование"
|
verbose_name = "Резервирование"
|
||||||
verbose_name_plural = "Резервирования"
|
verbose_name_plural = "Резервирования"
|
||||||
@@ -831,6 +843,7 @@ class DocumentCounter(models.Model):
|
|||||||
('writeoff', 'Списание товара'),
|
('writeoff', 'Списание товара'),
|
||||||
('incoming', 'Поступление товара'),
|
('incoming', 'Поступление товара'),
|
||||||
('inventory', 'Инвентаризация'),
|
('inventory', 'Инвентаризация'),
|
||||||
|
('transformation', 'Трансформация товара'),
|
||||||
]
|
]
|
||||||
|
|
||||||
counter_type = models.CharField(
|
counter_type = models.CharField(
|
||||||
@@ -1392,3 +1405,150 @@ class IncomingDocumentItem(models.Model):
|
|||||||
def total_cost(self):
|
def total_cost(self):
|
||||||
"""Себестоимость позиции (quantity * cost_price)"""
|
"""Себестоимость позиции (quantity * cost_price)"""
|
||||||
return self.quantity * self.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}"
|
||||||
|
|||||||
279
myproject/inventory/services/transformation_service.py
Normal file
279
myproject/inventory/services/transformation_service.py
Normal 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
|
||||||
@@ -13,7 +13,7 @@ from decimal import Decimal
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from orders.models import Order, OrderItem
|
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 import SaleProcessor
|
||||||
from inventory.services.batch_manager import StockBatchManager
|
from inventory.services.batch_manager import StockBatchManager
|
||||||
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
||||||
@@ -1524,3 +1524,169 @@ def release_reservation_on_writeoff_item_delete(sender, instance, **kwargs):
|
|||||||
instance.reservation.status = 'released'
|
instance.reservation.status = 'released'
|
||||||
instance.reservation.released_at = timezone.now()
|
instance.reservation.released_at = timezone.now()
|
||||||
instance.reservation.save(update_fields=['status', 'released_at'])
|
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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -160,6 +160,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -293,5 +311,13 @@
|
|||||||
.card-body {
|
.card-body {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-purple {
|
||||||
|
background-color: #6f42c1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-purple {
|
||||||
|
color: #6f42c1 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
135
myproject/inventory/templates/inventory/transformation/list.html
Normal file
135
myproject/inventory/templates/inventory/transformation/list.html
Normal 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 %}
|
||||||
@@ -42,6 +42,13 @@ from .views.incoming_document import (
|
|||||||
IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView,
|
IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView,
|
||||||
IncomingDocumentConfirmView, IncomingDocumentCancelView
|
IncomingDocumentConfirmView, IncomingDocumentCancelView
|
||||||
)
|
)
|
||||||
|
# Transformation views
|
||||||
|
from .views.transformation import (
|
||||||
|
TransformationListView, TransformationCreateView, TransformationDetailView,
|
||||||
|
TransformationAddInputView, TransformationAddOutputView,
|
||||||
|
TransformationRemoveInputView, TransformationRemoveOutputView,
|
||||||
|
TransformationConfirmView, TransformationCancelView
|
||||||
|
)
|
||||||
# Debug views
|
# Debug views
|
||||||
from .views.debug_views import debug_inventory_page
|
from .views.debug_views import debug_inventory_page
|
||||||
from . import views
|
from . import views
|
||||||
@@ -146,6 +153,17 @@ urlpatterns = [
|
|||||||
path('showcases/<int:pk>/delete/', ShowcaseDeleteView.as_view(), name='showcase-delete'),
|
path('showcases/<int:pk>/delete/', ShowcaseDeleteView.as_view(), name='showcase-delete'),
|
||||||
path('showcases/<int:pk>/set-default/', SetDefaultShowcaseView.as_view(), name='showcase-set-default'),
|
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) ====================
|
# ==================== DEBUG (SUPERUSER ONLY) ====================
|
||||||
path('debug/', debug_inventory_page, name='debug_page'),
|
path('debug/', debug_inventory_page, name='debug_page'),
|
||||||
]
|
]
|
||||||
|
|||||||
237
myproject/inventory/views/transformation.py
Normal file
237
myproject/inventory/views/transformation.py
Normal 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')
|
||||||
Reference in New Issue
Block a user