feat: Реализовать систему поступления товаров с партиями (IncomingBatch)

Основные изменения:
- Создана модель IncomingBatch для группировки товаров по документам
- Каждое поступление (Incoming) связано с одной батчем поступления
- Автоматическое создание StockBatch для каждого товара в приходе
- Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД
- Обновлены все представления (views) для работы с новой архитектурой
- Добавлены детальные страницы просмотра партий поступлений
- Обновлены шаблоны для отображения информации о партиях и их товарах
- Исправлена логика сигналов для создания StockBatch при приходе товара
- Обновлены формы для работы с новой структурой IncomingBatch

Архитектура FIFO:
- IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX)
- Incoming: товар в партии поступления
- StockBatch: одна партия товара на складе (создается для каждого товара)

Это позволяет системе правильно применять FIFO при продаже товаров.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-29 03:26:06 +03:00
parent 097d4ea304
commit 6735be9b08
73 changed files with 6536 additions and 122 deletions

View File

@@ -1,38 +1,420 @@
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="Активен")
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']),
]
def __str__(self):
return self.name
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.OneToOneField(Product, on_delete=models.CASCADE,
related_name='stock', verbose_name="Товар")
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="Доступное количество")
verbose_name="Доступное количество",
editable=False)
quantity_reserved = models.DecimalField(max_digits=10, decimal_places=3, default=0,
verbose_name="Зарезервированное количество")
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']),
models.Index(fields=['product', 'warehouse']),
]
def __str__(self):
return f"{self.product.name} - {self.quantity_available}"
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', 'Закупка'),
@@ -41,7 +423,7 @@ class StockMovement(models.Model):
('adjustment', 'Корректировка'),
]
product = models.ForeignKey(Product, on_delete=models.CASCADE,
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="Причина")