Добавлено сохранение 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="Дата инвентаризации")
|
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 = "Строки инвентаризации"
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user