- Создана модель Showcase (витрина) привязанная к складу - Расширена Reservation для поддержки витринных резервов - Добавлены поля в OrderItem для маркировки витринных продаж - Реализован ShowcaseManager с методами резервирования, продажи и разбора - Обновлён админ-интерфейс для управления витринами - Добавлена кнопка Витрина в POS (категории) и API для просмотра - Добавлена кнопка На витрину в панели действий POS - Миграции готовы к применению
674 lines
31 KiB
Python
674 lines
31 KiB
Python
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})"
|