Files
octopus/myproject/inventory/models.py
Andrey Smakotin 375ec5366a Унификация генерации номеров документов и оптимизация кода
- Унифицирован формат номеров документов: IN-XXXXXX (6 цифр), как WO-XXXXXX и MOVE-XXXXXX
- Убрано дублирование функции _extract_number_from_document_number
- Оптимизирована инициализация счетчика incoming: быстрая проверка перед полной инициализацией
- Удален неиспользуемый файл utils.py (функциональность перенесена в document_generator.py)
- Все функции генерации номеров используют единый подход через DocumentCounter.get_next_value()
2025-12-21 00:51:08 +03:00

1306 lines
53 KiB
Python
Raw 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.
from django.db import models
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.conf import settings
from decimal import Decimal
from products.models import Product
from phonenumber_field.modelfields import PhoneNumberField
class Warehouse(models.Model):
"""
Склад (физическое или логическое место хранения).
Может использоваться как точка самовывоза для заказов.
"""
name = models.CharField(max_length=200, verbose_name="Название")
description = models.TextField(blank=True, null=True, verbose_name="Описание")
# Адрес
street = models.CharField(max_length=255, blank=True, null=True, verbose_name="Улица")
building_number = models.CharField(max_length=20, blank=True, null=True, verbose_name="Номер здания")
# Контакты
phone = PhoneNumberField(region='BY', blank=True, null=True, verbose_name="Телефон")
email = models.EmailField(blank=True, null=True, verbose_name="Email")
# Настройки
is_active = models.BooleanField(default=True, verbose_name="Активен")
is_default = models.BooleanField(
default=False,
verbose_name="Склад по умолчанию",
help_text="Автоматически выбирается при создании новых документов"
)
is_pickup_point = models.BooleanField(
default=True,
verbose_name="Доступен для самовывоза",
help_text="Можно ли выбрать этот склад как точку самовывоза заказа"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Склад"
verbose_name_plural = "Склады"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['is_default']),
models.Index(fields=['is_pickup_point']),
]
def __str__(self):
if self.street and self.building_number:
return f"{self.name} ({self.street}, {self.building_number})"
return self.name
@property
def full_address(self):
"""Полный адрес склада"""
parts = []
if self.street:
parts.append(self.street)
if self.building_number:
parts.append(self.building_number)
return ', '.join(parts) if parts else "Адрес не указан"
def save(self, *args, **kwargs):
"""Обеспечиваем что только один склад может быть по умолчанию в рамках одного тенанта"""
if self.is_default:
# Снимаем флаг is_default со всех других складов этого тенанта
Warehouse.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
class StockBatch(models.Model):
"""
Партия товара (неделимая единица учета).
Ключевая сущность для FIFO.
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='stock_batches', verbose_name="Товар")
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='stock_batches', verbose_name="Склад")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
is_active = models.BooleanField(default=True, verbose_name="Активна")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Партия товара"
verbose_name_plural = "Партии товаров"
ordering = ['created_at'] # FIFO: старые партии первыми
indexes = [
models.Index(fields=['product', 'warehouse']),
models.Index(fields=['created_at']),
models.Index(fields=['is_active']),
]
def __str__(self):
return f"{self.product.name} на {self.warehouse.name} - Остаток: {self.quantity} шт @ {self.cost_price} за ед."
class IncomingBatch(models.Model):
"""
Партия поступления товара (один номер документа = одна партия).
Содержит один номер документа и может включать несколько товаров.
"""
RECEIPT_TYPE_CHOICES = [
('supplier', 'Поступление от поставщика'),
('inventory', 'Оприходование при инвентаризации'),
('adjustment', 'Оприходование без инвентаризации'),
]
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='incoming_batches', verbose_name="Склад")
document_number = models.CharField(max_length=100, unique=True, db_index=True,
verbose_name="Номер документа")
receipt_type = models.CharField(
max_length=20,
choices=RECEIPT_TYPE_CHOICES,
default='supplier',
db_index=True,
verbose_name="Тип поступления"
)
supplier_name = models.CharField(max_length=200, blank=True, null=True,
verbose_name="Наименование поставщика")
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Партия поступления"
verbose_name_plural = "Партии поступлений"
ordering = ['-created_at']
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['warehouse']),
models.Index(fields=['receipt_type']),
models.Index(fields=['-created_at']),
]
def __str__(self):
total_items = self.items.count()
total_qty = self.items.aggregate(models.Sum('quantity'))['quantity__sum'] or 0
return f"Партия {self.document_number}: {total_items} товаров, {total_qty} шт"
class Incoming(models.Model):
"""
Товар в партии поступления. Много товаров = одна партия (IncomingBatch).
"""
batch = models.ForeignKey(IncomingBatch, on_delete=models.CASCADE,
related_name='items', verbose_name="Партия")
product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='incomings', verbose_name="Товар")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
stock_batch = models.ForeignKey(StockBatch, on_delete=models.SET_NULL, null=True, blank=True,
related_name='incomings', verbose_name="Складская партия")
class Meta:
verbose_name = "Товар в поступлении"
verbose_name_plural = "Товары в поступлениях"
ordering = ['-created_at']
indexes = [
models.Index(fields=['batch']),
models.Index(fields=['product']),
models.Index(fields=['-created_at']),
]
unique_together = [['batch', 'product']] # Один товар максимум один раз в партии
def __str__(self):
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.document_number})"
class Sale(models.Model):
"""
Продажа товара. Списывает по FIFO.
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='sales', verbose_name="Товар")
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='sales', verbose_name="Склад")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи")
order = models.ForeignKey('orders.Order', on_delete=models.SET_NULL, null=True, blank=True,
related_name='sales', verbose_name="Заказ")
document_number = models.CharField(max_length=100, blank=True, null=True,
verbose_name="Номер документа")
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
processed = models.BooleanField(default=False, verbose_name="Обработана (FIFO применена)")
class Meta:
verbose_name = "Продажа"
verbose_name_plural = "Продажи"
ordering = ['-date']
indexes = [
models.Index(fields=['product', 'warehouse']),
models.Index(fields=['date']),
models.Index(fields=['order']),
]
def __str__(self):
return f"Продажа {self.product.name}: {self.quantity} шт @ {self.sale_price}"
class SaleBatchAllocation(models.Model):
"""
Связь между Sale и StockBatch для отслеживания FIFO-списания.
(Для аудита: какая партия использована при продаже)
"""
sale = models.ForeignKey(Sale, on_delete=models.CASCADE,
related_name='batch_allocations', verbose_name="Продажа")
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
related_name='sale_allocations', verbose_name="Партия")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
class Meta:
verbose_name = "Распределение продажи по партиям"
verbose_name_plural = "Распределения продаж по партиям"
def __str__(self):
return f"{self.sale}{self.batch} ({self.quantity} шт)"
class WriteOff(models.Model):
"""
Списание товара вручную (брак, порча, недостача).
Человек выбирает конкретную партию.
"""
REASON_CHOICES = [
('damage', 'Повреждение'),
('spoilage', 'Порча'),
('shortage', 'Недостача'),
('inventory', 'Инвентаризационная недостача'),
('other', 'Другое'),
]
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
related_name='writeoffs', verbose_name="Партия")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
reason = models.CharField(max_length=20, choices=REASON_CHOICES,
default='other', verbose_name="Причина")
cost_price = models.DecimalField(max_digits=10, decimal_places=2,
verbose_name="Закупочная цена", editable=False)
document_number = models.CharField(max_length=100, blank=True, null=True,
verbose_name="Номер документа")
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
class Meta:
verbose_name = "Списание"
verbose_name_plural = "Списания"
ordering = ['-date']
indexes = [
models.Index(fields=['batch']),
models.Index(fields=['date']),
]
def __str__(self):
return f"Списание {self.batch.product.name}: {self.quantity} шт ({self.get_reason_display()})"
def save(self, *args, **kwargs):
# Автоматически записываем cost_price из партии
if not self.pk: # Только при создании
self.cost_price = self.batch.cost_price
# Проверяем что не списываем больше чем есть
if self.quantity > self.batch.quantity:
raise ValidationError(
f"Невозможно списать {self.quantity} шт из партии, "
f"где только {self.batch.quantity} шт. "
f"Недостаток: {self.quantity - self.batch.quantity} шт."
)
# Уменьшаем количество в партии при создании списания
self.batch.quantity -= self.quantity
if self.batch.quantity <= 0:
self.batch.is_active = False
self.batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
super().save(*args, **kwargs)
class Transfer(models.Model):
"""
Перемещение товара между складами. Сохраняет партийность.
"""
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
related_name='transfers', verbose_name="Партия")
from_warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='transfers_from', verbose_name="Из склада")
to_warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='transfers_to', verbose_name="На склад")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
document_number = models.CharField(max_length=100, blank=True, null=True,
verbose_name="Номер документа")
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
new_batch = models.ForeignKey(StockBatch, on_delete=models.SET_NULL, null=True, blank=True,
related_name='transfer_sources', verbose_name="Новая партия")
class Meta:
verbose_name = "Перемещение"
verbose_name_plural = "Перемещения"
ordering = ['-date']
indexes = [
models.Index(fields=['from_warehouse', 'to_warehouse']),
models.Index(fields=['date']),
]
def __str__(self):
return f"Перемещение {self.batch.product.name} ({self.quantity} шт): {self.from_warehouse}{self.to_warehouse}"
class Inventory(models.Model):
"""
Инвентаризация (физический пересчет товаров).
"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('processing', 'В обработке'),
('completed', 'Завершена'),
]
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='inventories', verbose_name="Склад")
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации")
status = models.CharField(max_length=20, choices=STATUS_CHOICES,
default='draft', verbose_name="Статус")
conducted_by = models.CharField(max_length=200, blank=True, null=True,
verbose_name="Провел инвентаризацию")
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
class Meta:
verbose_name = "Инвентаризация"
verbose_name_plural = "Инвентаризации"
ordering = ['-date']
def __str__(self):
return f"Инвентаризация {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})"
class InventoryLine(models.Model):
"""
Строка инвентаризации (товар + фактическое количество).
"""
inventory = models.ForeignKey(Inventory, on_delete=models.CASCADE,
related_name='lines', verbose_name="Инвентаризация")
product = models.ForeignKey(Product, on_delete=models.CASCADE,
verbose_name="Товар")
quantity_system = models.DecimalField(max_digits=10, decimal_places=3,
verbose_name="Количество в системе")
quantity_fact = models.DecimalField(max_digits=10, decimal_places=3,
verbose_name="Фактическое количество")
difference = models.DecimalField(max_digits=10, decimal_places=3,
default=0, verbose_name="Разница (факт - система)",
editable=False)
processed = models.BooleanField(default=False,
verbose_name="Обработана (создана операция)")
class Meta:
verbose_name = "Строка инвентаризации"
verbose_name_plural = "Строки инвентаризации"
def __str__(self):
return f"{self.product.name}: {self.quantity_system} (сист.) vs {self.quantity_fact} (факт)"
def save(self, *args, **kwargs):
# Автоматически рассчитываем разницу
self.difference = self.quantity_fact - self.quantity_system
super().save(*args, **kwargs)
class Showcase(models.Model):
"""
Витрина - место выкладки собранных букетов/комплектов.
Привязана к конкретному складу для учёта резервов.
"""
name = models.CharField(max_length=200, verbose_name="Название")
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='showcases', verbose_name="Склад")
description = models.TextField(blank=True, null=True, verbose_name="Описание")
is_active = models.BooleanField(default=True, verbose_name="Активна")
is_default = models.BooleanField(default=False, verbose_name="По умолчанию")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Витрина"
verbose_name_plural = "Витрины"
ordering = ['warehouse', 'name']
indexes = [
models.Index(fields=['warehouse']),
models.Index(fields=['is_active']),
models.Index(fields=['is_default']),
]
def __str__(self):
return f"{self.name} ({self.warehouse.name})"
def save(self, *args, **kwargs):
"""Обеспечиваем что только одна витрина может быть по умолчанию для каждого склада"""
if self.is_default:
# Снимаем флаг is_default со всех других витрин этого склада
Showcase.objects.filter(warehouse=self.warehouse, is_default=True).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
class Reservation(models.Model):
"""
Резервирование товара для заказа или витрины.
Отслеживает, какой товар зарезервирован за каким заказом или витриной.
"""
STATUS_CHOICES = [
('reserved', 'Зарезервирован'),
('released', 'Освобожден'),
('converted_to_sale', 'Преобразован в продажу'),
('converted_to_writeoff', 'Преобразован в списание'),
]
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
related_name='reservations', verbose_name="Позиция заказа",
null=True, blank=True)
showcase = models.ForeignKey(Showcase, on_delete=models.CASCADE,
related_name='reservations', verbose_name="Витрина",
null=True, blank=True,
help_text="Витрина, на которой выложен букет")
product_kit = models.ForeignKey('products.ProductKit', on_delete=models.CASCADE,
related_name='reservations', verbose_name="Комплект",
null=True, blank=True,
help_text="Временный комплект, для которого создан резерв")
product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='reservations', verbose_name="Товар")
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='reservations', verbose_name="Склад")
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
status = models.CharField(max_length=25, choices=STATUS_CHOICES,
default='reserved', verbose_name="Статус")
reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования")
released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения")
converted_at = models.DateTimeField(null=True, blank=True,
verbose_name="Дата преобразования",
help_text="Дата преобразования в продажу или списание")
# Soft Lock для корзины POS (витринные комплекты)
cart_lock_expires_at = models.DateTimeField(
null=True, blank=True,
verbose_name="Блокировка корзины истекает",
help_text="Время истечения блокировки в корзине (для витринных комплектов)"
)
locked_by_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='cart_locks',
verbose_name="Заблокировано пользователем",
help_text="Кассир, который добавил комплект в корзину"
)
cart_session_id = models.CharField(
max_length=100,
null=True, blank=True,
verbose_name="ID сессии корзины",
help_text="Дополнительная идентификация сессии для надежности"
)
# Связь с конкретным экземпляром витринного букета
showcase_item = models.ForeignKey(
'ShowcaseItem',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='reservations',
verbose_name="Экземпляр на витрине",
help_text="Для какого физического экземпляра создан резерв"
)
# Связь с позицией документа списания (для резервирования в черновике)
writeoff_document_item = models.ForeignKey(
'WriteOffDocumentItem',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='reservations',
verbose_name="Позиция документа списания",
help_text="Резерв для документа списания (черновик)"
)
class Meta:
verbose_name = "Резервирование"
verbose_name_plural = "Резервирования"
ordering = ['-reserved_at']
indexes = [
models.Index(fields=['product', 'warehouse']),
models.Index(fields=['status']),
models.Index(fields=['order_item']),
models.Index(fields=['showcase']),
models.Index(fields=['product_kit']),
models.Index(fields=['cart_lock_expires_at']),
models.Index(fields=['locked_by_user']),
models.Index(fields=['product_kit', 'cart_lock_expires_at']),
models.Index(fields=['showcase_item']),
]
def __str__(self):
if self.order_item:
context = f" (заказ {self.order_item.order.order_number})"
elif self.product_kit:
context = f" (комплект {self.product_kit.name})"
elif self.showcase:
context = f" (витрина {self.showcase.name})"
else:
context = ""
return f"Резерв {self.product.name}: {self.quantity} шт{context} [{self.get_status_display()}]"
class ShowcaseItem(models.Model):
"""
Физический экземпляр комплекта на витрине.
Один ProductKit (шаблон) -> N ShowcaseItem (экземпляры).
Каждый экземпляр имеет свой набор резервов и может быть продан независимо.
Защита от двойной продажи:
- sold_order_item = OneToOneField гарантирует что один экземпляр
может быть продан только в один OrderItem (на уровне БД).
"""
showcase = models.ForeignKey(
'Showcase',
on_delete=models.CASCADE,
related_name='showcase_items',
verbose_name="Витрина"
)
product_kit = models.ForeignKey(
'products.ProductKit',
on_delete=models.CASCADE,
related_name='showcase_items',
verbose_name="Шаблон комплекта"
)
# Статусы жизненного цикла
STATUS_CHOICES = [
('available', 'Доступен'),
('in_cart', 'В корзине'),
('sold', 'Продан'),
('dismantled', 'Разобран'),
]
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='available',
db_index=True,
verbose_name="Статус"
)
# === ЗАЩИТА ОТ ДВОЙНОЙ ПРОДАЖИ ===
# ForeignKey позволяет привязать несколько ShowcaseItem к одному OrderItem
# (например, при продаже 2+ экземпляров одного букета)
sold_order_item = models.ForeignKey(
'orders.OrderItem',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='sold_showcase_items',
verbose_name="Позиция заказа (продажа)"
)
sold_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата продажи"
)
# === SOFT LOCK для корзины ===
locked_by_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='locked_showcase_items',
verbose_name="Заблокировано пользователем"
)
cart_lock_expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Блокировка истекает"
)
cart_session_id = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name="ID сессии корзины"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлен")
class Meta:
verbose_name = "Экземпляр на витрине"
verbose_name_plural = "Экземпляры на витрине"
indexes = [
models.Index(fields=['showcase', 'status']),
models.Index(fields=['product_kit', 'status']),
models.Index(fields=['status', 'cart_lock_expires_at']),
models.Index(fields=['locked_by_user', 'status']),
]
def __str__(self):
return f"{self.product_kit.name} #{self.id} ({self.get_status_display()})"
def lock_for_cart(self, user, session_id=None, duration_minutes=30):
"""Заблокировать экземпляр для корзины"""
from datetime import timedelta
self.status = 'in_cart'
self.locked_by_user = user
self.cart_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
self.cart_session_id = session_id
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
def release_lock(self):
"""Снять блокировку корзины"""
self.status = 'available'
self.locked_by_user = None
self.cart_lock_expires_at = None
self.cart_session_id = None
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
def mark_sold(self, order_item):
"""
Пометить как проданный.
Проверяет статус перед продажей чтобы избежать дублей.
"""
if self.status == 'sold':
raise ValidationError(f'Экземпляр {self} уже продан')
self.status = 'sold'
self.sold_order_item = order_item
self.sold_at = timezone.now()
self.locked_by_user = None
self.cart_lock_expires_at = None
self.cart_session_id = None
self.save()
def is_lock_expired(self):
"""Проверить истекла ли блокировка"""
if self.cart_lock_expires_at is None:
return True
return timezone.now() > self.cart_lock_expires_at
@classmethod
def cleanup_expired_locks(cls):
"""Снять все просроченные блокировки (для Celery задачи)"""
expired = cls.objects.filter(
status='in_cart',
cart_lock_expires_at__lt=timezone.now()
)
count = expired.update(
status='available',
locked_by_user=None,
cart_lock_expires_at=None,
cart_session_id=None
)
return count
class Stock(models.Model):
"""
Агрегированные остатки по товарам и складам.
Читаемое представление (может быть кешировано или пересчитано из StockBatch).
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='stocks', verbose_name="Товар")
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='stocks', verbose_name="Склад")
quantity_available = models.DecimalField(max_digits=10, decimal_places=3, default=0,
verbose_name="Доступное количество",
editable=False)
quantity_reserved = models.DecimalField(max_digits=10, decimal_places=3, default=0,
verbose_name="Зарезервированное количество",
editable=False)
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Остаток на складе"
verbose_name_plural = "Остатки на складе"
unique_together = [['product', 'warehouse']]
indexes = [
models.Index(fields=['product', 'warehouse']),
]
def __str__(self):
return f"{self.product.name} на {self.warehouse.name}: {self.quantity_available} (зарезерв: {self.quantity_reserved})"
@property
def quantity_free(self):
"""Свободное количество (доступное минус зарезервированное)"""
return self.quantity_available - self.quantity_reserved
def refresh_from_batches(self):
"""
Пересчитать остатки из StockBatch.
Можно вызвать для синхронизации после операций.
"""
total_qty = StockBatch.objects.filter(
product=self.product,
warehouse=self.warehouse,
is_active=True
).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
total_reserved = Reservation.objects.filter(
product=self.product,
warehouse=self.warehouse,
status='reserved'
).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
self.quantity_available = total_qty
self.quantity_reserved = total_reserved
self.save()
class StockMovement(models.Model):
"""
Журнал всех складских операций (приход, списание, коррекция).
Используется для аудита.
"""
REASON_CHOICES = [
('purchase', 'Закупка'),
('sale', 'Продажа'),
('write_off', 'Списание'),
('adjustment', 'Корректировка'),
]
product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='movements', verbose_name="Товар")
change = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Изменение")
reason = models.CharField(max_length=20, choices=REASON_CHOICES, verbose_name="Причина")
order = models.ForeignKey('orders.Order', on_delete=models.SET_NULL, null=True, blank=True,
related_name='stock_movements', verbose_name="Заказ")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Движение товара"
verbose_name_plural = "Движения товаров"
indexes = [
models.Index(fields=['product']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"{self.product.name}: {self.change} ({self.reason})"
class DocumentCounter(models.Model):
"""
Счетчик номеров документов для различных операций.
Используется для генерации уникальных номеров документов.
"""
COUNTER_TYPE_CHOICES = [
('transfer', 'Перемещение товара'),
('writeoff', 'Списание товара'),
('incoming', 'Поступление товара'),
]
counter_type = models.CharField(
max_length=20,
choices=COUNTER_TYPE_CHOICES,
unique=True,
verbose_name="Тип счетчика"
)
current_value = models.IntegerField(
default=0,
verbose_name="Текущее значение"
)
class Meta:
verbose_name = "Счетчик документов"
verbose_name_plural = "Счетчики документов"
def __str__(self):
return f"Счетчик {self.get_counter_type_display()}: {self.current_value}"
@classmethod
def get_next_value(cls, counter_type):
"""
Получить следующее значение для счетчика.
Thread-safe операция с использованием select_for_update.
"""
from django.db import transaction
with transaction.atomic():
obj, _ = cls.objects.select_for_update().get_or_create(
counter_type=counter_type
)
obj.current_value += 1
obj.save(update_fields=['current_value'])
return obj.current_value
class TransferBatch(models.Model):
"""
Документ перемещения товара между складами.
Один номер документа = одна операция перемещения множественных товаров.
"""
from_warehouse = models.ForeignKey(
Warehouse,
on_delete=models.CASCADE,
related_name='transfer_batches_from',
verbose_name="Склад-отгрузки"
)
to_warehouse = models.ForeignKey(
Warehouse,
on_delete=models.CASCADE,
related_name='transfer_batches_to',
verbose_name="Склад-приемки"
)
document_number = models.CharField(
max_length=100,
unique=True,
db_index=True,
verbose_name="Номер документа"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Примечания"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления"
)
class Meta:
verbose_name = "Документ перемещения"
verbose_name_plural = "Документы перемещения"
ordering = ['-created_at']
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['from_warehouse', 'to_warehouse']),
models.Index(fields=['-created_at']),
]
def __str__(self):
total_items = self.items.count()
total_qty = self.items.aggregate(
models.Sum('quantity')
)['quantity__sum'] or Decimal('0')
return f"Перемещение {self.document_number}: {total_items} товаров, {total_qty} шт ({self.from_warehouse}{self.to_warehouse})"
class TransferItem(models.Model):
"""
Строка документа перемещения (товар в перемещении).
Связь между документом и товарами.
"""
transfer_batch = models.ForeignKey(
TransferBatch,
on_delete=models.CASCADE,
related_name='items',
verbose_name="Документ перемещения"
)
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name='transfer_items',
verbose_name="Товар"
)
batch = models.ForeignKey(
StockBatch,
on_delete=models.CASCADE,
related_name='transfer_items',
verbose_name="Исходная партия (FIFO)"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
new_batch = models.ForeignKey(
StockBatch,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_items_created',
verbose_name="Созданная партия на целевом складе"
)
class Meta:
verbose_name = "Строка перемещения"
verbose_name_plural = "Строки перемещения"
unique_together = [['transfer_batch', 'batch']]
ordering = ['id']
indexes = [
models.Index(fields=['transfer_batch']),
models.Index(fields=['product']),
]
def __str__(self):
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.id})"
class WriteOffDocument(models.Model):
"""
Документ списания товара.
Сценарий использования:
1. В начале смены создается черновик (draft)
2. В течение дня добавляются испорченные товары (WriteOffDocumentItem)
3. Товары в черновике ЗАРЕЗЕРВИРОВАНЫ (уменьшают quantity_free)
4. В конце смены документ проводится (confirmed) → создаются WriteOff записи
"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('confirmed', 'Проведён'),
('cancelled', 'Отменён'),
]
document_number = models.CharField(
max_length=100,
unique=True,
db_index=True,
verbose_name="Номер документа"
)
warehouse = models.ForeignKey(
Warehouse,
on_delete=models.PROTECT,
related_name='writeoff_documents',
verbose_name="Склад"
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='draft',
db_index=True,
verbose_name="Статус"
)
date = models.DateField(
verbose_name="Дата документа",
help_text="Дата, к которой относится списание"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Примечания"
)
# Аудит
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_writeoff_documents',
verbose_name="Создал"
)
confirmed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='confirmed_writeoff_documents',
verbose_name="Провёл"
)
confirmed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата проведения"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
class Meta:
verbose_name = "Документ списания"
verbose_name_plural = "Документы списания"
ordering = ['-date', '-created_at']
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['warehouse', 'status']),
models.Index(fields=['date']),
models.Index(fields=['-created_at']),
]
def __str__(self):
return f"{self.document_number} ({self.get_status_display()})"
@property
def total_quantity(self):
"""Общее количество товаров в документе"""
return self.items.aggregate(
total=models.Sum('quantity')
)['total'] or Decimal('0')
@property
def total_cost(self):
"""Общая себестоимость списания"""
return sum(item.total_cost for item in self.items.select_related('product'))
@property
def can_edit(self):
"""Можно ли редактировать документ"""
return self.status == 'draft'
@property
def can_confirm(self):
"""Можно ли провести документ"""
return self.status == 'draft' and self.items.exists()
@property
def can_cancel(self):
"""Можно ли отменить документ"""
return self.status == 'draft'
class WriteOffDocumentItem(models.Model):
"""
Строка документа списания.
При создании:
1. Создается Reservation для резервирования товара
2. Stock.quantity_reserved увеличивается
3. Stock.quantity_free уменьшается
При проведении документа:
1. Создается WriteOff запись по FIFO
2. Reservation переводится в статус 'converted_to_sale'
"""
REASON_CHOICES = WriteOff.REASON_CHOICES
document = models.ForeignKey(
WriteOffDocument,
on_delete=models.CASCADE,
related_name='items',
verbose_name="Документ"
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
related_name='writeoff_document_items',
verbose_name="Товар"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
reason = models.CharField(
max_length=20,
choices=REASON_CHOICES,
default='damage',
verbose_name="Причина списания"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Примечания"
)
# Резерв (создается автоматически при добавлении в черновик)
reservation = models.OneToOneField(
Reservation,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='writeoff_document_item_reverse',
verbose_name="Резерв"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Позиция документа списания"
verbose_name_plural = "Позиции документа списания"
ordering = ['id']
indexes = [
models.Index(fields=['document']),
models.Index(fields=['product']),
]
def __str__(self):
return f"{self.product.name}: {self.quantity} шт ({self.get_reason_display()})"
@property
def total_cost(self):
"""Себестоимость позиции (средневзвешенная из cost_price товара)"""
return self.quantity * (self.product.cost_price or Decimal('0'))
class IncomingDocument(models.Model):
"""
Документ поступления товара на склад.
Сценарий использования:
1. Создается черновик (draft)
2. В течение дня добавляются товары (IncomingDocumentItem)
3. В конце смены документ проводится (confirmed) → создаются IncomingBatch и Incoming
4. Сигнал автоматически создает StockBatch и обновляет Stock
"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('confirmed', 'Проведён'),
('cancelled', 'Отменён'),
]
document_number = models.CharField(
max_length=100,
unique=True,
db_index=True,
verbose_name="Номер документа"
)
warehouse = models.ForeignKey(
Warehouse,
on_delete=models.PROTECT,
related_name='incoming_documents',
verbose_name="Склад"
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='draft',
db_index=True,
verbose_name="Статус"
)
date = models.DateField(
verbose_name="Дата документа",
help_text="Дата, к которой относится поступление"
)
receipt_type = models.CharField(
max_length=20,
choices=IncomingBatch.RECEIPT_TYPE_CHOICES,
default='supplier',
db_index=True,
verbose_name="Тип поступления"
)
supplier_name = models.CharField(
max_length=200,
blank=True,
null=True,
verbose_name="Наименование поставщика",
help_text="Заполняется для типа 'Поступление от поставщика'"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Примечания"
)
# Аудит
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_incoming_documents',
verbose_name="Создал"
)
confirmed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='confirmed_incoming_documents',
verbose_name="Провёл"
)
confirmed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата проведения"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
class Meta:
verbose_name = "Документ поступления"
verbose_name_plural = "Документы поступления"
ordering = ['-date', '-created_at']
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['warehouse', 'status']),
models.Index(fields=['date']),
models.Index(fields=['receipt_type']),
models.Index(fields=['-created_at']),
]
def __str__(self):
return f"{self.document_number} ({self.get_status_display()})"
@property
def total_quantity(self):
"""Общее количество товаров в документе"""
return self.items.aggregate(
total=models.Sum('quantity')
)['total'] or Decimal('0')
@property
def total_cost(self):
"""Общая себестоимость поступления"""
return sum(item.total_cost for item in self.items.select_related('product'))
@property
def can_edit(self):
"""Можно ли редактировать документ"""
return self.status == 'draft'
@property
def can_confirm(self):
"""Можно ли провести документ"""
return self.status == 'draft' and self.items.exists()
@property
def can_cancel(self):
"""Можно ли отменить документ"""
return self.status == 'draft'
class IncomingDocumentItem(models.Model):
"""
Строка документа поступления.
При создании:
- Товар добавляется в черновик документа
- Резервирование НЕ создается (товар еще не поступил)
При проведении документа:
1. Создается IncomingBatch с номером документа
2. Создается Incoming запись для каждого товара
3. Сигнал create_stock_batch_on_incoming автоматически создает StockBatch
"""
document = models.ForeignKey(
IncomingDocument,
on_delete=models.CASCADE,
related_name='items',
verbose_name="Документ"
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
related_name='incoming_document_items',
verbose_name="Товар"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
cost_price = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Закупочная цена"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Примечания"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
class Meta:
verbose_name = "Позиция документа поступления"
verbose_name_plural = "Позиции документа поступления"
ordering = ['id']
indexes = [
models.Index(fields=['document']),
models.Index(fields=['product']),
]
def __str__(self):
return f"{self.product.name}: {self.quantity} шт @ {self.cost_price}"
@property
def total_cost(self):
"""Себестоимость позиции (quantity * cost_price)"""
return self.quantity * self.cost_price