Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения 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:
@@ -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 = 'Обработать инвентаризацию'
|
||||||
|
|
||||||
|
|||||||
@@ -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='Инвентаризация'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
504
myproject/inventory/static/inventory/js/inventory_detail.js
Normal file
504
myproject/inventory/static/inventory/js/inventory_detail.js
Normal 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);
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}" начата.'
|
||||||
@@ -60,13 +74,135 @@ class InventoryDetailView(LoginRequiredMixin, DetailView):
|
|||||||
model = Inventory
|
model = Inventory
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user