from django.db import models from django.utils import timezone from django.core.exceptions import ValidationError 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): """ Партия поступления товара (один номер документа = одна партия). Содержит один номер документа и может включать несколько товаров. """ 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="Номер документа") 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=['-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="Активна") 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']), ] def __str__(self): return f"{self.name} ({self.warehouse.name})" class Reservation(models.Model): """ Резервирование товара для заказа или витрины. Отслеживает, какой товар зарезервирован за каким заказом или витриной. """ STATUS_CHOICES = [ ('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ] 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 = 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=20, 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="Дата преобразования в продажу") 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']), ] def __str__(self): if self.order_item: context = f" (заказ {self.order_item.order.order_number})" elif self.showcase: context = f" (витрина {self.showcase.name})" else: context = "" return f"Резерв {self.product.name}: {self.quantity} шт{context} [{self.get_status_display()}]" 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', 'Перемещение товара'), ] 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})"