- Добавлено поле receipt_type в модель IncomingBatch с типами: supplier, inventory, adjustment - Исправлен баг в InventoryProcessor: теперь корректно создается IncomingBatch при инвентаризации - Создан IncomingAdjustmentCreateView для оприходования без инвентаризации - Обновлены формы, шаблоны и админка для поддержки разных типов поступлений - Добавлена навигация и URL для оприходования - Тип поступления отображается в списках приходов и партий
1103 lines
46 KiB
Python
1103 lines
46 KiB
Python
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'))
|