- Автоматическое проведение документов списания и оприходования после завершения инвентаризации - Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation - Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available - Переименование поля 'По факту' в 'Подсчитано (факт, свободные)' - Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации - Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением - Центральное выравнивание значений в столбцах таблицы - Автоматическое выделение текста при фокусе на поле ввода количества - Исправление форматирования разницы (убраны лишние нули) - Изменение статуса 'Не обработана' на 'Не проведено' - Добавление номера документа для инвентаризаций (INV-XXXXXX) - Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem) - Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
503 lines
19 KiB
Python
503 lines
19 KiB
Python
"""
|
||
Тесты для складского учета с FIFO логикой.
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
from django.test import TestCase
|
||
|
||
from products.models import Product
|
||
from inventory.models import Warehouse, StockBatch, Sale, Transfer, Inventory, InventoryLine, Reservation, Stock
|
||
from inventory.services import StockBatchManager, SaleProcessor, InventoryProcessor
|
||
from orders.models import Order, OrderItem
|
||
from customers.models import Customer
|
||
|
||
|
||
class WarehouseModelTest(TestCase):
|
||
"""Тесты модели Warehouse."""
|
||
|
||
def setUp(self):
|
||
self.warehouse = Warehouse.objects.create(
|
||
name='Основной склад',
|
||
description='Главный склад компании'
|
||
)
|
||
|
||
def test_warehouse_creation(self):
|
||
"""Тест создания склада."""
|
||
self.assertEqual(self.warehouse.name, 'Основной склад')
|
||
self.assertTrue(self.warehouse.is_active)
|
||
self.assertIsNotNone(self.warehouse.created_at)
|
||
|
||
def test_warehouse_str(self):
|
||
"""Тест строкового представления склада."""
|
||
self.assertEqual(str(self.warehouse), 'Основной склад')
|
||
|
||
|
||
class StockBatchManagerFIFOTest(TestCase):
|
||
"""Тесты FIFO логики для партий товаров."""
|
||
|
||
def setUp(self):
|
||
"""Подготовка тестовых данных."""
|
||
# Создаем склад
|
||
self.warehouse = Warehouse.objects.create(name='Склад 1')
|
||
|
||
# Создаем товар
|
||
self.product = Product.objects.create(
|
||
name='Роза красная',
|
||
cost_price=Decimal('10.00'),
|
||
sale_price=Decimal('30.00')
|
||
)
|
||
|
||
def test_create_batch(self):
|
||
"""Тест создания новой партии."""
|
||
batch = StockBatchManager.create_batch(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('100'),
|
||
cost_price=Decimal('10.00')
|
||
)
|
||
|
||
self.assertEqual(batch.quantity, Decimal('100'))
|
||
self.assertEqual(batch.cost_price, Decimal('10.00'))
|
||
self.assertTrue(batch.is_active)
|
||
|
||
def test_fifo_write_off_single_batch(self):
|
||
"""Тест FIFO списания из одной партии."""
|
||
# Создаем партию
|
||
batch = StockBatchManager.create_batch(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('100'),
|
||
cost_price=Decimal('10.00')
|
||
)
|
||
|
||
# Списываем 50 шт
|
||
allocations = StockBatchManager.write_off_by_fifo(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity_to_write_off=Decimal('50')
|
||
)
|
||
|
||
# Проверяем результат
|
||
self.assertEqual(len(allocations), 1)
|
||
self.assertEqual(allocations[0][1], Decimal('50')) # qty_written
|
||
|
||
# Проверяем остаток в партии
|
||
batch.refresh_from_db()
|
||
self.assertEqual(batch.quantity, Decimal('50'))
|
||
self.assertTrue(batch.is_active)
|
||
|
||
def test_fifo_write_off_multiple_batches(self):
|
||
"""Тест FIFO списания из нескольких партий (старые первыми)."""
|
||
# Создаем 3 партии в разные моменты
|
||
batch1 = StockBatchManager.create_batch(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('30'),
|
||
cost_price=Decimal('10.00') # Старейшая
|
||
)
|
||
|
||
batch2 = StockBatchManager.create_batch(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('40'),
|
||
cost_price=Decimal('12.00')
|
||
)
|
||
|
||
batch3 = StockBatchManager.create_batch(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('50'),
|
||
cost_price=Decimal('15.00') # Новейшая
|
||
)
|
||
|
||
# Списываем 100 шт (должно быть: вся batch1, вся batch2, 30 из batch3)
|
||
allocations = StockBatchManager.write_off_by_fifo(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity_to_write_off=Decimal('100')
|
||
)
|
||
|
||
# Проверяем FIFO порядок
|
||
self.assertEqual(len(allocations), 3)
|
||
self.assertEqual(allocations[0][0].id, batch1.id) # Первая списана batch1
|
||
self.assertEqual(allocations[0][1], Decimal('30')) # Всё из batch1
|
||
|
||
self.assertEqual(allocations[1][0].id, batch2.id) # Вторая списана batch2
|
||
self.assertEqual(allocations[1][1], Decimal('40')) # Всё из batch2
|
||
|
||
self.assertEqual(allocations[2][0].id, batch3.id) # Третья batch3
|
||
self.assertEqual(allocations[2][1], Decimal('30')) # 30 из batch3
|
||
|
||
# Проверяем остатки
|
||
batch1.refresh_from_db()
|
||
batch2.refresh_from_db()
|
||
batch3.refresh_from_db()
|
||
|
||
self.assertEqual(batch1.quantity, Decimal('0'))
|
||
self.assertFalse(batch1.is_active) # Деактивирована
|
||
|
||
self.assertEqual(batch2.quantity, Decimal('0'))
|
||
self.assertFalse(batch2.is_active)
|
||
|
||
self.assertEqual(batch3.quantity, Decimal('20'))
|
||
self.assertTrue(batch3.is_active)
|
||
|
||
def test_insufficient_stock_error(self):
|
||
"""Тест ошибки при недостаточном товаре на складе."""
|
||
batch = StockBatchManager.create_batch(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('50'),
|
||
cost_price=Decimal('10.00')
|
||
)
|
||
|
||
# Пытаемся списать больше, чем есть
|
||
with self.assertRaises(ValueError) as context:
|
||
StockBatchManager.write_off_by_fifo(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity_to_write_off=Decimal('100')
|
||
)
|
||
|
||
self.assertIn('Недостаточно товара', str(context.exception))
|
||
|
||
def test_transfer_batch(self):
|
||
"""Тест перемещения товара между складами с сохранением цены."""
|
||
warehouse2 = Warehouse.objects.create(name='Склад 2')
|
||
|
||
# Создаем партию на первом складе
|
||
batch1 = StockBatchManager.create_batch(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('100'),
|
||
cost_price=Decimal('10.00')
|
||
)
|
||
|
||
# Переносим 40 шт на второй склад
|
||
new_batch = StockBatchManager.transfer_batch(
|
||
batch=batch1,
|
||
to_warehouse=warehouse2,
|
||
quantity=Decimal('40')
|
||
)
|
||
|
||
# Проверяем результаты
|
||
batch1.refresh_from_db()
|
||
self.assertEqual(batch1.quantity, Decimal('60'))
|
||
|
||
self.assertEqual(new_batch.warehouse, warehouse2)
|
||
self.assertEqual(new_batch.quantity, Decimal('40'))
|
||
self.assertEqual(new_batch.cost_price, Decimal('10.00')) # Цена сохранена!
|
||
|
||
|
||
class SaleProcessorTest(TestCase):
|
||
"""Тесты обработки продаж с FIFO списанием."""
|
||
|
||
def setUp(self):
|
||
self.warehouse = Warehouse.objects.create(name='Склад 1')
|
||
self.product = Product.objects.create(
|
||
name='Гвоздика',
|
||
cost_price=Decimal('5.00'),
|
||
sale_price=Decimal('20.00')
|
||
)
|
||
|
||
def test_create_sale_with_fifo(self):
|
||
"""Тест создания продажи с FIFO списанием."""
|
||
# Создаем партии
|
||
batch1 = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('30'), Decimal('5.00')
|
||
)
|
||
batch2 = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('50'), Decimal('6.00')
|
||
)
|
||
|
||
# Создаем продажу 40 шт
|
||
sale = SaleProcessor.create_sale(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('40'),
|
||
sale_price=Decimal('20.00')
|
||
)
|
||
|
||
# Проверяем Sale
|
||
self.assertTrue(sale.processed)
|
||
self.assertEqual(sale.quantity, Decimal('40'))
|
||
|
||
# Проверяем FIFO распределение
|
||
allocations = list(sale.batch_allocations.all())
|
||
self.assertEqual(len(allocations), 2)
|
||
self.assertEqual(allocations[0].quantity, Decimal('30')) # Всё из batch1
|
||
self.assertEqual(allocations[1].quantity, Decimal('10')) # 10 из batch2
|
||
|
||
def test_sale_cost_analysis(self):
|
||
"""Тест анализа себестоимости продажи."""
|
||
# Создаем партии с разными ценами
|
||
batch1 = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('30'), Decimal('5.00')
|
||
)
|
||
batch2 = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('50'), Decimal('10.00')
|
||
)
|
||
|
||
# Создаем продажу
|
||
sale = SaleProcessor.create_sale(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('40'),
|
||
sale_price=Decimal('25.00')
|
||
)
|
||
|
||
# Анализируем прибыль
|
||
analysis = SaleProcessor.get_sale_cost_analysis(sale)
|
||
|
||
# Проверяем финансы
|
||
# batch1: 30 * 5 = 150 себестоимость, 30 * 25 = 750 выручка
|
||
# batch2: 10 * 10 = 100 себестоимость, 10 * 25 = 250 выручка
|
||
# Итого: 250 себестоимость, 1000 выручка, 750 прибыль
|
||
|
||
self.assertEqual(analysis['total_cost'], Decimal('250'))
|
||
self.assertEqual(analysis['total_revenue'], Decimal('1000'))
|
||
self.assertEqual(analysis['total_profit'], Decimal('750'))
|
||
self.assertEqual(analysis['profit_margin'], Decimal('75.00')) # 750/1000*100
|
||
|
||
|
||
class InventoryProcessorTest(TestCase):
|
||
"""Тесты обработки инвентаризации."""
|
||
|
||
def setUp(self):
|
||
self.warehouse = Warehouse.objects.create(name='Склад 1')
|
||
self.product = Product.objects.create(
|
||
name='Тюльпан',
|
||
cost_price=Decimal('8.00'),
|
||
sale_price=Decimal('25.00')
|
||
)
|
||
|
||
def test_process_inventory_deficit(self):
|
||
"""Тест обработки недостачи при инвентаризации."""
|
||
# Создаем партию
|
||
batch = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('100'), Decimal('8.00')
|
||
)
|
||
|
||
# Создаем инвентаризацию
|
||
inventory = Inventory.objects.create(
|
||
warehouse=self.warehouse,
|
||
status='draft'
|
||
)
|
||
|
||
# Строка: в системе 100, по факту 85 (недостача 15)
|
||
line = InventoryLine.objects.create(
|
||
inventory=inventory,
|
||
product=self.product,
|
||
quantity_system=Decimal('100'),
|
||
quantity_fact=Decimal('85')
|
||
)
|
||
|
||
# Обрабатываем инвентаризацию
|
||
result = InventoryProcessor.process_inventory(inventory.id)
|
||
|
||
# Проверяем результат
|
||
self.assertEqual(result['processed_lines'], 1)
|
||
self.assertIsNotNone(result['writeoff_document'])
|
||
self.assertIsNone(result['incoming_document'])
|
||
|
||
# Проверяем, что создался документ списания (черновик)
|
||
writeoff_doc = result['writeoff_document']
|
||
self.assertEqual(writeoff_doc.status, 'draft')
|
||
self.assertEqual(writeoff_doc.inventory, inventory)
|
||
|
||
# Проверяем, что в документе есть позиция
|
||
items = writeoff_doc.items.all()
|
||
self.assertEqual(items.count(), 1)
|
||
self.assertEqual(items.first().product, self.product)
|
||
self.assertEqual(items.first().quantity, Decimal('15'))
|
||
|
||
# Проверяем, что документ еще не проведен - остаток не изменился
|
||
batch.refresh_from_db()
|
||
self.assertEqual(batch.quantity, Decimal('100')) # Остаток не изменился, т.к. документ не проведен
|
||
|
||
def test_process_inventory_surplus(self):
|
||
"""Тест обработки излишка при инвентаризации."""
|
||
# Создаем партию
|
||
batch = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('100'), Decimal('8.00')
|
||
)
|
||
|
||
# Создаем инвентаризацию
|
||
inventory = Inventory.objects.create(
|
||
warehouse=self.warehouse,
|
||
status='draft'
|
||
)
|
||
|
||
# Строка: в системе 100, по факту 120 (излишек 20)
|
||
line = InventoryLine.objects.create(
|
||
inventory=inventory,
|
||
product=self.product,
|
||
quantity_system=Decimal('100'),
|
||
quantity_fact=Decimal('120')
|
||
)
|
||
|
||
# Обрабатываем инвентаризацию
|
||
result = InventoryProcessor.process_inventory(inventory.id)
|
||
|
||
# Проверяем результат
|
||
self.assertEqual(result['processed_lines'], 1)
|
||
self.assertIsNone(result['writeoff_document'])
|
||
self.assertIsNotNone(result['incoming_document'])
|
||
|
||
# Проверяем, что создался документ оприходования (черновик)
|
||
incoming_doc = result['incoming_document']
|
||
self.assertEqual(incoming_doc.status, 'draft')
|
||
self.assertEqual(incoming_doc.inventory, inventory)
|
||
self.assertEqual(incoming_doc.receipt_type, 'inventory')
|
||
|
||
# Проверяем, что в документе есть позиция
|
||
items = incoming_doc.items.all()
|
||
self.assertEqual(items.count(), 1)
|
||
self.assertEqual(items.first().product, self.product)
|
||
self.assertEqual(items.first().quantity, Decimal('20'))
|
||
|
||
# Проверяем, что документ еще не проведен - новый StockBatch не создан
|
||
from inventory.models import StockBatch
|
||
batches = StockBatch.objects.filter(product=self.product, warehouse=self.warehouse)
|
||
self.assertEqual(batches.count(), 1) # Только исходная партия, новая не создана
|
||
|
||
|
||
class ReservationSignalsTest(TestCase):
|
||
"""Тесты автоматического резервирования через сигналы."""
|
||
|
||
def setUp(self):
|
||
self.warehouse = Warehouse.objects.create(name='Склад 1')
|
||
|
||
self.product = Product.objects.create(
|
||
name='Нарцисс',
|
||
cost_price=Decimal('6.00'),
|
||
sale_price=Decimal('18.00')
|
||
)
|
||
|
||
self.customer = Customer.objects.create(
|
||
name='Иван Иванов',
|
||
phone='+375291234567'
|
||
)
|
||
|
||
def test_reservation_on_order_create(self):
|
||
"""Тест создания резервирования при создании заказа."""
|
||
# Создаем заказ
|
||
order = Order.objects.create(
|
||
customer=self.customer,
|
||
order_number='ORD-20250101-0001',
|
||
delivery_type='courier'
|
||
)
|
||
|
||
# Добавляем товар в заказ
|
||
item = OrderItem.objects.create(
|
||
order=order,
|
||
product=self.product,
|
||
quantity=5,
|
||
price=Decimal('18.00')
|
||
)
|
||
|
||
# Проверяем, что резерв создан
|
||
reservations = Reservation.objects.filter(order_item=item)
|
||
self.assertEqual(reservations.count(), 1)
|
||
|
||
res = reservations.first()
|
||
self.assertEqual(res.quantity, Decimal('5'))
|
||
self.assertEqual(res.status, 'reserved')
|
||
|
||
def test_release_reservation_on_order_delete(self):
|
||
"""Тест освобождения резервирования при удалении заказа."""
|
||
# Создаем заказ с товаром
|
||
order = Order.objects.create(
|
||
customer=self.customer,
|
||
order_number='ORD-20250101-0002',
|
||
delivery_type='courier'
|
||
)
|
||
|
||
item = OrderItem.objects.create(
|
||
order=order,
|
||
product=self.product,
|
||
quantity=10,
|
||
price=Decimal('18.00')
|
||
)
|
||
|
||
# Проверяем, что резерв создан
|
||
res = Reservation.objects.get(order_item=item)
|
||
self.assertEqual(res.status, 'reserved')
|
||
|
||
# Удаляем заказ
|
||
order.delete()
|
||
|
||
# Проверяем, что резерв освобожден
|
||
res.refresh_from_db()
|
||
self.assertEqual(res.status, 'released')
|
||
self.assertIsNotNone(res.released_at)
|
||
|
||
|
||
class StockCacheTest(TestCase):
|
||
"""Тесты кеширования остатков в модели Stock."""
|
||
|
||
def setUp(self):
|
||
self.warehouse = Warehouse.objects.create(name='Склад 1')
|
||
self.product = Product.objects.create(
|
||
name='Лилия',
|
||
cost_price=Decimal('12.00'),
|
||
sale_price=Decimal('40.00')
|
||
)
|
||
|
||
def test_stock_refresh_from_batches(self):
|
||
"""Тест пересчета остатков из партий."""
|
||
# Создаем партии
|
||
batch1 = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('50'), Decimal('12.00')
|
||
)
|
||
batch2 = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('75'), Decimal('13.00')
|
||
)
|
||
|
||
# Получаем или создаем Stock
|
||
stock, created = Stock.objects.get_or_create(
|
||
product=self.product,
|
||
warehouse=self.warehouse
|
||
)
|
||
|
||
# Обновляем из батчей
|
||
stock.refresh_from_batches()
|
||
|
||
# Проверяем результат
|
||
self.assertEqual(stock.quantity_available, Decimal('125'))
|
||
|
||
def test_stock_quantity_free(self):
|
||
"""Тест расчета свободного количества."""
|
||
batch = StockBatchManager.create_batch(
|
||
self.product, self.warehouse,
|
||
Decimal('100'), Decimal('12.00')
|
||
)
|
||
|
||
# Создаем резерв
|
||
Reservation.objects.create(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
quantity=Decimal('30'),
|
||
status='reserved'
|
||
)
|
||
|
||
# Получаем Stock и обновляем
|
||
stock, created = Stock.objects.get_or_create(
|
||
product=self.product,
|
||
warehouse=self.warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
# Проверяем: доступно 100, зарезервировано 30, свободно 70
|
||
self.assertEqual(stock.quantity_available, Decimal('100'))
|
||
self.assertEqual(stock.quantity_reserved, Decimal('30'))
|
||
self.assertEqual(stock.quantity_free, Decimal('70'))
|