Files
octopus/myproject/inventory/tests.py
Andrey Smakotin 6735be9b08 feat: Реализовать систему поступления товаров с партиями (IncomingBatch)
Основные изменения:
- Создана модель IncomingBatch для группировки товаров по документам
- Каждое поступление (Incoming) связано с одной батчем поступления
- Автоматическое создание StockBatch для каждого товара в приходе
- Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД
- Обновлены все представления (views) для работы с новой архитектурой
- Добавлены детальные страницы просмотра партий поступлений
- Обновлены шаблоны для отображения информации о партиях и их товарах
- Исправлена логика сигналов для создания StockBatch при приходе товара
- Обновлены формы для работы с новой структурой IncomingBatch

Архитектура FIFO:
- IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX)
- Incoming: товар в партии поступления
- StockBatch: одна партия товара на складе (создается для каждого товара)

Это позволяет системе правильно применять FIFO при продаже товаров.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 03:26:06 +03:00

485 lines
18 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, Incoming, Sale, WriteOff, 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.assertEqual(result['writeoffs_created'], 1)
self.assertEqual(result['incomings_created'], 0)
# Проверяем, что создалось списание
writeoffs = WriteOff.objects.filter(batch=batch)
self.assertEqual(writeoffs.count(), 1)
self.assertEqual(writeoffs.first().quantity, Decimal('15'))
# Проверяем остаток в партии
batch.refresh_from_db()
self.assertEqual(batch.quantity, Decimal('85'))
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.assertEqual(result['writeoffs_created'], 0)
self.assertEqual(result['incomings_created'], 1)
# Проверяем, что создалось приходование
incomings = Incoming.objects.filter(product=self.product)
self.assertEqual(incomings.count(), 1)
self.assertEqual(incomings.first().quantity, Decimal('20'))
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'))