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( 'accounts.CustomUser', 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( 'accounts.CustomUser', 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( 'accounts.CustomUser', on_delete=models.SET_NULL, null=True, blank=True, related_name='created_writeoff_documents', verbose_name="Создал" ) confirmed_by = models.ForeignKey( 'accounts.CustomUser', 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( 'accounts.CustomUser', on_delete=models.SET_NULL, null=True, blank=True, related_name='created_incoming_documents', verbose_name="Создал" ) confirmed_by = models.ForeignKey( 'accounts.CustomUser', 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( 'accounts.CustomUser', 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}"