- Добавлено поле receipt_type в модель IncomingBatch с типами: supplier, inventory, adjustment - Исправлен баг в InventoryProcessor: теперь корректно создается IncomingBatch при инвентаризации - Создан IncomingAdjustmentCreateView для оприходования без инвентаризации - Обновлены формы, шаблоны и админка для поддержки разных типов поступлений - Добавлена навигация и URL для оприходования - Тип поступления отображается в списках приходов и партий
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""
|
||
Процессор для обработки инвентаризации.
|
||
Основной функционал:
|
||
- Обработка расхождений между фактом и системой
|
||
- Автоматическое создание 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, IncomingBatch,
|
||
StockBatch, Stock
|
||
)
|
||
from inventory.services.batch_manager import StockBatchManager
|
||
from inventory.utils import generate_incoming_document_number
|
||
|
||
|
||
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
|
||
)
|
||
|
||
# Генерируем номер документа для поступления
|
||
document_number = generate_incoming_document_number()
|
||
|
||
# Создаем IncomingBatch с типом 'inventory'
|
||
incoming_batch = IncomingBatch.objects.create(
|
||
warehouse=inventory.warehouse,
|
||
document_number=document_number,
|
||
receipt_type='inventory',
|
||
notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}'
|
||
)
|
||
|
||
# Создаем документ Incoming
|
||
# Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
|
||
Incoming.objects.create(
|
||
batch=incoming_batch,
|
||
product=line.product,
|
||
quantity=quantity_surplus,
|
||
cost_price=cost_price,
|
||
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
|
||
}
|