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>
This commit is contained in:
@@ -1,3 +1,484 @@
|
||||
"""
|
||||
Тесты для складского учета с FIFO логикой.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
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'))
|
||||
|
||||
Reference in New Issue
Block a user