Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения UI

- Автоматическое проведение документов списания и оприходования после завершения инвентаризации
- Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation
- Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available
- Переименование поля 'По факту' в 'Подсчитано (факт, свободные)'
- Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации
- Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением
- Центральное выравнивание значений в столбцах таблицы
- Автоматическое выделение текста при фокусе на поле ввода количества
- Исправление форматирования разницы (убраны лишние нули)
- Изменение статуса 'Не обработана' на 'Не проведено'
- Добавление номера документа для инвентаризаций (INV-XXXXXX)
- Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem)
- Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
This commit is contained in:
2025-12-21 23:59:02 +03:00
parent bb821f9ef4
commit a8ba5ce780
16 changed files with 1619 additions and 194 deletions

View File

@@ -289,14 +289,23 @@ class InventoryAdmin(admin.ModelAdmin):
for inventory in queryset:
result = InventoryProcessor.process_inventory(inventory.id)
self.message_user(
request,
msg_parts = [
f"Инвентаризация {inventory.warehouse.name}: "
f"обработано {result['processed_lines']} строк, "
f"создано {result['writeoffs_created']} списаний и "
f"{result['incomings_created']} приходов"
f"обработано {result['processed_lines']} строк."
]
if result.get('writeoff_document'):
msg_parts.append(
f"Создан документ списания: {result['writeoff_document'].document_number} (черновик)."
)
if result.get('incoming_document'):
msg_parts.append(
f"Создан документ оприходования: {result['incoming_document'].document_number} (черновик)."
)
self.message_user(request, ' '.join(msg_parts))
process_inventory.short_description = 'Обработать инвентаризацию'

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.0.10 on 2025-12-21 18:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0014_alter_documentcounter_counter_type_incomingdocument_and_more'),
]
operations = [
migrations.AddField(
model_name='incomingdocument',
name='inventory',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_documents', to='inventory.inventory', verbose_name='Инвентаризация'),
),
migrations.AddField(
model_name='writeoffdocument',
name='inventory',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_documents', to='inventory.inventory', verbose_name='Инвентаризация'),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.0.10 on 2025-12-21 19:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0015_add_inventory_foreign_keys'),
]
operations = [
migrations.AddField(
model_name='inventory',
name='document_number',
field=models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Номер документа'),
),
migrations.AlterField(
model_name='documentcounter',
name='counter_type',
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
migrations.AddIndex(
model_name='inventory',
index=models.Index(fields=['document_number'], name='inventory_i_documen_8df782_idx'),
),
]

View File

@@ -327,6 +327,14 @@ class Inventory(models.Model):
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='inventories', verbose_name="Склад")
document_number = models.CharField(
max_length=100,
unique=True,
db_index=True,
blank=True,
null=True,
verbose_name="Номер документа"
)
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации")
status = models.CharField(max_length=20, choices=STATUS_CHOICES,
default='draft', verbose_name="Статус")
@@ -338,8 +346,13 @@ class Inventory(models.Model):
verbose_name = "Инвентаризация"
verbose_name_plural = "Инвентаризации"
ordering = ['-date']
indexes = [
models.Index(fields=['document_number']),
]
def __str__(self):
if self.document_number:
return f"{self.document_number} - {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})"
return f"Инвентаризация {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})"
@@ -354,9 +367,11 @@ class InventoryLine(models.Model):
quantity_system = models.DecimalField(max_digits=10, decimal_places=3,
verbose_name="Количество в системе")
quantity_fact = models.DecimalField(max_digits=10, decimal_places=3,
verbose_name="Фактическое количество")
verbose_name="Подсчитано (факт, свободные)",
help_text="Количество свободных товаров, подсчитанных физически")
difference = models.DecimalField(max_digits=10, decimal_places=3,
default=0, verbose_name="Разница (факт - система)",
default=0, verbose_name="Итоговая разница",
help_text="(Подсчитано + Зарезервировано) - Всего на складе",
editable=False)
processed = models.BooleanField(default=False,
verbose_name="Обработана (создана операция)")
@@ -370,7 +385,32 @@ class InventoryLine(models.Model):
def save(self, *args, **kwargs):
# Автоматически рассчитываем разницу
# Формула: (quantity_fact + quantity_reserved) - quantity_available
# Где quantity_fact - подсчитанные свободные товары
# Для расчета нужны quantity_reserved и quantity_available из Stock
# Если они не переданы в kwargs, получаем из Stock
quantity_reserved = kwargs.pop('quantity_reserved', None)
quantity_available = kwargs.pop('quantity_available', None)
if quantity_reserved is None or quantity_available is None:
# Получаем из Stock для расчета
from inventory.models import Stock
stock = Stock.objects.filter(
product=self.product,
warehouse=self.inventory.warehouse
).first()
if stock:
stock.refresh_from_batches()
quantity_reserved = stock.quantity_reserved
quantity_available = stock.quantity_available
# Вычисляем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available
if quantity_reserved is not None and quantity_available is not None:
self.difference = (self.quantity_fact + quantity_reserved) - quantity_available
else:
# Fallback на старую формулу, если Stock недоступен (не должно происходить в нормальной работе)
self.difference = self.quantity_fact - self.quantity_system
super().save(*args, **kwargs)
@@ -762,6 +802,7 @@ class DocumentCounter(models.Model):
('transfer', 'Перемещение товара'),
('writeoff', 'Списание товара'),
('incoming', 'Поступление товара'),
('inventory', 'Инвентаризация'),
]
counter_type = models.CharField(
@@ -954,6 +995,16 @@ class WriteOffDocument(models.Model):
verbose_name="Примечания"
)
# Связь с инвентаризацией
inventory = models.ForeignKey(
'Inventory',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='writeoff_documents',
verbose_name="Инвентаризация"
)
# Аудит
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -1168,6 +1219,16 @@ class IncomingDocument(models.Model):
verbose_name="Примечания"
)
# Связь с инвентаризацией
inventory = models.ForeignKey(
'Inventory',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='incoming_documents',
verbose_name="Инвентаризация"
)
# Аудит
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,

View File

