Files
octopus/myproject/inventory/tests.py
Andrey Smakotin a8ba5ce780 Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения UI
- Автоматическое проведение документов списания и оприходования после завершения инвентаризации
- Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation
- Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available
- Переименование поля 'По факту' в 'Подсчитано (факт, свободные)'
- Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации
- Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением
- Центральное выравнивание значений в столбцах таблицы
- Автоматическое выделение текста при фокусе на поле ввода количества
- Исправление форматирования разницы (убраны лишние нули)
- Изменение статуса 'Не обработана' на 'Не проведено'
- Добавление номера документа для инвентаризаций (INV-XXXXXX)
- Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem)
- Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
2025-12-21 23:59:02 +03:00

503 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Тесты для складского учета с 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'))