Files
octopus/myproject/inventory/models.py
Andrey Smakotin 2aba3d2404 Улучшения в тестах переходов статусов заказов
- Исправлены комментарии и форматирование в signals.py
- Улучшена читаемость кода в models.py
- Обновлены шаблоны форм статусов
- Доработаны тесты переходов статусов
2026-01-05 21:30:25 +03:00

1599 lines
64 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} за ед."
# Модели IncomingBatch и Incoming удалены - заменены на IncomingDocument/IncomingDocumentItem
# Теперь используется упрощенная архитектура:
# IncomingDocument → IncomingDocumentItem → StockBatch (напрямую при проведении)
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 применена)")
# === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
sales_unit = models.ForeignKey(
'products.ProductSalesUnit',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='sales',
verbose_name="Единица продажи"
)
quantity_base = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
verbose_name="Количество в базовых единицах",
help_text="Количество в единицах хранения товара (для списания со склада)"
)
unit_name_snapshot = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name="Название единицы (snapshot)",
help_text="Название единицы продажи на момент продажи"
)
# === ПОЛЯ ДЛЯ ПРОДАЖ "В МИНУС" ===
is_pending_cost = models.BooleanField(
default=False,
verbose_name="Ожидает себестоимости",
help_text="True если продажа создана без партий (продажа 'в минус')"
)
pending_quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
default=Decimal('0'),
verbose_name="Ожидающее количество",
help_text="Количество, ожидающее привязки к партиям при приёмке"
)
class Meta:
verbose_name = "Продажа"
verbose_name_plural = "Продажи"
ordering = ['-date']
indexes = [
models.Index(fields=['product', 'warehouse']),
models.Index(fields=['date']),
models.Index(fields=['order']),
models.Index(fields=['is_pending_cost']),
]
def __str__(self):
unit_info = f" ({self.unit_name_snapshot})" if self.unit_name_snapshot else ""
return f"Продажа {self.product.name}: {self.quantity}{unit_info} @ {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 Inventory(models.Model):
"""
Инвентаризация (физический пересчет товаров).
"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('processing', 'В обработке'),
('completed', 'Завершена'),
]
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='inventories', verbose_name="Склад")
document_number = models.CharField(
max_length=100,
unique=True,
db_index=True,
blank=True,
null=True,
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.ForeignKey(
'user_roles.UserRole',
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name='inventories',
verbose_name="Провел инвентаризацию"
)
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
class Meta:
verbose_name = "Инвентаризация"
verbose_name_plural = "Инвентаризации"
ordering = ['-date']
indexes = [
models.Index(fields=['document_number']),
]
def __str__(self):
if self.document_number:
return f"{self.document_number} - {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})"
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="Подсчитано (факт, свободные)",
help_text="Количество свободных товаров, подсчитанных физически")
difference = models.DecimalField(max_digits=10, decimal_places=3,
default=0, verbose_name="Итоговая разница",
help_text="(Подсчитано + Зарезервировано) - Всего на складе",
editable=False)
processed = models.BooleanField(default=False,
verbose_name="Обработана (создана операция)")
# Snapshot-значения на момент завершения инвентаризации
snapshot_quantity_available = models.DecimalField(
max_digits=10, decimal_places=3, null=True, blank=True,
verbose_name="Всего на складе (snapshot)",
help_text="Всего на складе на момент завершения инвентаризации"
)
snapshot_quantity_reserved = models.DecimalField(
max_digits=10, decimal_places=3, null=True, blank=True,
verbose_name="В резервах (snapshot)",
help_text="В резервах на момент завершения инвентаризации"
)
snapshot_quantity_system = models.DecimalField(
max_digits=10, decimal_places=3, null=True, blank=True,
verbose_name="В системе свободно (snapshot)",
help_text="В системе свободно на момент завершения инвентаризации"
)
snapshot_difference = models.DecimalField(
max_digits=10, decimal_places=3, null=True, blank=True,
verbose_name="Итоговая разница (snapshot)",
help_text="Итоговая разница на момент завершения инвентаризации"
)
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):
# Автоматически рассчитываем разницу
# Формула: (quantity_fact + quantity_reserved) - quantity_available
# Где quantity_fact - подсчитанные свободные товары
# Для расчета нужны quantity_reserved и quantity_available из Stock
# Если они не переданы в kwargs, получаем из Stock
quantity_reserved = kwargs.pop('quantity_reserved', None)
quantity_available = kwargs.pop('quantity_available', None)
if quantity_reserved is None or quantity_available is None:
# Получаем из Stock для расчета
from inventory.models import Stock
stock = Stock.objects.filter(
product=self.product,
warehouse=self.inventory.warehouse
).first()
if stock:
stock.refresh_from_batches()
quantity_reserved = stock.quantity_reserved
quantity_available = stock.quantity_available
# Вычисляем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available
if quantity_reserved is not None and quantity_available is not None:
self.difference = (self.quantity_fact + quantity_reserved) - quantity_available
else:
# Fallback на старую формулу, если Stock недоступен (не должно происходить в нормальной работе)
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', 'Преобразован в списание'),
('converted_to_transformation', 'Преобразован в трансформацию'),
]
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=30, 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="Для какого физического экземпляра создан резерв"
)
# ЗАЩИТА ОТ КРАЖИ ВИТРИННЫХ РЕЗЕРВОВ: запоминаем исходный order_item при отвязывании
# Когда витринный резерв отвязывается от заказа (order_item=None), здесь сохраняется ID
# исходного OrderItem. При обратном привязывании резерв вернётся только к своему "хозяину".
original_order_item_id = models.IntegerField(
null=True,
blank=True,
db_index=True,
verbose_name="ID исходной позиции заказа",
help_text="Для витринных резервов: ID OrderItem, которому изначально принадлежал резерв (защита от кражи)"
)
# Связь с позицией документа списания (для резервирования в черновике)
writeoff_document_item = models.ForeignKey(
'WriteOffDocumentItem',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='reservations',
verbose_name="Позиция документа списания",
help_text="Резерв для документа списания (черновик)"
)
# Связь с входным товаром трансформации (для резервирования в черновике)
transformation_input = models.ForeignKey(
'TransformationInput',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='reservations',
verbose_name="Входной товар трансформации",
help_text="Резерв для входного товара трансформации (черновик)"
)
# === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
sales_unit = models.ForeignKey(
'products.ProductSalesUnit',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reservations',
verbose_name="Единица продажи"
)
quantity_base = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
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', 'В корзине'),
('reserved', 'Зарезервирован'),
('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 reserve_for_order(self, order_item):
"""
Зарезервировать экземпляр под конкретный заказ (жёсткая блокировка).
Используется для отложенных заказов, когда букет привязан к заказу,
но заказ ещё не в финальном статусе.
Args:
order_item: OrderItem - позиция заказа, к которой привязывается экземпляр
Raises:
ValidationError: если экземпляр уже продан или разобран
"""
if self.status == 'sold':
raise ValidationError(f'Экземпляр {self} уже продан')
if self.status == 'dismantled':
raise ValidationError(f'Экземпляр {self} разобран')
self.status = 'reserved'
self.sold_order_item = order_item
# sold_at оставляем пустым - это не финальная продажа
# Очищаем soft-lock поля корзины
self.locked_by_user = None
self.cart_lock_expires_at = None
self.cart_session_id = None
self.save(update_fields=['status', 'sold_order_item', 'locked_by_user',
'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
def mark_sold(self, order_item):
"""
Пометить как проданный.
Проверяет статус перед продажей чтобы избежать дублей.
Для прямой продажи (POS "Продать сейчас"):
available/in_cart -> sold
Для финализации отложенного заказа:
reserved -> sold (через mark_sold_from_reserved)
"""
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 mark_sold_from_reserved(self):
"""
Финализировать продажу из зарезервированного состояния.
Используется при переходе заказа в положительный конечный статус (completed).
Raises:
ValidationError: если экземпляр не в статусе 'reserved'
"""
if self.status != 'reserved':
raise ValidationError(
f'Экземпляр {self} не в статусе "reserved" (текущий: {self.get_status_display()})'
)
self.status = 'sold'
self.sold_at = timezone.now()
# sold_order_item уже установлен при резервировании
self.save(update_fields=['status', 'sold_at', 'updated_at'])
def return_to_available(self):
"""
Вернуть экземпляр на витрину (освободить).
Используется при отмене заказа.
Raises:
ValidationError: если экземпляр уже разобран
"""
if self.status == 'dismantled':
raise ValidationError(f'Экземпляр {self} разобран и не может быть возвращён на витрину')
self.status = 'available'
self.sold_order_item = None
self.sold_at = None
self.locked_by_user = None
self.cart_lock_expires_at = None
self.cart_session_id = None
self.save(update_fields=['status', 'sold_order_item', 'sold_at', 'locked_by_user',
'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
def return_to_reserved(self, order_item):
"""
Вернуть экземпляр в зарезервированное состояние.
Используется при откате заказа из completed в нейтральный статус.
Args:
order_item: OrderItem - позиция заказа, за которой остаётся экземпляр
"""
if self.status == 'dismantled':
raise ValidationError(f'Экземпляр {self} разобран')
self.status = 'reserved'
self.sold_order_item = order_item
self.sold_at = None # Сбрасываем дату продажи
self.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at'])
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.
Учитывает "ожидающие" продажи (продажи "в минус").
quantity_available может быть отрицательным!
"""
# Сумма из активных партий
total_qty = StockBatch.objects.filter(
product=self.product,
warehouse=self.warehouse,
is_active=True
).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
# Учитываем ожидающие продажи (уменьшают доступное количество)
pending_sales = Sale.objects.filter(
product=self.product,
warehouse=self.warehouse,
is_pending_cost=True
).aggregate(models.Sum('pending_quantity'))['pending_quantity__sum'] or Decimal('0')
# quantity_available может быть отрицательным при продажах "в минус"
self.quantity_available = total_qty - pending_sales
# Резервы остаются как есть
total_reserved = Reservation.objects.filter(
product=self.product,
warehouse=self.warehouse,
status='reserved'
).aggregate(models.Sum('quantity_base'))['quantity_base__sum'] or Decimal('0')
self.quantity_reserved = total_reserved
self.save()
class DocumentCounter(models.Model):
"""
Счетчик номеров документов для различных операций.
Используется для генерации уникальных номеров документов.
"""
COUNTER_TYPE_CHOICES = [
('transfer', 'Перемещение товара'),
('writeoff', 'Списание товара'),
('incoming', 'Поступление товара'),
('inventory', 'Инвентаризация'),
('transformation', 'Трансформация товара'),
]
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 TransferDocument(models.Model):
"""
Документ перемещения товара между складами.
Один номер документа = одна операция перемещения множественных товаров.
"""
from_warehouse = models.ForeignKey(
Warehouse,
on_delete=models.CASCADE,
related_name='transfer_documents_from',
verbose_name="Склад-отгрузки"
)
to_warehouse = models.ForeignKey(
Warehouse,
on_delete=models.CASCADE,
related_name='transfer_documents_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 TransferDocumentItem(models.Model):
"""
Строка документа перемещения (товар в перемещении).
Связь между документом и товарами.
"""
transfer_document = models.ForeignKey(
TransferDocument,
on_delete=models.CASCADE,
related_name='items',
verbose_name="Документ перемещения"
)
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name='transfer_document_items',
verbose_name="Товар"
)
batch = models.ForeignKey(
StockBatch,
on_delete=models.CASCADE,
related_name='transfer_document_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_document_items_created',
verbose_name="Созданная партия на целевом складе"
)
class Meta:
verbose_name = "Строка перемещения"
verbose_name_plural = "Строки перемещения"
unique_together = [['transfer_document', 'batch']]
ordering = ['id']
indexes = [
models.Index(fields=['transfer_document']),
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="Примечания"
)
# Связь с инвентаризацией
inventory = models.ForeignKey(
'Inventory',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='writeoff_documents',
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) → создается StockBatch напрямую
4. Stock автоматически обновляется
"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('confirmed', 'Проведён'),
('cancelled', 'Отменён'),
]
RECEIPT_TYPE_CHOICES = [
('supplier', 'Поступление от поставщика'),
('inventory', 'Оприходование при инвентаризации'),
('adjustment', 'Оприходование без инвентаризации'),
]
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=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="Примечания"
)
# Связь с инвентаризацией
inventory = models.ForeignKey(
'Inventory',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='incoming_documents',
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. Для каждой позиции напрямую создается StockBatch
2. Stock автоматически обновляется
"""
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
class Transformation(models.Model):
"""
Документ трансформации товара (превращение одного товара в другой).
Пример: белая гипсофила → крашеная гипсофила
"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('completed', 'Проведён'),
('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='transformations',
verbose_name="Склад"
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='draft',
db_index=True,
verbose_name="Статус"
)
date = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transformations',
verbose_name="Сотрудник"
)
comment = models.TextField(
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']
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['warehouse', 'status']),
models.Index(fields=['-date']),
]
def __str__(self):
return f"{self.document_number} ({self.get_status_display()})"
class TransformationInput(models.Model):
"""
Входной товар трансформации (что списываем).
"""
transformation = models.ForeignKey(
Transformation,
on_delete=models.CASCADE,
related_name='inputs',
verbose_name="Трансформация"
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
related_name='transformation_inputs',
verbose_name="Товар"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
# Резерв (создается автоматически при draft)
# Связь через Reservation.transformation_input
class Meta:
verbose_name = "Входной товар трансформации"
verbose_name_plural = "Входные товары трансформации"
def __str__(self):
return f"{self.product.name}: {self.quantity}"
class TransformationOutput(models.Model):
"""
Выходной товар трансформации (что получаем).
"""
transformation = models.ForeignKey(
Transformation,
on_delete=models.CASCADE,
related_name='outputs',
verbose_name="Трансформация"
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
related_name='transformation_outputs',
verbose_name="Товар"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
# Ссылка на созданную партию (после проведения)
stock_batch = models.ForeignKey(
StockBatch,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transformation_outputs',
verbose_name="Созданная партия"
)
class Meta:
verbose_name = "Выходной товар трансформации"
verbose_name_plural = "Выходные товары трансформации"
def __str__(self):
return f"{self.product.name}: {self.quantity}"