Добавлена модель документа списания товаров (WriteOffDocument)
- Создана модель WriteOffDocument для коллективного списания с поддержкой статусов (черновик/проведен/отменен) - Добавлена модель WriteOffDocumentItem для позиций документа - Расширена модель Reservation связью с WriteOffDocumentItem для резервирования товара в черновике - Добавлен тип счетчика 'writeoff' в DocumentCounter для автонумерации - Реализована логика резервирования товара в черновике документа (уменьшает quantity_free) - При проведении документа создаются WriteOff записи по методу FIFO
This commit is contained in:
@@ -461,6 +461,17 @@ class Reservation(models.Model):
|
||||
help_text="Для какого физического экземпляра создан резерв"
|
||||
)
|
||||
|
||||
# Связь с позицией документа списания (для резервирования в черновике)
|
||||
writeoff_document_item = models.ForeignKey(
|
||||
'WriteOffDocumentItem',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reservations',
|
||||
verbose_name="Позиция документа списания",
|
||||
help_text="Резерв для документа списания (черновик)"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Резервирование"
|
||||
verbose_name_plural = "Резервирования"
|
||||
@@ -729,6 +740,7 @@ class DocumentCounter(models.Model):
|
||||
"""
|
||||
COUNTER_TYPE_CHOICES = [
|
||||
('transfer', 'Перемещение товара'),
|
||||
('writeoff', 'Списание товара'),
|
||||
]
|
||||
|
||||
counter_type = models.CharField(
|
||||
@@ -870,3 +882,201 @@ class TransferItem(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.id})"
|
||||
|
||||
|
||||
class WriteOffDocument(models.Model):
|
||||
"""
|
||||
Документ списания товара.
|
||||
|
||||
Сценарий использования:
|
||||
1. В начале смены создается черновик (draft)
|
||||
2. В течение дня добавляются испорченные товары (WriteOffDocumentItem)
|
||||
3. Товары в черновике ЗАРЕЗЕРВИРОВАНЫ (уменьшают quantity_free)
|
||||
4. В конце смены документ проводится (confirmed) → создаются WriteOff записи
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Черновик'),
|
||||
('confirmed', 'Проведён'),
|
||||
('cancelled', 'Отменён'),
|
||||
]
|
||||
|
||||
document_number = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
verbose_name="Номер документа"
|
||||
)
|
||||
|
||||
warehouse = models.ForeignKey(
|
||||
Warehouse,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='writeoff_documents',
|
||||
verbose_name="Склад"
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
db_index=True,
|
||||
verbose_name="Статус"
|
||||
)
|
||||
|
||||
date = models.DateField(
|
||||
verbose_name="Дата документа",
|
||||
help_text="Дата, к которой относится списание"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Примечания"
|
||||
)
|
||||
|
||||
# Аудит
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_writeoff_documents',
|
||||
verbose_name="Создал"
|
||||
)
|
||||
|
||||
confirmed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='confirmed_writeoff_documents',
|
||||
verbose_name="Провёл"
|
||||
)
|
||||
|
||||
confirmed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата проведения"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Документ списания"
|
||||
verbose_name_plural = "Документы списания"
|
||||
ordering = ['-date', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['document_number']),
|
||||
models.Index(fields=['warehouse', 'status']),
|
||||
models.Index(fields=['date']),
|
||||
models.Index(fields=['-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.document_number} ({self.get_status_display()})"
|
||||
|
||||
@property
|
||||
def total_quantity(self):
|
||||
"""Общее количество товаров в документе"""
|
||||
return self.items.aggregate(
|
||||
total=models.Sum('quantity')
|
||||
)['total'] or Decimal('0')
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
"""Общая себестоимость списания"""
|
||||
return sum(item.total_cost for item in self.items.select_related('product'))
|
||||
|
||||
@property
|
||||
def can_edit(self):
|
||||
"""Можно ли редактировать документ"""
|
||||
return self.status == 'draft'
|
||||
|
||||
@property
|
||||
def can_confirm(self):
|
||||
"""Можно ли провести документ"""
|
||||
return self.status == 'draft' and self.items.exists()
|
||||
|
||||
@property
|
||||
def can_cancel(self):
|
||||
"""Можно ли отменить документ"""
|
||||
return self.status == 'draft'
|
||||
|
||||
|
||||
class WriteOffDocumentItem(models.Model):
|
||||
"""
|
||||
Строка документа списания.
|
||||
|
||||
При создании:
|
||||
1. Создается Reservation для резервирования товара
|
||||
2. Stock.quantity_reserved увеличивается
|
||||
3. Stock.quantity_free уменьшается
|
||||
|
||||
При проведении документа:
|
||||
1. Создается WriteOff запись по FIFO
|
||||
2. Reservation переводится в статус 'converted_to_sale'
|
||||
"""
|
||||
REASON_CHOICES = WriteOff.REASON_CHOICES
|
||||
|
||||
document = models.ForeignKey(
|
||||
WriteOffDocument,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name="Документ"
|
||||
)
|
||||
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='writeoff_document_items',
|
||||
verbose_name="Товар"
|
||||
)
|
||||
|
||||
quantity = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=3,
|
||||
verbose_name="Количество"
|
||||
)
|
||||
|
||||
reason = models.CharField(
|
||||
max_length=20,
|
||||
choices=REASON_CHOICES,
|
||||
default='damage',
|
||||
verbose_name="Причина списания"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Примечания"
|
||||
)
|
||||
|
||||
# Резерв (создается автоматически при добавлении в черновик)
|
||||
reservation = models.OneToOneField(
|
||||
Reservation,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='writeoff_document_item_reverse',
|
||||
verbose_name="Резерв"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Позиция документа списания"
|
||||
verbose_name_plural = "Позиции документа списания"
|
||||
ordering = ['id']
|
||||
indexes = [
|
||||
models.Index(fields=['document']),
|
||||
models.Index(fields=['product']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name}: {self.quantity} шт ({self.get_reason_display()})"
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
"""Себестоимость позиции (средневзвешенная из cost_price товара)"""
|
||||
return self.quantity * (self.product.cost_price or Decimal('0'))
|
||||
|
||||
Reference in New Issue
Block a user