Добавлено сохранение snapshot-значений для проведенных инвентаризаций
- Добавлены поля snapshot_* в модель InventoryLine для фиксации значений на момент завершения - Обновлен InventoryProcessor для сохранения snapshot перед обработкой - Обновлен InventoryDetailView для отображения snapshot-значений в проведенных инвентаризациях - Добавлена миграция 0018 для новых полей - Теперь в проведенных инвентаризациях отображаются оригинальные значения и правильная разница, а не текущие скорректированные остатки
This commit is contained in:
@@ -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='Подсчитано (факт, свободные)'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
@@ -376,6 +382,28 @@ class InventoryLine(models.Model):
|
||||
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 = "Строка инвентаризации"
|
||||
verbose_name_plural = "Строки инвентаризации"
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -52,19 +52,34 @@ 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()
|
||||
# Для завершенных инвентаризаций используем 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()
|
||||
|
||||
# Для незавершенных инвентаризаций обновляем quantity_system динамически
|
||||
if self.object.status != 'completed':
|
||||
# Используем актуальное свободное количество из Stock
|
||||
line.quantity_system = stock.quantity_free
|
||||
line.quantity_reserved = stock.quantity_reserved
|
||||
line.quantity_available = stock.quantity_available
|
||||
|
||||
# Добавляем 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
|
||||
# Пересчитываем разницу по новой формуле: (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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user