@@ -2,8 +2,8 @@
Процессор для обработки инвентаризации.
Основной функционал:
- Обработка расхождений между фактом и системой
- Автоматическое создание WriteOff для недостач (по FIFO)
- Автоматическое создание Incoming для излишков
- Автоматическое создание WriteOffDocument для недостач (черновик)
- Автоматическое создание IncomingDocument для излишков (черновик)
"""
from decimal import Decimal
@@ -11,11 +11,11 @@ from django.db import transaction
from django.utils import timezone
from inventory.models import (
Inventory, InventoryLine, WriteOff, Incoming, IncomingBatch,
StockBatch, Stock
Inventory, InventoryLine, StockBatch, Stock
)
from inventory.services.batch_manager import StockBatchManager
from inventory.utils import generate_incoming_document_number
from inventory.services.writeoff_document_service import WriteOffDocumentService
from inventory.services.incoming_document_service import IncomingDocumentService
class InventoryProcessor:
@@ -28,9 +28,10 @@ class InventoryProcessor:
def process_inventory(inventory_id):
"""
Обработать инвентаризацию:
- Для недостач (разница < 0): создать WriteOff по FIFO
- Для излишков (разница > 0): создать Incoming с новой партией
- Для недостач (разница < 0): создать WriteOffDocument (черновик) с позициями
- Для излишков (разница > 0): создать IncomingDocument (черновик) с позициями
- Обновить статус inventory и lines
- НЕ проводить документы сразу - они остаются в статусе 'draft'
Args:
inventory_id: ID объекта Inventory
@@ -39,34 +40,31 @@ class InventoryProcessor:
dict: {
'inventory': Inventory,
'processed_lines': int,
'writeoffs_created': int,
'incomings_created': int,
'writeoff_document': WriteOffDocument или None,
'incoming_document': IncomingDocument или None,
'errors': [...]
}
"""
inventory = Inventory.objects.get(id=inventory_id)
lines = InventoryLine.objects.filter(inventory=inventory, processed=False)
writeoffs_created = 0
incomings_created = 0
errors = []
writeoff_document = None
incoming_document = None
# Собираем недостачи и излишки
deficit_lines = []
surplus_lines = []
try:
for line in lines:
try:
if line.difference < 0:
# Недостача: списать по FIFO
InventoryProcessor._create_writeoff_for_deficit(
inventory, line
)
writeoffs_created += 1
# Недостача
deficit_lines.append(line)
elif line.difference > 0:
# Излишек: создать новую партию
InventoryProcessor._create_incoming_for_surplus(
inventory, line
)
incomings_created += 1
# Излишек
surplus_lines.append(line)
# Отмечаем строку как обработанную
line.processed = True
@@ -78,6 +76,18 @@ class InventoryProcessor:
'error': str(e)
})
# Создаем WriteOffDocument для недостач (если есть)
if deficit_lines:
writeoff_document = InventoryProcessor._create_writeoff_document(
inventory, deficit_lines
)
# Создаем IncomingDocument для излишков (если есть)
if surplus_lines:
incoming_document = InventoryProcessor._create_incoming_document(
inventory, surplus_lines
)
# Обновляем статус инвентаризации
inventory.status = 'completed'
inventory.save(update_fields=['status'])
@@ -91,50 +101,84 @@ class InventoryProcessor:
return {
'inventory': inventory,
'processed_lines': lines.count(),
'writeoffs_created': writeoffs_created,
'incomings_created': incomings_created,
'writeoff_document': writeoff_document,
'incoming_document': incoming_document,
'errors': errors
}
@staticmethod
def _create_writeoff_for_deficit(inventory, line):
def _create_writeoff_document(inventory, deficit_lines):
"""
Создать операцию WriteOff для недостачи при инвентаризации.
Списывается по FIFO из старейших партий.
Создать документ списания (WriteOffDocument) для недостач при инвентаризации.
Документ создается в статусе 'draft' и не проводится сразу.
Args:
inventory: объект Inventory
line: объект InventoryLine с negative difference
deficit_lines: список InventoryLine с negative difference
Returns:
WriteOffDocument
"""
if not deficit_lines:
return None
# Создаем документ списания (черновик)
writeoff_document = WriteOffDocumentService.create_document(
warehouse=inventory.warehouse,
date=inventory.date.date(),
notes=f'Списание по результатам инвентаризации #{inventory.id}',
created_by=None # Можно добавить пользователя если передавать в process_inventory
)
# Связываем документ с инвентаризацией
writeoff_document.inventory = inventory
writeoff_document.save(update_fields=['inventory'])
# Добавляем позиции в документ
for line in deficit_lines:
quantity_to_writeoff = abs(line.difference)
# Списываем по FIFO
allocations = StockBatchManager.write_off_by_fifo(
line.product,
inventory.warehouse,
quantity_to_writeoff
)
# Создаем WriteOff для каждой партии
for batch, qty_allocated in allocations:
WriteOff.objects.create(
batch=batch,
quantity=qty_allocated,
WriteOffDocumentService.add_item(
document=writeoff_document,
product=line.product,
quantity=quantity_to_writeoff,
reason='inventory',
cost_price=batch.cost_price,
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
notes=f'Инвентаризация #{inventory.id}, строка #{line.id}'
)
return writeoff_document
@staticmethod
def _create_incoming_for_surplus(inventory, line):
def _create_incoming_document(inventory, surplus_lines):
"""
Создать операцию Incoming для излишка при инвентаризации.
Новая партия создается с последней известной cost_price товара.
Создать документ поступления (IncomingDocument) для излишков при инвентаризации.
Документ создается в статусе 'draft' и не проводится сразу.
Args:
inventory: объект Inventory
line: объект InventoryLine с positive difference
surplus_lines: список InventoryLine с positive difference
Returns:
IncomingDocument
"""
if not surplus_lines:
return None
# Создаем документ поступления (черновик) с типом 'inventory'
incoming_document = IncomingDocumentService.create_document(
warehouse=inventory.warehouse,
date=inventory.date.date(),
receipt_type='inventory',
supplier_name=None,
notes=f'Оприходование по результатам инвентаризации #{inventory.id}',
created_by=None # Можно добавить пользователя если передавать в process_inventory
)
# Связываем документ с инвентаризацией
incoming_document.inventory = inventory
incoming_document.save(update_fields=['inventory'])
# Добавляем позиции в документ
for line in surplus_lines:
quantity_surplus = line.difference
# Получаем последнюю known cost_price
@@ -143,27 +187,16 @@ class InventoryProcessor:
inventory.warehouse
)
# Генерируем номер документа для поступления
document_number = generate_incoming_document_number()
# Создаем IncomingBatch с типом 'inventory'
incoming_batch = IncomingBatch.objects.create(
warehouse=inventory.warehouse,
document_number=document_number,
receipt_type='inventory',
notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}'
)
# Создаем документ Incoming
# Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
Incoming.objects.create(
batch=incoming_batch,
IncomingDocumentService.add_item(
document=incoming_document,
product=line.product,
quantity=quantity_surplus,
cost_price=cost_price,
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
notes=f'Инвентаризация #{inventory.id}, строка #{line.id}'
)
return incoming_document
@staticmethod
def _get_last_cost_price(product, warehouse):
"""

View File

