diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py index 0da9c34..c7a154c 100644 --- a/myproject/inventory/admin.py +++ b/myproject/inventory/admin.py @@ -289,13 +289,22 @@ 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 = 'Обработать инвентаризацию' diff --git a/myproject/inventory/migrations/0015_add_inventory_foreign_keys.py b/myproject/inventory/migrations/0015_add_inventory_foreign_keys.py new file mode 100644 index 0000000..cc8d28b --- /dev/null +++ b/myproject/inventory/migrations/0015_add_inventory_foreign_keys.py @@ -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='Инвентаризация'), + ), + ] diff --git a/myproject/inventory/migrations/0016_add_document_number_to_inventory.py b/myproject/inventory/migrations/0016_add_document_number_to_inventory.py new file mode 100644 index 0000000..aa1af9d --- /dev/null +++ b/myproject/inventory/migrations/0016_add_document_number_to_inventory.py @@ -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'), + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index eb67ea5..f38d75d 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -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): # Автоматически рассчитываем разницу - 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) @@ -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, diff --git a/myproject/inventory/services/inventory_processor.py b/myproject/inventory/services/inventory_processor.py index 4c84f40..398349a 100644 --- a/myproject/inventory/services/inventory_processor.py +++ b/myproject/inventory/services/inventory_processor.py @@ -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,78 +101,101 @@ 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 - """ - quantity_to_writeoff = abs(line.difference) + deficit_lines: список InventoryLine с negative difference - # Списываем по FIFO - allocations = StockBatchManager.write_off_by_fifo( - line.product, - inventory.warehouse, - quantity_to_writeoff + 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 для каждой партии - for batch, qty_allocated in allocations: - WriteOff.objects.create( - batch=batch, - quantity=qty_allocated, + # Связываем документ с инвентаризацией + writeoff_document.inventory = inventory + writeoff_document.save(update_fields=['inventory']) + + # Добавляем позиции в документ + 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', - 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 """ - quantity_surplus = line.difference + if not surplus_lines: + return None - # Получаем последнюю known cost_price - cost_price = InventoryProcessor._get_last_cost_price( - line.product, - inventory.warehouse - ) - - # Генерируем номер документа для поступления - document_number = generate_incoming_document_number() - - # Создаем IncomingBatch с типом 'inventory' - incoming_batch = IncomingBatch.objects.create( + # Создаем документ поступления (черновик) с типом 'inventory' + incoming_document = IncomingDocumentService.create_document( warehouse=inventory.warehouse, - document_number=document_number, + date=inventory.date.date(), 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.objects.create( - batch=incoming_batch, - product=line.product, - quantity=quantity_surplus, - cost_price=cost_price, - notes=f'Инвентаризация {inventory.id}, строка {line.id}' - ) + # Связываем документ с инвентаризацией + incoming_document.inventory = inventory + incoming_document.save(update_fields=['inventory']) + + # Добавляем позиции в документ + for line in surplus_lines: + quantity_surplus = line.difference + + # Получаем последнюю 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 def _get_last_cost_price(product, warehouse): diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 9e9c38d..041d08c 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -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) diff --git a/myproject/inventory/static/inventory/js/inventory_detail.js b/myproject/inventory/static/inventory/js/inventory_detail.js new file mode 100644 index 0000000..9eb2864 --- /dev/null +++ b/myproject/inventory/static/inventory/js/inventory_detail.js @@ -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 = '' + badgeText + ''; + } + + // Обновляем класс строки в зависимости от разницы + 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 = ' Строк инвентаризации не добавлено.'; + } + + // Отключаем кнопку завершения, если нет строк + 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 = ''; + 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 = '
Добавление...'; + 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 = '0'; + if (difference > 0) { + const formatted = this.formatQuantity(difference); + differenceBadge = '+' + formatted + ''; + } else if (difference < 0) { + const formatted = this.formatQuantity(difference); + differenceBadge = '' + formatted + ''; + } + + 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 = + '' + lineData.product_name + '' + + '' + formattedQuantityAvailable + '' + + '' + formattedQuantityReserved + '' + + '' + formattedQuantitySystem + '' + + '' + + '' + + '' + + '' + differenceBadge + '' + + 'Не проведено' + + '' + + '' + + ''; + + 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); + diff --git a/myproject/inventory/templates/inventory/debug_page.html b/myproject/inventory/templates/inventory/debug_page.html index f50715e..83f6013 100644 --- a/myproject/inventory/templates/inventory/debug_page.html +++ b/myproject/inventory/templates/inventory/debug_page.html @@ -384,9 +384,9 @@ - +
-

📤 Списания SaleBatchAllocation ({{ allocations.count }})

+

📤 Списания из продаж SaleBatchAllocation ({{ allocations.count }})

@@ -410,7 +410,124 @@ {% empty %} - + + {% endfor %} + +
{{ alloc.cost_price|floatformat:2 }}
Нет списаний
Нет списаний из продаж
+
+
+ + +
+

🗑️ Списания WriteOff ({{ writeoffs.count }})

+
+ + + + + + + + + + + + + + {% for wo in writeoffs %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
IDТоварСкладПартия IDКол-воПричинаДата
{{ wo.id }}{{ wo.batch.product.name }}{{ wo.batch.warehouse.name }}{{ wo.batch.id }}{{ wo.quantity }}{{ wo.reason|default:"-" }}{{ wo.date|date:"d.m.Y H:i:s" }}
Нет списаний WriteOff
+
+
+ + +
+

📄 Документы списания WriteOffDocument ({{ writeoff_documents.count }})

+
+ + + + + + + + + + + + + {% for doc in writeoff_documents %} + + + + + + + + + {% empty %} + + {% endfor %} + +
IDНомерСкладСтатусДатаИнвентаризация
{{ doc.id }}{{ doc.document_number|default:"-" }}{{ doc.warehouse.name }} + {% if doc.status == 'draft' %} + Черновик + {% elif doc.status == 'confirmed' %} + Проведен + {% elif doc.status == 'cancelled' %} + Отменен + {% else %} + {{ doc.status }} + {% endif %} + {{ doc.date|date:"d.m.Y" }} + {% if doc.inventory %} + INV-{{ doc.inventory.id }} + {% else %} + - + {% endif %} +
Нет документов списания
+
+
+ + +
+

📋 Строки документов списания WriteOffDocumentItem ({{ writeoff_document_items.count }})

+
+ + + + + + + + + + + + + {% for item in writeoff_document_items %} + + + + + + + + + {% empty %} + {% endfor %}
IDДокументТоварСкладКол-воПричина
{{ item.id }}{{ item.document.document_number|default:"#" }}{{ item.document.id }}{{ item.product.name }}{{ item.document.warehouse.name }}{{ item.quantity }}{{ item.reason|default:"-" }}
Нет строк документов списания
diff --git a/myproject/inventory/templates/inventory/inventory/inventory_detail.html b/myproject/inventory/templates/inventory/inventory/inventory_detail.html index 8cebe45..fcfc54e 100644 --- a/myproject/inventory/templates/inventory/inventory/inventory_detail.html +++ b/myproject/inventory/templates/inventory/inventory/inventory_detail.html @@ -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 %} + +

Инвентаризация: {{ inventory.warehouse.name }}

@@ -18,6 +21,12 @@
Информация
+ {% if inventory.document_number %} + + + + + {% endif %} @@ -26,11 +35,17 @@ @@ -48,66 +63,209 @@ + {% if inventory.status == 'completed' %} + +
+
Инвентаризация завершена
+
+ {% if writeoff_document %} +

+ Документ списания: + + {{ writeoff_document.document_number }} + + + {{ writeoff_document.get_status_display }} + +

+ {% endif %} + {% if incoming_document %} +

+ Документ оприходования: + + {{ incoming_document.document_number }} + + + {{ incoming_document.get_status_display }} + +

+ {% endif %} +
+
+ {% endif %} +
Строки инвентаризации
- {% if lines %} -
-
Номер документа:{{ inventory.document_number }}
Склад: {{ inventory.warehouse.name }}Статус: {% if inventory.status == 'draft' %} - Черновик + + Черновик + {% elif inventory.status == 'processing' %} - В обработке + + В обработке + {% else %} - Завершена + + Завершена + {% endif %}
- - - - - - - - - - - {% for line in lines %} - - - - - + + {% if inventory.status != 'completed' %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
ТоварВ системеПо фактуРазницаСтатус
{{ line.product.name }}{{ line.quantity_system }}{{ line.quantity_fact }} + + {% if inventory.status != 'completed' %} +
+
+
Добавить товар в инвентаризацию
+
+
+ {% 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' %} +
+
+ {% endif %} + + +
+ + + + + + + + + + + {% if inventory.status != 'completed' %} + + {% endif %} + + + + {% for line in lines %} + + + + + + + - - - {% endfor %} - -
ТоварВсего на складеВ резервахВ системе (свободно)Подсчитано (факт, свободные)Итоговая разницаСтатусДействия
{{ line.product.name }}{{ line.quantity_available|smart_quantity }}{{ line.quantity_reserved|smart_quantity }}{{ line.quantity_system|smart_quantity }} + {% if inventory.status != 'completed' %} + + {% else %} + {{ line.quantity_fact|smart_quantity }} + {% endif %} + + {% if line.difference > 0 %} - +{{ line.difference }} + +{{ line.difference|smart_quantity }} {% elif line.difference < 0 %} - {{ line.difference }} + {{ line.difference|smart_quantity }} {% else %} 0 {% endif %} - - {% if line.processed %} - Обработана - {% else %} - Не обработана - {% endif %} -
-
- {% else %} -
- Строк инвентаризации не добавлено. - Добавить строки -
- {% endif %} + +
+ {% if line.processed %} + Обработана + {% else %} + Не проведено + {% endif %} + + +
+ Строк инвентаризации не добавлено. +
+
-
+ +
{% if inventory.status != 'completed' %} - - Добавить строки - + +
+ + +
{% endif %} - - Вернуться к списку - + +
+ + + + + + + + {% endblock %} diff --git a/myproject/inventory/templates/inventory/inventory/inventory_list.html b/myproject/inventory/templates/inventory/inventory/inventory_list.html index d270c96..cf5c967 100644 --- a/myproject/inventory/templates/inventory/inventory/inventory_list.html +++ b/myproject/inventory/templates/inventory/inventory/inventory_list.html @@ -47,6 +47,7 @@ + @@ -57,6 +58,7 @@ {% for inventory in inventories %} + @@ -114,6 +116,13 @@ {% endif %} + +
+ + Как использовать: Создайте инвентаризацию, затем откройте её (иконка карандаша) для добавления товаров и указания фактических количеств. + После завершения будут автоматически созданы документы списания и оприходования (черновики), которые можно провести отдельно. +
+
Номер Склад Статус Провёл
{{ inventory.document_number|default:"—" }} {{ inventory.warehouse.name }} @@ -69,8 +71,8 @@ {{ inventory.conducted_by|default:"—" }} {{ inventory.date|date:"d.m.Y H:i" }} - - + +