Исправление конфликта сигналов при отмене трансформации
Исправлена проблема, когда при отмене проведенной трансформации оба сигнала выполнялись последовательно:
- rollback_transformation_on_cancel возвращал резервы в 'reserved'
- release_reservations_on_draft_cancel ошибочно освобождал их в 'released'
Изменена проверка в release_reservations_on_draft_cancel: вместо проверки наличия партий Output (которые уже удалены) теперь проверяется статус резервов ('converted_to_transformation') или наличие поля converted_at, что работает независимо от порядка выполнения сигналов.
This commit is contained in:
66
check_duplicates.py
Normal file
66
check_duplicates.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
|
||||||
|
# Setup Django
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'myproject'))
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from inventory.models import Reservation
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
# Найти дубликаты резервов трансформаций
|
||||||
|
duplicates = Reservation.objects.filter(
|
||||||
|
transformation_input__isnull=False
|
||||||
|
).values(
|
||||||
|
'transformation_input', 'product', 'warehouse'
|
||||||
|
).annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).filter(
|
||||||
|
count__gt=1
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Found {duplicates.count()} duplicate transformation reservations")
|
||||||
|
|
||||||
|
if duplicates.exists():
|
||||||
|
print("\nDuplicate groups:")
|
||||||
|
for dup in duplicates[:10]:
|
||||||
|
print(f" TransformationInput ID: {dup['transformation_input']}, "
|
||||||
|
f"Product ID: {dup['product']}, "
|
||||||
|
f"Warehouse ID: {dup['warehouse']}, "
|
||||||
|
f"Count: {dup['count']}")
|
||||||
|
|
||||||
|
# Show actual reservations
|
||||||
|
reservations = Reservation.objects.filter(
|
||||||
|
transformation_input_id=dup['transformation_input'],
|
||||||
|
product_id=dup['product'],
|
||||||
|
warehouse_id=dup['warehouse']
|
||||||
|
)
|
||||||
|
for res in reservations:
|
||||||
|
print(f" - Reservation ID {res.id}: quantity={res.quantity}, status={res.status}")
|
||||||
|
|
||||||
|
print("\n--- CLEANING DUPLICATES ---")
|
||||||
|
|
||||||
|
# Для каждой группы дубликатов оставляем только один резерв
|
||||||
|
for dup in duplicates:
|
||||||
|
reservations = Reservation.objects.filter(
|
||||||
|
transformation_input_id=dup['transformation_input'],
|
||||||
|
product_id=dup['product'],
|
||||||
|
warehouse_id=dup['warehouse']
|
||||||
|
).order_by('id')
|
||||||
|
|
||||||
|
# Оставляем первый, удаляем остальные
|
||||||
|
first = reservations.first()
|
||||||
|
others = reservations.exclude(id=first.id)
|
||||||
|
count = others.count()
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
others.delete()
|
||||||
|
print(f"Deleted {count} duplicate reservations for TransformationInput {dup['transformation_input']}")
|
||||||
|
|
||||||
|
print("\n--- DONE ---")
|
||||||
|
else:
|
||||||
|
print("No duplicates found!")
|
||||||
@@ -747,6 +747,23 @@ class TransformationOutputForm(forms.Form):
|
|||||||
# Проверяем что товар еще не добавлен
|
# Проверяем что товар еще не добавлен
|
||||||
if self.transformation and self.transformation.outputs.filter(product=product).exists():
|
if self.transformation and self.transformation.outputs.filter(product=product).exists():
|
||||||
raise ValidationError(f'Товар "{product.name}" уже добавлен в качестве выходного')
|
raise ValidationError(f'Товар "{product.name}" уже добавлен в качестве выходного')
|
||||||
|
|
||||||
|
# Проверяем что сумма выходных не превышает сумму входных
|
||||||
|
if self.transformation:
|
||||||
|
total_input = sum(
|
||||||
|
trans_input.quantity for trans_input in self.transformation.inputs.all()
|
||||||
|
)
|
||||||
|
total_output_existing = sum(
|
||||||
|
trans_output.quantity for trans_output in self.transformation.outputs.all()
|
||||||
|
)
|
||||||
|
total_output_new = total_output_existing + quantity
|
||||||
|
|
||||||
|
if total_output_new > total_input:
|
||||||
|
raise ValidationError(
|
||||||
|
f'Сумма выходных количеств ({total_output_new}) не может превышать '
|
||||||
|
f'сумму входных количеств ({total_input}). '
|
||||||
|
f'Максимально можно добавить: {total_input - total_output_existing}'
|
||||||
|
)
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class StockBatchManager:
|
|||||||
return batch
|
return batch
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def write_off_by_fifo(product, warehouse, quantity_to_write_off, exclude_order=None):
|
def write_off_by_fifo(product, warehouse, quantity_to_write_off, exclude_order=None, exclude_transformation=None):
|
||||||
"""
|
"""
|
||||||
Списать товар по FIFO (старые партии первыми).
|
Списать товар по FIFO (старые партии первыми).
|
||||||
ВАЖНО: Учитывает зарезервированное количество товара.
|
ВАЖНО: Учитывает зарезервированное количество товара.
|
||||||
@@ -83,6 +83,9 @@ class StockBatchManager:
|
|||||||
exclude_order: (опционально) объект Order - исключить резервы этого заказа из расчёта.
|
exclude_order: (опционально) объект Order - исключить резервы этого заказа из расчёта.
|
||||||
Используется при переводе заказа в 'completed', когда резервы
|
Используется при переводе заказа в 'completed', когда резервы
|
||||||
заказа ещё не переведены в 'converted_to_sale'.
|
заказа ещё не переведены в 'converted_to_sale'.
|
||||||
|
exclude_transformation: (опционально) объект Transformation - исключить резервы этой трансформации из расчёта.
|
||||||
|
Используется при переводе трансформации в 'completed', когда резервы
|
||||||
|
трансформации ещё не переведены в 'converted_to_transformation'.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: [(batch, qty_written), ...] - какие партии и сколько списано
|
list: [(batch, qty_written), ...] - какие партии и сколько списано
|
||||||
@@ -96,12 +99,26 @@ class StockBatchManager:
|
|||||||
allocations = []
|
allocations = []
|
||||||
|
|
||||||
# Получаем общее количество зарезервированного товара (все резервы со статусом 'reserved')
|
# Получаем общее количество зарезервированного товара (все резервы со статусом 'reserved')
|
||||||
# Исключаем резервы заказа, для которого делается списание (если передан)
|
# Исключаем резервы заказа или трансформации, для которых делается списание (если переданы)
|
||||||
reservation_filter = Reservation.objects.filter(
|
reservation_filter = Reservation.objects.filter(
|
||||||
product=product,
|
product=product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
status='reserved'
|
status='reserved'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Специальная обработка для трансформации: нужно списывать из зарезервированного товара трансформации
|
||||||
|
transformation_reserved_qty = Decimal('0')
|
||||||
|
if exclude_transformation:
|
||||||
|
transformation_reservations = Reservation.objects.filter(
|
||||||
|
product=product,
|
||||||
|
warehouse=warehouse,
|
||||||
|
status='reserved',
|
||||||
|
transformation_input__transformation=exclude_transformation
|
||||||
|
)
|
||||||
|
transformation_reserved_qty = transformation_reservations.aggregate(total=Sum('quantity'))['total'] or Decimal('0')
|
||||||
|
# Исключаем резервы трансформации из общего расчета резервов
|
||||||
|
reservation_filter = reservation_filter.exclude(transformation_input__transformation=exclude_transformation)
|
||||||
|
|
||||||
if exclude_order:
|
if exclude_order:
|
||||||
reservation_filter = reservation_filter.exclude(order_item__order=exclude_order)
|
reservation_filter = reservation_filter.exclude(order_item__order=exclude_order)
|
||||||
|
|
||||||
@@ -110,8 +127,10 @@ class StockBatchManager:
|
|||||||
# Получаем партии по FIFO
|
# Получаем партии по FIFO
|
||||||
batches = StockBatchManager.get_batches_for_fifo(product, warehouse)
|
batches = StockBatchManager.get_batches_for_fifo(product, warehouse)
|
||||||
|
|
||||||
# Проходим партии, списывая только СВОБОДНОЕ количество
|
# Проходим партии, списывая товар
|
||||||
reserved_remaining = total_reserved # Сколько резерва еще не распределено по партиям
|
# Если есть exclude_transformation, сначала списываем из зарезервированного товара трансформации
|
||||||
|
reserved_remaining = total_reserved # Сколько резерва (кроме трансформации) еще не распределено по партиям
|
||||||
|
transformation_reserved_remaining = transformation_reserved_qty # Сколько резерва трансформации еще не распределено
|
||||||
|
|
||||||
for batch in batches:
|
for batch in batches:
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
@@ -119,32 +138,57 @@ class StockBatchManager:
|
|||||||
|
|
||||||
# Определяем сколько в этой партии зарезервировано (пропорционально)
|
# Определяем сколько в этой партии зарезервировано (пропорционально)
|
||||||
# Логика: старые партии "съедают" резерв первыми (как и при списании)
|
# Логика: старые партии "съедают" резерв первыми (как и при списании)
|
||||||
batch_reserved = min(batch.quantity, reserved_remaining)
|
batch_reserved_other = min(batch.quantity, reserved_remaining)
|
||||||
reserved_remaining -= batch_reserved
|
reserved_remaining -= batch_reserved_other
|
||||||
|
|
||||||
|
# Если есть резерв трансформации, распределяем его по партиям
|
||||||
|
batch_reserved_transformation = Decimal('0')
|
||||||
|
if transformation_reserved_remaining > 0:
|
||||||
|
# Распределяем резерв трансформации по партиям
|
||||||
|
batch_reserved_transformation = min(batch.quantity - batch_reserved_other, transformation_reserved_remaining)
|
||||||
|
transformation_reserved_remaining -= batch_reserved_transformation
|
||||||
|
|
||||||
|
# Общее зарезервированное в партии
|
||||||
|
batch_reserved = batch_reserved_other + batch_reserved_transformation
|
||||||
|
|
||||||
# Свободное количество в партии
|
# Свободное количество в партии
|
||||||
batch_free = batch.quantity - batch_reserved
|
batch_free = batch.quantity - batch_reserved
|
||||||
|
|
||||||
if batch_free <= 0:
|
# Если есть резерв трансформации в этой партии, списываем из него
|
||||||
# Партия полностью зарезервирована → пропускаем
|
if batch_reserved_transformation > 0:
|
||||||
continue
|
# Списываем из зарезервированного товара трансформации
|
||||||
|
qty_from_reserved = min(batch_reserved_transformation, remaining)
|
||||||
|
batch.quantity -= qty_from_reserved
|
||||||
|
batch.save(update_fields=['quantity', 'updated_at'])
|
||||||
|
remaining -= qty_from_reserved
|
||||||
|
allocations.append((batch, qty_from_reserved))
|
||||||
|
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Если партия опустошена, деактивируем её
|
||||||
|
if batch.quantity <= 0:
|
||||||
|
batch.is_active = False
|
||||||
|
batch.save(update_fields=['is_active'])
|
||||||
|
continue
|
||||||
|
|
||||||
# Сколько можем списать из этой партии (только свободное)
|
# Если осталось списать, списываем из свободного
|
||||||
qty_from_this_batch = min(batch_free, remaining)
|
if batch_free > 0 and remaining > 0:
|
||||||
|
qty_from_this_batch = min(batch_free, remaining)
|
||||||
|
|
||||||
# Списываем
|
# Списываем
|
||||||
batch.quantity -= qty_from_this_batch
|
batch.quantity -= qty_from_this_batch
|
||||||
batch.save(update_fields=['quantity', 'updated_at'])
|
batch.save(update_fields=['quantity', 'updated_at'])
|
||||||
|
|
||||||
remaining -= qty_from_this_batch
|
remaining -= qty_from_this_batch
|
||||||
|
|
||||||
# Фиксируем распределение
|
# Фиксируем распределение
|
||||||
allocations.append((batch, qty_from_this_batch))
|
allocations.append((batch, qty_from_this_batch))
|
||||||
|
|
||||||
# Если партия опустошена, деактивируем её
|
# Если партия опустошена, деактивируем её
|
||||||
if batch.quantity <= 0:
|
if batch.quantity <= 0:
|
||||||
batch.is_active = False
|
batch.is_active = False
|
||||||
batch.save(update_fields=['is_active'])
|
batch.save(update_fields=['is_active'])
|
||||||
|
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|||||||
@@ -218,6 +218,20 @@ class TransformationService:
|
|||||||
if not transformation.outputs.exists():
|
if not transformation.outputs.exists():
|
||||||
raise ValidationError("Добавьте хотя бы один выходной товар")
|
raise ValidationError("Добавьте хотя бы один выходной товар")
|
||||||
|
|
||||||
|
# Проверяем что сумма входных количеств равна сумме выходных
|
||||||
|
total_input_quantity = sum(
|
||||||
|
trans_input.quantity for trans_input in transformation.inputs.all()
|
||||||
|
)
|
||||||
|
total_output_quantity = sum(
|
||||||
|
trans_output.quantity for trans_output in transformation.outputs.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_input_quantity != total_output_quantity:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Сумма входных количеств ({total_input_quantity}) должна быть равна "
|
||||||
|
f"сумме выходных количеств ({total_output_quantity})"
|
||||||
|
)
|
||||||
|
|
||||||
# Проверяем наличие всех входных товаров
|
# Проверяем наличие всех входных товаров
|
||||||
for trans_input in transformation.inputs.all():
|
for trans_input in transformation.inputs.all():
|
||||||
stock = Stock.objects.filter(
|
stock = Stock.objects.filter(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.db.models.signals import post_save, pre_delete, post_delete
|
from django.db.models.signals import post_save, pre_delete, post_delete
|
||||||
|
from django.db.models import Q
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -1533,19 +1534,21 @@ def reserve_on_transformation_input_create(sender, instance, created, **kwargs):
|
|||||||
"""
|
"""
|
||||||
При создании входного товара в черновике - резервируем его.
|
При создании входного товара в черновике - резервируем его.
|
||||||
"""
|
"""
|
||||||
|
# Резервируем только при создании нового входного товара
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
# Резервируем только если трансформация в draft
|
# Резервируем только если трансформация в draft
|
||||||
if instance.transformation.status != 'draft':
|
if instance.transformation.status != 'draft':
|
||||||
return
|
return
|
||||||
|
|
||||||
# Создаем или обновляем резерв
|
# Создаем резерв
|
||||||
Reservation.objects.update_or_create(
|
Reservation.objects.create(
|
||||||
transformation_input=instance,
|
transformation_input=instance,
|
||||||
product=instance.product,
|
product=instance.product,
|
||||||
warehouse=instance.transformation.warehouse,
|
warehouse=instance.transformation.warehouse,
|
||||||
defaults={
|
quantity=instance.quantity,
|
||||||
'quantity': instance.quantity,
|
status='reserved'
|
||||||
'status': 'reserved'
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1582,7 +1585,8 @@ def process_transformation_on_complete(sender, instance, created, **kwargs):
|
|||||||
allocations = StockBatchManager.write_off_by_fifo(
|
allocations = StockBatchManager.write_off_by_fifo(
|
||||||
product=trans_input.product,
|
product=trans_input.product,
|
||||||
warehouse=instance.warehouse,
|
warehouse=instance.warehouse,
|
||||||
quantity_to_write_off=trans_input.quantity
|
quantity_to_write_off=trans_input.quantity,
|
||||||
|
exclude_transformation=instance # Исключаем резервы этой трансформации
|
||||||
)
|
)
|
||||||
|
|
||||||
# Суммируем себестоимость списанного
|
# Суммируем себестоимость списанного
|
||||||
@@ -1590,13 +1594,22 @@ def process_transformation_on_complete(sender, instance, created, **kwargs):
|
|||||||
total_input_cost += batch.cost_price * qty
|
total_input_cost += batch.cost_price * qty
|
||||||
|
|
||||||
# Обновляем резерв
|
# Обновляем резерв
|
||||||
Reservation.objects.filter(
|
reservations_updated = Reservation.objects.filter(
|
||||||
transformation_input=trans_input,
|
transformation_input=trans_input,
|
||||||
status='reserved'
|
status='reserved'
|
||||||
).update(
|
).update(
|
||||||
status='converted_to_transformation',
|
status='converted_to_transformation',
|
||||||
converted_at=timezone.now()
|
converted_at=timezone.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ВАЖНО: .update() не вызывает сигналы, поэтому нужно вручную обновить Stock
|
||||||
|
if reservations_updated > 0:
|
||||||
|
stock = Stock.objects.filter(
|
||||||
|
product=trans_input.product,
|
||||||
|
warehouse=instance.warehouse
|
||||||
|
).first()
|
||||||
|
if stock:
|
||||||
|
stock.refresh_from_batches()
|
||||||
|
|
||||||
# 2. Создаем партии Output
|
# 2. Создаем партии Output
|
||||||
for trans_output in instance.outputs.all():
|
for trans_output in instance.outputs.all():
|
||||||
@@ -1678,8 +1691,16 @@ def release_reservations_on_draft_cancel(sender, instance, **kwargs):
|
|||||||
if instance.status != 'cancelled':
|
if instance.status != 'cancelled':
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверяем что это был черновик (нет созданных партий)
|
# Проверяем, были ли резервы в статусе 'converted_to_transformation'
|
||||||
if instance.outputs.filter(stock_batch__isnull=False).exists():
|
# или имеют заполненное поле converted_at (что означает, что трансформация была проведена)
|
||||||
|
# Это работает независимо от порядка выполнения сигналов
|
||||||
|
has_converted_reservations = Reservation.objects.filter(
|
||||||
|
transformation_input__transformation=instance
|
||||||
|
).filter(
|
||||||
|
Q(status='converted_to_transformation') | Q(converted_at__isnull=False)
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if has_converted_reservations:
|
||||||
return # Это была проведенная трансформация, обрабатывается другим сигналом
|
return # Это была проведенная трансформация, обрабатывается другим сигналом
|
||||||
|
|
||||||
# Освобождаем все резервы
|
# Освобождаем все резервы
|
||||||
|
|||||||
@@ -98,195 +98,233 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Добавление входного товара -->
|
<!-- ОДИН виджет поиска товаров -->
|
||||||
{% if transformation.status == 'draft' %}
|
{% if transformation.status == 'draft' %}
|
||||||
<div class="card border-0 shadow-sm mb-3">
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
<div class="card-header bg-light py-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>
|
<h6 class="mb-0"><i class="bi bi-search me-2"></i>Найти и добавить товар</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- Компонент поиска товаров -->
|
<!-- Компонент поиска товаров -->
|
||||||
<div class="mb-3">
|
<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' %}
|
{% include 'products/components/product_search_picker.html' with container_id='transformation-product-picker' title='Найти товар' warehouse_id=transformation.warehouse.id categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Форма добавления входного товара -->
|
<!-- ДВЕ кнопки действий (показываются после выбора товара) -->
|
||||||
<form method="post" action="{% url 'inventory:transformation-add-input' transformation.pk %}" id="add-input-form">
|
<div id="action-buttons" class="row g-3 align-items-end" style="display:none;">
|
||||||
{% csrf_token %}
|
<div class="col-md-4">
|
||||||
|
<label for="product-quantity" class="form-label">Количество</label>
|
||||||
<div class="row g-3">
|
<input type="number" class="form-control" id="product-quantity" value="1" min="0.001" step="0.001" placeholder="Введите количество">
|
||||||
<div class="col-md-6">
|
<small id="quantity-hint" class="form-text text-muted" style="display: none; font-size: 0.75rem;"></small>
|
||||||
<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>
|
</div>
|
||||||
</form>
|
<div class="col-md-4">
|
||||||
|
<button type="button" id="add-to-input-btn" class="btn btn-warning w-100">
|
||||||
|
<i class="bi bi-box-arrow-in-down me-1"></i>Добавить во входящий
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button type="button" id="add-to-output-btn" class="btn btn-success w-100">
|
||||||
|
<i class="bi bi-box-arrow-up me-1"></i>Добавить в исходящий
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Таблица входных товаров -->
|
<!-- ДВЕ КОЛОНКИ рядом -->
|
||||||
<div class="card border-0 shadow-sm mb-3">
|
<div class="row g-3 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 class="col-md-5">
|
||||||
|
<div class="card border-0 shadow-sm h-100" style="border-left: 4px solid #ffc107 !important;">
|
||||||
|
<div class="card-header bg-warning bg-opacity-10 py-3">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="bi bi-box-arrow-in-down me-2 text-warning"></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: 120px;">Кол-во</th>
|
||||||
|
{% if transformation.status == 'draft' %}
|
||||||
|
<th scope="col" class="px-3 py-2 text-end" style="width: 60px;"></th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="input-products-tbody">
|
||||||
|
{% for input in transformation.inputs.all %}
|
||||||
|
<tr data-input-id="{{ input.pk }}">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<a href="{% url 'products:product-detail' input.product.id %}">
|
||||||
|
{{ input.product.name }}
|
||||||
|
</a>
|
||||||
|
<small class="text-muted d-block">{{ input.product.sku }}</small>
|
||||||
|
</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('Удалить?');">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr class="empty-row">
|
||||||
|
<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-3 d-block mb-2"></i>
|
||||||
|
Нет товаров
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive">
|
<!-- СТРЕЛКА посередине -->
|
||||||
<table class="table table-hover mb-0">
|
<div class="col-md-2 d-flex align-items-center justify-content-center">
|
||||||
<thead class="table-light">
|
<div class="text-center">
|
||||||
<tr>
|
<i class="bi bi-arrow-right fs-1 text-primary d-none d-md-inline"></i>
|
||||||
<th scope="col" class="px-3 py-2">Товар</th>
|
<i class="bi bi-arrow-down fs-1 text-primary d-md-none"></i>
|
||||||
<th scope="col" class="px-3 py-2 text-end" style="width: 150px;">Количество</th>
|
<div class="small text-muted mt-2">Трансформация</div>
|
||||||
{% if transformation.status == 'draft' %}
|
{% if transformation.status == 'draft' %}
|
||||||
<th scope="col" class="px-3 py-2 text-end" style="width: 100px;"></th>
|
<div class="mt-3">
|
||||||
{% endif %}
|
<div class="card border-0 shadow-sm">
|
||||||
</tr>
|
<div class="card-body p-2">
|
||||||
</thead>
|
<div class="small text-muted mb-1">Баланс количеств</div>
|
||||||
<tbody>
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
{% for input in transformation.inputs.all %}
|
<span class="small">Вход:</span>
|
||||||
<tr>
|
<strong id="total-input-quantity" class="text-warning">
|
||||||
<td class="px-3 py-2">
|
{{ total_input_quantity|floatformat:3 }}
|
||||||
<a href="{% url 'products:product-detail' input.product.id %}">{{ input.product.name }}</a>
|
</strong>
|
||||||
</td>
|
</div>
|
||||||
<td class="px-3 py-2 text-end">{{ input.quantity|smart_quantity }}</td>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
{% if transformation.status == 'draft' %}
|
<span class="small">Выход:</span>
|
||||||
<td class="px-3 py-2 text-end">
|
<strong id="total-output-quantity" class="text-success">
|
||||||
<form method="post" action="{% url 'inventory:transformation-remove-input' transformation.pk input.pk %}" class="d-inline">
|
{{ total_output_quantity|floatformat:3 }}
|
||||||
{% csrf_token %}
|
</strong>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
</div>
|
||||||
onclick="return confirm('Удалить входной товар?');"
|
<hr class="my-2">
|
||||||
title="Удалить">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-trash"></i>
|
<span class="small">Остаток:</span>
|
||||||
</button>
|
<strong id="quantity-balance" class="{% if quantity_balance == 0 %}text-success{% elif quantity_balance > 0 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
</form>
|
{{ quantity_balance|floatformat:3 }}
|
||||||
</td>
|
</strong>
|
||||||
{% endif %}
|
</div>
|
||||||
</tr>
|
<div id="balance-hint" class="small mt-2" style="font-size: 0.75rem;">
|
||||||
{% empty %}
|
{% if total_input_quantity == 0 %}
|
||||||
<tr>
|
<span class="text-muted">Добавьте входные товары</span>
|
||||||
<td colspan="{% if transformation.status == 'draft' %}3{% else %}2{% endif %}" class="px-3 py-4 text-center text-muted">
|
{% elif quantity_balance == 0 %}
|
||||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
<span class="text-success"><i class="bi bi-check-circle"></i> Количества равны</span>
|
||||||
Входные товары не добавлены
|
{% elif quantity_balance > 0 %}
|
||||||
</td>
|
<span class="text-info">Можно добавить еще: <strong>{{ quantity_balance|floatformat:3 }}</strong></span>
|
||||||
</tr>
|
{% else %}
|
||||||
{% endfor %}
|
<span class="text-danger"><i class="bi bi-exclamation-triangle"></i> Превышение на {{ quantity_balance|floatformat:3|slice:"1:" }}</span>
|
||||||
</tbody>
|
{% endif %}
|
||||||
</table>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ПРАВАЯ КОЛОНКА: Выходные товары -->
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card border-0 shadow-sm h-100" style="border-left: 4px solid #198754 !important;">
|
||||||
|
<div class="card-header bg-success bg-opacity-10 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: 120px;">Кол-во</th>
|
||||||
|
{% if transformation.status == 'draft' %}
|
||||||
|
<th scope="col" class="px-3 py-2 text-end" style="width: 60px;"></th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="output-products-tbody">
|
||||||
|
{% for output in transformation.outputs.all %}
|
||||||
|
<tr data-output-id="{{ output.pk }}">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<a href="{% url 'products:product-detail' output.product.id %}">
|
||||||
|
{{ output.product.name }}
|
||||||
|
</a>
|
||||||
|
<small class="text-muted d-block">{{ output.product.sku }}</small>
|
||||||
|
</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('Удалить?');">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr class="empty-row">
|
||||||
|
<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-3 d-block mb-2"></i>
|
||||||
|
Нет товаров
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Добавление выходного товара -->
|
<!-- Скрытые формы для AJAX отправки -->
|
||||||
{% if transformation.status == 'draft' %}
|
{% if transformation.status == 'draft' %}
|
||||||
<div class="card border-0 shadow-sm mb-3">
|
<form method="post"
|
||||||
<div class="card-header bg-light py-3">
|
action="{% url 'inventory:transformation-add-input' transformation.pk %}"
|
||||||
<h6 class="mb-0"><i class="bi bi-box-arrow-up me-2 text-success"></i>Добавить выходной товар (что получаем)</h6>
|
id="add-input-form"
|
||||||
</div>
|
style="display:none;">
|
||||||
<div class="card-body">
|
{% csrf_token %}
|
||||||
<!-- Компонент поиска товаров -->
|
<select name="product" id="hidden-input-product"></select>
|
||||||
<div class="mb-3">
|
<input type="number" name="quantity" id="hidden-input-quantity" value="1" step="0.001">
|
||||||
{% include 'products/components/product_search_picker.html' with container_id='output-product-picker' title='Найти выходной товар' categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма добавления выходного товара -->
|
<form method="post"
|
||||||
<form method="post" action="{% url 'inventory:transformation-add-output' transformation.pk %}" id="add-output-form">
|
action="{% url 'inventory:transformation-add-output' transformation.pk %}"
|
||||||
{% csrf_token %}
|
id="add-output-form"
|
||||||
|
style="display:none;">
|
||||||
<div class="row g-3">
|
{% csrf_token %}
|
||||||
<div class="col-md-6">
|
<select name="product" id="hidden-output-product"></select>
|
||||||
<label for="id_output_product" class="form-label">Товар <span class="text-danger">*</span></label>
|
<input type="number" name="quantity" id="hidden-output-quantity" value="1" step="0.001">
|
||||||
{{ output_form.product }}
|
</form>
|
||||||
{% 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 %}
|
{% 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,49 +334,226 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Инициализация компонента поиска для входных товаров
|
var selectedProduct = null;
|
||||||
if (document.getElementById('input-product-picker')) {
|
var picker = null;
|
||||||
ProductSearchPicker.init('#input-product-picker', {
|
|
||||||
onAddSelected: function(product, instance) {
|
// Инициализация ОДНОГО компонента поиска
|
||||||
// Заполняем форму входного товара
|
if (document.getElementById('transformation-product-picker')) {
|
||||||
const productSelect = document.getElementById('id_input_product');
|
picker = ProductSearchPicker.init('#transformation-product-picker', {
|
||||||
if (productSelect) {
|
onSelect: function(product, instance) {
|
||||||
// Добавляем опцию если её нет
|
// Товар выбран - показываем кнопки действий
|
||||||
let option = productSelect.querySelector(`option[value="${product.id}"]`);
|
selectedProduct = product;
|
||||||
if (!option) {
|
var actionButtons = document.getElementById('action-buttons');
|
||||||
option = document.createElement('option');
|
actionButtons.style.display = 'block';
|
||||||
option.value = product.id;
|
|
||||||
option.text = product.text;
|
// Автофокус на поле количества и выделяем текст
|
||||||
productSelect.appendChild(option);
|
setTimeout(function() {
|
||||||
|
var quantityInput = document.getElementById('product-quantity');
|
||||||
|
if (quantityInput) {
|
||||||
|
quantityInput.focus();
|
||||||
|
quantityInput.select();
|
||||||
|
|
||||||
|
// Обработчик Enter - добавляет в входящие по умолчанию
|
||||||
|
quantityInput.onkeypress = function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('add-to-input-btn').click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик изменения количества для подсказки при добавлении в выходные
|
||||||
|
quantityInput.addEventListener('input', function() {
|
||||||
|
updateQuantityHint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Функция обновления подсказки количества
|
||||||
|
function updateQuantityHint() {
|
||||||
|
var quantityInput = document.getElementById('product-quantity');
|
||||||
|
var quantityHint = document.getElementById('quantity-hint');
|
||||||
|
var totalInputEl = document.getElementById('total-input-quantity');
|
||||||
|
var totalOutputEl = document.getElementById('total-output-quantity');
|
||||||
|
|
||||||
|
if (!quantityInput || !quantityHint || !totalInputEl || !totalOutputEl) return;
|
||||||
|
|
||||||
|
var quantity = parseFloat(quantityInput.value) || 0;
|
||||||
|
var totalInput = parseFloat(totalInputEl.textContent.replace(/\s/g, '').replace(',', '.'));
|
||||||
|
var totalOutput = parseFloat(totalOutputEl.textContent.replace(/\s/g, '').replace(',', '.'));
|
||||||
|
var newTotalOutput = totalOutput + quantity;
|
||||||
|
|
||||||
|
if (quantity > 0 && totalInput > 0) {
|
||||||
|
if (newTotalOutput > totalInput) {
|
||||||
|
var maxAllowed = totalInput - totalOutput;
|
||||||
|
quantityHint.textContent = 'Максимум: ' + maxAllowed.toFixed(3) + ' (превышение!)';
|
||||||
|
quantityHint.className = 'form-text text-danger';
|
||||||
|
quantityHint.style.display = 'block';
|
||||||
|
} else if (newTotalOutput === totalInput) {
|
||||||
|
quantityHint.textContent = 'Количества будут равны ✓';
|
||||||
|
quantityHint.className = 'form-text text-success';
|
||||||
|
quantityHint.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
var remaining = totalInput - newTotalOutput;
|
||||||
|
quantityHint.textContent = 'Остаток после добавления: ' + remaining.toFixed(3);
|
||||||
|
quantityHint.className = 'form-text text-info';
|
||||||
|
quantityHint.style.display = 'block';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quantityHint.style.display = 'none';
|
||||||
}
|
}
|
||||||
productSelect.value = product.id;
|
|
||||||
}
|
}
|
||||||
// Очищаем выбор в пикере
|
},
|
||||||
instance.clearSelection();
|
onDeselect: function(instance) {
|
||||||
|
// Товар отменен - скрываем кнопки
|
||||||
|
selectedProduct = null;
|
||||||
|
document.getElementById('action-buttons').style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация компонента поиска для выходных товаров
|
// Кнопка "Добавить во входящий товар"
|
||||||
if (document.getElementById('output-product-picker')) {
|
var addToInputBtn = document.getElementById('add-to-input-btn');
|
||||||
ProductSearchPicker.init('#output-product-picker', {
|
if (addToInputBtn) {
|
||||||
onAddSelected: function(product, instance) {
|
addToInputBtn.addEventListener('click', function() {
|
||||||
// Заполняем форму выходного товара
|
if (!selectedProduct) return;
|
||||||
const productSelect = document.getElementById('id_output_product');
|
|
||||||
if (productSelect) {
|
// Получаем количество из поля ввода
|
||||||
// Добавляем опцию если её нет
|
var quantityInput = document.getElementById('product-quantity');
|
||||||
let option = productSelect.querySelector(`option[value="${product.id}"]`);
|
var quantity = parseFloat(quantityInput.value);
|
||||||
if (!option) {
|
|
||||||
option = document.createElement('option');
|
// Валидация количества
|
||||||
option.value = product.id;
|
if (!quantity || quantity <= 0) {
|
||||||
option.text = product.text;
|
alert('Введите корректное количество (больше 0)');
|
||||||
productSelect.appendChild(option);
|
quantityInput.focus();
|
||||||
}
|
return;
|
||||||
productSelect.value = product.id;
|
|
||||||
}
|
|
||||||
// Очищаем выбор в пикере
|
|
||||||
instance.clearSelection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Заполняем скрытую форму
|
||||||
|
var productSelect = document.getElementById('hidden-input-product');
|
||||||
|
productSelect.innerHTML = '<option value="' + selectedProduct.id + '">' +
|
||||||
|
selectedProduct.text + '</option>';
|
||||||
|
productSelect.value = selectedProduct.id;
|
||||||
|
|
||||||
|
// Устанавливаем количество
|
||||||
|
document.getElementById('hidden-input-quantity').value = quantity;
|
||||||
|
|
||||||
|
// Отправляем форму через AJAX
|
||||||
|
var form = document.getElementById('add-input-form');
|
||||||
|
var formData = new FormData(form);
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Перезагружаем страницу чтобы обновить список
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при добавлении товара');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Очищаем выбор в пикере
|
||||||
|
if (picker) {
|
||||||
|
picker.clearSelection();
|
||||||
|
}
|
||||||
|
selectedProduct = null;
|
||||||
|
document.getElementById('action-buttons').style.display = 'none';
|
||||||
|
// Сбрасываем количество на 1
|
||||||
|
document.getElementById('product-quantity').value = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Добавить в исходящий товар"
|
||||||
|
var addToOutputBtn = document.getElementById('add-to-output-btn');
|
||||||
|
if (addToOutputBtn) {
|
||||||
|
addToOutputBtn.addEventListener('click', function() {
|
||||||
|
if (!selectedProduct) return;
|
||||||
|
|
||||||
|
// Получаем количество из поля ввода
|
||||||
|
var quantityInput = document.getElementById('product-quantity');
|
||||||
|
var quantity = parseFloat(quantityInput.value);
|
||||||
|
|
||||||
|
// Валидация количества
|
||||||
|
if (!quantity || quantity <= 0) {
|
||||||
|
alert('Введите корректное количество (больше 0)');
|
||||||
|
quantityInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем баланс количеств
|
||||||
|
var totalInputEl = document.getElementById('total-input-quantity');
|
||||||
|
var totalOutputEl = document.getElementById('total-output-quantity');
|
||||||
|
if (totalInputEl && totalOutputEl) {
|
||||||
|
var totalInput = parseFloat(totalInputEl.textContent.replace(/\s/g, '').replace(',', '.'));
|
||||||
|
var totalOutput = parseFloat(totalOutputEl.textContent.replace(/\s/g, '').replace(',', '.'));
|
||||||
|
var newTotalOutput = totalOutput + quantity;
|
||||||
|
|
||||||
|
if (newTotalOutput > totalInput) {
|
||||||
|
var maxAllowed = totalInput - totalOutput;
|
||||||
|
alert('Сумма выходных количеств не может превышать сумму входных количеств.\n' +
|
||||||
|
'Вход: ' + totalInput + '\n' +
|
||||||
|
'Выход (текущий): ' + totalOutput + '\n' +
|
||||||
|
'Максимально можно добавить: ' + maxAllowed.toFixed(3));
|
||||||
|
quantityInput.focus();
|
||||||
|
quantityInput.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем скрытую форму
|
||||||
|
var productSelect = document.getElementById('hidden-output-product');
|
||||||
|
productSelect.innerHTML = '<option value="' + selectedProduct.id + '">' +
|
||||||
|
selectedProduct.text + '</option>';
|
||||||
|
productSelect.value = selectedProduct.id;
|
||||||
|
|
||||||
|
// Устанавливаем количество
|
||||||
|
document.getElementById('hidden-output-quantity').value = quantity;
|
||||||
|
|
||||||
|
// Отправляем форму через AJAX
|
||||||
|
var form = document.getElementById('add-output-form');
|
||||||
|
var formData = new FormData(form);
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Перезагружаем страницу чтобы обновить список
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при добавлении товара');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Очищаем выбор в пикере
|
||||||
|
if (picker) {
|
||||||
|
picker.clearSelection();
|
||||||
|
}
|
||||||
|
selectedProduct = null;
|
||||||
|
document.getElementById('action-buttons').style.display = 'none';
|
||||||
|
// Сбрасываем количество на 1
|
||||||
|
document.getElementById('product-quantity').value = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ class TransformationDetailView(LoginRequiredMixin, DetailView):
|
|||||||
from products.models import ProductCategory, ProductTag
|
from products.models import ProductCategory, ProductTag
|
||||||
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
|
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
|
||||||
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
|
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
|
||||||
|
|
||||||
|
# Вычисляем суммы для подсказки
|
||||||
|
from decimal import Decimal
|
||||||
|
total_input = sum(
|
||||||
|
trans_input.quantity for trans_input in self.object.inputs.all()
|
||||||
|
)
|
||||||
|
total_output = sum(
|
||||||
|
trans_output.quantity for trans_output in self.object.outputs.all()
|
||||||
|
)
|
||||||
|
context['total_input_quantity'] = total_input
|
||||||
|
context['total_output_quantity'] = total_output
|
||||||
|
context['quantity_balance'] = total_input - total_output
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user