@@ -16,7 +16,7 @@ from orders.models import Order, OrderItem
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem
from inventory.services import SaleProcessor
from inventory.services.batch_manager import StockBatchManager
from inventory.services.inventory_processor import InventoryProcessor
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
def update_is_returned_flag(order):
@@ -1234,41 +1234,9 @@ def update_order_on_sale_delete(sender, instance, **kwargs):
)
@receiver(post_save, sender=Inventory)
def process_inventory_reconciliation(sender, instance, created, **kwargs):
"""
Сигнал: При завершении инвентаризации (status='completed')
автоматически обрабатываются расхождения.
Процесс:
1. Проверяем, изменился ли статус на 'completed'
2. Вызываем InventoryProcessor для обработки дефицитов/излишков
3. Создаются WriteOff для недостач и Incoming для излишков
"""
if created:
return # Только для обновлений
# Проверяем, изменился ли статус на 'completed'
if instance.status != 'completed':
return
try:
# Обрабатываем инвентаризацию
result = InventoryProcessor.process_inventory(instance.id)
import logging
logger = logging.getLogger(__name__)
logger.info(
f"Inventory {instance.id} processed: "
f"lines={result['processed_lines']}, "
f"writeoffs={result['writeoffs_created']}, "
f"incomings={result['incomings_created']}"
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Ошибка при обработке Inventory {instance.id}: {str(e)}", exc_info=True)
# Сигнал process_inventory_reconciliation удален
# Теперь обработка инвентаризации вызывается явно через InventoryCompleteView
# Это позволяет пользователю контролировать момент создания документов
@receiver(post_save, sender=WriteOff)

View File

@@ -0,0 +1,504 @@
/**
* JavaScript для страницы детального просмотра инвентаризации
* Обрабатывает AJAX запросы для добавления, обновления и удаления строк
*/
(function(window) {
'use strict';
// Глобальная функция инициализации
window.initInventoryDetailHandlers = function(inventoryId, config) {
const handlers = new InventoryDetailHandlers(inventoryId, config);
handlers.init();
return handlers;
};
/**
* Класс для обработки действий на странице инвентаризации
*/
function InventoryDetailHandlers(inventoryId, config) {
this.inventoryId = inventoryId;
this.config = config || {};
this.debounceTimer = null;
this.debounceDelay = 500; // Задержка для автосохранения в мс
}
/**
* Инициализация всех обработчиков
*/
InventoryDetailHandlers.prototype.init = function() {
this.initQuantityInputs();
this.initDeleteButtons();
this.initCompleteButton();
};
/**
* Инициализация обработчиков для полей quantity_fact
*/
InventoryDetailHandlers.prototype.initQuantityInputs = function() {
const self = this;
const tbody = document.getElementById('inventory-lines-tbody');
if (!tbody) return;
// Обработчик фокуса - выделяем весь текст для удобного ввода
tbody.addEventListener('focus', function(e) {
if (e.target.classList.contains('quantity-fact-input')) {
e.target.select();
}
}, true);
// Обработчик изменения значения (debounce)
tbody.addEventListener('input', function(e) {
if (e.target.classList.contains('quantity-fact-input')) {
const lineId = e.target.dataset.lineId;
const row = e.target.closest('tr');
// Визуальная индикация изменения
row.classList.add('table-warning');
// Очищаем предыдущий таймер
clearTimeout(self.debounceTimer);
// Устанавливаем новый таймер для автосохранения
self.debounceTimer = setTimeout(function() {
self.updateLineQuantity(lineId, e.target.value, row);
}, self.debounceDelay);
}
});
// Обработчик потери фокуса (сохранение сразу)
// Используем делегирование событий для динамически добавленных элементов
tbody.addEventListener('blur', function(e) {
if (e.target.classList.contains('quantity-fact-input')) {
clearTimeout(self.debounceTimer);
const lineId = e.target.dataset.lineId;
const row = e.target.closest('tr');
if (lineId && row) {
self.updateLineQuantity(lineId, e.target.value, row);
}
}
}, true);
};
/**
* Форматирование количества: убирает лишние нули, целые числа без дробной части
* Аналог фильтра smart_quantity из Django
*/
InventoryDetailHandlers.prototype.formatQuantity = function(value) {
const num = parseFloat(value) || 0;
// Проверяем, является ли число целым
if (num % 1 === 0) {
return num.toString();
} else {
// Убираем лишние нули справа и заменяем точку на запятую
return num.toString().replace(/\.?0+$/, '').replace('.', ',');
}
};
/**
* Обновление quantity_fact через AJAX
*/
InventoryDetailHandlers.prototype.updateLineQuantity = function(lineId, quantity, row) {
const self = this;
// Формируем URL: заменяем placeholder на реальный line_id
// URL содержит '999' который нужно заменить на реальный line_id
const url = this.config.updateLineUrl.replace('999', lineId);
// Показываем индикатор загрузки
const input = row.querySelector('.quantity-fact-input');
const originalValue = input.value;
input.disabled = true;
// Создаем FormData
const formData = new FormData();
formData.append('quantity_fact', quantity);
formData.append('csrfmiddlewaretoken', this.getCsrfToken());
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
input.disabled = false;
if (data.success) {
// Обновляем "Всего на складе" (quantity_available)
const quantityAvailableCell = row.querySelector('td:nth-child(2)');
if (quantityAvailableCell && data.line.quantity_available !== undefined) {
const quantityAvailable = parseFloat(data.line.quantity_available) || 0;
const formattedQuantityAvailable = quantityAvailable % 1 === 0
? quantityAvailable.toString()
: quantityAvailable.toString().replace('.', ',');
quantityAvailableCell.textContent = formattedQuantityAvailable;
}
// Обновляем "В резервах" (quantity_reserved)
const quantityReservedCell = row.querySelector('td:nth-child(3)');
if (quantityReservedCell && data.line.quantity_reserved !== undefined) {
const quantityReserved = parseFloat(data.line.quantity_reserved) || 0;
const formattedQuantityReserved = quantityReserved % 1 === 0
? quantityReserved.toString()
: quantityReserved.toString().replace('.', ',');
quantityReservedCell.textContent = formattedQuantityReserved;
}
// Обновляем "В системе (свободно)" (quantity_system)
const quantitySystemCell = row.querySelector('td:nth-child(4)');
if (quantitySystemCell && data.line.quantity_system !== undefined) {
const quantitySystem = parseFloat(data.line.quantity_system) || 0;
const formattedQuantitySystem = quantitySystem % 1 === 0
? quantitySystem.toString()
: quantitySystem.toString().replace('.', ',');
quantitySystemCell.textContent = formattedQuantitySystem;
}
// Обновляем разницу
const differenceBadge = row.querySelector('.difference-badge');
const difference = parseFloat(data.line.difference);
if (differenceBadge) {
let badgeClass = 'bg-secondary';
let badgeText = '0';
if (difference > 0) {
badgeClass = 'bg-success';
// Форматируем с убиранием лишних нулей
const formatted = this.formatQuantity(difference);
badgeText = '+' + formatted;
} else if (difference < 0) {
badgeClass = 'bg-danger';
// Форматируем с убиранием лишних нулей
badgeText = this.formatQuantity(difference);
}
differenceBadge.innerHTML = '<span class="badge ' + badgeClass + '">' + badgeText + '</span>';
}
// Обновляем класс строки в зависимости от разницы
if (difference !== 0) {
row.classList.add('table-warning');
} else {
row.classList.remove('table-warning');
}
// Показываем уведомление об успешном сохранении
this.showNotification('Изменения сохранены', 'success');
} else {
// Восстанавливаем значение при ошибке
input.value = originalValue;
this.showNotification('Ошибка: ' + (data.error || 'Не удалось сохранить'), 'error');
}
})
.catch(error => {
input.disabled = false;
input.value = originalValue;
this.showNotification('Ошибка при сохранении: ' + error.message, 'error');
console.error('Error updating line:', error);
});
};
/**
* Инициализация кнопок удаления строк
*/
InventoryDetailHandlers.prototype.initDeleteButtons = function() {
const self = this;
const tbody = document.getElementById('inventory-lines-tbody');
if (!tbody) return;
tbody.addEventListener('click', function(e) {
if (e.target.closest('.delete-line-btn')) {
const btn = e.target.closest('.delete-line-btn');
const lineId = btn.dataset.lineId;
const productName = btn.dataset.productName || 'товар';
if (confirm('Удалить строку для товара "' + productName + '"?')) {
self.deleteLine(lineId, btn.closest('tr'));
}
}
});
};
/**
* Удаление строки через AJAX
*/
InventoryDetailHandlers.prototype.deleteLine = function(lineId, row) {
const self = this;
// Формируем URL: заменяем placeholder на реальный line_id
// URL содержит '999' который нужно заменить на реальный line_id
const url = this.config.deleteLineUrl.replace('999', lineId);
// Показываем индикатор загрузки
row.style.opacity = '0.5';
const deleteBtn = row.querySelector('.delete-line-btn');
if (deleteBtn) deleteBtn.disabled = true;
const formData = new FormData();
formData.append('csrfmiddlewaretoken', this.getCsrfToken());
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Удаляем строку из таблицы
row.remove();
// Проверяем, не пустая ли таблица
const tbody = document.getElementById('inventory-lines-tbody');
if (tbody && tbody.querySelectorAll('tr').length === 0) {
// Определяем количество столбцов: 7 если не завершена (есть столбец "Действия"), 6 если завершена
const hasActionsColumn = document.getElementById('inventory-lines-table')?.querySelector('th:last-child')?.textContent.includes('Действия');
const colspan = hasActionsColumn ? 7 : 6;
tbody.innerHTML = '<tr id="empty-lines-message"><td colspan="' + colspan + '" class="text-center text-muted py-4"><i class="bi bi-info-circle"></i> Строк инвентаризации не добавлено.</td></tr>';
}
// Отключаем кнопку завершения, если нет строк
const completeBtn = document.getElementById('complete-inventory-btn');
if (completeBtn && tbody.querySelectorAll('tr[data-line-id]').length === 0) {
completeBtn.disabled = true;
}
this.showNotification('Строка удалена', 'success');
} else {
row.style.opacity = '1';
if (deleteBtn) deleteBtn.disabled = false;
this.showNotification('Ошибка: ' + (data.error || 'Не удалось удалить'), 'error');
}
})
.catch(error => {
row.style.opacity = '1';
if (deleteBtn) deleteBtn.disabled = false;
this.showNotification('Ошибка при удалении: ' + error.message, 'error');
console.error('Error deleting line:', error);
});
};
/**
* Инициализация кнопки завершения инвентаризации
*/
InventoryDetailHandlers.prototype.initCompleteButton = function() {
const self = this;
const btn = document.getElementById('complete-inventory-btn');
if (!btn) return;
btn.addEventListener('click', function() {
self.showCompleteModal();
});
};
/**
* Показ модального окна завершения инвентаризации
*/
InventoryDetailHandlers.prototype.showCompleteModal = function() {
const tbody = document.getElementById('inventory-lines-tbody');
if (!tbody) return;
const lines = tbody.querySelectorAll('tr[data-line-id]');
let deficitCount = 0;
let surplusCount = 0;
let deficitTotal = 0;
let surplusTotal = 0;
lines.forEach(function(row) {
const differenceInput = row.querySelector('.difference-badge .badge');
if (differenceInput) {
const difference = parseFloat(differenceInput.textContent.replace('+', ''));
if (difference < 0) {
deficitCount++;
deficitTotal += Math.abs(difference);
} else if (difference > 0) {
surplusCount++;
surplusTotal += difference;
}
}
});
const summary = document.getElementById('complete-summary');
if (summary) {
let html = '<ul class="mb-0">';
html += '<li>Строк для обработки: <strong>' + lines.length + '</strong></li>';
if (deficitCount > 0) {
html += '<li>Недостач: <strong>' + deficitCount + '</strong> (всего: ' + deficitTotal.toFixed(3) + ' шт) → будет создан документ списания</li>';
}
if (surplusCount > 0) {
html += '<li>Излишков: <strong>' + surplusCount + '</strong> (всего: ' + surplusTotal.toFixed(3) + ' шт) → будет создан документ оприходования</li>';
}
if (deficitCount === 0 && surplusCount === 0) {
html += '<li>Расхождений не обнаружено</li>';
}
html += '</ul>';
summary.innerHTML = html;
}
const modal = new bootstrap.Modal(document.getElementById('confirmCompleteModal'));
modal.show();
};
/**
* Глобальная функция добавления строки (вызывается из product_search_picker)
*/
window.addInventoryLine = function(productId) {
const handlers = window.inventoryDetailHandlers;
if (handlers) {
handlers.addLine(productId);
}
};
/**
* Добавление строки через AJAX
*/
InventoryDetailHandlers.prototype.addLine = function(productId) {
const self = this;
const url = this.config.addLineUrl;
const tbody = document.getElementById('inventory-lines-tbody');
if (!tbody) return;
const formData = new FormData();
formData.append('product_id', productId);
formData.append('csrfmiddlewaretoken', this.getCsrfToken());
// Показываем индикатор загрузки
const loadingRow = document.createElement('tr');
// Определяем количество столбцов: 8 если не завершена (есть столбец "Действия"), 7 если завершена
const hasActionsColumn = document.getElementById('inventory-lines-table')?.querySelector('th:last-child')?.textContent.includes('Действия');
const colspan = hasActionsColumn ? 8 : 7;
loadingRow.innerHTML = '<td colspan="' + colspan + '" class="text-center py-2"><div class="spinner-border spinner-border-sm" role="status"></div> Добавление...</td>';
tbody.appendChild(loadingRow);
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
loadingRow.remove();
if (data.success) {
// Удаляем сообщение о пустой таблице
const emptyMessage = document.getElementById('empty-lines-message');
if (emptyMessage) emptyMessage.remove();
// Добавляем новую строку
const newRow = self.createLineRow(data.line);
tbody.appendChild(newRow);
// Включаем кнопку завершения
const completeBtn = document.getElementById('complete-inventory-btn');
if (completeBtn) completeBtn.disabled = false;
this.showNotification('Товар добавлен', 'success');
} else {
this.showNotification('Ошибка: ' + (data.error || 'Не удалось добавить товар'), 'error');
}
})
.catch(error => {
loadingRow.remove();
this.showNotification('Ошибка при добавлении: ' + error.message, 'error');
console.error('Error adding line:', error);
});
};
/**
* Создание HTML строки для таблицы
*/
InventoryDetailHandlers.prototype.createLineRow = function(lineData) {
const row = document.createElement('tr');
row.setAttribute('data-line-id', lineData.id);
const difference = parseFloat(lineData.difference);
let differenceBadge = '<span class="badge bg-secondary">0</span>';
if (difference > 0) {
const formatted = this.formatQuantity(difference);
differenceBadge = '<span class="badge bg-success">+' + formatted + '</span>';
} else if (difference < 0) {
const formatted = this.formatQuantity(difference);
differenceBadge = '<span class="badge bg-danger">' + formatted + '</span>';
}
if (difference !== 0) {
row.classList.add('table-warning');
}
// Форматируем quantity_fact для input type="number" (должна быть точка, а не запятая)
const quantityFact = parseFloat(lineData.quantity_fact) || 0;
// Форматируем quantity_system: убираем лишние нули, целые числа без дробной части
const quantitySystem = parseFloat(lineData.quantity_system) || 0;
const formattedQuantitySystem = quantitySystem % 1 === 0
? quantitySystem.toString()
: quantitySystem.toString().replace('.', ',');
// Форматируем quantity_reserved: убираем лишние нули, целые числа без дробной части
const quantityReserved = parseFloat(lineData.quantity_reserved || 0) || 0;
const formattedQuantityReserved = quantityReserved % 1 === 0
? quantityReserved.toString()
: quantityReserved.toString().replace('.', ',');
// Форматируем quantity_available: убираем лишние нули, целые числа без дробной части
const quantityAvailable = parseFloat(lineData.quantity_available || 0) || 0;
const formattedQuantityAvailable = quantityAvailable % 1 === 0
? quantityAvailable.toString()
: quantityAvailable.toString().replace('.', ',');
row.innerHTML =
'<td>' + lineData.product_name + '</td>' +
'<td class="text-center">' + formattedQuantityAvailable + '</td>' +
'<td class="text-center">' + formattedQuantityReserved + '</td>' +
'<td class="bg-light fw-semibold text-center">' + formattedQuantitySystem + '</td>' +
'<td class="text-center">' +
'<input type="number" class="form-control form-control-sm quantity-fact-input mx-auto" ' +
'value="' + quantityFact + '" min="0" step="0.001" ' +
'data-line-id="' + lineData.id + '" style="width: 100px;">' +
'</td>' +
'<td class="text-center"><span class="difference-badge" data-line-id="' + lineData.id + '">' + differenceBadge + '</span></td>' +
'<td class="text-center"><span class="badge bg-warning">Не проведено</span></td>' +
'<td class="text-center">' +
'<button class="btn btn-sm btn-danger delete-line-btn" ' +
'data-line-id="' + lineData.id + '" ' +
'data-product-name="' + lineData.product_name + '" ' +
'title="Удалить строку"><i class="bi bi-trash"></i></button>' +
'</td>';
return row;
};
/**
* Получение CSRF токена
*/
InventoryDetailHandlers.prototype.getCsrfToken = function() {
const token = document.querySelector('[name=csrfmiddlewaretoken]');
return token ? token.value : '';
};
/**
* Показ уведомления
*/
InventoryDetailHandlers.prototype.showNotification = function(message, type) {
// Используем Bootstrap toast или простой alert
// Можно улучшить, добавив toast уведомления
console.log('[' + type.toUpperCase() + ']', message);
// Простое уведомление через alert (можно заменить на toast)
if (type === 'error') {
// Можно показать через Bootstrap alert
}
};
})(window);

