Files
octopus/myproject/inventory/models.py
Andrey Smakotin 6c8af5ab2c fix: Улучшения системы ценообразования комплектов
Исправлены 4 проблемы:
1. Расчёт цены первого товара - улучшена валидация в getProductPrice и calculateFinalPrice
2. Отображение actual_price в Select2 вместо обычной цены
3. Количество по умолчанию = 1 для новых форм компонентов
4. Auto-select текста при клике на поле количества для удобства редактирования

Изменённые файлы:
- products/forms.py: добавлен __init__ в KitItemForm для quantity.initial = 1
- products/templates/includes/select2-product-init.html: обновлена formatSelectResult
- products/templates/productkit_create.html: добавлен focus handler для auto-select
- products/templates/productkit_edit.html: добавлен focus handler для auto-select

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 19:04:03 +03:00

457 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
class Warehouse(models.Model):
"""
Склад (физическое или логическое место хранения).
"""
name = models.CharField(max_length=200, 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="Склад по умолчанию",
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']),
]
def __str__(self):
return self.name
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 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)
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']),
]
def __str__(self):
order_info = f" (заказ {self.order_item.order.order_number})" if self.order_item else ""
return f"Резерв {self.product.name}: {self.quantity} шт{order_info} [{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})"