Добавлено сохранение 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

@@ -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)