View File

@@ -384,9 +384,9 @@
</div>
</div>
<!-- СПИСАНИЯ (SaleBatchAllocation) -->
<!-- СПИСАНИЯ ИЗ ПРОДАЖ (SaleBatchAllocation) -->
<div class="section-card">
<h3>📤 Списания SaleBatchAllocation ({{ allocations.count }})</h3>
<h3>📤 Списания из продаж SaleBatchAllocation ({{ allocations.count }})</h3>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover">
<thead>
@@ -410,7 +410,124 @@
<td>{{ alloc.cost_price|floatformat:2 }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted">Нет списаний</td></tr>
<tr><td colspan="6" class="text-center text-muted">Нет списаний из продаж</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- СПИСАНИЯ WriteOff (одиночные записи) -->
<div class="section-card">
<h3>🗑️ Списания WriteOff ({{ writeoffs.count }})</h3>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover">
<thead>
<tr>
<th>ID</th>
<th>Товар</th>
<th>Склад</th>
<th>Партия ID</th>
<th>Кол-во</th>
<th>Причина</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
{% for wo in writeoffs %}
<tr>
<td>{{ wo.id }}</td>
<td><strong>{{ wo.batch.product.name }}</strong></td>
<td>{{ wo.batch.warehouse.name }}</td>
<td>{{ wo.batch.id }}</td>
<td><span class="badge bg-danger">{{ wo.quantity }}</span></td>
<td>{{ wo.reason|default:"-" }}</td>
<td class="text-muted-small">{{ wo.date|date:"d.m.Y H:i:s" }}</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center text-muted">Нет списаний WriteOff</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- ДОКУМЕНТЫ СПИСАНИЯ (WriteOffDocument) -->
<div class="section-card">
<h3>📄 Документы списания WriteOffDocument ({{ writeoff_documents.count }})</h3>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover">
<thead>
<tr>
<th>ID</th>
<th>Номер</th>
<th>Склад</th>
<th>Статус</th>
<th>Дата</th>
<th>Инвентаризация</th>
</tr>
</thead>
<tbody>
{% for doc in writeoff_documents %}
<tr>
<td>{{ doc.id }}</td>
<td><strong>{{ doc.document_number|default:"-" }}</strong></td>
<td>{{ doc.warehouse.name }}</td>
<td>
{% if doc.status == 'draft' %}
<span class="badge bg-warning text-dark">Черновик</span>
{% elif doc.status == 'confirmed' %}
<span class="badge bg-success">Проведен</span>
{% elif doc.status == 'cancelled' %}
<span class="badge bg-danger">Отменен</span>
{% else %}
<span class="badge bg-secondary">{{ doc.status }}</span>
{% endif %}
</td>
<td class="text-muted-small">{{ doc.date|date:"d.m.Y" }}</td>
<td>
{% if doc.inventory %}
<span class="badge bg-info">INV-{{ doc.inventory.id }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted">Нет документов списания</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- СТРОКИ ДОКУМЕНТОВ СПИСАНИЯ (WriteOffDocumentItem) -->
<div class="section-card">
<h3>📋 Строки документов списания WriteOffDocumentItem ({{ writeoff_document_items.count }})</h3>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover">
<thead>
<tr>
<th>ID</th>
<th>Документ</th>
<th>Товар</th>
<th>Склад</th>
<th>Кол-во</th>
<th>Причина</th>
</tr>
</thead>
<tbody>
{% for item in writeoff_document_items %}
<tr>
<td>{{ item.id }}</td>
<td><strong>{{ item.document.document_number|default:"#" }}{{ item.document.id }}</strong></td>
<td><strong>{{ item.product.name }}</strong></td>
<td>{{ item.document.warehouse.name }}</td>
<td><span class="badge bg-danger">{{ item.quantity }}</span></td>
<td>{{ item.reason|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted">Нет строк документов списания</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -1,10 +1,13 @@
{% extends 'inventory/base_inventory_minimal.html' %}
{% load inventory_filters %}
{% load static %}
{% block inventory_title %}Детали инвентаризации{% endblock %}
{% block breadcrumb_current %}Инвентаризация{% endblock %}
{% block inventory_content %}
<!-- CSS для компонента поиска товаров -->
<link rel="stylesheet" href="{% static 'products/css/product-search-picker.css' %}">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Инвентаризация: {{ inventory.warehouse.name }}</h4>
@@ -18,6 +21,12 @@
<div class="col-md-6">
<h5>Информация</h5>
<table class="table table-borderless">
{% if inventory.document_number %}
<tr>
<th>Номер документа:</th>
<td><strong>{{ inventory.document_number }}</strong></td>
</tr>
{% endif %}
<tr>
<th>Склад:</th>
<td><strong>{{ inventory.warehouse.name }}</strong></td>
@@ -26,11 +35,17 @@
<th>Статус:</th>
<td>
{% if inventory.status == 'draft' %}
<span class="badge bg-secondary">Черновик</span>
<span class="badge bg-secondary fs-6 px-3 py-2">
<i class="bi bi-file-earmark"></i> Черновик
</span>
{% elif inventory.status == 'processing' %}
<span class="badge bg-warning">В обработке</span>
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
<i class="bi bi-hourglass-split"></i> В обработке
</span>
{% else %}
<span class="badge bg-success">Завершена</span>
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-check-circle-fill"></i> Завершена
</span>
{% endif %}
</td>
</tr>
@@ -48,66 +63,209 @@
</div>
</div>
{% if inventory.status == 'completed' %}
<!-- Информация о созданных документах -->
<div class="alert alert-info mb-4">
<h6><i class="bi bi-info-circle"></i> Инвентаризация завершена</h6>
<div class="mt-2">
{% if writeoff_document %}
<p class="mb-1">
<strong>Документ списания:</strong>
<a href="{% url 'inventory:writeoff-document-detail' writeoff_document.pk %}" target="_blank">
{{ writeoff_document.document_number }}
</a>
<span class="badge {% if writeoff_document.status == 'confirmed' %}bg-success{% else %}bg-warning{% endif %} ms-2">
{{ writeoff_document.get_status_display }}
</span>
</p>
{% endif %}
{% if incoming_document %}
<p class="mb-1">
<strong>Документ оприходования:</strong>
<a href="{% url 'inventory:incoming-document-detail' incoming_document.pk %}" target="_blank">
{{ incoming_document.document_number }}
</a>
<span class="badge {% if incoming_document.status == 'confirmed' %}bg-success{% else %}bg-warning{% endif %} ms-2">
{{ incoming_document.get_status_display }}
</span>
</p>
{% endif %}
</div>
</div>
{% endif %}
<hr>
<h5>Строки инвентаризации</h5>
{% if lines %}
<!-- Компонент поиска товаров (только если не завершена) -->
{% if inventory.status != 'completed' %}
<div class="card border-primary mb-4" id="product-search-section">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
</div>
<div class="card-body">
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара для инвентаризации...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить товар' content_height='250px' %}
</div>
</div>
{% endif %}
<!-- Таблица строк инвентаризации -->
<div class="table-responsive">
<table class="table table-sm">
<table class="table table-sm table-hover" id="inventory-lines-table">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>В системе</th>
<th>По факту</th>
<th>Разница</th>
<th>Статус</th>
<th class="text-center">Всего на складе</th>
<th class="text-center">В резервах</th>
<th class="bg-light text-center">В системе (свободно)</th>
<th class="text-center">Подсчитано (факт, свободные)</th>
<th class="text-center">Итоговая разница</th>
<th class="text-center">Статус</th>
{% if inventory.status != 'completed' %}
<th class="text-center">Действия</th>
{% endif %}
</tr>
</thead>
<tbody>
<tbody id="inventory-lines-tbody">
{% for line in lines %}
<tr>
<tr data-line-id="{{ line.id }}" class="{% if line.difference != 0 %}table-warning{% endif %}">
<td>{{ line.product.name }}</td>
<td>{{ line.quantity_system }}</td>
<td>{{ line.quantity_fact }}</td>
<td>
<td class="text-center">{{ line.quantity_available|smart_quantity }}</td>
<td class="text-center">{{ line.quantity_reserved|smart_quantity }}</td>
<td class="bg-light fw-semibold text-center">{{ line.quantity_system|smart_quantity }}</td>
<td class="text-center">
{% if inventory.status != 'completed' %}
<input type="number"
class="form-control form-control-sm quantity-fact-input mx-auto"
value="{{ line.quantity_fact|stringformat:'g' }}"
min="0"
step="0.001"
data-line-id="{{ line.id }}"
style="width: 100px;">
{% else %}
{{ line.quantity_fact|smart_quantity }}
{% endif %}
</td>
<td class="text-center">
<span class="difference-badge" data-line-id="{{ line.id }}">
{% if line.difference > 0 %}
<span class="badge bg-success">+{{ line.difference }}</span>
<span class="badge bg-success">+{{ line.difference|smart_quantity }}</span>
{% elif line.difference < 0 %}
<span class="badge bg-danger">{{ line.difference }}</span>
<span class="badge bg-danger">{{ line.difference|smart_quantity }}</span>
{% else %}
<span class="badge bg-secondary">0</span>
{% endif %}
</span>
</td>
<td>
<td class="text-center">
{% if line.processed %}
<span class="badge bg-success">Обработана</span>
{% else %}
<span class="badge bg-warning">Не обработана</span>
<span class="badge bg-warning">Не проведено</span>
{% endif %}
</td>
{% if inventory.status != 'completed' %}
<td class="text-center">
<button class="btn btn-sm btn-danger delete-line-btn"
data-line-id="{{ line.id }}"
data-product-name="{{ line.product.name }}"
title="Удалить строку">
<i class="bi bi-trash"></i>
</button>
</td>
{% endif %}
</tr>
{% empty %}
<tr id="empty-lines-message">
<td colspan="{% if inventory.status != 'completed' %}8{% else %}7{% endif %}" class="text-center text-muted py-4">
<i class="bi bi-info-circle"></i> Строк инвентаризации не добавлено.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Строк инвентаризации не добавлено.
<a href="{% url 'inventory:inventory-lines-add' inventory.pk %}" class="alert-link">Добавить строки</a>
<!-- Кнопки действий -->
<div class="d-flex gap-2 mt-4 align-items-center">
{% if inventory.status != 'completed' %}
<!-- Группа кнопок для незавершенной инвентаризации -->
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary" id="save-inventory-btn" onclick="location.reload();" title="Обновить страницу (изменения сохраняются автоматически)">
<i class="bi bi-save"></i> Сохранить
</button>
<button type="button" class="btn btn-primary" id="complete-inventory-btn" {% if not lines %}disabled{% endif %}>
<i class="bi bi-check-circle"></i> Завершить инвентаризацию
</button>
</div>
{% endif %}
<div class="d-flex gap-2 mt-4">
{% if inventory.status != 'completed' %}
<a href="{% url 'inventory:inventory-lines-add' inventory.pk %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Добавить строки
</a>
{% endif %}
<!-- Кнопка возврата всегда видна -->
<div class="ms-auto">
<a href="{% url 'inventory:inventory-list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Вернуться к списку
</a>
</div>
</div>
</div>
</div>
<!-- Модальное окно подтверждения завершения инвентаризации -->
<div class="modal fade" id="confirmCompleteModal" tabindex="-1" aria-labelledby="confirmCompleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title" id="confirmCompleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Завершить инвентаризацию?
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Вы уверены, что хотите завершить инвентаризацию?</p>
<div id="complete-summary" class="alert alert-info">
<!-- Заполняется через JavaScript -->
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> После завершения редактирование строк будет недоступно.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<form method="post" action="{% url 'inventory:inventory-complete' inventory.pk %}" id="complete-inventory-form">
{% csrf_token %}
<button type="submit" class="btn btn-warning">Завершить инвентаризацию</button>
</form>
</div>
</div>
</div>
</div>
<!-- Подключаем JavaScript -->
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
<script src="{% static 'inventory/js/inventory_detail.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация компонента поиска товаров
{% if inventory.status != 'completed' %}
const picker = ProductSearchPicker.init('#inventory-product-picker', {
onAddSelected: function(product, instance) {
if (product) {
addInventoryLine(product.id);
instance.clearSelection();
}
}
});
{% endif %}
// Инициализация обработчиков
const inventoryId = {{ inventory.pk }};
window.inventoryDetailHandlers = initInventoryDetailHandlers(inventoryId, {
addLineUrl: '{% url "inventory:inventory-line-add" inventory.pk %}',
updateLineUrl: '{% url "inventory:inventory-line-update" inventory.pk 999 %}',
deleteLineUrl: '{% url "inventory:inventory-line-delete" inventory.pk 999 %}',
completeUrl: '{% url "inventory:inventory-complete" inventory.pk %}'
});
});
</script>
{% endblock %}

View File

@@ -47,6 +47,7 @@
<table class="table table-sm table-hover minimal-table">
<thead>
<tr>
<th>Номер</th>
<th>Склад</th>
<th>Статус</th>
<th>Провёл</th>
@@ -57,6 +58,7 @@
<tbody>
{% for inventory in inventories %}
<tr>
<td class="fw-medium">{{ inventory.document_number|default:"—" }}</td>
<td class="fw-medium">{{ inventory.warehouse.name }}</td>
<td>
<span class="status-dot status-{{ inventory.status }}"></span>
@@ -69,8 +71,8 @@
<td class="text-muted">{{ inventory.conducted_by|default:"—" }}</td>
<td class="text-muted">{{ inventory.date|date:"d.m.Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'inventory:inventory-detail' inventory.pk %}" class="btn-icon" title="Просмотр">
<i class="bi bi-eye"></i>
<a href="{% url 'inventory:inventory-detail' inventory.pk %}" class="btn-icon" title="Открыть для редактирования">
<i class="bi bi-pencil-square"></i>
</a>
</td>
</tr>
@@ -114,6 +116,13 @@
</div>
{% endif %}
<!-- Подсказка для пользователя -->
<div class="alert alert-info mt-3" style="font-size: 0.875rem;">
<i class="bi bi-info-circle"></i>
<strong>Как использовать:</strong> Создайте инвентаризацию, затем откройте её (иконка карандаша) для добавления товаров и указания фактических количеств.
После завершения будут автоматически созданы документы списания и оприходования (черновики), которые можно провести отдельно.
</div>
<style>
/* Минималистичная таблица */
.minimal-table {

View File

@@ -6,7 +6,7 @@ from decimal import Decimal
from django.test import TestCase
from products.models import Product
from inventory.models import Warehouse, StockBatch, Incoming, Sale, WriteOff, Transfer, Inventory, InventoryLine, Reservation, Stock
from inventory.models import Warehouse, StockBatch, Sale, Transfer, Inventory, InventoryLine, Reservation, Stock
from inventory.services import StockBatchManager, SaleProcessor, InventoryProcessor
from orders.models import Order, OrderItem
from customers.models import Customer
@@ -302,17 +302,23 @@ class InventoryProcessorTest(TestCase):
# Проверяем результат
self.assertEqual(result['processed_lines'], 1)
self.assertEqual(result['writeoffs_created'], 1)
self.assertEqual(result['incomings_created'], 0)
self.assertIsNotNone(result['writeoff_document'])
self.assertIsNone(result['incoming_document'])
# Проверяем, что создалось списание
writeoffs = WriteOff.objects.filter(batch=batch)
self.assertEqual(writeoffs.count(), 1)
self.assertEqual(writeoffs.first().quantity, Decimal('15'))
# Проверяем, что создался документ списания (черновик)
writeoff_doc = result['writeoff_document']
self.assertEqual(writeoff_doc.status, 'draft')
self.assertEqual(writeoff_doc.inventory, inventory)
# Проверяем остаток в партии
# Проверяем, что в документе есть позиция
items = writeoff_doc.items.all()
self.assertEqual(items.count(), 1)
self.assertEqual(items.first().product, self.product)
self.assertEqual(items.first().quantity, Decimal('15'))
# Проверяем, что документ еще не проведен - остаток не изменился
batch.refresh_from_db()
self.assertEqual(batch.quantity, Decimal('85'))
self.assertEqual(batch.quantity, Decimal('100')) # Остаток не изменился, т.к. документ не проведен
def test_process_inventory_surplus(self):
"""Тест обработки излишка при инвентаризации."""
@@ -341,13 +347,25 @@ class InventoryProcessorTest(TestCase):
# Проверяем результат
self.assertEqual(result['processed_lines'], 1)
self.assertEqual(result['writeoffs_created'], 0)
self.assertEqual(result['incomings_created'], 1)
self.assertIsNone(result['writeoff_document'])
self.assertIsNotNone(result['incoming_document'])
# Проверяем, что создалось приходование
incomings = Incoming.objects.filter(product=self.product)
self.assertEqual(incomings.count(), 1)
self.assertEqual(incomings.first().quantity, Decimal('20'))
# Проверяем, что создался документ оприходования (черновик)
incoming_doc = result['incoming_document']
self.assertEqual(incoming_doc.status, 'draft')
self.assertEqual(incoming_doc.inventory, inventory)
self.assertEqual(incoming_doc.receipt_type, 'inventory')
# Проверяем, что в документе есть позиция
items = incoming_doc.items.all()
self.assertEqual(items.count(), 1)
self.assertEqual(items.first().product, self.product)
self.assertEqual(items.first().quantity, Decimal('20'))
# Проверяем, что документ еще не проведен - новый StockBatch не создан
from inventory.models import StockBatch
batches = StockBatch.objects.filter(product=self.product, warehouse=self.warehouse)
self.assertEqual(batches.count(), 1) # Только исходная партия, новая не создана
class ReservationSignalsTest(TestCase):

View File

@@ -11,6 +11,8 @@ from .views import (
SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView,
# Inventory
InventoryListView, InventoryCreateView, InventoryDetailView, InventoryLineCreateBulkView,
InventoryLineAddView, InventoryLineUpdateView, InventoryLineDeleteView,
InventoryCompleteView,
# WriteOff
WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView,
# Transfer
@@ -79,7 +81,12 @@ urlpatterns = [
path('inventory-ops/', InventoryListView.as_view(), name='inventory-list'),
path('inventory-ops/create/', InventoryCreateView.as_view(), name='inventory-create'),
path('inventory-ops/<int:pk>/', InventoryDetailView.as_view(), name='inventory-detail'),
path('inventory-ops/<int:pk>/lines/add/', InventoryLineCreateBulkView.as_view(), name='inventory-lines-add'),
path('inventory-ops/<int:pk>/lines/add-bulk/', InventoryLineCreateBulkView.as_view(), name='inventory-lines-add'),
# AJAX endpoints для работы со строками инвентаризации
path('inventory-ops/<int:inventory_id>/lines/add/', InventoryLineAddView.as_view(), name='inventory-line-add'),
path('inventory-ops/<int:inventory_id>/lines/<int:line_id>/update/', InventoryLineUpdateView.as_view(), name='inventory-line-update'),
path('inventory-ops/<int:inventory_id>/lines/<int:line_id>/delete/', InventoryLineDeleteView.as_view(), name='inventory-line-delete'),
path('inventory-ops/<int:inventory_id>/complete/', InventoryCompleteView.as_view(), name='inventory-complete'),
# ==================== WRITEOFF (одиночные записи) ====================
path('writeoffs/', WriteOffListView.as_view(), name='writeoff-list'),

View File

@@ -146,3 +146,17 @@ def generate_incoming_document_number():
# Используем стандартный метод, как и другие функции
next_number = DocumentCounter.get_next_value('incoming')
return f"IN-{next_number:06d}"
def generate_inventory_document_number():
"""
Генерирует уникальный номер документа инвентаризации.
Формат: INV-XXXXXX (6 цифр)
Thread-safe через DocumentCounter.
Returns:
str: Сгенерированный номер документа (например, INV-000001)
"""
next_number = DocumentCounter.get_next_value('inventory')
return f"INV-{next_number:06d}"

View File

@@ -24,7 +24,8 @@ from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchLis
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
from .inventory_ops import (
InventoryListView, InventoryCreateView, InventoryDetailView,
InventoryLineCreateBulkView
InventoryLineCreateBulkView, InventoryLineAddView, InventoryLineUpdateView,
InventoryLineDeleteView, InventoryCompleteView
)
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView
from .writeoff_document import (
@@ -65,6 +66,8 @@ __all__ = [
'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView',
# Inventory
'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView',
'InventoryLineAddView', 'InventoryLineUpdateView', 'InventoryLineDeleteView',
'InventoryCompleteView',
# WriteOff
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
# WriteOffDocument

View File

@@ -5,7 +5,7 @@
from django.contrib.auth.decorators import login_required, user_passes_test
from django.shortcuts import render
from django.db.models import Q, Sum, Count
from inventory.models import StockBatch, Stock, Reservation, Sale, SaleBatchAllocation
from inventory.models import StockBatch, Stock, Reservation, Sale, SaleBatchAllocation, WriteOff, WriteOffDocument, WriteOffDocumentItem
from orders.models import Order
from products.models import Product
from inventory.models import Warehouse
@@ -38,6 +38,12 @@ def debug_inventory_page(request):
allocations = SaleBatchAllocation.objects.select_related(
'sale__product', 'batch'
).order_by('-id')
# Все списания: из продаж (WriteOff) и из документов списания (WriteOffDocumentItem)
writeoffs = WriteOff.objects.select_related('batch__product', 'batch__warehouse').order_by('-date')
writeoff_documents = WriteOffDocument.objects.select_related('warehouse').order_by('-date')
writeoff_document_items = WriteOffDocumentItem.objects.select_related(
'product', 'document__warehouse'
).order_by('-id')
orders = Order.objects.prefetch_related('items').order_by('-created_at')
# Применяем фильтры
@@ -48,6 +54,8 @@ def debug_inventory_page(request):
reservations = reservations.filter(product_id=product_id)
sales = sales.filter(product_id=product_id)
allocations = allocations.filter(sale__product_id=product_id)
writeoffs = writeoffs.filter(batch__product_id=product_id)
writeoff_document_items = writeoff_document_items.filter(product_id=product_id)
orders = orders.filter(items__product_id=product_id).distinct()
else:
product = None
@@ -85,6 +93,9 @@ def debug_inventory_page(request):
stocks = stocks.filter(warehouse_id=warehouse_id)
reservations = reservations.filter(warehouse_id=warehouse_id)
sales = sales.filter(warehouse_id=warehouse_id)
writeoffs = writeoffs.filter(batch__warehouse_id=warehouse_id)
writeoff_documents = writeoff_documents.filter(warehouse_id=warehouse_id)
writeoff_document_items = writeoff_document_items.filter(document__warehouse_id=warehouse_id)
else:
warehouse = None
@@ -94,6 +105,9 @@ def debug_inventory_page(request):
reservations = reservations[:100]
sales = sales[:100]
allocations = allocations[:100]
writeoffs = writeoffs[:100]
writeoff_documents = writeoff_documents[:50]
writeoff_document_items = writeoff_document_items[:100]
orders = orders[:50]
# Списки для фильтров
@@ -106,6 +120,9 @@ def debug_inventory_page(request):
'reservations': reservations,
'sales': sales,
'allocations': allocations,
'writeoffs': writeoffs,
'writeoff_documents': writeoff_documents,
'writeoff_document_items': writeoff_document_items,
'orders': orders,
'products': products,
'warehouses': warehouses,

View File

@@ -1,12 +1,23 @@
# -*- coding: utf-8 -*-
from django.shortcuts import render, redirect
from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import ListView, CreateView, DetailView, View, FormView
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.http import HttpResponseRedirect
from ..models import Inventory, InventoryLine
from django.http import HttpResponseRedirect, JsonResponse
from django.db import transaction
from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from decimal import Decimal
import json
from ..models import Inventory, InventoryLine, Stock
from ..forms import InventoryForm, InventoryLineForm
from ..services.inventory_processor import InventoryProcessor
from ..services.writeoff_document_service import WriteOffDocumentService
from ..services.incoming_document_service import IncomingDocumentService
from django.core.exceptions import ValidationError
from products.models import Product, ProductCategory, ProductTag
class InventoryListView(LoginRequiredMixin, ListView):
@@ -44,7 +55,10 @@ class InventoryCreateView(LoginRequiredMixin, CreateView):
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()
messages.success(
self.request,
f'Инвентаризация склада "{form.instance.warehouse.name}" начата.'
@@ -61,12 +75,134 @@ class InventoryDetailView(LoginRequiredMixin, DetailView):
template_name = 'inventory/inventory/inventory_detail.html'
context_object_name = 'inventory'
def get_queryset(self):
"""Оптимизация: предзагружаем warehouse"""
return Inventory.objects.select_related('warehouse')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Получаем все строки этой инвентаризации
context['lines'] = InventoryLine.objects.filter(
from inventory.models import Stock, StockBatch, Reservation
from django.db.models import Sum, Q
from decimal import Decimal
# Получаем все строки этой инвентаризации с оптимизацией
lines = InventoryLine.objects.filter(
inventory=self.object
).select_related('product')
).select_related('product').order_by('product__name')
if not lines.exists():
context['lines'] = []
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
if self.object.status == 'completed':
context['writeoff_document'] = self.object.writeoff_documents.filter(status='draft').first()
context['incoming_document'] = self.object.incoming_documents.filter(status='draft').first()
return context
# Получаем все product_id из строк
product_ids = [line.product_id for line in lines]
warehouse = self.object.warehouse
# Получаем или создаем все Stock объекты одним запросом
stocks = Stock.objects.filter(
product_id__in=product_ids,
warehouse=warehouse
)
existing_stocks = {stock.product_id: stock for stock in stocks}
# Создаем недостающие Stock объекты
missing_product_ids = set(product_ids) - set(existing_stocks.keys())
if missing_product_ids:
new_stocks = [
Stock(product_id=pid, warehouse=warehouse)
for pid in missing_product_ids
]
Stock.objects.bulk_create(new_stocks)
# Перезагружаем все Stock объекты
stocks = Stock.objects.filter(
product_id__in=product_ids,
warehouse=warehouse
)
existing_stocks = {stock.product_id: stock for stock in stocks}
# Bulk запрос для получения всех StockBatch данных
stock_batches_qs = StockBatch.objects.filter(
product_id__in=product_ids,
warehouse=warehouse,
is_active=True
).values('product_id').annotate(
total_qty=Sum('quantity')
)
stock_batches_dict = {
item['product_id']: item['total_qty'] or Decimal('0')
for item in stock_batches_qs
}
# Bulk запрос для получения всех Reservation данных
reservations_qs = Reservation.objects.filter(
product_id__in=product_ids,
warehouse=warehouse,
status='reserved'
).values('product_id').annotate(
total_reserved=Sum('quantity')
)
reservations_dict = {
item['product_id']: item['total_reserved'] or Decimal('0')
for item in reservations_qs
}
# Обновляем все Stock объекты bulk update
stocks_to_update = []
for stock in stocks:
product_id = stock.product_id
stock.quantity_available = stock_batches_dict.get(product_id, Decimal('0'))
stock.quantity_reserved = reservations_dict.get(product_id, Decimal('0'))
stocks_to_update.append(stock)
if stocks_to_update:
Stock.objects.bulk_update(
stocks_to_update,
['quantity_available', 'quantity_reserved', 'updated_at'],
batch_size=100
)
# Добавляем 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':
# Используем актуальное свободное количество из 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
lines_with_reserved.append(line)
context['lines'] = lines_with_reserved
# Получаем категории и теги для компонента поиска товаров
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
# Получаем созданные документы (если инвентаризация завершена)
if self.object.status == 'completed':
context['writeoff_document'] = self.object.writeoff_documents.first()
context['incoming_document'] = self.object.incoming_documents.first()
return context
@@ -100,3 +236,323 @@ class InventoryLineCreateBulkView(LoginRequiredMixin, View):
# TODO: Реализовать обработку формы с множественными строками
messages.success(request, 'Результаты инвентаризации добавлены.')
return redirect('inventory:inventory-detail', pk=pk)
class InventoryLineAddView(LoginRequiredMixin, View):
"""
AJAX view для добавления строки инвентаризации.
Принимает product_id через POST, создает InventoryLine с quantity_system = quantity_free.
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
return JsonResponse({
'success': False,
'error': 'Нельзя добавлять строки в завершенную инвентаризацию'
}, status=400)
# Получаем product_id из POST
product_id = request.POST.get('product_id')
if not product_id:
return JsonResponse({
'success': False,
'error': 'Не указан product_id'
}, status=400)
product = get_object_or_404(Product, id=product_id)
# Проверяем, нет ли уже такой строки
existing_line = InventoryLine.objects.filter(
inventory=inventory,
product=product
).first()
if existing_line:
return JsonResponse({
'success': False,
'error': f'Товар "{product.name}" уже добавлен в инвентаризацию'
}, status=400)
# Получаем свободное количество (quantity_free)
stock, _ = Stock.objects.get_or_create(
product=product,
warehouse=inventory.warehouse
)
stock.refresh_from_batches() # Обновить из партий
quantity_system = stock.quantity_free # Свободное незарезервированное количество
quantity_reserved = stock.quantity_reserved
quantity_available = stock.quantity_available
# Создаем строку инвентаризации
# Передаем quantity_reserved и quantity_available для корректного расчета разницы в save()
line = InventoryLine(
inventory=inventory,
product=product,
quantity_system=quantity_system,
quantity_fact=Decimal('0'), # Оператор заполнит позже
processed=False
)
line.save(quantity_reserved=quantity_reserved, quantity_available=quantity_available)
return JsonResponse({
'success': True,
'line': {
'id': line.id,
'product_id': product.id,
'product_name': product.name,
'quantity_system': str(line.quantity_system),
'quantity_reserved': str(quantity_reserved),
'quantity_available': str(quantity_available),
'quantity_fact': str(line.quantity_fact),
'difference': str(line.difference),
'processed': line.processed
}
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class InventoryLineUpdateView(LoginRequiredMixin, View):
"""
AJAX view для обновления quantity_fact строки инвентаризации.
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id, line_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
return JsonResponse({
'success': False,
'error': 'Нельзя редактировать строки завершенной инвентаризации'
}, status=400)
line = get_object_or_404(InventoryLine, id=line_id, inventory=inventory)
# Проверяем что строка не обработана
if line.processed:
return JsonResponse({
'success': False,
'error': 'Нельзя редактировать обработанную строку'
}, status=400)
# Получаем quantity_fact из POST
quantity_fact_str = request.POST.get('quantity_fact')
if quantity_fact_str is None:
return JsonResponse({
'success': False,
'error': 'Не указано quantity_fact'
}, status=400)
try:
quantity_fact = Decimal(str(quantity_fact_str))
if quantity_fact < 0:
return JsonResponse({
'success': False,
'error': 'Количество не может быть отрицательным'
}, status=400)
except (ValueError, TypeError):
return JsonResponse({
'success': False,
'error': 'Некорректное значение quantity_fact'
}, status=400)
# Обновляем quantity_fact
line.quantity_fact = quantity_fact
# Получаем актуальные данные из Stock для расчета разницы
stock, _ = Stock.objects.get_or_create(
product=line.product,
warehouse=inventory.warehouse
)
stock.refresh_from_batches()
# Для незавершенных инвентаризаций обновляем quantity_system динамически
if inventory.status != 'completed':
line.quantity_system = stock.quantity_free
# Пересчитываем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available
line.difference = (line.quantity_fact + stock.quantity_reserved) - stock.quantity_available
# Сохраняем с передачей quantity_reserved и quantity_available для корректного расчета в save()
line.save(quantity_reserved=stock.quantity_reserved, quantity_available=stock.quantity_available)
return JsonResponse({
'success': True,
'line': {
'id': line.id,
'product_id': line.product.id,
'product_name': line.product.name,
'quantity_system': str(line.quantity_system),
'quantity_reserved': str(stock.quantity_reserved),
'quantity_available': str(stock.quantity_available),
'quantity_fact': str(line.quantity_fact),
'difference': str(line.difference),
'processed': line.processed
}
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class InventoryLineDeleteView(LoginRequiredMixin, View):
"""
AJAX view для удаления строки инвентаризации.
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id, line_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
return JsonResponse({
'success': False,
'error': 'Нельзя удалять строки завершенной инвентаризации'
}, status=400)
line = get_object_or_404(InventoryLine, id=line_id, inventory=inventory)
# Проверяем что строка не обработана
if line.processed:
return JsonResponse({
'success': False,
'error': 'Нельзя удалять обработанную строку'
}, status=400)
product_name = line.product.name
line.delete()
return JsonResponse({
'success': True,
'message': f'Строка для товара "{product_name}" удалена'
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class InventoryCompleteView(LoginRequiredMixin, View):
"""
View для завершения инвентаризации.
Создает документы списания и оприходования (черновики).
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
messages.warning(request, 'Инвентаризация уже завершена.')
return redirect('inventory:inventory-detail', pk=inventory_id)
# Проверяем что есть строки
lines_count = InventoryLine.objects.filter(inventory=inventory).count()
if lines_count == 0:
messages.error(request, 'Нельзя завершить инвентаризацию без строк.')
return redirect('inventory:inventory-detail', pk=inventory_id)
# Обрабатываем инвентаризацию (создает документы-черновики)
result = InventoryProcessor.process_inventory(inventory_id)
# Автоматически проводим созданные документы
writeoff_confirmed = False
incoming_confirmed = False
confirmation_errors = []
if result['writeoff_document'] and result['writeoff_document'].status == 'draft':
try:
WriteOffDocumentService.confirm_document(
result['writeoff_document'],
confirmed_by=request.user
)
writeoff_confirmed = True
except ValidationError as e:
confirmation_errors.append(f'Ошибка проведения документа списания: {str(e)}')
if result['incoming_document'] and result['incoming_document'].status == 'draft':
try:
IncomingDocumentService.confirm_document(
result['incoming_document'],
confirmed_by=request.user
)
incoming_confirmed = True
except ValidationError as e:
confirmation_errors.append(f'Ошибка проведения документа оприходования: {str(e)}')
# Формируем сообщение
msg_parts = [f'Инвентаризация завершена. Обработано строк: {result["processed_lines"]}.']
if result['writeoff_document']:
if writeoff_confirmed:
msg_parts.append(
f'Документ списания {result["writeoff_document"].document_number} создан и автоматически проведен.'
)
else:
msg_parts.append(
f'Документ списания: {result["writeoff_document"].document_number}.'
)
if result['incoming_document']:
if incoming_confirmed:
msg_parts.append(
f'Документ оприходования {result["incoming_document"].document_number} создан и автоматически проведен.'
)
else:
msg_parts.append(
f'Документ оприходования: {result["incoming_document"].document_number}.'
)
if result['errors']:
msg_parts.append(f'Ошибок при обработке: {len(result["errors"])}.')
if confirmation_errors:
for error in confirmation_errors:
messages.error(request, error)
messages.success(request, ' '.join(msg_parts))
return redirect('inventory:inventory-detail', pk=inventory_id)
except Exception as e:
messages.error(request, f'Ошибка при завершении инвентаризации: {str(e)}')
return redirect('inventory:inventory-detail', pk=inventory_id)