Добавлена система трансформации товаров
Реализована полная система трансформации товаров (превращение одного товара в другой). Пример: белая гипсофила → крашеная гипсофила. Особенности реализации: - Резервирование входных товаров в статусе 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,
|
||||
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 = 'Выходов'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', 'Освобожден'),
|
||||
('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}"
|
||||
|
||||
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 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()
|
||||
)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
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'),
|
||||
]
|
||||
|
||||
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