Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения 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,13 +289,22 @@ class InventoryAdmin(admin.ModelAdmin):
for inventory in queryset: for inventory in queryset:
result = InventoryProcessor.process_inventory(inventory.id) result = InventoryProcessor.process_inventory(inventory.id)
self.message_user( msg_parts = [
request,
f"Инвентаризация {inventory.warehouse.name}: " f"Инвентаризация {inventory.warehouse.name}: "
f"обработано {result['processed_lines']} строк, " f"обработано {result['processed_lines']} строк."
f"создано {result['writeoffs_created']} списаний и " ]
f"{result['incomings_created']} приходов"
) 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 = 'Обработать инвентаризацию' 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, warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='inventories', verbose_name="Склад") 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="Дата инвентаризации") 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="Статус")
@@ -338,8 +346,13 @@ class Inventory(models.Model):
verbose_name = "Инвентаризация" verbose_name = "Инвентаризация"
verbose_name_plural = "Инвентаризации" verbose_name_plural = "Инвентаризации"
ordering = ['-date'] ordering = ['-date']
indexes = [
models.Index(fields=['document_number']),
]
def __str__(self): 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')})" 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, quantity_system = models.DecimalField(max_digits=10, decimal_places=3,
verbose_name="Количество в системе") verbose_name="Количество в системе")
quantity_fact = models.DecimalField(max_digits=10, decimal_places=3, 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, difference = models.DecimalField(max_digits=10, decimal_places=3,
default=0, verbose_name="Разница (факт - система)", default=0, verbose_name="Итоговая разница",
help_text="(Подсчитано + Зарезервировано) - Всего на складе",
editable=False) editable=False)
processed = models.BooleanField(default=False, processed = models.BooleanField(default=False,
verbose_name="Обработана (создана операция)") verbose_name="Обработана (создана операция)")
@@ -370,7 +385,32 @@ class InventoryLine(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Автоматически рассчитываем разницу # Автоматически рассчитываем разницу
self.difference = self.quantity_fact - self.quantity_system # Формула: (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) super().save(*args, **kwargs)
@@ -762,6 +802,7 @@ class DocumentCounter(models.Model):
('transfer', 'Перемещение товара'), ('transfer', 'Перемещение товара'),
('writeoff', 'Списание товара'), ('writeoff', 'Списание товара'),
('incoming', 'Поступление товара'), ('incoming', 'Поступление товара'),
('inventory', 'Инвентаризация'),
] ]
counter_type = models.CharField( counter_type = models.CharField(
@@ -954,6 +995,16 @@ class WriteOffDocument(models.Model):
verbose_name="Примечания" 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( created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@@ -1168,6 +1219,16 @@ class IncomingDocument(models.Model):
verbose_name="Примечания" 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( created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,

View File

@@ -2,8 +2,8 @@
Процессор для обработки инвентаризации. Процессор для обработки инвентаризации.
Основной функционал: Основной функционал:
- Обработка расхождений между фактом и системой - Обработка расхождений между фактом и системой
- Автоматическое создание WriteOff для недостач (по FIFO) - Автоматическое создание WriteOffDocument для недостач (черновик)
- Автоматическое создание Incoming для излишков - Автоматическое создание IncomingDocument для излишков (черновик)
""" """
from decimal import Decimal from decimal import Decimal
@@ -11,11 +11,11 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from inventory.models import ( from inventory.models import (
Inventory, InventoryLine, WriteOff, Incoming, IncomingBatch, Inventory, InventoryLine, StockBatch, Stock
StockBatch, Stock
) )
from inventory.services.batch_manager import StockBatchManager 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: class InventoryProcessor:
@@ -28,9 +28,10 @@ class InventoryProcessor:
def process_inventory(inventory_id): def process_inventory(inventory_id):
""" """
Обработать инвентаризацию: Обработать инвентаризацию:
- Для недостач (разница < 0): создать WriteOff по FIFO - Для недостач (разница < 0): создать WriteOffDocument (черновик) с позициями
- Для излишков (разница > 0): создать Incoming с новой партией - Для излишков (разница > 0): создать IncomingDocument (черновик) с позициями
- Обновить статус inventory и lines - Обновить статус inventory и lines
- НЕ проводить документы сразу - они остаются в статусе 'draft'
Args: Args:
inventory_id: ID объекта Inventory inventory_id: ID объекта Inventory
@@ -39,34 +40,31 @@ class InventoryProcessor:
dict: { dict: {
'inventory': Inventory, 'inventory': Inventory,
'processed_lines': int, 'processed_lines': int,
'writeoffs_created': int, 'writeoff_document': WriteOffDocument или None,
'incomings_created': int, 'incoming_document': IncomingDocument или None,
'errors': [...] 'errors': [...]
} }
""" """
inventory = Inventory.objects.get(id=inventory_id) inventory = Inventory.objects.get(id=inventory_id)
lines = InventoryLine.objects.filter(inventory=inventory, processed=False) lines = InventoryLine.objects.filter(inventory=inventory, processed=False)
writeoffs_created = 0
incomings_created = 0
errors = [] errors = []
writeoff_document = None
incoming_document = None
# Собираем недостачи и излишки
deficit_lines = []
surplus_lines = []
try: try:
for line in lines: for line in lines:
try: try:
if line.difference < 0: if line.difference < 0:
# Недостача: списать по FIFO # Недостача
InventoryProcessor._create_writeoff_for_deficit( deficit_lines.append(line)
inventory, line
)
writeoffs_created += 1
elif line.difference > 0: elif line.difference > 0:
# Излишек: создать новую партию # Излишек
InventoryProcessor._create_incoming_for_surplus( surplus_lines.append(line)
inventory, line
)
incomings_created += 1
# Отмечаем строку как обработанную # Отмечаем строку как обработанную
line.processed = True line.processed = True
@@ -78,6 +76,18 @@ class InventoryProcessor:
'error': str(e) '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.status = 'completed'
inventory.save(update_fields=['status']) inventory.save(update_fields=['status'])
@@ -91,78 +101,101 @@ class InventoryProcessor:
return { return {
'inventory': inventory, 'inventory': inventory,
'processed_lines': lines.count(), 'processed_lines': lines.count(),
'writeoffs_created': writeoffs_created, 'writeoff_document': writeoff_document,
'incomings_created': incomings_created, 'incoming_document': incoming_document,
'errors': errors 'errors': errors
} }
@staticmethod @staticmethod
def _create_writeoff_for_deficit(inventory, line): def _create_writeoff_document(inventory, deficit_lines):
""" """
Создать операцию WriteOff для недостачи при инвентаризации. Создать документ списания (WriteOffDocument) для недостач при инвентаризации.
Списывается по FIFO из старейших партий. Документ создается в статусе 'draft' и не проводится сразу.
Args: Args:
inventory: объект Inventory inventory: объект Inventory
line: объект InventoryLine с negative difference deficit_lines: список InventoryLine с negative difference
"""
quantity_to_writeoff = abs(line.difference)
# Списываем по FIFO Returns:
allocations = StockBatchManager.write_off_by_fifo( WriteOffDocument
line.product, """
inventory.warehouse, if not deficit_lines:
quantity_to_writeoff return None
# Создаем документ списания (черновик)
writeoff_document = WriteOffDocumentService.create_document(
warehouse=inventory.warehouse,
date=inventory.date.date(),
notes=f'Списание по результатам инвентаризации #{inventory.id}',
created_by=None # Можно добавить пользователя если передавать в process_inventory
) )
# Создаем WriteOff для каждой партии # Связываем документ с инвентаризацией
for batch, qty_allocated in allocations: writeoff_document.inventory = inventory
WriteOff.objects.create( writeoff_document.save(update_fields=['inventory'])
batch=batch,
quantity=qty_allocated, # Добавляем позиции в документ
for line in deficit_lines:
quantity_to_writeoff = abs(line.difference)
WriteOffDocumentService.add_item(
document=writeoff_document,
product=line.product,
quantity=quantity_to_writeoff,
reason='inventory', reason='inventory',
cost_price=batch.cost_price, notes=f'Инвентаризация #{inventory.id}, строка #{line.id}'
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
) )
return writeoff_document
@staticmethod @staticmethod
def _create_incoming_for_surplus(inventory, line): def _create_incoming_document(inventory, surplus_lines):
""" """
Создать операцию Incoming для излишка при инвентаризации. Создать документ поступления (IncomingDocument) для излишков при инвентаризации.
Новая партия создается с последней известной cost_price товара. Документ создается в статусе 'draft' и не проводится сразу.
Args: Args:
inventory: объект Inventory inventory: объект Inventory
line: объект InventoryLine с positive difference surplus_lines: список InventoryLine с positive difference
Returns:
IncomingDocument
""" """
quantity_surplus = line.difference if not surplus_lines:
return None
# Получаем последнюю known cost_price # Создаем документ поступления (черновик) с типом 'inventory'
cost_price = InventoryProcessor._get_last_cost_price( incoming_document = IncomingDocumentService.create_document(
line.product,
inventory.warehouse
)
# Генерируем номер документа для поступления
document_number = generate_incoming_document_number()
# Создаем IncomingBatch с типом 'inventory'
incoming_batch = IncomingBatch.objects.create(
warehouse=inventory.warehouse, warehouse=inventory.warehouse,
document_number=document_number, date=inventory.date.date(),
receipt_type='inventory', receipt_type='inventory',
notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}' supplier_name=None,
notes=f'Оприходование по результатам инвентаризации #{inventory.id}',
created_by=None # Можно добавить пользователя если передавать в process_inventory
) )
# Создаем документ Incoming # Связываем документ с инвентаризацией
# Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch incoming_document.inventory = inventory
Incoming.objects.create( incoming_document.save(update_fields=['inventory'])
batch=incoming_batch,
product=line.product, # Добавляем позиции в документ
quantity=quantity_surplus, for line in surplus_lines:
cost_price=cost_price, quantity_surplus = line.difference
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
) # Получаем последнюю known cost_price
cost_price = InventoryProcessor._get_last_cost_price(
line.product,
inventory.warehouse
)
IncomingDocumentService.add_item(
document=incoming_document,
product=line.product,
quantity=quantity_surplus,
cost_price=cost_price,
notes=f'Инвентаризация #{inventory.id}, строка #{line.id}'
)
return incoming_document
@staticmethod @staticmethod
def _get_last_cost_price(product, warehouse): 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.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem
from inventory.services import SaleProcessor from inventory.services import SaleProcessor
from inventory.services.batch_manager import StockBatchManager from inventory.services.batch_manager import StockBatchManager
from inventory.services.inventory_processor import InventoryProcessor # InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
def update_is_returned_flag(order): def update_is_returned_flag(order):
@@ -1234,41 +1234,9 @@ def update_order_on_sale_delete(sender, instance, **kwargs):
) )
@receiver(post_save, sender=Inventory) # Сигнал process_inventory_reconciliation удален
def process_inventory_reconciliation(sender, instance, created, **kwargs): # Теперь обработка инвентаризации вызывается явно через InventoryCompleteView
""" # Это позволяет пользователю контролировать момент создания документов
Сигнал: При завершении инвентаризации (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)
@receiver(post_save, sender=WriteOff) @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>
</div> </div>
<!-- СПИСАНИЯ (SaleBatchAllocation) --> <!-- СПИСАНИЯ ИЗ ПРОДАЖ (SaleBatchAllocation) -->
<div class="section-card"> <div class="section-card">
<h3>📤 Списания SaleBatchAllocation ({{ allocations.count }})</h3> <h3>📤 Списания из продаж SaleBatchAllocation ({{ allocations.count }})</h3>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-bordered table-hover"> <table class="table table-sm table-bordered table-hover">
<thead> <thead>
@@ -410,7 +410,124 @@
<td>{{ alloc.cost_price|floatformat:2 }}</td> <td>{{ alloc.cost_price|floatformat:2 }}</td>
</tr> </tr>
{% empty %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -1,10 +1,13 @@
{% extends 'inventory/base_inventory_minimal.html' %} {% extends 'inventory/base_inventory_minimal.html' %}
{% load inventory_filters %} {% load inventory_filters %}
{% load static %}
{% block inventory_title %}Детали инвентаризации{% endblock %} {% block inventory_title %}Детали инвентаризации{% endblock %}
{% block breadcrumb_current %}Инвентаризация{% endblock %} {% block breadcrumb_current %}Инвентаризация{% endblock %}
{% block inventory_content %} {% block inventory_content %}
<!-- CSS для компонента поиска товаров -->
<link rel="stylesheet" href="{% static 'products/css/product-search-picker.css' %}">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Инвентаризация: {{ inventory.warehouse.name }}</h4> <h4 class="mb-0">Инвентаризация: {{ inventory.warehouse.name }}</h4>
@@ -18,6 +21,12 @@
<div class="col-md-6"> <div class="col-md-6">
<h5>Информация</h5> <h5>Информация</h5>
<table class="table table-borderless"> <table class="table table-borderless">
{% if inventory.document_number %}
<tr>
<th>Номер документа:</th>
<td><strong>{{ inventory.document_number }}</strong></td>
</tr>
{% endif %}
<tr> <tr>
<th>Склад:</th> <th>Склад:</th>
<td><strong>{{ inventory.warehouse.name }}</strong></td> <td><strong>{{ inventory.warehouse.name }}</strong></td>
@@ -26,11 +35,17 @@
<th>Статус:</th> <th>Статус:</th>
<td> <td>
{% if inventory.status == 'draft' %} {% 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' %} {% 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 %} {% 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 %} {% endif %}
</td> </td>
</tr> </tr>
@@ -48,66 +63,209 @@
</div> </div>
</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> <hr>
<h5>Строки инвентаризации</h5> <h5>Строки инвентаризации</h5>
{% if lines %} <!-- Компонент поиска товаров (только если не завершена) -->
<div class="table-responsive"> {% if inventory.status != 'completed' %}
<table class="table table-sm"> <div class="card border-primary mb-4" id="product-search-section">
<thead class="table-light"> <div class="card-header bg-light">
<tr> <h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
<th>Товар</th> </div>
<th>В системе</th> <div class="card-body">
<th>По факту</th> {% 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' %}
<th>Разница</th> </div>
<th>Статус</th> </div>
</tr> {% endif %}
</thead>
<tbody> <!-- Таблица строк инвентаризации -->
{% for line in lines %} <div class="table-responsive">
<tr> <table class="table table-sm table-hover" id="inventory-lines-table">
<td>{{ line.product.name }}</td> <thead class="table-light">
<td>{{ line.quantity_system }}</td> <tr>
<td>{{ line.quantity_fact }}</td> <th>Товар</th>
<td> <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 id="inventory-lines-tbody">
{% for line in lines %}
<tr data-line-id="{{ line.id }}" class="{% if line.difference != 0 %}table-warning{% endif %}">
<td>{{ line.product.name }}</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 %} {% 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 %} {% elif line.difference < 0 %}
<span class="badge bg-danger">{{ line.difference }}</span> <span class="badge bg-danger">{{ line.difference|smart_quantity }}</span>
{% else %} {% else %}
<span class="badge bg-secondary">0</span> <span class="badge bg-secondary">0</span>
{% endif %} {% endif %}
</td> </span>
<td> </td>
{% if line.processed %} <td class="text-center">
<span class="badge bg-success">Обработана</span> {% if line.processed %}
{% else %} <span class="badge bg-success">Обработана</span>
<span class="badge bg-warning">Не обработана</span> {% else %}
{% endif %} <span class="badge bg-warning">Не проведено</span>
</td> {% endif %}
</tr> </td>
{% endfor %} {% if inventory.status != 'completed' %}
</tbody> <td class="text-center">
</table> <button class="btn btn-sm btn-danger delete-line-btn"
</div> data-line-id="{{ line.id }}"
{% else %} data-product-name="{{ line.product.name }}"
<div class="alert alert-info"> title="Удалить строку">
<i class="bi bi-info-circle"></i> Строк инвентаризации не добавлено. <i class="bi bi-trash"></i>
<a href="{% url 'inventory:inventory-lines-add' inventory.pk %}" class="alert-link">Добавить строки</a> </button>
</div> </td>
{% endif %} {% 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>
<div class="d-flex gap-2 mt-4"> <!-- Кнопки действий -->
<div class="d-flex gap-2 mt-4 align-items-center">
{% if inventory.status != 'completed' %} {% if inventory.status != 'completed' %}
<a href="{% url 'inventory:inventory-lines-add' inventory.pk %}" class="btn btn-primary"> <!-- Группа кнопок для незавершенной инвентаризации -->
<i class="bi bi-plus-circle"></i> Добавить строки <div class="d-flex gap-2">
</a> <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 %} {% endif %}
<a href="{% url 'inventory:inventory-list' %}" class="btn btn-secondary"> <!-- Кнопка возврата всегда видна -->
<i class="bi bi-arrow-left"></i> Вернуться к списку <div class="ms-auto">
</a> <a href="{% url 'inventory:inventory-list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Вернуться к списку
</a>
</div>
</div> </div>
</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 %} {% endblock %}

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ from .views import (
SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView, SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView,
# Inventory # Inventory
InventoryListView, InventoryCreateView, InventoryDetailView, InventoryLineCreateBulkView, InventoryListView, InventoryCreateView, InventoryDetailView, InventoryLineCreateBulkView,
InventoryLineAddView, InventoryLineUpdateView, InventoryLineDeleteView,
InventoryCompleteView,
# WriteOff # WriteOff
WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView, WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView,
# Transfer # Transfer
@@ -79,7 +81,12 @@ urlpatterns = [
path('inventory-ops/', InventoryListView.as_view(), name='inventory-list'), path('inventory-ops/', InventoryListView.as_view(), name='inventory-list'),
path('inventory-ops/create/', InventoryCreateView.as_view(), name='inventory-create'), 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>/', 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 (одиночные записи) ==================== # ==================== WRITEOFF (одиночные записи) ====================
path('writeoffs/', WriteOffListView.as_view(), name='writeoff-list'), 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') next_number = DocumentCounter.get_next_value('incoming')
return f"IN-{next_number:06d}" 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 .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
from .inventory_ops import ( from .inventory_ops import (
InventoryListView, InventoryCreateView, InventoryDetailView, InventoryListView, InventoryCreateView, InventoryDetailView,
InventoryLineCreateBulkView InventoryLineCreateBulkView, InventoryLineAddView, InventoryLineUpdateView,
InventoryLineDeleteView, InventoryCompleteView
) )
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView
from .writeoff_document import ( from .writeoff_document import (
@@ -65,6 +66,8 @@ __all__ = [
'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView', 'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView',
# Inventory # Inventory
'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView', 'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView',
'InventoryLineAddView', 'InventoryLineUpdateView', 'InventoryLineDeleteView',
'InventoryCompleteView',
# WriteOff # WriteOff
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView', 'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
# WriteOffDocument # WriteOffDocument

View File

@@ -5,7 +5,7 @@
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.shortcuts import render from django.shortcuts import render
from django.db.models import Q, Sum, Count 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 orders.models import Order
from products.models import Product from products.models import Product
from inventory.models import Warehouse from inventory.models import Warehouse
@@ -38,6 +38,12 @@ def debug_inventory_page(request):
allocations = SaleBatchAllocation.objects.select_related( allocations = SaleBatchAllocation.objects.select_related(
'sale__product', 'batch' 'sale__product', 'batch'
).order_by('-id') ).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') 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) reservations = reservations.filter(product_id=product_id)
sales = sales.filter(product_id=product_id) sales = sales.filter(product_id=product_id)
allocations = allocations.filter(sale__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() orders = orders.filter(items__product_id=product_id).distinct()
else: else:
product = None product = None
@@ -85,6 +93,9 @@ def debug_inventory_page(request):
stocks = stocks.filter(warehouse_id=warehouse_id) stocks = stocks.filter(warehouse_id=warehouse_id)
reservations = reservations.filter(warehouse_id=warehouse_id) reservations = reservations.filter(warehouse_id=warehouse_id)
sales = sales.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: else:
warehouse = None warehouse = None
@@ -94,6 +105,9 @@ def debug_inventory_page(request):
reservations = reservations[:100] reservations = reservations[:100]
sales = sales[:100] sales = sales[:100]
allocations = allocations[:100] allocations = allocations[:100]
writeoffs = writeoffs[:100]
writeoff_documents = writeoff_documents[:50]
writeoff_document_items = writeoff_document_items[:100]
orders = orders[:50] orders = orders[:50]
# Списки для фильтров # Списки для фильтров
@@ -106,6 +120,9 @@ def debug_inventory_page(request):
'reservations': reservations, 'reservations': reservations,
'sales': sales, 'sales': sales,
'allocations': allocations, 'allocations': allocations,
'writeoffs': writeoffs,
'writeoff_documents': writeoff_documents,
'writeoff_document_items': writeoff_document_items,
'orders': orders, 'orders': orders,
'products': products, 'products': products,
'warehouses': warehouses, 'warehouses': warehouses,

View File

@@ -1,12 +1,23 @@
# -*- coding: utf-8 -*- # -*- 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.views.generic import ListView, CreateView, DetailView, View, FormView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages from django.contrib import messages
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect, JsonResponse
from ..models import Inventory, InventoryLine 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 ..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): class InventoryListView(LoginRequiredMixin, ListView):
@@ -44,7 +55,10 @@ class InventoryCreateView(LoginRequiredMixin, CreateView):
success_url = reverse_lazy('inventory:inventory-list') 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
form.instance.status = 'processing' form.instance.status = 'processing'
form.instance.document_number = generate_inventory_document_number()
messages.success( messages.success(
self.request, self.request,
f'Инвентаризация склада "{form.instance.warehouse.name}" начата.' f'Инвентаризация склада "{form.instance.warehouse.name}" начата.'
@@ -61,12 +75,134 @@ class InventoryDetailView(LoginRequiredMixin, DetailView):
template_name = 'inventory/inventory/inventory_detail.html' template_name = 'inventory/inventory/inventory_detail.html'
context_object_name = 'inventory' context_object_name = 'inventory'
def get_queryset(self):
"""Оптимизация: предзагружаем warehouse"""
return Inventory.objects.select_related('warehouse')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# Получаем все строки этой инвентаризации from inventory.models import Stock, StockBatch, Reservation
context['lines'] = InventoryLine.objects.filter( from django.db.models import Sum, Q
from decimal import Decimal
# Получаем все строки этой инвентаризации с оптимизацией
lines = InventoryLine.objects.filter(
inventory=self.object 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 return context
@@ -100,3 +236,323 @@ class InventoryLineCreateBulkView(LoginRequiredMixin, View):
# TODO: Реализовать обработку формы с множественными строками # TODO: Реализовать обработку формы с множественными строками
messages.success(request, 'Результаты инвентаризации добавлены.') messages.success(request, 'Результаты инвентаризации добавлены.')
return redirect('inventory:inventory-detail', pk=pk) 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)