From bc13750d16b4b344fa9e9257684ff24fc6fe8970 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 25 Dec 2025 22:54:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправлена проблема, когда при отмене проведенной трансформации оба сигнала выполнялись последовательно: - rollback_transformation_on_cancel возвращал резервы в 'reserved' - release_reservations_on_draft_cancel ошибочно освобождал их в 'released' Изменена проверка в release_reservations_on_draft_cancel: вместо проверки наличия партий Output (которые уже удалены) теперь проверяется статус резервов ('converted_to_transformation') или наличие поля converted_at, что работает независимо от порядка выполнения сигналов. --- check_duplicates.py | 66 ++ myproject/inventory/forms.py | 17 + myproject/inventory/services/batch_manager.py | 88 ++- .../services/transformation_service.py | 14 + myproject/inventory/signals.py | 41 +- .../inventory/transformation/detail.html | 621 ++++++++++++------ myproject/inventory/views/transformation.py | 12 + 7 files changed, 624 insertions(+), 235 deletions(-) create mode 100644 check_duplicates.py diff --git a/check_duplicates.py b/check_duplicates.py new file mode 100644 index 0000000..5f7d560 --- /dev/null +++ b/check_duplicates.py @@ -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!") diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py index 5e7709f..84cab83 100644 --- a/myproject/inventory/forms.py +++ b/myproject/inventory/forms.py @@ -747,6 +747,23 @@ class TransformationOutputForm(forms.Form): # Проверяем что товар еще не добавлен if self.transformation and self.transformation.outputs.filter(product=product).exists(): 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 diff --git a/myproject/inventory/services/batch_manager.py b/myproject/inventory/services/batch_manager.py index 2edced5..d44d877 100644 --- a/myproject/inventory/services/batch_manager.py +++ b/myproject/inventory/services/batch_manager.py @@ -70,7 +70,7 @@ class StockBatchManager: return batch @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 (старые партии первыми). ВАЖНО: Учитывает зарезервированное количество товара. @@ -83,6 +83,9 @@ class StockBatchManager: exclude_order: (опционально) объект Order - исключить резервы этого заказа из расчёта. Используется при переводе заказа в 'completed', когда резервы заказа ещё не переведены в 'converted_to_sale'. + exclude_transformation: (опционально) объект Transformation - исключить резервы этой трансформации из расчёта. + Используется при переводе трансформации в 'completed', когда резервы + трансформации ещё не переведены в 'converted_to_transformation'. Returns: list: [(batch, qty_written), ...] - какие партии и сколько списано @@ -96,12 +99,26 @@ class StockBatchManager: allocations = [] # Получаем общее количество зарезервированного товара (все резервы со статусом 'reserved') - # Исключаем резервы заказа, для которого делается списание (если передан) + # Исключаем резервы заказа или трансформации, для которых делается списание (если переданы) reservation_filter = Reservation.objects.filter( product=product, warehouse=warehouse, 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: reservation_filter = reservation_filter.exclude(order_item__order=exclude_order) @@ -110,8 +127,10 @@ class StockBatchManager: # Получаем партии по FIFO 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: if remaining <= 0: @@ -119,32 +138,57 @@ class StockBatchManager: # Определяем сколько в этой партии зарезервировано (пропорционально) # Логика: старые партии "съедают" резерв первыми (как и при списании) - batch_reserved = min(batch.quantity, reserved_remaining) - reserved_remaining -= batch_reserved - + batch_reserved_other = min(batch.quantity, reserved_remaining) + 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 - if batch_free <= 0: - # Партия полностью зарезервирована → пропускаем - continue + # Если есть резерв трансформации в этой партии, списываем из него + if batch_reserved_transformation > 0: + # Списываем из зарезервированного товара трансформации + 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.save(update_fields=['quantity', 'updated_at']) + # Списываем + batch.quantity -= qty_from_this_batch + 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: - batch.is_active = False - batch.save(update_fields=['is_active']) + # Если партия опустошена, деактивируем её + if batch.quantity <= 0: + batch.is_active = False + batch.save(update_fields=['is_active']) if remaining > 0: raise ValueError( diff --git a/myproject/inventory/services/transformation_service.py b/myproject/inventory/services/transformation_service.py index 6e1e90d..7a4da46 100644 --- a/myproject/inventory/services/transformation_service.py +++ b/myproject/inventory/services/transformation_service.py @@ -218,6 +218,20 @@ class TransformationService: if not transformation.outputs.exists(): 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(): stock = Stock.objects.filter( diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index ec740b5..e027c66 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -5,6 +5,7 @@ """ 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.dispatch import receiver from django.utils import timezone @@ -1533,19 +1534,21 @@ def reserve_on_transformation_input_create(sender, instance, created, **kwargs): """ При создании входного товара в черновике - резервируем его. """ + # Резервируем только при создании нового входного товара + if not created: + return + # Резервируем только если трансформация в draft if instance.transformation.status != 'draft': return - # Создаем или обновляем резерв - Reservation.objects.update_or_create( + # Создаем резерв + Reservation.objects.create( transformation_input=instance, product=instance.product, warehouse=instance.transformation.warehouse, - defaults={ - 'quantity': instance.quantity, - 'status': 'reserved' - } + quantity=instance.quantity, + status='reserved' ) @@ -1582,7 +1585,8 @@ def process_transformation_on_complete(sender, instance, created, **kwargs): allocations = StockBatchManager.write_off_by_fifo( product=trans_input.product, 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 # Обновляем резерв - Reservation.objects.filter( + reservations_updated = Reservation.objects.filter( transformation_input=trans_input, status='reserved' ).update( status='converted_to_transformation', 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 for trans_output in instance.outputs.all(): @@ -1678,8 +1691,16 @@ def release_reservations_on_draft_cancel(sender, instance, **kwargs): if instance.status != 'cancelled': return - # Проверяем что это был черновик (нет созданных партий) - if instance.outputs.filter(stock_batch__isnull=False).exists(): + # Проверяем, были ли резервы в статусе 'converted_to_transformation' + # или имеют заполненное поле 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 # Это была проведенная трансформация, обрабатывается другим сигналом # Освобождаем все резервы diff --git a/myproject/inventory/templates/inventory/transformation/detail.html b/myproject/inventory/templates/inventory/transformation/detail.html index 6180058..f3fdb86 100644 --- a/myproject/inventory/templates/inventory/transformation/detail.html +++ b/myproject/inventory/templates/inventory/transformation/detail.html @@ -98,195 +98,233 @@ - + {% if transformation.status == 'draft' %}
-
Добавить входной товар (что списываем)
+
Найти и добавить товар
- {% 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' %}
- -
- {% csrf_token %} - -
-
- - {{ input_form.product }} - {% if input_form.product.errors %} -
{{ input_form.product.errors.0 }}
- {% endif %} -
- -
- - {{ input_form.quantity }} - {% if input_form.quantity.errors %} -
{{ input_form.quantity.errors.0 }}
- {% endif %} -
- -
- -
+ +
{% endif %} - -
-
-
Входные товары (списание)
+ +
+ +
+
+
+
+ + Входной товар (списание) +
+
+
+
+ + + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + + + {% for input in transformation.inputs.all %} + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
ТоварКол-во
+ + {{ input.product.name }} + + {{ input.product.sku }} + {{ input.quantity|smart_quantity }} +
+ {% csrf_token %} + +
+
+ + Нет товаров +
+
+
+
-
-
- - - - - - {% if transformation.status == 'draft' %} - - {% endif %} - - - - {% for input in transformation.inputs.all %} - - - - {% if transformation.status == 'draft' %} - - {% endif %} - - {% empty %} - - - - {% endfor %} - -
ТоварКоличество
- {{ input.product.name }} - {{ input.quantity|smart_quantity }} -
- {% csrf_token %} - -
-
- - Входные товары не добавлены -
+ + +
+
+ + +
Трансформация
+ {% if transformation.status == 'draft' %} +
+
+
+
Баланс количеств
+
+ Вход: + + {{ total_input_quantity|floatformat:3 }} + +
+
+ Выход: + + {{ total_output_quantity|floatformat:3 }} + +
+
+
+ Остаток: + + {{ quantity_balance|floatformat:3 }} + +
+
+ {% if total_input_quantity == 0 %} + Добавьте входные товары + {% elif quantity_balance == 0 %} + Количества равны + {% elif quantity_balance > 0 %} + Можно добавить еще: {{ quantity_balance|floatformat:3 }} + {% else %} + Превышение на {{ quantity_balance|floatformat:3|slice:"1:" }} + {% endif %} +
+
+
+
+ {% endif %} +
+
+ + +
+
+
+
+ + Исходящий товар (оприходование) +
+
+
+
+ + + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + + + {% for output in transformation.outputs.all %} + + + + {% if transformation.status == 'draft' %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
ТоварКол-во
+ + {{ output.product.name }} + + {{ output.product.sku }} + {{ output.quantity|smart_quantity }} +
+ {% csrf_token %} + +
+
+ + Нет товаров +
+
+
- + {% if transformation.status == 'draft' %} -
-
-
Добавить выходной товар (что получаем)
-
-
- -
- {% include 'products/components/product_search_picker.html' with container_id='output-product-picker' title='Найти выходной товар' categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %} -
+ - -
- {% csrf_token %} - -
-
- - {{ output_form.product }} - {% if output_form.product.errors %} -
{{ output_form.product.errors.0 }}
- {% endif %} -
- -
- - {{ output_form.quantity }} - {% if output_form.quantity.errors %} -
{{ output_form.quantity.errors.0 }}
- {% endif %} -
- -
- -
-
-
-
-
+ {% endif %} - - -
-
-
Выходные товары (оприходование)
-
-
-
- - - - - - {% if transformation.status == 'draft' %} - - {% endif %} - - - - {% for output in transformation.outputs.all %} - - - - {% if transformation.status == 'draft' %} - - {% endif %} - - {% empty %} - - - - {% endfor %} - -
ТоварКоличество
- {{ output.product.name }} - {{ output.quantity|smart_quantity }} -
- {% csrf_token %} - -
-
- - Выходные товары не добавлены -
-
-
-
@@ -296,49 +334,226 @@