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:
2025-10-29 03:26:06 +03:00
parent 097d4ea304
commit 6735be9b08
73 changed files with 6536 additions and 122 deletions

View File

@@ -0,0 +1,286 @@
"""
Процессор для обработки инвентаризации.
Основной функционал:
- Обработка расхождений между фактом и системой
- Автоматическое создание WriteOff для недостач (по FIFO)
- Автоматическое создание Incoming для излишков
"""
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from inventory.models import (
Inventory, InventoryLine, WriteOff, Incoming,
StockBatch, Stock
)
from inventory.services.batch_manager import StockBatchManager
class InventoryProcessor:
"""
Обработчик инвентаризации с автоматической коррекцией остатков.
"""
@staticmethod
@transaction.atomic
def process_inventory(inventory_id):
"""
Обработать инвентаризацию:
- Для недостач (разница < 0): создать WriteOff по FIFO
- Для излишков (разница > 0): создать Incoming с новой партией
- Обновить статус inventory и lines
Args:
inventory_id: ID объекта Inventory
Returns:
dict: {
'inventory': Inventory,
'processed_lines': int,
'writeoffs_created': int,
'incomings_created': int,
'errors': [...]
}
"""
inventory = Inventory.objects.get(id=inventory_id)
lines = InventoryLine.objects.filter(inventory=inventory, processed=False)
writeoffs_created = 0
incomings_created = 0
errors = []
try:
for line in lines:
try:
if line.difference < 0:
# Недостача: списать по FIFO
InventoryProcessor._create_writeoff_for_deficit(
inventory, line
)
writeoffs_created += 1
elif line.difference > 0:
# Излишек: создать новую партию
InventoryProcessor._create_incoming_for_surplus(
inventory, line
)
incomings_created += 1
# Отмечаем строку как обработанную
line.processed = True
line.save(update_fields=['processed'])
except Exception as e:
errors.append({
'line': line,
'error': str(e)
})
# Обновляем статус инвентаризации
inventory.status = 'completed'
inventory.save(update_fields=['status'])
except Exception as e:
errors.append({
'inventory': inventory,
'error': str(e)
})
return {
'inventory': inventory,
'processed_lines': lines.count(),
'writeoffs_created': writeoffs_created,
'incomings_created': incomings_created,
'errors': errors
}
@staticmethod
def _create_writeoff_for_deficit(inventory, line):
"""
Создать операцию WriteOff для недостачи при инвентаризации.
Списывается по FIFO из старейших партий.
Args:
inventory: объект Inventory
line: объект InventoryLine с negative difference
"""
quantity_to_writeoff = abs(line.difference)
# Списываем по FIFO
allocations = StockBatchManager.write_off_by_fifo(
line.product,
inventory.warehouse,
quantity_to_writeoff
)
# Создаем WriteOff для каждой партии
for batch, qty_allocated in allocations:
WriteOff.objects.create(
batch=batch,
quantity=qty_allocated,
reason='inventory',
cost_price=batch.cost_price,
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
)
@staticmethod
def _create_incoming_for_surplus(inventory, line):
"""
Создать операцию Incoming для излишка при инвентаризации.
Новая партия создается с последней известной cost_price товара.
Args:
inventory: объект Inventory
line: объект InventoryLine с positive difference
"""
quantity_surplus = line.difference
# Получаем последнюю known cost_price
cost_price = InventoryProcessor._get_last_cost_price(
line.product,
inventory.warehouse
)
# Создаем новую партию
batch = StockBatchManager.create_batch(
line.product,
inventory.warehouse,
quantity_surplus,
cost_price
)
# Создаем документ Incoming
Incoming.objects.create(
product=line.product,
warehouse=inventory.warehouse,
quantity=quantity_surplus,
cost_price=cost_price,
batch=batch,
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
)
@staticmethod
def _get_last_cost_price(product, warehouse):
"""
Получить последнюю известную закупочную цену товара на складе.
Используется для создания новой партии при излишке.
Порядок поиска:
1. Последняя активная партия на этом складе
2. Последняя активная партия на любом складе
3. cost_price из карточки Product (если есть)
4. Дефолт 0 (если ничего не найдено)
Args:
product: объект Product
warehouse: объект Warehouse
Returns:
Decimal - закупочная цена
"""
from inventory.models import StockBatch
# Вариант 1: последняя партия на этом складе
last_batch = StockBatch.objects.filter(
product=product,
warehouse=warehouse,
is_active=True
).order_by('-created_at').first()
if last_batch:
return last_batch.cost_price
# Вариант 2: последняя партия на любом складе
last_batch_any = StockBatch.objects.filter(
product=product,
is_active=True
).order_by('-created_at').first()
if last_batch_any:
return last_batch_any.cost_price
# Вариант 3: cost_price из карточки товара
if product.cost_price:
return product.cost_price
# Вариант 4: ноль (не должно быть)
return Decimal('0')
@staticmethod
def create_inventory_lines_from_current_stock(inventory):
"""
Автоматически создать InventoryLine для всех товаров на складе.
Используется для удобства: оператор может сразу начать вводить фактические
количества, имея под рукой системные остатки.
Args:
inventory: объект Inventory
"""
from inventory.models import StockBatch
# Получаем все товары, которые есть на этом складе
batches = StockBatch.objects.filter(
warehouse=inventory.warehouse,
is_active=True
).values('product').distinct()
for batch_dict in batches:
product = batch_dict['product']
# Рассчитываем системный остаток
quantity_system = StockBatchManager.get_total_stock(product, inventory.warehouse)
# Создаем строку инвентаризации (факт будет заполнен оператором)
InventoryLine.objects.get_or_create(
inventory=inventory,
product_id=product,
defaults={
'quantity_system': quantity_system,
'quantity_fact': 0, # Оператор должен заполнить
}
)
@staticmethod
def get_inventory_report(inventory):
"""
Получить отчет по инвентаризации.
Args:
inventory: объект Inventory
Returns:
dict: {
'inventory': Inventory,
'total_lines': int,
'total_deficit': Decimal,
'total_surplus': Decimal,
'lines': [...]
}
"""
lines = InventoryLine.objects.filter(inventory=inventory).select_related('product')
total_deficit = Decimal('0')
total_surplus = Decimal('0')
lines_data = []
for line in lines:
if line.difference < 0:
total_deficit += abs(line.difference)
elif line.difference > 0:
total_surplus += line.difference
lines_data.append({
'line': line,
'system_value': line.quantity_system * line.product.cost_price,
'fact_value': line.quantity_fact * line.product.cost_price,
'value_difference': (line.quantity_fact - line.quantity_system) * line.product.cost_price,
})
return {
'inventory': inventory,
'total_lines': lines.count(),
'total_deficit': total_deficit,
'total_surplus': total_surplus,
'lines': lines_data
}