Files
octopus/myproject/inventory/services/incoming_document_service.py
Andrey Smakotin a03f3df086 feat(inventory): add support for selling in negative stock
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
2026-01-04 12:27:10 +03:00

351 lines
12 KiB
Python
Raw Permalink 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.
"""
Сервис для работы с документами поступления (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')