Добавлена модель документа списания товаров (WriteOffDocument)

- Создана модель WriteOffDocument для коллективного списания с поддержкой статусов (черновик/проведен/отменен)
- Добавлена модель WriteOffDocumentItem для позиций документа
- Расширена модель Reservation связью с WriteOffDocumentItem для резервирования товара в черновике
- Добавлен тип счетчика 'writeoff' в DocumentCounter для автонумерации
- Реализована логика резервирования товара в черновике документа (уменьшает quantity_free)
- При проведении документа создаются WriteOff записи по методу FIFO
This commit is contained in:
2025-12-10 23:34:43 +03:00
parent c76163640e
commit 56a04ae4be
2 changed files with 301 additions and 0 deletions

View File

@@ -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'))