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', 'Списание товара'), ] 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'))