Implement functionality to allow sales even when stock is insufficient, tracking pending quantities and resolving them when new stock arrives via incoming documents. This includes new fields in Sale model (is_pending_cost, pending_quantity), updates to batch manager for negative write-offs, and signal handlers for automatic processing. - Add is_pending_cost and pending_quantity fields to Sale model - Modify write_off_by_fifo to support allow_negative flag and return pending quantity - Update incoming document service to allocate pending sales to new batches - Enhance sale processor and signals to handle pending sales - Remove outdated tests.py file - Add migration for new Sale fields
1500 lines
58 KiB
Python
1500 lines
58 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} за ед."
|
||
|
||
|
||
# Модели IncomingBatch и Incoming удалены - заменены на IncomingDocument/IncomingDocumentItem
|
||
# Теперь используется упрощенная архитектура:
|
||
# IncomingDocument → IncomingDocumentItem → StockBatch (напрямую при проведении)
|
||
|
||
|
||
class Sale(models.Model):
|
||
"""
|
||
Продажа товара. Списывает по FIFO.
|
||
"""
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||
related_name='sales', verbose_name="Товар")
|
||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||
related_name='sales', verbose_name="Склад")
|
||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи")
|
||
order = models.ForeignKey('orders.Order', on_delete=models.SET_NULL, null=True, blank=True,
|
||
related_name='sales', verbose_name="Заказ")
|
||
document_number = models.CharField(max_length=100, blank=True, null=True,
|
||
verbose_name="Номер документа")
|
||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
|
||
processed = models.BooleanField(default=False, verbose_name="Обработана (FIFO применена)")
|
||
|
||
# === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
|
||
sales_unit = models.ForeignKey(
|
||
'products.ProductSalesUnit',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='sales',
|
||
verbose_name="Единица продажи"
|
||
)
|
||
quantity_base = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=6,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Количество в базовых единицах",
|
||
help_text="Количество в единицах хранения товара (для списания со склада)"
|
||
)
|
||
unit_name_snapshot = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
default='',
|
||
verbose_name="Название единицы (snapshot)",
|
||
help_text="Название единицы продажи на момент продажи"
|
||
)
|
||
|
||
# === ПОЛЯ ДЛЯ ПРОДАЖ "В МИНУС" ===
|
||
is_pending_cost = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Ожидает себестоимости",
|
||
help_text="True если продажа создана без партий (продажа 'в минус')"
|
||
)
|
||
pending_quantity = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
default=Decimal('0'),
|
||
verbose_name="Ожидающее количество",
|
||
help_text="Количество, ожидающее привязки к партиям при приёмке"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Продажа"
|
||
verbose_name_plural = "Продажи"
|
||
ordering = ['-date']
|
||
indexes = [
|
||
models.Index(fields=['product', 'warehouse']),
|
||
models.Index(fields=['date']),
|
||
models.Index(fields=['order']),
|
||
models.Index(fields=['is_pending_cost']),
|
||
]
|
||
|
||
def __str__(self):
|
||
unit_info = f" ({self.unit_name_snapshot})" if self.unit_name_snapshot else ""
|
||
return f"Продажа {self.product.name}: {self.quantity}{unit_info} @ {self.sale_price}"
|
||
|
||
|
||
class SaleBatchAllocation(models.Model):
|
||
"""
|
||
Связь между Sale и StockBatch для отслеживания FIFO-списания.
|
||
(Для аудита: какая партия использована при продаже)
|
||
"""
|
||
sale = models.ForeignKey(Sale, on_delete=models.CASCADE,
|
||
related_name='batch_allocations', verbose_name="Продажа")
|
||
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
|
||
related_name='sale_allocations', verbose_name="Партия")
|
||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
|
||
|
||
class Meta:
|
||
verbose_name = "Распределение продажи по партиям"
|
||
verbose_name_plural = "Распределения продаж по партиям"
|
||
|
||
def __str__(self):
|
||
return f"{self.sale} ← {self.batch} ({self.quantity} шт)"
|
||
|
||
|
||
class WriteOff(models.Model):
|
||
"""
|
||
Списание товара вручную (брак, порча, недостача).
|
||
Человек выбирает конкретную партию.
|
||
"""
|
||
REASON_CHOICES = [
|
||
('damage', 'Повреждение'),
|
||
('spoilage', 'Порча'),
|
||
('shortage', 'Недостача'),
|
||
('inventory', 'Инвентаризационная недостача'),
|
||
('other', 'Другое'),
|
||
]
|
||
|
||
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
|
||
related_name='writeoffs', verbose_name="Партия")
|
||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||
reason = models.CharField(max_length=20, choices=REASON_CHOICES,
|
||
default='other', verbose_name="Причина")
|
||
cost_price = models.DecimalField(max_digits=10, decimal_places=2,
|
||
verbose_name="Закупочная цена", editable=False)
|
||
document_number = models.CharField(max_length=100, blank=True, null=True,
|
||
verbose_name="Номер документа")
|
||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
|
||
|
||
class Meta:
|
||
verbose_name = "Списание"
|
||
verbose_name_plural = "Списания"
|
||
ordering = ['-date']
|
||
indexes = [
|
||
models.Index(fields=['batch']),
|
||
models.Index(fields=['date']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"Списание {self.batch.product.name}: {self.quantity} шт ({self.get_reason_display()})"
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Автоматически записываем cost_price из партии
|
||
if not self.pk: # Только при создании
|
||
self.cost_price = self.batch.cost_price
|
||
|
||
# Проверяем что не списываем больше чем есть
|
||
if self.quantity > self.batch.quantity:
|
||
raise ValidationError(
|
||
f"Невозможно списать {self.quantity} шт из партии, "
|
||
f"где только {self.batch.quantity} шт. "
|
||
f"Недостаток: {self.quantity - self.batch.quantity} шт."
|
||
)
|
||
|
||
# Уменьшаем количество в партии при создании списания
|
||
self.batch.quantity -= self.quantity
|
||
if self.batch.quantity <= 0:
|
||
self.batch.is_active = False
|
||
self.batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
|
||
class Inventory(models.Model):
|
||
"""
|
||
Инвентаризация (физический пересчет товаров).
|
||
"""
|
||
STATUS_CHOICES = [
|
||
('draft', 'Черновик'),
|
||
('processing', 'В обработке'),
|
||
('completed', 'Завершена'),
|
||
]
|
||
|
||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||
related_name='inventories', verbose_name="Склад")
|
||
document_number = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
db_index=True,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Номер документа"
|
||
)
|
||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации")
|
||
status = models.CharField(max_length=20, choices=STATUS_CHOICES,
|
||
default='draft', verbose_name="Статус")
|
||
conducted_by = models.ForeignKey(
|
||
'user_roles.UserRole',
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name='inventories',
|
||
verbose_name="Провел инвентаризацию"
|
||
)
|
||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
||
|
||
class Meta:
|
||
verbose_name = "Инвентаризация"
|
||
verbose_name_plural = "Инвентаризации"
|
||
ordering = ['-date']
|
||
indexes = [
|
||
models.Index(fields=['document_number']),
|
||
]
|
||
|
||
def __str__(self):
|
||
if self.document_number:
|
||
return f"{self.document_number} - {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})"
|
||
return f"Инвентаризация {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})"
|
||
|
||
|
||
class InventoryLine(models.Model):
|
||
"""
|
||
Строка инвентаризации (товар + фактическое количество).
|
||
"""
|
||
inventory = models.ForeignKey(Inventory, on_delete=models.CASCADE,
|
||
related_name='lines', verbose_name="Инвентаризация")
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||
verbose_name="Товар")
|
||
quantity_system = models.DecimalField(max_digits=10, decimal_places=3,
|
||
verbose_name="Количество в системе")
|
||
quantity_fact = models.DecimalField(max_digits=10, decimal_places=3,
|
||
verbose_name="Подсчитано (факт, свободные)",
|
||
help_text="Количество свободных товаров, подсчитанных физически")
|
||
difference = models.DecimalField(max_digits=10, decimal_places=3,
|
||
default=0, verbose_name="Итоговая разница",
|
||
help_text="(Подсчитано + Зарезервировано) - Всего на складе",
|
||
editable=False)
|
||
processed = models.BooleanField(default=False,
|
||
verbose_name="Обработана (создана операция)")
|
||
|
||
# Snapshot-значения на момент завершения инвентаризации
|
||
snapshot_quantity_available = models.DecimalField(
|
||
max_digits=10, decimal_places=3, null=True, blank=True,
|
||
verbose_name="Всего на складе (snapshot)",
|
||
help_text="Всего на складе на момент завершения инвентаризации"
|
||
)
|
||
snapshot_quantity_reserved = models.DecimalField(
|
||
max_digits=10, decimal_places=3, null=True, blank=True,
|
||
verbose_name="В резервах (snapshot)",
|
||
help_text="В резервах на момент завершения инвентаризации"
|
||
)
|
||
snapshot_quantity_system = models.DecimalField(
|
||
max_digits=10, decimal_places=3, null=True, blank=True,
|
||
verbose_name="В системе свободно (snapshot)",
|
||
help_text="В системе свободно на момент завершения инвентаризации"
|
||
)
|
||
snapshot_difference = models.DecimalField(
|
||
max_digits=10, decimal_places=3, null=True, blank=True,
|
||
verbose_name="Итоговая разница (snapshot)",
|
||
help_text="Итоговая разница на момент завершения инвентаризации"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Строка инвентаризации"
|
||
verbose_name_plural = "Строки инвентаризации"
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name}: {self.quantity_system} (сист.) vs {self.quantity_fact} (факт)"
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Автоматически рассчитываем разницу
|
||
# Формула: (quantity_fact + quantity_reserved) - quantity_available
|
||
# Где quantity_fact - подсчитанные свободные товары
|
||
# Для расчета нужны quantity_reserved и quantity_available из Stock
|
||
# Если они не переданы в kwargs, получаем из Stock
|
||
quantity_reserved = kwargs.pop('quantity_reserved', None)
|
||
quantity_available = kwargs.pop('quantity_available', None)
|
||
|
||
if quantity_reserved is None or quantity_available is None:
|
||
# Получаем из Stock для расчета
|
||
from inventory.models import Stock
|
||
stock = Stock.objects.filter(
|
||
product=self.product,
|
||
warehouse=self.inventory.warehouse
|
||
).first()
|
||
if stock:
|
||
stock.refresh_from_batches()
|
||
quantity_reserved = stock.quantity_reserved
|
||
quantity_available = stock.quantity_available
|
||
|
||
# Вычисляем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available
|
||
if quantity_reserved is not None and quantity_available is not None:
|
||
self.difference = (self.quantity_fact + quantity_reserved) - quantity_available
|
||
else:
|
||
# Fallback на старую формулу, если Stock недоступен (не должно происходить в нормальной работе)
|
||
self.difference = self.quantity_fact - self.quantity_system
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class Showcase(models.Model):
|
||
"""
|
||
Витрина - место выкладки собранных букетов/комплектов.
|
||
Привязана к конкретному складу для учёта резервов.
|
||
"""
|
||
name = models.CharField(max_length=200, verbose_name="Название")
|
||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||
related_name='showcases', verbose_name="Склад")
|
||
description = models.TextField(blank=True, null=True, verbose_name="Описание")
|
||
is_active = models.BooleanField(default=True, verbose_name="Активна")
|
||
is_default = models.BooleanField(default=False, verbose_name="По умолчанию")
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||
|
||
class Meta:
|
||
verbose_name = "Витрина"
|
||
verbose_name_plural = "Витрины"
|
||
ordering = ['warehouse', 'name']
|
||
indexes = [
|
||
models.Index(fields=['warehouse']),
|
||
models.Index(fields=['is_active']),
|
||
models.Index(fields=['is_default']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.warehouse.name})"
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""Обеспечиваем что только одна витрина может быть по умолчанию для каждого склада"""
|
||
if self.is_default:
|
||
# Снимаем флаг is_default со всех других витрин этого склада
|
||
Showcase.objects.filter(warehouse=self.warehouse, is_default=True).exclude(pk=self.pk).update(is_default=False)
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class Reservation(models.Model):
|
||
"""
|
||
Резервирование товара для заказа или витрины.
|
||
Отслеживает, какой товар зарезервирован за каким заказом или витриной.
|
||
"""
|
||
STATUS_CHOICES = [
|
||
('reserved', 'Зарезервирован'),
|
||
('released', 'Освобожден'),
|
||
('converted_to_sale', 'Преобразован в продажу'),
|
||
('converted_to_writeoff', 'Преобразован в списание'),
|
||
('converted_to_transformation', 'Преобразован в трансформацию'),
|
||
]
|
||
|
||
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
|
||
related_name='reservations', verbose_name="Позиция заказа",
|
||
null=True, blank=True)
|
||
showcase = models.ForeignKey(Showcase, on_delete=models.CASCADE,
|
||
related_name='reservations', verbose_name="Витрина",
|
||
null=True, blank=True,
|
||
help_text="Витрина, на которой выложен букет")
|
||
product_kit = models.ForeignKey('products.ProductKit', on_delete=models.CASCADE,
|
||
related_name='reservations', verbose_name="Комплект",
|
||
null=True, blank=True,
|
||
help_text="Временный комплект, для которого создан резерв")
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||
related_name='reservations', verbose_name="Товар")
|
||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||
related_name='reservations', verbose_name="Склад")
|
||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||
status = models.CharField(max_length=30, choices=STATUS_CHOICES,
|
||
default='reserved', verbose_name="Статус")
|
||
reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования")
|
||
released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения")
|
||
converted_at = models.DateTimeField(null=True, blank=True,
|
||
verbose_name="Дата преобразования",
|
||
help_text="Дата преобразования в продажу или списание")
|
||
|
||
# Soft Lock для корзины POS (витринные комплекты)
|
||
cart_lock_expires_at = models.DateTimeField(
|
||
null=True, blank=True,
|
||
verbose_name="Блокировка корзины истекает",
|
||
help_text="Время истечения блокировки в корзине (для витринных комплектов)"
|
||
)
|
||
locked_by_user = models.ForeignKey(
|
||
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="Резерв для документа списания (черновик)"
|
||
)
|
||
|
||
# Связь с входным товаром трансформации (для резервирования в черновике)
|
||
transformation_input = models.ForeignKey(
|
||
'TransformationInput',
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name='reservations',
|
||
verbose_name="Входной товар трансформации",
|
||
help_text="Резерв для входного товара трансформации (черновик)"
|
||
)
|
||
|
||
# === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
|
||
sales_unit = models.ForeignKey(
|
||
'products.ProductSalesUnit',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='reservations',
|
||
verbose_name="Единица продажи"
|
||
)
|
||
quantity_base = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=6,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Количество в базовых единицах",
|
||
help_text="Количество в единицах хранения товара"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Резервирование"
|
||
verbose_name_plural = "Резервирования"
|
||
ordering = ['-reserved_at']
|
||
indexes = [
|
||
models.Index(fields=['product', 'warehouse']),
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['order_item']),
|
||
models.Index(fields=['showcase']),
|
||
models.Index(fields=['product_kit']),
|
||
models.Index(fields=['cart_lock_expires_at']),
|
||
models.Index(fields=['locked_by_user']),
|
||
models.Index(fields=['product_kit', 'cart_lock_expires_at']),
|
||
models.Index(fields=['showcase_item']),
|
||
]
|
||
|
||
def __str__(self):
|
||
if self.order_item:
|
||
context = f" (заказ {self.order_item.order.order_number})"
|
||
elif self.product_kit:
|
||
context = f" (комплект {self.product_kit.name})"
|
||
elif self.showcase:
|
||
context = f" (витрина {self.showcase.name})"
|
||
else:
|
||
context = ""
|
||
return f"Резерв {self.product.name}: {self.quantity} шт{context} [{self.get_status_display()}]"
|
||
|
||
|
||
class ShowcaseItem(models.Model):
|
||
"""
|
||
Физический экземпляр комплекта на витрине.
|
||
|
||
Один ProductKit (шаблон) -> N ShowcaseItem (экземпляры).
|
||
Каждый экземпляр имеет свой набор резервов и может быть продан независимо.
|
||
|
||
Защита от двойной продажи:
|
||
- sold_order_item = OneToOneField гарантирует что один экземпляр
|
||
может быть продан только в один OrderItem (на уровне БД).
|
||
"""
|
||
showcase = models.ForeignKey(
|
||
'Showcase',
|
||
on_delete=models.CASCADE,
|
||
related_name='showcase_items',
|
||
verbose_name="Витрина"
|
||
)
|
||
product_kit = models.ForeignKey(
|
||
'products.ProductKit',
|
||
on_delete=models.CASCADE,
|
||
related_name='showcase_items',
|
||
verbose_name="Шаблон комплекта"
|
||
)
|
||
|
||
# Статусы жизненного цикла
|
||
STATUS_CHOICES = [
|
||
('available', 'Доступен'),
|
||
('in_cart', 'В корзине'),
|
||
('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.
|
||
Учитывает "ожидающие" продажи (продажи "в минус").
|
||
quantity_available может быть отрицательным!
|
||
"""
|
||
# Сумма из активных партий
|
||
total_qty = StockBatch.objects.filter(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
is_active=True
|
||
).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
|
||
|
||
# Учитываем ожидающие продажи (уменьшают доступное количество)
|
||
pending_sales = Sale.objects.filter(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
is_pending_cost=True
|
||
).aggregate(models.Sum('pending_quantity'))['pending_quantity__sum'] or Decimal('0')
|
||
|
||
# quantity_available может быть отрицательным при продажах "в минус"
|
||
self.quantity_available = total_qty - pending_sales
|
||
|
||
# Резервы остаются как есть
|
||
total_reserved = Reservation.objects.filter(
|
||
product=self.product,
|
||
warehouse=self.warehouse,
|
||
status='reserved'
|
||
).aggregate(models.Sum('quantity_base'))['quantity_base__sum'] or Decimal('0')
|
||
|
||
self.quantity_reserved = total_reserved
|
||
self.save()
|
||
|
||
|
||
class DocumentCounter(models.Model):
|
||
"""
|
||
Счетчик номеров документов для различных операций.
|
||
Используется для генерации уникальных номеров документов.
|
||
"""
|
||
COUNTER_TYPE_CHOICES = [
|
||
('transfer', 'Перемещение товара'),
|
||
('writeoff', 'Списание товара'),
|
||
('incoming', 'Поступление товара'),
|
||
('inventory', 'Инвентаризация'),
|
||
('transformation', 'Трансформация товара'),
|
||
]
|
||
|
||
counter_type = models.CharField(
|
||
max_length=20,
|
||
choices=COUNTER_TYPE_CHOICES,
|
||
unique=True,
|
||
verbose_name="Тип счетчика"
|
||
)
|
||
current_value = models.IntegerField(
|
||
default=0,
|
||
verbose_name="Текущее значение"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Счетчик документов"
|
||
verbose_name_plural = "Счетчики документов"
|
||
|
||
def __str__(self):
|
||
return f"Счетчик {self.get_counter_type_display()}: {self.current_value}"
|
||
|
||
@classmethod
|
||
def get_next_value(cls, counter_type):
|
||
"""
|
||
Получить следующее значение для счетчика.
|
||
Thread-safe операция с использованием select_for_update.
|
||
"""
|
||
from django.db import transaction
|
||
|
||
with transaction.atomic():
|
||
obj, _ = cls.objects.select_for_update().get_or_create(
|
||
counter_type=counter_type
|
||
)
|
||
obj.current_value += 1
|
||
obj.save(update_fields=['current_value'])
|
||
return obj.current_value
|
||
|
||
|
||
class TransferDocument(models.Model):
|
||
"""
|
||
Документ перемещения товара между складами.
|
||
Один номер документа = одна операция перемещения множественных товаров.
|
||
"""
|
||
from_warehouse = models.ForeignKey(
|
||
Warehouse,
|
||
on_delete=models.CASCADE,
|
||
related_name='transfer_documents_from',
|
||
verbose_name="Склад-отгрузки"
|
||
)
|
||
to_warehouse = models.ForeignKey(
|
||
Warehouse,
|
||
on_delete=models.CASCADE,
|
||
related_name='transfer_documents_to',
|
||
verbose_name="Склад-приемки"
|
||
)
|
||
document_number = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name="Номер документа"
|
||
)
|
||
notes = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Примечания"
|
||
)
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания"
|
||
)
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name="Дата обновления"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Документ перемещения"
|
||
verbose_name_plural = "Документы перемещения"
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['document_number']),
|
||
models.Index(fields=['from_warehouse', 'to_warehouse']),
|
||
models.Index(fields=['-created_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
total_items = self.items.count()
|
||
total_qty = self.items.aggregate(
|
||
models.Sum('quantity')
|
||
)['quantity__sum'] or Decimal('0')
|
||
return f"Перемещение {self.document_number}: {total_items} товаров, {total_qty} шт ({self.from_warehouse} → {self.to_warehouse})"
|
||
|
||
|
||
class TransferDocumentItem(models.Model):
|
||
"""
|
||
Строка документа перемещения (товар в перемещении).
|
||
Связь между документом и товарами.
|
||
"""
|
||
transfer_document = models.ForeignKey(
|
||
TransferDocument,
|
||
on_delete=models.CASCADE,
|
||
related_name='items',
|
||
verbose_name="Документ перемещения"
|
||
)
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.CASCADE,
|
||
related_name='transfer_document_items',
|
||
verbose_name="Товар"
|
||
)
|
||
batch = models.ForeignKey(
|
||
StockBatch,
|
||
on_delete=models.CASCADE,
|
||
related_name='transfer_document_items',
|
||
verbose_name="Исходная партия (FIFO)"
|
||
)
|
||
quantity = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
verbose_name="Количество"
|
||
)
|
||
new_batch = models.ForeignKey(
|
||
StockBatch,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='transfer_document_items_created',
|
||
verbose_name="Созданная партия на целевом складе"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Строка перемещения"
|
||
verbose_name_plural = "Строки перемещения"
|
||
unique_together = [['transfer_document', 'batch']]
|
||
ordering = ['id']
|
||
indexes = [
|
||
models.Index(fields=['transfer_document']),
|
||
models.Index(fields=['product']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.id})"
|
||
|
||
|
||
class WriteOffDocument(models.Model):
|
||
"""
|
||
Документ списания товара.
|
||
|
||
Сценарий использования:
|
||
1. В начале смены создается черновик (draft)
|
||
2. В течение дня добавляются испорченные товары (WriteOffDocumentItem)
|
||
3. Товары в черновике ЗАРЕЗЕРВИРОВАНЫ (уменьшают quantity_free)
|
||
4. В конце смены документ проводится (confirmed) → создаются WriteOff записи
|
||
"""
|
||
STATUS_CHOICES = [
|
||
('draft', 'Черновик'),
|
||
('confirmed', 'Проведён'),
|
||
('cancelled', 'Отменён'),
|
||
]
|
||
|
||
document_number = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name="Номер документа"
|
||
)
|
||
|
||
warehouse = models.ForeignKey(
|
||
Warehouse,
|
||
on_delete=models.PROTECT,
|
||
related_name='writeoff_documents',
|
||
verbose_name="Склад"
|
||
)
|
||
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='draft',
|
||
db_index=True,
|
||
verbose_name="Статус"
|
||
)
|
||
|
||
date = models.DateField(
|
||
verbose_name="Дата документа",
|
||
help_text="Дата, к которой относится списание"
|
||
)
|
||
|
||
notes = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Примечания"
|
||
)
|
||
|
||
# Связь с инвентаризацией
|
||
inventory = models.ForeignKey(
|
||
'Inventory',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='writeoff_documents',
|
||
verbose_name="Инвентаризация"
|
||
)
|
||
|
||
# Аудит
|
||
created_by = models.ForeignKey(
|
||
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'))
|
||
|
||
|
||
class IncomingDocument(models.Model):
|
||
"""
|
||
Документ поступления товара на склад.
|
||
|
||
Сценарий использования:
|
||
1. Создается черновик (draft)
|
||
2. В течение дня добавляются товары (IncomingDocumentItem)
|
||
3. В конце смены документ проводится (confirmed) → создается StockBatch напрямую
|
||
4. Stock автоматически обновляется
|
||
"""
|
||
STATUS_CHOICES = [
|
||
('draft', 'Черновик'),
|
||
('confirmed', 'Проведён'),
|
||
('cancelled', 'Отменён'),
|
||
]
|
||
|
||
RECEIPT_TYPE_CHOICES = [
|
||
('supplier', 'Поступление от поставщика'),
|
||
('inventory', 'Оприходование при инвентаризации'),
|
||
('adjustment', 'Оприходование без инвентаризации'),
|
||
]
|
||
|
||
document_number = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name="Номер документа"
|
||
)
|
||
|
||
warehouse = models.ForeignKey(
|
||
Warehouse,
|
||
on_delete=models.PROTECT,
|
||
related_name='incoming_documents',
|
||
verbose_name="Склад"
|
||
)
|
||
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='draft',
|
||
db_index=True,
|
||
verbose_name="Статус"
|
||
)
|
||
|
||
date = models.DateField(
|
||
verbose_name="Дата документа",
|
||
help_text="Дата, к которой относится поступление"
|
||
)
|
||
|
||
receipt_type = models.CharField(
|
||
max_length=20,
|
||
choices=RECEIPT_TYPE_CHOICES,
|
||
default='supplier',
|
||
db_index=True,
|
||
verbose_name="Тип поступления"
|
||
)
|
||
|
||
supplier_name = models.CharField(
|
||
max_length=200,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Наименование поставщика",
|
||
help_text="Заполняется для типа 'Поступление от поставщика'"
|
||
)
|
||
|
||
notes = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Примечания"
|
||
)
|
||
|
||
# Связь с инвентаризацией
|
||
inventory = models.ForeignKey(
|
||
'Inventory',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='incoming_documents',
|
||
verbose_name="Инвентаризация"
|
||
)
|
||
|
||
# Аудит
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='created_incoming_documents',
|
||
verbose_name="Создал"
|
||
)
|
||
|
||
confirmed_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='confirmed_incoming_documents',
|
||
verbose_name="Провёл"
|
||
)
|
||
|
||
confirmed_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Дата проведения"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
|
||
|
||
class Meta:
|
||
verbose_name = "Документ поступления"
|
||
verbose_name_plural = "Документы поступления"
|
||
ordering = ['-date', '-created_at']
|
||
indexes = [
|
||
models.Index(fields=['document_number']),
|
||
models.Index(fields=['warehouse', 'status']),
|
||
models.Index(fields=['date']),
|
||
models.Index(fields=['receipt_type']),
|
||
models.Index(fields=['-created_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.document_number} ({self.get_status_display()})"
|
||
|
||
@property
|
||
def total_quantity(self):
|
||
"""Общее количество товаров в документе"""
|
||
return self.items.aggregate(
|
||
total=models.Sum('quantity')
|
||
)['total'] or Decimal('0')
|
||
|
||
@property
|
||
def total_cost(self):
|
||
"""Общая себестоимость поступления"""
|
||
return sum(item.total_cost for item in self.items.select_related('product'))
|
||
|
||
@property
|
||
def can_edit(self):
|
||
"""Можно ли редактировать документ"""
|
||
return self.status == 'draft'
|
||
|
||
@property
|
||
def can_confirm(self):
|
||
"""Можно ли провести документ"""
|
||
return self.status == 'draft' and self.items.exists()
|
||
|
||
@property
|
||
def can_cancel(self):
|
||
"""Можно ли отменить документ"""
|
||
return self.status == 'draft'
|
||
|
||
|
||
class IncomingDocumentItem(models.Model):
|
||
"""
|
||
Строка документа поступления.
|
||
|
||
При создании:
|
||
- Товар добавляется в черновик документа
|
||
- Резервирование НЕ создается (товар еще не поступил)
|
||
|
||
При проведении документа:
|
||
1. Для каждой позиции напрямую создается StockBatch
|
||
2. Stock автоматически обновляется
|
||
"""
|
||
document = models.ForeignKey(
|
||
IncomingDocument,
|
||
on_delete=models.CASCADE,
|
||
related_name='items',
|
||
verbose_name="Документ"
|
||
)
|
||
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.PROTECT,
|
||
related_name='incoming_document_items',
|
||
verbose_name="Товар"
|
||
)
|
||
|
||
quantity = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
verbose_name="Количество"
|
||
)
|
||
|
||
cost_price = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name="Закупочная цена"
|
||
)
|
||
|
||
notes = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Примечания"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
|
||
|
||
class Meta:
|
||
verbose_name = "Позиция документа поступления"
|
||
verbose_name_plural = "Позиции документа поступления"
|
||
ordering = ['id']
|
||
indexes = [
|
||
models.Index(fields=['document']),
|
||
models.Index(fields=['product']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name}: {self.quantity} шт @ {self.cost_price}"
|
||
|
||
@property
|
||
def total_cost(self):
|
||
"""Себестоимость позиции (quantity * cost_price)"""
|
||
return self.quantity * self.cost_price
|
||
|
||
|
||
class Transformation(models.Model):
|
||
"""
|
||
Документ трансформации товара (превращение одного товара в другой).
|
||
|
||
Пример: белая гипсофила → крашеная гипсофила
|
||
"""
|
||
STATUS_CHOICES = [
|
||
('draft', 'Черновик'),
|
||
('completed', 'Проведён'),
|
||
('cancelled', 'Отменён'),
|
||
]
|
||
|
||
document_number = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name="Номер документа"
|
||
)
|
||
|
||
warehouse = models.ForeignKey(
|
||
Warehouse,
|
||
on_delete=models.PROTECT,
|
||
related_name='transformations',
|
||
verbose_name="Склад"
|
||
)
|
||
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='draft',
|
||
db_index=True,
|
||
verbose_name="Статус"
|
||
)
|
||
|
||
date = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания"
|
||
)
|
||
|
||
employee = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='transformations',
|
||
verbose_name="Сотрудник"
|
||
)
|
||
|
||
comment = models.TextField(
|
||
blank=True,
|
||
verbose_name="Комментарий"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
|
||
|
||
class Meta:
|
||
verbose_name = "Трансформация товара"
|
||
verbose_name_plural = "Трансформации товаров"
|
||
ordering = ['-date']
|
||
indexes = [
|
||
models.Index(fields=['document_number']),
|
||
models.Index(fields=['warehouse', 'status']),
|
||
models.Index(fields=['-date']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.document_number} ({self.get_status_display()})"
|
||
|
||
|
||
class TransformationInput(models.Model):
|
||
"""
|
||
Входной товар трансформации (что списываем).
|
||
"""
|
||
transformation = models.ForeignKey(
|
||
Transformation,
|
||
on_delete=models.CASCADE,
|
||
related_name='inputs',
|
||
verbose_name="Трансформация"
|
||
)
|
||
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.PROTECT,
|
||
related_name='transformation_inputs',
|
||
verbose_name="Товар"
|
||
)
|
||
|
||
quantity = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
verbose_name="Количество"
|
||
)
|
||
|
||
# Резерв (создается автоматически при draft)
|
||
# Связь через Reservation.transformation_input
|
||
|
||
class Meta:
|
||
verbose_name = "Входной товар трансформации"
|
||
verbose_name_plural = "Входные товары трансформации"
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name}: {self.quantity}"
|
||
|
||
|
||
class TransformationOutput(models.Model):
|
||
"""
|
||
Выходной товар трансформации (что получаем).
|
||
"""
|
||
transformation = models.ForeignKey(
|
||
Transformation,
|
||
on_delete=models.CASCADE,
|
||
related_name='outputs',
|
||
verbose_name="Трансформация"
|
||
)
|
||
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.PROTECT,
|
||
related_name='transformation_outputs',
|
||
verbose_name="Товар"
|
||
)
|
||
|
||
quantity = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
verbose_name="Количество"
|
||
)
|
||
|
||
# Ссылка на созданную партию (после проведения)
|
||
stock_batch = models.ForeignKey(
|
||
StockBatch,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='transformation_outputs',
|
||
verbose_name="Созданная партия"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Выходной товар трансформации"
|
||
verbose_name_plural = "Выходные товары трансформации"
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name}: {self.quantity}"
|