diff --git a/myproject/inventory/migrations/0018_inventoryline_snapshot_difference_and_more.py b/myproject/inventory/migrations/0018_inventoryline_snapshot_difference_and_more.py new file mode 100644 index 0000000..0d7cc29 --- /dev/null +++ b/myproject/inventory/migrations/0018_inventoryline_snapshot_difference_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.10 on 2025-12-22 10:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0017_change_conducted_by_to_fk'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryline', + name='snapshot_difference', + field=models.DecimalField(blank=True, decimal_places=3, help_text='Итоговая разница на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Итоговая разница (snapshot)'), + ), + migrations.AddField( + model_name='inventoryline', + name='snapshot_quantity_available', + field=models.DecimalField(blank=True, decimal_places=3, help_text='Всего на складе на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Всего на складе (snapshot)'), + ), + migrations.AddField( + model_name='inventoryline', + name='snapshot_quantity_reserved', + field=models.DecimalField(blank=True, decimal_places=3, help_text='В резервах на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В резервах (snapshot)'), + ), + migrations.AddField( + model_name='inventoryline', + name='snapshot_quantity_system', + field=models.DecimalField(blank=True, decimal_places=3, help_text='В системе свободно на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В системе свободно (snapshot)'), + ), + migrations.AlterField( + model_name='inventoryline', + name='difference', + field=models.DecimalField(decimal_places=3, default=0, editable=False, help_text='(Подсчитано + Зарезервировано) - Всего на складе', max_digits=10, verbose_name='Итоговая разница'), + ), + migrations.AlterField( + model_name='inventoryline', + name='quantity_fact', + field=models.DecimalField(decimal_places=3, help_text='Количество свободных товаров, подсчитанных физически', max_digits=10, verbose_name='Подсчитано (факт, свободные)'), + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index f38d75d..6b7ab7e 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -338,8 +338,14 @@ class Inventory(models.Model): date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="Статус") - conducted_by = models.CharField(max_length=200, blank=True, null=True, - verbose_name="Провел инвентаризацию") + conducted_by = models.ForeignKey( + 'user_roles.UserRole', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='inventories', + verbose_name="Провел инвентаризацию" + ) notes = models.TextField(blank=True, null=True, verbose_name="Примечания") class Meta: @@ -375,6 +381,28 @@ class InventoryLine(models.Model): editable=False) processed = models.BooleanField(default=False, verbose_name="Обработана (создана операция)") + + # Snapshot-значения на момент завершения инвентаризации + snapshot_quantity_available = models.DecimalField( + max_digits=10, decimal_places=3, null=True, blank=True, + verbose_name="Всего на складе (snapshot)", + help_text="Всего на складе на момент завершения инвентаризации" + ) + snapshot_quantity_reserved = models.DecimalField( + max_digits=10, decimal_places=3, null=True, blank=True, + verbose_name="В резервах (snapshot)", + help_text="В резервах на момент завершения инвентаризации" + ) + snapshot_quantity_system = models.DecimalField( + max_digits=10, decimal_places=3, null=True, blank=True, + verbose_name="В системе свободно (snapshot)", + help_text="В системе свободно на момент завершения инвентаризации" + ) + snapshot_difference = models.DecimalField( + max_digits=10, decimal_places=3, null=True, blank=True, + verbose_name="Итоговая разница (snapshot)", + help_text="Итоговая разница на момент завершения инвентаризации" + ) class Meta: verbose_name = "Строка инвентаризации" diff --git a/myproject/inventory/services/inventory_processor.py b/myproject/inventory/services/inventory_processor.py index 398349a..9965751 100644 --- a/myproject/inventory/services/inventory_processor.py +++ b/myproject/inventory/services/inventory_processor.py @@ -46,12 +46,47 @@ class InventoryProcessor: } """ inventory = Inventory.objects.get(id=inventory_id) - lines = InventoryLine.objects.filter(inventory=inventory, processed=False) + # Получаем все строки инвентаризации для сохранения snapshot + all_lines = InventoryLine.objects.filter(inventory=inventory) + # Получаем только необработанные строки для обработки + lines = all_lines.filter(processed=False) errors = [] writeoff_document = None incoming_document = None + # Сохраняем snapshot-значения для ВСЕХ строк ПЕРЕД обработкой + # Это нужно чтобы зафиксировать состояние на момент подсчета + from inventory.models import Stock + for line in all_lines: + try: + stock, _ = Stock.objects.get_or_create( + product=line.product, + warehouse=inventory.warehouse + ) + stock.refresh_from_batches() + + # Пересчитываем разницу перед сохранением snapshot + # чтобы убедиться что она актуальна + current_difference = (line.quantity_fact + stock.quantity_reserved) - stock.quantity_available + + # Сохраняем snapshot-значения на момент завершения + line.snapshot_quantity_available = stock.quantity_available + line.snapshot_quantity_reserved = stock.quantity_reserved + line.snapshot_quantity_system = stock.quantity_free + line.snapshot_difference = current_difference + line.save(update_fields=[ + 'snapshot_quantity_available', + 'snapshot_quantity_reserved', + 'snapshot_quantity_system', + 'snapshot_difference' + ]) + except Exception as e: + errors.append({ + 'line': line, + 'error': f'Ошибка сохранения snapshot: {str(e)}' + }) + # Собираем недостачи и излишки deficit_lines = [] surplus_lines = [] diff --git a/myproject/inventory/views/inventory_ops.py b/myproject/inventory/views/inventory_ops.py index 46acfa8..620095e 100644 --- a/myproject/inventory/views/inventory_ops.py +++ b/myproject/inventory/views/inventory_ops.py @@ -52,18 +52,33 @@ class InventoryCreateView(LoginRequiredMixin, CreateView): model = Inventory form_class = InventoryForm template_name = 'inventory/inventory/inventory_form.html' - success_url = reverse_lazy('inventory:inventory-list') def form_valid(self, form): from inventory.utils.document_generator import generate_inventory_document_number form.instance.status = 'processing' form.instance.document_number = generate_inventory_document_number() + + # Автоматически проставляем роль пользователя, который создает инвентаризацию + # Если у пользователя есть роль в тенанте - проставляем её, иначе оставляем NULL + try: + if hasattr(self.request.user, 'tenant_role'): + user_role = getattr(self.request.user, 'tenant_role', None) + if user_role and user_role.is_active: + form.instance.conducted_by = user_role + except (AttributeError, Exception): + # Если у пользователя нет роли (суперюзер/админ без роли) - оставляем NULL + pass + messages.success( self.request, f'Инвентаризация склада "{form.instance.warehouse.name}" начата.' ) return super().form_valid(form) + + def get_success_url(self): + """Перенаправляем на страницу редактирования созданной инвентаризации""" + return reverse_lazy('inventory:inventory-detail', kwargs={'pk': self.object.pk}) class InventoryDetailView(LoginRequiredMixin, DetailView): @@ -169,26 +184,52 @@ class InventoryDetailView(LoginRequiredMixin, DetailView): # Добавляем quantity_reserved и обновляем quantity_system для каждой строки lines_with_reserved = [] for line in lines: - stock = existing_stocks.get(line.product_id) - if not stock: - # Fallback на старый способ, если что-то пошло не так - stock, _ = Stock.objects.get_or_create( - product=line.product, - warehouse=warehouse - ) - stock.refresh_from_batches() - - # Для незавершенных инвентаризаций обновляем quantity_system динамически - if self.object.status != 'completed': + # Для завершенных инвентаризаций используем snapshot-значения + if self.object.status == 'completed': + # Используем snapshot-значения если они есть (для новых записей) + if line.snapshot_quantity_available is not None: + line.quantity_available = line.snapshot_quantity_available + line.quantity_reserved = line.snapshot_quantity_reserved + line.quantity_system = line.snapshot_quantity_system + # Используем snapshot_difference, если он есть, иначе пересчитываем + if line.snapshot_difference is not None: + line.difference = line.snapshot_difference + else: + # Fallback: пересчитываем из snapshot-значений + line.difference = (line.quantity_fact + line.snapshot_quantity_reserved) - line.snapshot_quantity_available + else: + # Fallback для старых записей без snapshot + # Используем текущие значения Stock, но это не идеально + # так как остатки уже могли измениться после завершения инвентаризации + stock = existing_stocks.get(line.product_id) + if not stock: + stock, _ = Stock.objects.get_or_create( + product=line.product, + warehouse=warehouse + ) + stock.refresh_from_batches() + line.quantity_reserved = stock.quantity_reserved + line.quantity_available = stock.quantity_available + # Для старых записей используем сохраненное quantity_system из модели + # и пересчитываем разницу + line.difference = (line.quantity_fact + line.quantity_reserved) - line.quantity_available + else: + # Для незавершенных инвентаризаций используем актуальные значения + stock = existing_stocks.get(line.product_id) + if not stock: + stock, _ = Stock.objects.get_or_create( + product=line.product, + warehouse=warehouse + ) + stock.refresh_from_batches() + # Используем актуальное свободное количество из Stock line.quantity_system = stock.quantity_free - - # Добавляем quantity_reserved и quantity_available - line.quantity_reserved = stock.quantity_reserved - line.quantity_available = stock.quantity_available - - # Пересчитываем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available - line.difference = (line.quantity_fact + line.quantity_reserved) - line.quantity_available + line.quantity_reserved = stock.quantity_reserved + line.quantity_available = stock.quantity_available + + # Пересчитываем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available + line.difference = (line.quantity_fact + line.quantity_reserved) - line.quantity_available lines_with_reserved.append(line) @@ -556,3 +597,68 @@ class InventoryCompleteView(LoginRequiredMixin, View): return redirect('inventory:inventory-detail', pk=inventory_id) +class InventoryDeleteView(LoginRequiredMixin, View): + """ + Удаление инвентаризации. + Можно удалять только инвентаризации со статусом 'draft' или 'processing'. + Завершенные инвентаризации удалять нельзя, так как документы уже проведены. + """ + + @method_decorator(require_http_methods(["POST"])) + @transaction.atomic + def dispatch(self, *args, **kwargs): + return super().dispatch(*args, **kwargs) + + def post(self, request, pk): + try: + inventory = get_object_or_404(Inventory, pk=pk) + + # Проверка статуса - завершенные нельзя удалять + if inventory.status == 'completed': + messages.error( + request, + 'Нельзя удалить завершенную инвентаризацию. Документы уже проведены.' + ) + return redirect('inventory:inventory-detail', pk=pk) + + # Проверка связанных документов + writeoff_docs = inventory.writeoff_documents.all() + incoming_docs = inventory.incoming_documents.all() + + # Проверяем, есть ли проведенные документы + if writeoff_docs.filter(status='confirmed').exists(): + messages.error( + request, + 'Нельзя удалить инвентаризацию с проведенными документами списания.' + ) + return redirect('inventory:inventory-detail', pk=pk) + + if incoming_docs.filter(status='confirmed').exists(): + messages.error( + request, + 'Нельзя удалить инвентаризацию с проведенными документами поступления.' + ) + return redirect('inventory:inventory-detail', pk=pk) + + # Сохраняем информацию для сообщения + warehouse_name = inventory.warehouse.name + document_number = inventory.document_number or f"#{inventory.id}" + + # Удаляем документы-черновики вместе с инвентаризацией + writeoff_docs.filter(status='draft').delete() + incoming_docs.filter(status='draft').delete() + + # Удаляем инвентаризацию (InventoryLine удалятся автоматически через CASCADE) + inventory.delete() + + messages.success( + request, + f'Инвентаризация {document_number} склада "{warehouse_name}" успешно удалена.' + ) + return redirect('inventory:inventory-list') + + except Exception as e: + messages.error(request, f'Ошибка при удалении инвентаризации: {str(e)}') + return redirect('inventory:inventory-detail', pk=pk) + +