Добавлено сохранение snapshot-значений для проведенных инвентаризаций

- Добавлены поля snapshot_* в модель InventoryLine для фиксации значений на момент завершения
- Обновлен InventoryProcessor для сохранения snapshot перед обработкой
- Обновлен InventoryDetailView для отображения snapshot-значений в проведенных инвентаризациях
- Добавлена миграция 0018 для новых полей
- Теперь в проведенных инвентаризациях отображаются оригинальные значения и правильная разница, а не текущие скорректированные остатки
This commit is contained in:
2025-12-22 13:43:35 +03:00
parent 9b430c7eb0
commit c476eafd4a
4 changed files with 234 additions and 22 deletions

View File

@@ -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='Подсчитано (факт, свободные)'),
),
]

View File

@@ -338,8 +338,14 @@ class Inventory(models.Model):
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации") date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, status = models.CharField(max_length=20, choices=STATUS_CHOICES,
default='draft', verbose_name="Статус") default='draft', verbose_name="Статус")
conducted_by = models.CharField(max_length=200, blank=True, null=True, conducted_by = models.ForeignKey(
verbose_name="Провел инвентаризацию") '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="Примечания") notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
class Meta: class Meta:
@@ -376,6 +382,28 @@ class InventoryLine(models.Model):
processed = models.BooleanField(default=False, processed = models.BooleanField(default=False,
verbose_name="Обработана (создана операция)") 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: class Meta:
verbose_name = "Строка инвентаризации" verbose_name = "Строка инвентаризации"
verbose_name_plural = "Строки инвентаризации" verbose_name_plural = "Строки инвентаризации"

View File

@@ -46,12 +46,47 @@ class InventoryProcessor:
} }
""" """
inventory = Inventory.objects.get(id=inventory_id) 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 = [] errors = []
writeoff_document = None writeoff_document = None
incoming_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 = [] deficit_lines = []
surplus_lines = [] surplus_lines = []

View File

@@ -52,19 +52,34 @@ class InventoryCreateView(LoginRequiredMixin, CreateView):
model = Inventory model = Inventory
form_class = InventoryForm form_class = InventoryForm
template_name = 'inventory/inventory/inventory_form.html' template_name = 'inventory/inventory/inventory_form.html'
success_url = reverse_lazy('inventory:inventory-list')
def form_valid(self, form): def form_valid(self, form):
from inventory.utils.document_generator import generate_inventory_document_number from inventory.utils.document_generator import generate_inventory_document_number
form.instance.status = 'processing' form.instance.status = 'processing'
form.instance.document_number = generate_inventory_document_number() 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( messages.success(
self.request, self.request,
f'Инвентаризация склада "{form.instance.warehouse.name}" начата.' f'Инвентаризация склада "{form.instance.warehouse.name}" начата.'
) )
return super().form_valid(form) 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): class InventoryDetailView(LoginRequiredMixin, DetailView):
""" """
@@ -169,21 +184,47 @@ class InventoryDetailView(LoginRequiredMixin, DetailView):
# Добавляем quantity_reserved и обновляем quantity_system для каждой строки # Добавляем quantity_reserved и обновляем quantity_system для каждой строки
lines_with_reserved = [] lines_with_reserved = []
for line in lines: for line in lines:
# Для завершенных инвентаризаций используем 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) stock = existing_stocks.get(line.product_id)
if not stock: if not stock:
# Fallback на старый способ, если что-то пошло не так
stock, _ = Stock.objects.get_or_create( stock, _ = Stock.objects.get_or_create(
product=line.product, product=line.product,
warehouse=warehouse warehouse=warehouse
) )
stock.refresh_from_batches() stock.refresh_from_batches()
# Для незавершенных инвентаризаций обновляем quantity_system динамически
if self.object.status != 'completed':
# Используем актуальное свободное количество из Stock # Используем актуальное свободное количество из Stock
line.quantity_system = stock.quantity_free line.quantity_system = stock.quantity_free
# Добавляем quantity_reserved и quantity_available
line.quantity_reserved = stock.quantity_reserved line.quantity_reserved = stock.quantity_reserved
line.quantity_available = stock.quantity_available line.quantity_available = stock.quantity_available
@@ -556,3 +597,68 @@ class InventoryCompleteView(LoginRequiredMixin, View):
return redirect('inventory:inventory-detail', pk=inventory_id) 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)