Implement functionality to allow sales even when stock is insufficient, tracking pending quantities and resolving them when new stock arrives via incoming documents. This includes new fields in Sale model (is_pending_cost, pending_quantity), updates to batch manager for negative write-offs, and signal handlers for automatic processing. - Add is_pending_cost and pending_quantity fields to Sale model - Modify write_off_by_fifo to support allow_negative flag and return pending quantity - Update incoming document service to allocate pending sales to new batches - Enhance sale processor and signals to handle pending sales - Remove outdated tests.py file - Add migration for new Sale fields
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""
|
||
Сервис для работы с документами поступления (IncomingDocument).
|
||
|
||
Обеспечивает:
|
||
- Создание документов с автонумерацией
|
||
- Добавление позиций в черновик
|
||
- Проведение документов (создание IncomingBatch и Incoming)
|
||
- Отмену документов
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
from django.core.exceptions import ValidationError
|
||
|
||
from inventory.models import (
|
||
IncomingDocument, IncomingDocumentItem, StockBatch, Stock
|
||
)
|
||
from inventory.utils.document_generator import generate_incoming_document_number
|
||
|
||
|
||
class IncomingDocumentService:
|
||
"""
|
||
Сервис для работы с документами поступления.
|
||
"""
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def create_document(cls, warehouse, date, receipt_type='supplier', supplier_name=None, notes=None, created_by=None):
|
||
"""
|
||
Создать новый документ поступления (черновик).
|
||
|
||
Args:
|
||
warehouse: объект Warehouse
|
||
date: дата документа (date)
|
||
receipt_type: тип поступления ('supplier', 'inventory', 'adjustment')
|
||
supplier_name: наименование поставщика (str, опционально, для типа 'supplier')
|
||
notes: примечания (str, опционально)
|
||
created_by: пользователь (User, опционально)
|
||
|
||
Returns:
|
||
IncomingDocument
|
||
"""
|
||
document = IncomingDocument.objects.create(
|
||
document_number=generate_incoming_document_number(),
|
||
warehouse=warehouse,
|
||
status='draft',
|
||
date=date,
|
||
receipt_type=receipt_type,
|
||
supplier_name=supplier_name if receipt_type == 'supplier' else None,
|
||
notes=notes,
|
||
created_by=created_by
|
||
)
|
||
return document
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def add_item(cls, document, product, quantity, cost_price, notes=None):
|
||
"""
|
||
Добавить позицию в документ поступления.
|
||
|
||
Args:
|
||
document: IncomingDocument
|
||
product: Product
|
||
quantity: Decimal - количество товара
|
||
cost_price: Decimal - закупочная цена
|
||
notes: str - примечания
|
||
|
||
Returns:
|
||
IncomingDocumentItem
|
||
|
||
Raises:
|
||
ValidationError: если документ не черновик
|
||
"""
|
||
if document.status != 'draft':
|
||
raise ValidationError(
|
||
"Нельзя добавлять позиции в проведённый или отменённый документ"
|
||
)
|
||
|
||
quantity = Decimal(str(quantity))
|
||
if quantity <= 0:
|
||
raise ValidationError("Количество должно быть больше нуля")
|
||
|
||
cost_price = Decimal(str(cost_price))
|
||
if cost_price < 0:
|
||
raise ValidationError("Закупочная цена не может быть отрицательной")
|
||
|
||
# Создаем позицию документа
|
||
item = IncomingDocumentItem.objects.create(
|
||
document=document,
|
||
product=product,
|
||
quantity=quantity,
|
||
cost_price=cost_price,
|
||
notes=notes
|
||
)
|
||
|
||
return item
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def update_item(cls, item, quantity=None, cost_price=None, notes=None):
|
||
"""
|
||
Обновить позицию документа.
|
||
|
||
Args:
|
||
item: IncomingDocumentItem
|
||
quantity: новое количество (опционально)
|
||
cost_price: новая закупочная цена (опционально)
|
||
notes: новые примечания (опционально)
|
||
|
||
Returns:
|
||
IncomingDocumentItem
|
||
|
||
Raises:
|
||
ValidationError: если документ не черновик
|
||
"""
|
||
if item.document.status != 'draft':
|
||
raise ValidationError(
|
||
"Нельзя редактировать позиции проведённого или отменённого документа"
|
||
)
|
||
|
||
if quantity is not None:
|
||
quantity = Decimal(str(quantity))
|
||
if quantity <= 0:
|
||
raise ValidationError("Количество должно быть больше нуля")
|
||
item.quantity = quantity
|
||
|
||
if cost_price is not None:
|
||
cost_price = Decimal(str(cost_price))
|
||
if cost_price < 0:
|
||
raise ValidationError("Закупочная цена не может быть отрицательной")
|
||
item.cost_price = cost_price
|
||
|
||
if notes is not None:
|
||
item.notes = notes
|
||
|
||
item.save()
|
||
return item
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def remove_item(cls, item):
|
||
"""
|
||
Удалить позицию из документа.
|
||
|
||
Args:
|
||
item: IncomingDocumentItem
|
||
|
||
Raises:
|
||
ValidationError: если документ не черновик
|
||
"""
|
||
if item.document.status != 'draft':
|
||
raise ValidationError(
|
||
"Нельзя удалять позиции из проведённого или отменённого документа"
|
||
)
|
||
|
||
# Удаляем позицию
|
||
item.delete()
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def confirm_document(cls, document, confirmed_by=None):
|
||
"""
|
||
Провести документ поступления.
|
||
|
||
Процесс:
|
||
1. Проверяем что документ - черновик и имеет позиции
|
||
2. Для каждой позиции создаем StockBatch напрямую
|
||
3. Обновляем Stock
|
||
4. Меняем статус документа на 'confirmed'
|
||
|
||
Args:
|
||
document: IncomingDocument
|
||
confirmed_by: User - кто проводит документ
|
||
|
||
Returns:
|
||
dict: результат проведения
|
||
|
||
Raises:
|
||
ValidationError: если документ нельзя провести
|
||
"""
|
||
if document.status != 'draft':
|
||
raise ValidationError(
|
||
f"Документ уже проведён или отменён (статус: {document.get_status_display()})"
|
||
)
|
||
|
||
if not document.items.exists():
|
||
raise ValidationError("Нельзя провести пустой документ")
|
||
|
||
# Создаем StockBatch напрямую для каждого товара
|
||
batches_created = []
|
||
total_cost = Decimal('0')
|
||
|
||
for item in document.items.select_related('product'):
|
||
# Создаем партию товара на складе
|
||
stock_batch = StockBatch.objects.create(
|
||
product=item.product,
|
||
warehouse=document.warehouse,
|
||
quantity=item.quantity,
|
||
cost_price=item.cost_price,
|
||
is_active=True
|
||
)
|
||
batches_created.append(stock_batch)
|
||
total_cost += item.total_cost
|
||
|
||
# Обрабатываем ожидающие продажи "в минус" для этого товара
|
||
cls._process_pending_sales(
|
||
product=item.product,
|
||
warehouse=document.warehouse,
|
||
new_batch=stock_batch
|
||
)
|
||
|
||
# Обновляем или создаем запись в Stock
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=item.product,
|
||
warehouse=document.warehouse
|
||
)
|
||
# Пересчитываем остаток из всех активных партий
|
||
stock.refresh_from_batches()
|
||
|
||
# Обновляем статус документа
|
||
document.status = 'confirmed'
|
||
document.confirmed_by = confirmed_by
|
||
document.confirmed_at = timezone.now()
|
||
document.save(update_fields=['status', 'confirmed_by', 'confirmed_at', 'updated_at'])
|
||
|
||
return {
|
||
'document': document,
|
||
'batches_created': len(batches_created),
|
||
'total_quantity': document.total_quantity,
|
||
'total_cost': total_cost
|
||
}
|
||
|
||
@classmethod
|
||
def _process_pending_sales(cls, product, warehouse, new_batch):
|
||
"""
|
||
Привязать ожидающие продажи "в минус" к новой партии по FIFO.
|
||
Себестоимость берётся из этой партии.
|
||
|
||
Args:
|
||
product: объект Product
|
||
warehouse: объект Warehouse
|
||
new_batch: объект StockBatch (только что созданная партия)
|
||
"""
|
||
from inventory.models import Sale, SaleBatchAllocation
|
||
|
||
# Ожидающие продажи по дате (старые первыми - FIFO)
|
||
pending_sales = Sale.objects.filter(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
is_pending_cost=True,
|
||
pending_quantity__gt=0
|
||
).order_by('date')
|
||
|
||
available_qty = new_batch.quantity
|
||
|
||
for sale in pending_sales:
|
||
if available_qty <= 0:
|
||
break
|
||
|
||
qty_to_allocate = min(sale.pending_quantity, available_qty)
|
||
|
||
# Создаем SaleBatchAllocation с себестоимостью из приёмки
|
||
SaleBatchAllocation.objects.create(
|
||
sale=sale,
|
||
batch=new_batch,
|
||
quantity=qty_to_allocate,
|
||
cost_price=new_batch.cost_price
|
||
)
|
||
|
||
# Уменьшаем pending в Sale
|
||
sale.pending_quantity -= qty_to_allocate
|
||
if sale.pending_quantity <= 0:
|
||
sale.is_pending_cost = False
|
||
sale.save(update_fields=['pending_quantity', 'is_pending_cost'])
|
||
|
||
# Уменьшаем партию (товар уже был "продан" ранее)
|
||
new_batch.quantity -= qty_to_allocate
|
||
available_qty -= qty_to_allocate
|
||
|
||
# Сохраняем партию с оставшимся количеством
|
||
if new_batch.quantity <= 0:
|
||
new_batch.is_active = False
|
||
new_batch.save(update_fields=['quantity', 'is_active'])
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def cancel_document(cls, document):
|
||
"""
|
||
Отменить документ поступления (черновик).
|
||
|
||
Args:
|
||
document: IncomingDocument
|
||
|
||
Returns:
|
||
IncomingDocument
|
||
|
||
Raises:
|
||
ValidationError: если документ уже проведён
|
||
"""
|
||
if document.status == 'confirmed':
|
||
raise ValidationError(
|
||
"Нельзя отменить проведённый документ. "
|
||
"Создайте новый документ для корректировки."
|
||
)
|
||
|
||
if document.status == 'cancelled':
|
||
raise ValidationError("Документ уже отменён")
|
||
|
||
# Обновляем статус документа
|
||
document.status = 'cancelled'
|
||
document.save(update_fields=['status', 'updated_at'])
|
||
|
||
return document
|
||
|
||
@staticmethod
|
||
def get_draft_documents(warehouse=None):
|
||
"""
|
||
Получить все черновики документов поступления.
|
||
|
||
Args:
|
||
warehouse: фильтр по складу (опционально)
|
||
|
||
Returns:
|
||
QuerySet[IncomingDocument]
|
||
"""
|
||
qs = IncomingDocument.objects.filter(status='draft')
|
||
if warehouse:
|
||
qs = qs.filter(warehouse=warehouse)
|
||
return qs.select_related('warehouse', 'created_by').prefetch_related('items')
|
||
|
||
@staticmethod
|
||
def get_today_drafts(warehouse):
|
||
"""
|
||
Получить черновики за сегодня для склада.
|
||
|
||
Args:
|
||
warehouse: Warehouse
|
||
|
||
Returns:
|
||
QuerySet[IncomingDocument]
|
||
"""
|
||
today = timezone.now().date()
|
||
|
||
return IncomingDocument.objects.filter(
|
||
warehouse=warehouse,
|
||
status='draft',
|
||
date=today
|
||
).select_related('warehouse', 'created_by')
|
||
|